상속 vs 조합: 취약한 계층 대신 유연한 협업을 고르는 법
Dev
마지막 업데이트

상속 vs 조합: 취약한 계층 대신 유연한 협업을 고르는 법


객체 지향을 처음 배우면 상속이 가장 “정석적인” 도구처럼 보입니다. 부모 클래스를 만들고, 자식 클래스가 이를 확장하면서 코드를 재사용하니 그럴듯해 보이죠. 그래서 많은 입문자가 상속을 꽤 빨리 좋아하게 됩니다.

그런데 실무 코드로 들어가면 자주 듣는 말이 하나 있습니다. 바로 상속보다 조합을 선호하라는 말입니다. 이 말은 상속이 나쁘다는 뜻이 아닙니다. 상속은 관계가 생각보다 강해서, 코드가 자주 바뀌는 환경에서는 부담이 빨리 커질 수 있다는 뜻에 가깝습니다.

이 글에서는 아래 내용을 다룹니다.

  • 상속과 조합이 실제로 어떻게 다른지
  • 상속 구조가 왜 생각보다 빨리 취약해지는지
  • 조합이 왜 변화에 더 잘 버티는지
  • 그래도 상속이 잘 맞는 상황은 언제인지

짧게 요약하면 이렇습니다. 상속은 자식 타입이 부모 계약을 정말 자연스럽게 만족할 때 강하고, 조합은 여러 행동을 섞고 바꾸고 실험해야 할 때 훨씬 안전한 기본값이 됩니다.

상속이란 무엇인가

상속은 기존 타입을 확장해서 새로운 타입을 만드는 방식입니다.

예를 들면:

  • Animal
  • Dog extends Animal

처럼 자식 타입이 부모의 데이터나 메서드를 이어받습니다. 이 방식은 “정말로 is-a 관계가 성립하는가”가 핵심입니다.

상속은 단순 재사용이 아닙니다. 아래 약속이 함께 들어갑니다.

  • 자식은 부모가 기대되는 자리에 들어가도 자연스럽게 동작해야 하고
  • 부모가 바뀌면 자식도 영향을 받을 수 있으며
  • 자식의 모양이 부모 계약에 묶입니다

즉 상속은 편리할 수 있지만, 결코 가벼운 관계는 아닙니다.

조합이란 무엇인가

조합은 한 객체가 필요한 기능을 다른 객체들과 협력하면서 구성하는 방식입니다.

즉:

  • “이 클래스는 어떤 부모를 확장할까?”

보다

  • “이 객체는 어떤 협력 객체를 사용할까?”

를 먼저 생각하게 만듭니다.

예를 들면 한 객체가 아래 역할을 조합할 수 있습니다.

  • 전송 방식
  • 재시도 정책
  • 로깅 도구

이렇게 설계하면 각 역할을 따로 바꿀 수 있어서, 변화가 한 곳에 갇히고 결합도가 낮아지는 경우가 많습니다.

왜 실무에서는 조합이 더 안전한 경우가 많을까

상속은 시작할 때는 무척 단순해 보입니다. 하지만 요구사항이 조금만 구체화되어도 바로 부담이 커집니다.

  • 이메일만 재시도가 필요하다
  • SMS만 속도 제한이 필요하다
  • 특정 알림만 감사 로그를 남겨야 한다
  • 어떤 알림은 공통 전처리 과정을 건너뛰어야 한다

이 순간 부모 클래스 하나가 너무 많은 역할을 떠안기 시작합니다.

  • 공통 데이터
  • 공통 흐름
  • 오버라이드 지점
  • 서브타입 제약

하나의 추상화가 이 모든 걸 다 책임지려 하면 구조가 빠르게 흔들립니다. 조합은 보통 이런 압력을 더 잘 견딥니다. 역할을 잘게 나누고 필요한 것만 섞어서 쓰면 되기 때문입니다.

실전 예시: 상속으로 알림 흐름을 만들었을 때

처음에는 아래처럼 시작할 수 있습니다.

abstract class Notifier {
  send(message: string): void {
    this.beforeSend(message);
    this.deliver(message);
    this.afterSend(message);
  }

  protected beforeSend(message: string): void {
    console.log('prepare', message);
  }

  protected afterSend(message: string): void {
    console.log('done');
  }

  protected abstract deliver(message: string): void;
}

class EmailNotifier extends Notifier {
  protected deliver(message: string): void {
    console.log('email', message);
  }
}

class SmsNotifier extends Notifier {
  protected deliver(message: string): void {
    console.log('sms', message);
  }
}

처음엔 깔끔해 보입니다. 하지만 금방 추가 요구가 붙습니다.

  • 이메일은 실패 시 재시도해야 한다
  • SMS는 속도 제한이 필요하다
  • 일부 알림은 감사 로그를 남겨야 한다
  • 어떤 알림은 beforeSend 자체가 필요 없다

그럼 계층은 금방 이렇게 퍼집니다.

class RetryingEmailNotifier extends EmailNotifier {
  protected override afterSend(message: string): void {
    console.log('retry on failure for', message);
  }
}

class AuditedRetryingEmailNotifier extends RetryingEmailNotifier {
  protected override beforeSend(message: string): void {
    console.log('audit start', message);
  }
}

여기서 상속의 불편함이 드러납니다.

  • 기능 조합이 곧 서브클래스 조합이 됩니다
  • 부모 클래스의 훅이 예외 케이스를 버티기 위한 장치로 늘어납니다
  • 부모 흐름을 조금만 바꿔도 여러 자식이 같이 흔들립니다

이제 이 구조는 “진짜 계층”을 표현하는 게 아니라, 기능 조합표를 억지로 상속 트리에 욱여넣는 상태에 가깝습니다.

리팩터링: 역할을 조합해서 유연하게 만들기

이럴 때는 역할을 분리하는 편이 더 좋습니다.

interface MessageTransport {
  send(message: string): Promise<void>;
}

interface RetryPolicy {
  run(task: () => Promise<void>): Promise<void>;
}

interface Logger {
  info(message: string): void;
}

class FixedRetryPolicy implements RetryPolicy {
  constructor(private attempts: number) {}

  async run(task: () => Promise<void>): Promise<void> {
    for (let tryCount = 1; tryCount <= this.attempts; tryCount += 1) {
      try {
        await task();
        return;
      } catch (error) {
        if (tryCount === this.attempts) throw error;
      }
    }
  }
}

class EmailTransport implements MessageTransport {
  async send(message: string): Promise<void> {
    console.log('email', message);
  }
}

class NotificationSender {
  constructor(
    private transport: MessageTransport,
    private retryPolicy: RetryPolicy,
    private logger: Logger
  ) {}

  async send(message: string): Promise<void> {
    this.logger.info('sending notification');
    await this.retryPolicy.run(() => this.transport.send(message));
  }
}

이 구조에서는 변화가 훨씬 다루기 쉬워집니다.

  • 전송 수단만 바꾸고 싶으면 transport만 교체하면 되고
  • 재시도 정책만 바꾸고 싶으면 retryPolicy만 교체하면 되며
  • 로깅 방식이 달라도 다른 객체를 주입하면 됩니다

SMS를 쓰고 싶으면 SmsTransport를 넣으면 됩니다. 재시도가 필요 없으면 NoRetryPolicy를 넣으면 됩니다. 기능 조합이 상속 계층이 아니라 객체 연결에서 해결됩니다.

이게 조합의 가장 큰 장점입니다. 변화를 부모-자식 관계에 밀어 넣지 않고, 역할 단위로 분리해서 다룰 수 있습니다.

상속이 잘 맞는 경우

상속은 아래 조건에서 여전히 좋은 선택입니다.

  • 자식이 정말 부모의 특수한 형태라고 말할 수 있고
  • 부모 계약이 분명하고 자주 흔들리지 않으며
  • 공통 동작이 “대체로 비슷함”이 아니라 정말 공통이고
  • 계층이 얕고 이해하기 쉬운 경우

프레임워크의 라이프사이클 훅처럼 확장 지점이 분명한 경우에는 상속이 자연스럽게 맞기도 합니다. 도메인 모델에서도 관계가 아주 정직하다면 상속이 깔끔할 수 있습니다.

중요한 질문은 “이렇게 재사용할 수 있나?”가 아니라 “이 자식은 정말 이 부모 계약 아래 있어도 자연스러운가?”입니다.

조합이 더 안전한 경우

반대로 아래 상황이라면 조합이 기본값으로 더 안전합니다.

  • 환경이나 설정에 따라 행동이 달라질 수 있을 때
  • 같은 흐름에 여러 정책 조합이 필요할 때
  • 테스트에서 구현을 쉽게 바꿔 끼우고 싶을 때
  • 핵심이 정체성보다 협력에 있을 때
  • 기능이 늘수록 서브클래스 수가 같이 늘어날 때

그래서 조합은 아래 개념들과 특히 잘 맞습니다.

  • 전략 패턴
  • 의존성 주입
  • 레포지토리 경계
  • 데코레이터식 기능 래핑

이 패턴들은 모두 역할을 분리하고 교체 가능하게 두는 편이 유리합니다.

자주 하는 실수

1. 재사용이 필요하면 일단 상속부터 떠올리기

아주 흔한 입문자 습관입니다. 하지만 재사용 자체만으로는 상속을 정당화하기 어렵습니다. 재사용은 협력 객체, 전략, 헬퍼 함수로도 충분히 얻을 수 있습니다.

2. 조합은 클래스 수가 늘어나니 무조건 나쁜 설계라고 생각하기

조합은 작은 조각을 늘릴 수 있습니다. 하지만 그 조각들이 각자 역할이 분명하다면, 거대한 부모 클래스 하나보다 훨씬 바꾸기 쉬운 구조가 됩니다.

3. 상속을 피하려고 억지로 모든 계층을 부정하기

상속은 금지 대상이 아닙니다. 관계가 정말 정직하다면 상속이 더 읽기 쉬울 수 있습니다. 핵심은 자동 반사가 아니라 맥락 판단입니다.

4. 추상 베이스 클래스로 상속 문제를 가리기

부모 클래스에 protected 훅, 플래그, 오버라이드 지점이 계속 늘어난다면 이미 추상화가 약해졌을 가능성이 큽니다.

선택 전에 보는 체크리스트

상속을 고르기 전에 아래를 먼저 물어보면 좋습니다.

  • 이건 정말 is-a 관계인가, 아니면 행동 공유인가?
  • 부모가 바뀌면 여러 자식이 함께 흔들릴 위험이 큰가?
  • 앞으로 기능 조합이 늘어날 가능성이 있는가?
  • 협력 객체로 분리하면 테스트가 더 쉬워지는가?

질문의 답이 변화, 조합, 교체 가능성 쪽으로 기울면 조합이 보통 더 안전한 선택입니다.

FAQ

Q. 상속보다 조합을 선호하라는 말은 상속을 쓰지 말라는 뜻인가요?

아닙니다. 상속은 더 강한 결합과 더 무거운 약속을 만들기 때문에, 그만큼 더 엄격하게 판단하라는 뜻에 가깝습니다.

Q. 왜 조합이 테스트에 유리한가요?

전체 계층을 만들거나 오버라이드하지 않고도, 작은 협력 객체만 가짜 구현으로 바꿔 끼울 수 있기 때문입니다.

Q. 조합이 항상 더 단순한가요?

처음 보기엔 아닐 수 있습니다. 하지만 자주 바뀌는 코드에서는 시간이 갈수록 훨씬 단순하게 유지되는 경우가 많습니다.

먼저 읽어볼 가이드

검색 유입이 많은 핵심 글부터 이어서 보세요.

광고