PHpullh
개발자 면접 준비/언어 기초 인터뷰

언어 기초 인터뷰

객체지향·함수형 기본기 인터뷰 질문

캡슐화, 다형성, 상속과 합성, 불변성, 순수 함수의 선택 기준을 실제 코드 설계 관점에서 정리했습니다.

패턴 이름을 외운 답은 금방 들킵니다

객체지향 면접에서 “SOLID를 설명해 보세요”라는 질문에 다섯 원칙을 순서대로 읊는 답은 거의 점수를 얻지 못합니다. 검색으로 나오는 문장을 재생한 것과 구분되지 않기 때문입니다. 실제로 보는 것은 코드 앞에서의 판단입니다. 이 코드에서 무엇이 자주 바뀌는지, 그 변경이 어디까지 번지는지, 번지지 않게 하려면 경계를 어디에 그어야 하는지입니다.

그래서 이 페이지는 원칙 목록 대신 리팩터링 한 건을 끝까지 따라갑니다. 흔한 형태의 코드에서 출발해, 무엇이 문제인지 진단하고, 경계를 옮기고, 그 대가로 무엇을 잃었는지까지 말합니다.

연습 문제: 결제 수단이 늘어날 때마다 이 함수가 커집니다

주문 금액을 계산하는 코드에 결제 수단별 처리가 들어 있습니다. 수단이 추가될 때마다 이 함수를 열어 분기를 하나 더 넣습니다. 이걸 어떻게 개선하겠습니까.

JAVA · 분기 누적에서 다형성으로

// BEFORE — 수단이 늘 때마다 이 파일을 연다
class Checkout {
    Receipt pay(Order order, String method) {
        if ("CARD".equals(method)) {
            if (order.amount() < 1000) throw new IllegalArgumentException("최소 금액 미달");
            return cardGateway.charge(order.amount());
        } else if ("POINT".equals(method)) {
            if (user.points() < order.amount()) throw new IllegalStateException("포인트 부족");
            return pointLedger.deduct(order.amount());
        } else if ("TRANSFER".equals(method)) {
            return bank.request(order.amount(), order.buyerId());
        }
        throw new IllegalArgumentException("알 수 없는 결제 수단: " + method);
    }
}

// AFTER — 수단마다 규칙과 데이터를 같은 곳에 둔다
interface PaymentMethod {
    boolean supports(Order order);        // 이 주문에 쓸 수 있는가
    Receipt pay(Order order);             // 실제 결제
}

final class CardPayment implements PaymentMethod {
    private static final long MIN_AMOUNT = 1000L;
    private final CardGateway gateway;

    CardPayment(CardGateway gateway) { this.gateway = gateway; }

    @Override public boolean supports(Order order) {
        return order.amount() >= MIN_AMOUNT;
    }
    @Override public Receipt pay(Order order) {
        if (!supports(order)) throw new PaymentRejected("카드 최소 금액 미달");
        return gateway.charge(order.amount());
    }
}

final class Checkout {
    private final Map<PaymentType, PaymentMethod> methods;

    Checkout(Map<PaymentType, PaymentMethod> methods) { this.methods = methods; }

    Receipt pay(Order order, PaymentType type) {
        PaymentMethod method = methods.get(type);
        if (method == null) throw new PaymentRejected("지원하지 않는 결제 수단");
        return method.pay(order);         // 분기 대신 위임
    }
}

약한 답

“if 문이 길어지니까 인터페이스를 만들고 각 결제 수단을 클래스로 분리하겠습니다. 다형성을 쓰면 확장에 열려 있고 변경에 닫혀 있게 됩니다. 전략 패턴입니다.”

강한 답

“먼저 무엇이 문제인지 정확히 짚겠습니다. 이 코드가 나쁜 이유는 if 문이 길어서가 아닙니다. 결제 수단을 하나 추가하는 변경이 Checkout이라는 전혀 다른 책임을 가진 클래스를 건드리게 만든다는 점, 그리고 카드의 최소 금액 규칙이 카드와 아무 상관 없는 파일에 흩어져 있다는 점입니다. 즉 변경의 이유가 여러 개인 클래스가 되었습니다.

그래서 경계를 결제 수단 단위로 다시 긋겠습니다. PaymentMethod 인터페이스를 두고 수단마다 구현을 만들되, 그 수단에만 해당하는 검증 규칙과 상수를 구현 안으로 함께 옮깁니다. 규칙과 데이터가 같은 곳에 있는 것이 캡슐화의 실질이고, getter만 늘어놓는 것은 캡슐화가 아닙니다.

Checkout은 이제 어떤 구현을 쓸지 고르고 위임하는 일만 합니다. 구현 목록은 생성 시점에 주입받으므로 Checkout은 구체 클래스 이름을 전혀 모르고, 테스트에서 가짜 구현을 넣기도 쉽습니다.

대가도 말씀드리겠습니다. 파일 수가 늘고, 결제 전체 흐름을 파악하려면 한 파일이 아니라 여러 파일을 봐야 합니다. 그래서 이 리팩터링은 수단이 두세 개이고 더 늘 계획이 없다면 하지 않는 편이 낫습니다. 지금은 수단이 계속 추가된다는 것이 관측된 사실이라 값을 지불할 만합니다. 그리고 문자열 대신 열거 타입을 쓴 것도 의도적입니다. 오타로 인한 실패를 실행 시점이 아니라 컴파일 시점으로 옮깁니다.”

두 답의 실제 차이

결론은 같습니다. 인터페이스와 구현으로 나누자는 것입니다. 차이는 네 군데입니다. 첫째, 약한 답은 증상을 문제로 지목했습니다. “if가 길다”는 증상이고 “변경의 이유가 여럿이다”가 원인입니다. 원인을 짚으면 어떤 축으로 나눌지가 자동으로 정해지지만, 증상을 짚으면 나누는 축이 임의가 됩니다. 둘째, 약한 답은 원칙 이름을 근거로 썼습니다. “확장에 열려 있고”는 결과에 붙이는 이름이지 이유가 아닙니다. 셋째, 약한 답은 규칙을 어디로 옮길지 말하지 않았습니다. 클래스만 나누고 검증 로직은 여전히 호출부에 남기는 리팩터링을 실제로 많이 봅니다. 그러면 파일만 늘고 응집도는 그대로입니다. 넷째, 약한 답은 대가를 말하지 않았습니다. 모든 분리는 비용이 있고, 그 비용을 인지하고 있다는 신호가 없으면 “배운 대로 항상 나누는 사람”으로 읽힙니다.

TIP

설계 질문에서 원칙 이름은 마지막에 붙이십시오. 먼저 “이 변경이 여기까지 번지는 것이 문제입니다”라고 구체적으로 말한 뒤, 필요하면 “흔히 단일 책임이라고 부르는 것입니다”를 덧붙이는 순서가 훨씬 강하게 들립니다. 순서를 뒤집으면 암기로 읽힙니다.

상속과 합성, 판단 기준

“상속보다 합성”은 슬로건으로는 맞지만 근거 없이 반복하면 역시 암기로 들립니다. 판단 기준을 두 가지로 정리해 두는 편이 낫습니다.

첫째는 치환 가능성입니다. 하위 타입은 상위 타입을 기대하는 모든 자리에 들어가도 프로그램이 여전히 옳아야 합니다. 부모의 메서드를 오버라이드하면서 “이 경우에는 예외를 던집니다”라거나 “이 메서드는 지원하지 않습니다”를 넣는 순간 이 조건이 깨집니다. 깨진 상속은 호출자가 타입을 검사하게 만들고, 그러면 다형성으로 없앤 분기가 다른 곳에서 되살아납니다.

둘째는 결합의 방향입니다. 상속은 부모의 구현 세부사항에 자식을 묶습니다. 부모가 내부에서 자기 메서드를 호출하는 방식만 바꿔도 자식이 조용히 깨질 수 있습니다. 합성은 인터페이스만 보므로 이런 일이 없습니다. 대신 위임 코드를 직접 써야 합니다.

그래서 실무 기준은 이렇게 정리됩니다. 진짜 개념적 분류이고 하위 타입이 상위 타입의 계약을 온전히 지킬 때만 상속을 쓰고, 나머지는 인터페이스와 합성을 씁니다. 언어별 문법 차이는 언어별 클래스 비교에서 확인할 수 있고, 자바와 타입스크립트 관점은 자바 OOP와 타입스크립트 OOP 가이드에 있습니다.

인터페이스를 잘 나누는 감각

인터페이스는 구현 쪽이 아니라 사용하는 쪽의 필요로 나눠야 합니다. 구현 클래스에 있는 메서드를 전부 인터페이스로 올리면 그건 인터페이스가 아니라 구현의 그림자입니다. 반대로 호출자가 실제로 쓰는 두세 개만 노출하면, 구현을 갈아치울 때 고칠 곳이 그만큼 줄어듭니다.

또 하나는 인터페이스가 구현 세부사항을 흘리지 않게 하는 것입니다. 메서드 이름에 특정 기술 용어가 들어가거나, 반환 타입이 특정 라이브러리 타입이면 그 인터페이스는 이미 그 구현에 묶인 것입니다. 인터페이스를 만들어 놓고도 교체가 안 되는 코드는 대부분 이 이유입니다.

WARNING

구현이 하나뿐인데 인터페이스를 미리 만들어 두는 것은 대개 손해입니다. 파일과 간접 참조만 늘고 유연성은 실제로 생기지 않습니다. 두 번째 구현이 나타났을 때, 혹은 테스트를 위해 반드시 대체가 필요할 때 만드는 편이 낫습니다. 면접에서 이 판단을 말하면 균형 감각이 있다는 신호가 됩니다.

이어지는 질문들

결제 수단이 두 개뿐이라도 이렇게 나누시겠습니까?

나누지 않겠다고 답하고 조건을 붙이는 것이 맞습니다. 분기 두 개는 한 화면에서 읽히고, 파일 하나가 여러 개보다 이해가 빠릅니다. 나누는 시점은 세 번째 분기가 들어올 때이거나, 그전이라도 각 분기가 자기만의 상태와 규칙을 갖기 시작할 때입니다. 무조건 나누겠다고 답하면 오히려 감점 요인입니다.

새 수단을 추가할 때 정말 기존 코드를 안 고치나요?

구현 클래스는 안 고칩니다. 다만 어딘가에서는 그 구현을 목록에 등록해야 하므로 조립 지점 한 곳은 바뀝니다. 정직하게 “분기가 흩어지지 않고 조립 지점 한 곳으로 모입니다”라고 답해야 합니다. 아무것도 안 바뀐다고 답하면 실제 코드를 안 써 본 것으로 읽힙니다.

모든 수단에 공통으로 필요한 처리는 어디에 두나요?

추상 클래스로 올려 상속하는 방법과, 공통 처리를 감싸는 데코레이터를 두는 방법이 있습니다. 로깅이나 재시도처럼 결제 자체와 무관한 관심사라면 데코레이터 쪽이 낫습니다. 상속으로 올리면 나중에 “이 수단만 예외”가 생겼을 때 치환 가능성이 깨집니다.

캡슐화가 getter와 setter를 만드는 것인가요?

아닙니다. 모든 필드에 대해 기계적으로 접근자를 만들면 필드를 공개한 것과 사실상 같습니다. 캡슐화의 실질은 상태를 바꾸는 방법을 도메인 동작으로 제한하는 것입니다. 잔액 필드를 직접 대입하게 두는 대신 충전과 결제 메서드만 노출하면, 잔액이 음수가 되는 상태를 애초에 만들 수 없습니다.

이 설계를 어떻게 테스트하나요?

각 구현은 게이트웨이를 가짜로 넣고 단독으로 테스트합니다. Checkout은 올바른 구현에 위임하는지와 미지원 수단에서 실패하는지만 확인하면 되고, 결제 규칙을 다시 테스트할 필요가 없습니다. 테스트가 이렇게 쪼개진다는 사실 자체가 경계를 잘 그었다는 증거입니다. 관련 실습은 자바 테스트 가이드에서 이어 볼 수 있습니다.

이 영역에서 실제로 자주 나오는 실수

첫째, 원칙 이름으로 근거를 대신하는 것입니다. “SRP 위반입니다”는 판정이지 설명이 아닙니다. 무엇이 바뀔 때 어디가 함께 바뀌는지를 말해야 설명입니다.

둘째, 클래스를 나눴지만 로직은 그대로 호출부에 남기는 것입니다. 데이터만 옮기고 규칙을 안 옮기면 응집도는 변하지 않고 파일만 늘어납니다. 이걸 리팩터링이라고 부르면 오히려 판단력이 없어 보입니다.

셋째, 상속을 코드 재사용 수단으로 쓰는 것입니다. “공통 코드가 있으니 부모 클래스로 뺐습니다”는 상속의 목적이 아닙니다. 공통 코드를 모으려면 별도 협력 객체로 빼서 양쪽이 갖다 쓰는 편이 안전합니다.

넷째, 추상화를 미리 과하게 만드는 것입니다. 아직 오지 않은 요구를 위해 인터페이스와 팩토리를 겹겹이 쌓아 두면, 정작 요구가 왔을 때 예상과 다른 축이라 전부 다시 만듭니다. 실제로 발생한 변경을 근거로 나누는 편이 훨씬 정확합니다.

다섯째, 언어의 특성을 무시하는 것입니다. 인터페이스를 명시적으로 선언하는 언어와 구조적으로 판단하는 언어, 클래스 대신 함수와 클로저로 같은 목적을 달성하는 언어에서 답이 달라집니다. “객체지향이 항상 정답”이 아니라 “이 문제에서 상태와 규칙이 함께 움직여서 객체가 맞습니다”라고 말해야 합니다.

말하기 전 점검

  • 무엇이 자주 바뀌는지 먼저 지목했는가
  • 그 변경이 어디까지 번지는지 구체적으로 말했는가
  • 나누는 축이 변경의 축과 일치하는가
  • 규칙과 데이터를 같은 곳으로 옮겼는가
  • 상속을 쓴다면 치환 가능성이 지켜지는지 확인했는가
  • 인터페이스를 사용하는 쪽의 필요로 좁혔는가
  • 이 분리가 지불하는 비용을 스스로 말했는가
  • 지금 나누지 않는 편이 나은 조건도 제시했는가

연습 방법

자기가 쓴 코드에서 최근 3개월간 가장 자주 고친 파일을 하나 고르십시오. 그 파일이 왜 자주 열렸는지 이유를 나열해 보면 대개 서로 다른 이유가 두세 개 나옵니다. 그 이유들이 곧 나눠야 할 축입니다. 축을 정한 뒤 실제로 나눠 보고, 나눈 다음에 코드를 처음 보는 사람이 흐름을 따라갈 수 있는지 스스로 확인하십시오. 흐름이 오히려 어려워졌다면 그 분리는 되돌리는 것이 맞습니다.

면접 답변 훈련으로는 위 결제 예제를 조건만 바꿔 다시 설명해 보십시오. 수단이 두 개뿐이라면, 수단마다 환불 정책이 다르다면, 한 주문에 두 수단을 섞어 쓸 수 있다면 설계가 어떻게 달라지는지 말로 답해 보면 됩니다. 패턴 카탈로그는 자바 디자인 패턴에서, 전체 학습 경로는 심층 가이드에서 이어 갈 수 있습니다.

관련 언어 학습으로 복습하기