|

상속보다 조합이 더 나은 순간: 객체지향 설계에서 조합을 고르는 기준

상속보다 조합이 더 나은 순간을 객체지향 설계 기준으로 설명하는 대표 이미지
상속과 조합의 차이는 문법보다 변경 비용과 역할 분리에서 더 분명해진다

상속보다 조합이 늘 정답은 아닙니다. 하지만 변경 방향이 자주 갈라지고, 역할을 조립해야 하고, 부모-자식 관계가 자연스럽지 않을 때는 조합이 더 나은 경우가 많습니다. 이번 글에서는 객체지향 설계에서 조합을 고르는 기준을 변경 비용, 역할 재사용, 위임, 치환 가능성, 옵션 조합 관점으로 실무적으로 정리하겠습니다.


오해부터 정리

상속보다 조합이라는 말이 퍼진 이유는 분명합니다. 실무에서 많은 상속이 “같은 코드가 조금 들어 있으니 부모로 빼자”에서 시작되기 때문입니다. 문제는 코드 몇 줄을 아끼려고 만든 상속이 나중에는 더 큰 변경 비용으로 돌아온다는 점입니다.

부모 클래스에 기능이 하나 추가되면 자식 여러 개가 같이 흔들립니다. 자식이 부모의 메서드를 일부만 쓰거나, 어떤 메서드는 막아야 하거나, 부모의 기본 동작을 계속 덮어써야 한다면 이미 신호가 나온 것입니다.

상속이 불편한 이유는 문법이 아니라 변경이 한 계층을 따라 강하게 전파된다는 데 있습니다.


변경 비용

조합이 빛나는 첫 번째 순간은 변경 축이 여러 개인 경우입니다. 예를 들어 알림 기능을 만든다고 해보겠습니다. 처음에는 이메일만 보내면 되지만, 나중에는 슬랙도 붙고, 일부 고객은 SMS도 원할 수 있습니다. 여기에 메시지 포맷 정책까지 달라지면 변화는 두 축으로 갈라집니다.

  • 어떤 채널로 보낼지
  • 어떤 형식으로 보낼지

이런 상황에서 상속으로 풀면 EmailNotifier, SlackNotifier, MarketingEmailNotifier처럼 계층이 빠르게 불어납니다. 변화 축이 곱해지는 순간 상속 구조는 급격히 불편해집니다. 이때는 역할을 쪼개서 조합하는 편이 더 낫습니다.

interface MessageSender {
    void send(String message);
}

interface MessageFormatter {
    String format(String raw);
}

class NotificationService {
    private final MessageSender sender;
    private final MessageFormatter formatter;

    NotificationService(MessageSender sender, MessageFormatter formatter) {
        this.sender = sender;
        this.formatter = formatter;
    }

    void notify(String raw) {
        sender.send(formatter.format(raw));
    }
}

이 구조에서는 채널 변경과 포맷 변경이 서로 덜 엮입니다. 변경 방향이 둘 이상이면 상속보다 조합이 더 안전해질 가능성이 큽니다.


역할 재사용

상속은 코드 재사용에 강해 보이지만, 실무에서 더 중요한 것은 역할 재사용입니다. 결제 서비스에서 핵심 서비스가 진짜 필요한 것은 Stripe 코드 자체가 아니라 “결제를 승인할 수 있는 역할”일 수 있습니다.

interface PaymentGateway {
    void approve(int amount);
}

class PaymentService {
    private final PaymentGateway gateway;

    PaymentService(PaymentGateway gateway) {
        this.gateway = gateway;
    }

    void pay(int amount) {
        gateway.approve(amount);
    }
}

여기서 재사용되는 것은 특정 구현체가 아니라 역할에 의존하는 서비스 구조입니다. 상속은 부모의 내부 구조까지 함께 끌고 오기 쉽지만, 조합은 필요한 역할만 붙여 쓸 수 있습니다.


위임 구조

조합을 쓴다는 말은 결국 다른 객체에게 일을 위임한다는 뜻입니다. 위임이 잘 맞는 순간은 기능이 한 객체의 본질이 아니라 협력 결과일 때입니다. 저장 기능, 자동 저장, 암호화 저장처럼 옵션이 덧붙는 구조가 대표적입니다.

interface Saver {
    void save(String content);
}

class FileSaver implements Saver {
    @Override
    public void save(String content) {
        System.out.println("save file: " + content);
    }
}

class EncryptingSaver implements Saver {
    private final Saver delegate;

    EncryptingSaver(Saver delegate) {
        this.delegate = delegate;
    }

    @Override
    public void save(String content) {
        delegate.save("[encrypted] " + content);
    }
}

이 구조에서는 기능을 겹겹이 붙일 수 있습니다. 조합이 강한 이유는 “한 클래스가 무엇인가”보다 “여러 객체가 함께 무엇을 하게 만들 것인가”에 집중하게 해주기 때문입니다.


치환 가능성

상속이 자연스러운지 보려면 어려운 원칙 이름보다 더 쉬운 질문이 먼저입니다. 부모 자리에 자식을 넣었을 때 기대가 깨지지 않는가? 이 질문에 자주 막히면 상속보다 조합이나 역할 분리가 더 적합할 가능성이 큽니다.

예를 들어 Bird가 fly()를 갖고 있고 Penguin이 Bird를 상속받는데 fly()에서 예외를 던진다면, 문법상 상속은 되더라도 설계상 관계는 어색합니다. 부모가 약속한 동작을 자식이 자연스럽게 이어받지 못하기 때문입니다.

이런 경우는 Bird라는 분류 모델과 Flying이라는 역할을 분리하는 편이 더 낫습니다. is-a 문장이 한국어로 자연스럽다고 해서 코드 상속이 자연스러운 것은 아닙니다.

이 판단은 리스코프 치환 원칙(LSP)은 왜 자주 오해될까 글과도 바로 이어집니다. 상속은 재사용보다 먼저 계약과 기대 가능한 동작을 봐야 합니다.


옵션 조합

조합이 특히 강한 분야는 옵션이 계속 쌓이는 구조입니다. 할인 정책처럼 정액 할인, 정률 할인, 회원 등급, 기간 한정 정책이 붙는 순간 상속 계층은 금방 비대해집니다.

interface DiscountPolicy {
    int discount(int price);
}

class FixedDiscount implements DiscountPolicy {
    @Override
    public int discount(int price) {
        return 1000;
    }
}

class RateDiscount implements DiscountPolicy {
    @Override
    public int discount(int price) {
        return price / 10;
    }
}

class OrderCalculator {
    private final DiscountPolicy discountPolicy;

    OrderCalculator(DiscountPolicy discountPolicy) {
        this.discountPolicy = discountPolicy;
    }

    int calculate(int price) {
        return price - discountPolicy.discount(price);
    }
}

이 구조는 정책을 바꿔 끼우기 쉽고 테스트도 단순합니다. 이런 상황에서는 공통 코드 재사용보다 정책 교체 가능성이 더 큰 가치가 됩니다.


상속이 자연스러운 경우

여기까지 보면 조합이 늘 더 좋아 보일 수 있습니다. 하지만 상속이 더 자연스러운 경우도 분명합니다. 진짜 계층이 있고, 공통 상태와 기본 동작이 있으며, 하위 타입이 그 계약을 무리 없이 이어받는다면 상속이 코드와 개념을 함께 단순하게 만들 수 있습니다.

  • 공통 상태와 공통 흐름이 안정적으로 공유된다
  • 하위 타입이 부모 계약을 자연스럽게 지킨다
  • 큰 처리 골격은 같고 일부 단계만 다르다
  • 프레임워크가 확장 포인트를 상속으로 제공한다

이 지점은 추상 클래스와 인터페이스 차이 글과도 연결됩니다. Oracle의 InheritanceAbstract Classes Compared to Interfaces 문서도, 상속과 추상화 도구를 계약과 공통 골격 관점에서 구분해서 설명합니다.


조합의 비용

조합도 만능은 아닙니다. 객체 수가 늘고, 위임 코드가 생기고, 설계가 잘못되면 구조가 지나치게 잘게 쪼개질 수 있습니다. 조합은 결합을 약하게 만드는 대신 구조를 설계해야 하는 책임을 더 요구합니다.

그래서 단순한 프로그램에서 모든 것을 인터페이스와 래퍼로 쪼개면 오히려 읽기 어려워질 수 있습니다. 중요한 것은 조합 자체가 아니라, 변경 비용을 줄일 만큼 분리 가치가 있는가입니다.


실무 체크리스트

  • 부모와 자식 관계가 개념적으로도 자연스러운가?
  • 부모 자리에 자식을 넣어도 동작 기대가 깨지지 않는가?
  • 변화가 한 축이 아니라 여러 축으로 갈라지는가?
  • 재사용하려는 것이 공통 골격인가, 역할 조합인가?
  • 기능이 계속 옵션처럼 붙고 빠지는 구조인가?
  • 테스트에서 일부 역할만 바꿔 끼우는 이점이 큰가?

이 질문에서 아래쪽 질문에 더 많이 해당한다면 조합 쪽이 유리할 가능성이 큽니다. 반대로 부모-자식 계약과 공통 흐름이 분명하면 상속이 더 단순할 수 있습니다.


정리

상속보다 조합이 더 나은 순간은 분명합니다. 변경 축이 여러 개이고, 역할을 섞어야 하고, 위임이 자연스럽고, 치환 가능성이 애매하고, 옵션 조합이 많을 때입니다. 반대로 안정적인 계층과 공통 골격이 분명하다면 상속이 더 자연스러울 수 있습니다.

결국 좋은 판단 기준은 하나입니다. 이 관계가 진짜 계층인지, 아니면 재사용 욕심 때문에 만든 구조인지를 먼저 보는 것입니다. 같이 읽으면 좋은 글은 객체지향 설계에서 결합도가 중요한 이유, 리스코프 치환 원칙(LSP)은 왜 자주 오해될까, 추상 클래스와 인터페이스 차이, SOLID 원칙, 실무에서는 어떻게 봐야 할까입니다.

함께보면 좋은 글