원문: 면접에서 “확장성”을 말하는 순간, 면접관은 이것부터 확인한다 — Greg Lee’s Lab (2026-08-09) 저자: Greg Lee 정리일: 2026-08-11 비고: 원문이 한국어이므로 번역이 아닌 원문 전문 보존·구조화 노트로 정리했다.
목차 요약
- 확장성은 코드를 덜 고치게 만드는 능력이다.
- 변하는 축을 인터페이스로 추상화하면 서비스 코드를 수정하지 않고 요건을 추가할 수 있다.
- 상속과 컴포지션은 같은 OCP를 구현해도 결합의 대가가 다르다.
- 인터페이스 이름에 구현체 용어가 새면 추상화의 경계가 무너진다.
- 확장성은 패턴 암기가 아니라 경계, 결합, 이름을 설계하는 능력으로 드러난다.
확장성에 대한 이해는 말이 아니라 설계에서 드러난다
면접에서 “확장성을 고려했습니다”라는 말은 거의 모든 지원자가 한다. 하지만 경험 있는 면접관은 이 말을 그대로 믿지 않고, 바로 다음 질문으로 그 말의 진위를 검증한다. 그리고 그 검증의 핵심은 화려한 아키텍처 다이어그램이 아니라 “추상화를 제대로 이해하고 있는가”라는 훨씬 단순하고 날카로운 지점이다.
이 글은 면접관이 “확장성”이라는 말을 듣고 난 이후에, 무엇을 확인하는지 따라가 본 기록이다.

확장성은 “많이 붙일 수 있음”이 아니라 “안 고쳐도 됨”이다
확장성의 핵심은 코드를 “덜 고치게 만드는 능력”을 부여하는 것이다.
이 정의를 조금 더 뜯어보자. 새로운 요구가 들어왔을 때 기존 코드를 최소한만 건드리고 새 코드를 추가하는 것만으로 대응되는 구조 — 소프트웨어 설계에서 말하는 OCP(개방-폐쇄 원칙)가 그것이다. 확장에는 열려 있고, 변경에는 닫혀 있는 상태.
그리고 이 상태를 만드는 유일한 열쇠가 추상화다. 추상화란 무언가를 뭉뚱그려 감추는 게 아니다. 변하는 축이 무엇인지 식별하고, 그 축을 구체적인 구현 용어에 의존하지 않는 중립적인 계약(인터페이스)으로 선언하는 것이다. 이 정의를 손에 쥐고 있느냐 아니냐가, 뒤에 나올 모든 대화의 깊이를 결정한다.
서비스를 고치지 않고 요건을 추가한다
가장 먼저 확인하는 건 “인터페이스를 진짜로 쓸 줄 아는가”다.
결제 생성 과정을 예로 들자. 여기에는 정합성 체크 로직이 수시로 붙고 바뀐다. MID 소유권 확인, orderNo 중복 확인, 1회 한도 검증 — 이런 요건은 사업이 자라면서 계속 늘어난다. 실제로 2017년 무렵, 내가 맡았던 간편결제 서비스엔 비트코인 구매 결제를 가려내 한도를 거는 요건이 새로 생기기도 했다. 이걸 서비스 안에 if 문으로 쌓기 시작하면, 요건이 하나 추가될 때마다 결제 생성 서비스를 열어 고쳐야 한다. 변경에 닫혀 있지 않은 것이다.
대신 검증이라는 변하는 축을 하나의 인터페이스로 선언한다.
/**
* 도메인 검증 규칙 포트. 하나의 검증 요건을 표현한다.
* 충족 시 정상 반환(void), 위반 시 도메인 예외를 던진다.
*/
fun interface Validator<in T> {
fun validate(target: T)
}
그리고 서비스는 구현체를 하나도 모른 채, 계약의 목록만 주입받아 실행한다.
@Service
class PaymentServiceImpl(
// 검증 목적의 interface 목록
private val createValidators: List<Validator<CreatePaymentCommand>>,
// ...
) : PaymentService {
@Transactional
override fun create(command: CreatePaymentCommand): PaymentInfo {
// 도메인 검증: MID 소유권·orderNo 중복·1회 한도 …
createValidators.forEach { it.validate(target = command) }
// ...
}
}
핵심은 마지막 한 줄이다. 새로운 정합성 요건이 생기면 Validator<CreatePaymentCommand>를 구현한 빈 하나를 추가하면 끝이고, 결제 생성 서비스는 단 한 줄도 바뀌지 않는다. 서비스는 “검증한다”는 계약에만 의존할 뿐, “무엇을 검증하는지”는 알 필요가 없기 때문이다.
같은 원리가 외부 서비스 연동에도 그대로 적용된다. 로그인 provider, 결제 PG, 배송 업체, CRM — 이들 역시 마찬가지다. 우리가 필요로 하는 행위를 인터페이스로 먼저 선언하고, 업체 스펙에 맞는 구현체를 우리가 직접 만든 뒤, DI 프레임워크를 통해 그중 필요한 구현체를 골라 쓴다. 업체가 하나 늘어도 인터페이스를 소비하는 쪽 코드는 그대로다. 추상화가 몸에 밴 사람은 여기까지 막힘없이 설명한다. 이러한 수준의 개념과 코드 구현까지 가능하다면 충분하겠지만, 이후 면접관은 한 걸음 더 들어간다 — 그다음은 어떤가.
상속으로도 확장은 되지만, 대가가 따른다
진짜 갈리는 지점은 여기서부터다. 템플릿 메서드 패턴을 떠올려 보자. 결제에서 쿠폰을 쓸 때 흐름은 ① 유효한 쿠폰인지 확인 ② 할인 금액 계산 ③ 결제 반영 순으로 고정돼 있고, 쿠폰 종류마다 달라지는 건 ②뿐이다. 그러면 골격은 부모가 쥐고, 변하는 ②만 추상 메서드로 열어 자식이 채우게 만들 수 있다.
abstract class CouponUseTemplate {
// 알고리즘 골격(불변)은 부모가 고정한다
fun use(command: UseCouponCommand): PaymentDiscount {
validate(command) // 1) 고정
val amount = calculateDiscount(command) // 2) 가변 — 훅
return apply(command, amount) // 3) 고정
}
protected abstract fun calculateDiscount(command: UseCouponCommand): Money
}
여기서 멈추면 “확장성 있는 구조”는 맞다. 하지만 면접관이 파고드는 건 그 한 걸음 안쪽이다. 이 구조와 앞의 Validator는 같은 원칙(OCP)의 다른 표현이지만, 확장의 방향과 결합 방식이 다르다. Validator는 규칙을 병렬로 추가하고 컴포지션(주입)으로 느슨하게 묶는다. 템플릿 메서드는 하나의 골격 안에서 단계를 치환하고 상속으로 단단히 묶는다. 상속은 강한 결합이라, 부모 골격이 바뀌면 모든 자식이 흔들리고, 자식은 이미 단일 상속 슬롯을 소진하며(코틀린은 open까지 강제한다), 훅 하나를 테스트하려 해도 부모 골격을 통째로 돌려야 한다.
그래서 이 쿠폰 예시에는 함정이 있다. 변하는 축이 “할인 금액 계산” 단 하나로 이미 깔끔하게 격리돼 있다면, 그 단계야말로 상속이 아니라 인터페이스로 뽑아내기 좋은 후보다.
fun interface DiscountPolicy {
fun calculate(command: UseCouponCommand): Money
}
// ...
@Service
class CouponService(
private val policies: Map<CouponType, DiscountPolicy>, // 타입별 전략을 주입
) {
fun use(command: UseCouponCommand): PaymentDiscount {
validate(command)
val amount = policies.getValue(command.type).calculate(command)
return apply(command, amount)
}
}
굳이 Map으로 관리하고 싶지 않다면, DiscountPolicy 인터페이스에 fun supports(type: CouponType): Boolean 같은 메서드를 하나 더 두고, List<DiscountPolicy> 에서 맞는 정책을 찾아 쓰는 방식도 가능하다 — 앞의 Validator 목록과 같은 결이다.
변하는 단계가 하나로 격리됐다면, 상속으로 골격에 묶기보다 이렇게 전략을 주입하는 편이 더 유연하고 테스트도 쉽다. 템플릿 메서드가 정당해지는 건 변하는 훅이 둘 이상이라 부모 상태를 함께 나눠 써야 할 때, 혹은 프레임워크의 base class 확장처럼 상속이 강제될 때다.
“확장성을 고려했다”는 말의 깊이는, 상속과 컴포지션 사이에서 결합의 대가를 계산해 고를 수 있는가에서 갈린다.
추상화의 완성도는 인터페이스의 ‘이름’에서 드러난다
그리고 마지막, 가장 조용하지만 가장 확실하게 실력을 드러내는 지점 — 네이밍이다.
인터페이스를 선언했다고 추상화가 완성되는 게 아니다. 그 안에 구현체 용어가 새어 들어가면, 겉모습만 추상이고 속은 특정 구현에 묶여 있는 것이다. OAuth2 소셜 로그인이 이 함정을 가장 선명하게 보여준다.
같은 “사용자 고유 식별자”라는 개념을, 구글과 카카오는 이름도 위치도 타입도 다르게 표현한다. 구글(OIDC)은 ID 토큰 안의 sub 클레임을 쓰고, 이는 발급자 내에서 유일하며 재할당되지 않는 안정 식별자다(이메일은 이 용도로 쓰면 안 된다). 카카오는 응답 최상위의 id, 즉 회원번호를 쓰고 이메일·프로필 같은 값은 그 아래 kakao_account에 중첩한다. 이 차이를 그대로 도메인에 끌고 오면 이런 이름이 태어난다.
// 구현체 용어가 새어 든 이름 — 신규 provider가 추가될 때 무너진다
data class SocialUser(
val googleSub: String?,
val kakaoId: Long?,
)
provider가 하나 더 붙는 순간 필드가 하나 더 늘고, 이 타입을 쓰는 모든 곳이 nullable 지옥에 빠진다. 추상화가 샌 것이다. 한 발 물러서서, 특정 업체 스펙을 따라가지 말고 중립적이면서 실제 의미를 정확히 담는 이름을 쓰면 확장이 열린다.
data class OAuthPrincipal(
val provider: OAuthProvider, // GOOGLE, KAKAO, APPLE …
val providerUserId: String, // google=sub, kakao=id(회원번호)
val email: String?,
)
providerUserId라는 이름은 어느 업체에도 종속되지 않으면서 “제공자가 부여한 사용자 식별자”라는 의미를 정확히 담는다. 이름 하나가 경계를 긋고, 그 경계가 다음 provider를 받아들일 여지를 만든다. 면접관이 마지막에 확인하는 건 이것이다 — 이 사람이 구현의 언어로 사고하는가, 도메인의 언어로 사고하는가.
확장성은 코드가 아니라 경계와 이름에서 드러난다
그래서 면접에서 “확장성을 고려했다”고 말하는 순간, 면접관이 확인하는 건 패턴의 암기가 아니다.
변하는 축을 식별해 냈는가, 그 축을 구체 구현에 의존하지 않는 계약으로 선언했는가, 상속과 컴포지션 중 결합의 대가를 계산하고 골랐는가, 그리고 그 계약에 업체 용어가 새지 않는 중립적인 이름을 붙였는가. 확장성의 진위는 실행 코드의 화려함이 아니라, 인터페이스의 경계와 이름이라는 조용한 곳에서 드러난다.
결국 확장성이란 기능을 더 많이 얹는 능력이 아니라, 새 요구가 와도 기존 코드를 덜 고치게 만드는 능력이다. 그리고 그 능력은 추상화에서 나온다 — 변하는 축을 찾아 구체 구현에 기대지 않는 계약으로 선언하고, 결합의 대가를 따져 상속보다 컴포지션을 먼저 택하며, 그 계약에 특정 업체의 색이 묻지 않는 이름을 붙이는 것. 확장성 있는 설계란 결국 이 과정을 습관처럼 반복하는 일이다.
핵심 요약 (40줄)
- 면접에서 “확장성을 고려했다”는 말은 흔하지만, 말 자체는 설계 역량을 증명하지 못한다.
- 경험 있는 면접관은 확장성이라는 주장을 추상화에 대한 이해로 검증한다.
- 확장성의 본질은 기능을 많이 연결하는 것이 아니라 기존 코드를 덜 고치는 데 있다.
- 새 요구가 들어와도 기존 코드를 최소한만 수정하고 새 코드를 추가할 수 있어야 한다.
- 이 구조는 OCP(개방-폐쇄 원칙), 즉 확장에는 열려 있고 변경에는 닫힌 상태와 맞닿아 있다.
- 추상화는 무언가를 막연하게 감추는 기법이 아니다.
- 추상화는 변하는 축을 식별하고 그 축을 중립적인 계약으로 선언하는 작업이다.
- 결제 검증처럼 사업 성장에 따라 계속 늘어나는 요건은 변하는 축의 좋은 예다.
- 검증 요건을 서비스 내부의
if문으로 쌓으면 요건마다 핵심 서비스를 수정해야 한다. Validator<T>인터페이스를 도입하면 검증 규칙을 별도 구현체로 분리할 수 있다.- 결제 서비스는 구체 검증 구현체가 아니라 검증 계약의 목록만 주입받아 실행한다.
- 새로운 검증 요건은 빈 하나를 추가하는 방식으로 도입할 수 있다.
- 이때 결제 생성 서비스는 한 줄도 수정하지 않아도 된다.
- 로그인 provider, 결제 PG, 배송 업체, CRM 연동에도 동일한 추상화 원칙을 적용할 수 있다.
- 템플릿 메서드 패턴 역시 고정된 알고리즘 골격과 가변적인 훅을 분리한다.
- 하지만 템플릿 메서드는 상속을 사용하므로 부모와 자식 사이의 결합이 강해진다.
- 부모 골격이 바뀌면 모든 자식 구현이 영향을 받고 테스트에도 부모 구조가 따라온다.
- 변하는 단계가 하나라면 상속보다 전략 인터페이스를 주입하는 편이 더 유연하다.
DiscountPolicy를 주입하면 쿠폰별 할인 계산만 독립적으로 교체할 수 있다.- 템플릿 메서드는 여러 훅이 부모 상태를 공유하거나 상속이 프레임워크에 의해 강제될 때 더 적합하다.
- 확장성 있는 설계자는 상속과 컴포지션 중 하나를 무조건 선택하지 않는다.
- 그는 각 방식의 결합 비용과 변경 파급효과를 계산해 선택한다.
- 인터페이스를 선언했다고 해서 추상화가 자동으로 완성되는 것은 아니다.
- 인터페이스 이름에 특정 구현체나 업체의 용어가 들어가면 추상화 경계가 새어 나간다.
- OAuth2 소셜 로그인은 provider별 명칭 차이가 도메인에 침투하기 쉬운 사례다.
googleSub와kakaoId를 하나의 도메인 타입에 넣으면 provider가 늘 때 필드와 nullable 값이 계속 증가한다.- 구현체 중심의 이름은 새로운 provider를 받아들이기 어려운 구조를 만든다.
providerUserId처럼 도메인의 의미를 정확히 담는 중립적인 이름은 provider 확장을 가능하게 한다.- 구글의
sub와 카카오의id는 서로 다르지만 도메인에서는 제공자 사용자 식별자로 통합할 수 있다. - 좋은 이름은 구현과 도메인의 경계를 긋는 설계 도구다.
- 면접관은 지원자가 구현의 언어와 도메인의 언어를 구분해 사고하는지 확인한다.
- 패턴을 알고 있는지보다 변하는 축을 찾아냈는지가 더 중요하다.
- 계약이 구체 구현에 의존하지 않는지 역시 확장성의 핵심 검증 대상이다.
- 상속과 컴포지션의 선택은 결합 방식에 대한 판단력을 보여준다.
- 인터페이스 이름에 업체 용어가 새지 않는지는 추상화의 완성도를 보여준다.
- 화려한 아키텍처 다이어그램만으로는 확장 가능한 설계를 증명할 수 없다.
- 확장성은 실행 코드의 규모보다 인터페이스의 경계에서 드러난다.
- 확장성은 기능을 더 많이 얹는 능력이 아니라 변경을 국소화하는 능력이다.
- 이를 위해 변하는 축을 계약으로 만들고 결합의 대가를 계산해야 한다.
- 확장성 있는 설계는 추상화, 컴포지션, 중립적인 네이밍을 반복적으로 습관화한 결과다.
관련 자료
- 원문 — Greg Lee’s Lab
- OCP(개방-폐쇄 원칙), 추상화, 의존성 주입(Dependency Injection)
- 전략 패턴(Strategy Pattern)과 템플릿 메서드 패턴(Template Method Pattern)