10장은 상속의 두 함정에서 끝났다 — 취약한 기반 클래스, 그리고 조합에 대한 무력함. 세금과 심야 할인을 함께 적용하려는 순간 TaxableNightlyDiscountPhone 같은 클래스가 조합 수만큼 필요해진다. 이 장의 답은 합성(composition) 이다. 상속이 “무엇을 물려받는가(is-a)“라면 합성은 “무엇을 가지고 협력하는가(has-a)“다. 물려받는 대신 인터페이스 뒤의 객체를 가지고, 그 객체를 런타임에 갈아 끼우고 겹겹이 감싼다. 상속의 컴파일 시점 결합이 실행 시점 조립으로 바뀐다.

상속을 합성으로 바꾸는 일반 절차

구체 코드로 들어가기 전에, 상속을 합성으로 옮기는 기계적인 절차를 세워 두면 이후 모든 예제가 같은 틀로 읽힌다. 절차는 세 단계다.

  1. 상속 관계를 인스턴스 변수로 바꾼다. class Child extends Parent에서 extends Parent를 지우고, Child 안에 private Parent parent; 필드를 둔다. “물려받는” 관계가 “가지는” 관계로 바뀐다.
  2. 부모의 기능이 필요하면 그 필드에 위임(포워딩)한다. 자식이 부모의 메서드를 그냥 쓰던 자리에서, 이제 parent.method()를 호출하도록 바꾼다.
  3. 밖을 향한 인터페이스를 자식이 직접 정의한다. 무엇을 노출하고 무엇을 감출지 자식이 고른다 — 부모의 전체 인터페이스를 강제로 물려받지 않는다.
// before — 상속
public class Child extends Parent {
    public void doWork() { helper(); } // 부모의 helper 를 물려받아 쓴다
}
 
// after — 합성
public class Child {
    private Parent parent = new Parent(); // 1) 상속을 인스턴스 변수로
    public void doWork() { parent.helper(); } // 2) 필요한 기능은 위임
    // 3) parent 의 나머지 메서드는 노출하지 않는다 — 인터페이스를 자식이 고른다
}

판단 기준: 이 세 단계는 상속의 “전부 물려받음”을 “필요한 것만 골라 씀”으로 바꾼다. 그 대가로 위임 코드(포워딩 메서드)가 늘지만, 부모의 구현 세부에 대한 의존이 인터페이스에 대한 의존으로 좁혀진다. 함정: 포워딩이 부담스러워 부모의 모든 메서드를 기계적으로 노출하면(자바 Stack을 합성으로 감싸며 Vector의 모든 메서드를 그대로 뚫으면) 합성의 이점이 사라진다 — 감출 것은 감춰야 합성이다.

상속 폭발에서 합성으로

Refactoring Step 상속 폭발 — 정책 조합마다 클래스가 하나씩 필요하다
1/3
public class Phone { /* 일반 요금 */ }public class NightlyDiscountPhone extends Phone { /* 심야 할인 */ }// 여기에 "세금"을 부가 정책으로 더하면 — 조합마다 클래스가 생긴다public class TaxableRegularPhone extends Phone { }public class TaxableNightlyDiscountPhone extends NightlyDiscountPhone { }// "기본요금 할인"까지 더하면public class RateDiscountableRegularPhone extends Phone { }public class RateDiscountableNightlyDiscountPhone extends NightlyDiscountPhone { }public class TaxableAndRateDiscountableRegularPhone extends TaxableRegularPhone { }// ... 기본 정책 m개 × 부가 정책 조합 2^n = 클래스 폭발

계약을 이루는 나머지 조각들

스텝 플레이어는 골격을 보여 준다. 실제 구현체들은 각 추상 메서드 하나씩만 채우면 끝난다. 기본 정책 둘과 부가 정책 둘, 그리고 Phone이 이 장이 남기는 최종 계약이다.

// 기본 정책 — BasicRatePolicy 를 상속해 통화당 요금만 정의한다
public class RegularPolicy extends BasicRatePolicy {
    private Money amount;
    private Duration seconds;
 
    @Override
    protected Money calculateCallFee(Call call) {
        return amount.times(call.getDuration().getSeconds() / seconds.getSeconds());
    }
}
 
public class NightlyDiscountPolicy extends BasicRatePolicy {
    private Money nightlyAmount;
    private Money regularAmount;
    private Duration seconds;
 
    @Override
    protected Money calculateCallFee(Call call) {
        if (call.getFrom().getHour() >= 22)
            return nightlyAmount.times(call.getDuration().getSeconds() / seconds.getSeconds());
        return regularAmount.times(call.getDuration().getSeconds() / seconds.getSeconds());
    }
}
 
// 부가 정책 — AdditionalRatePolicy 를 상속해 '얹는 몫'만 정의한다
public class TaxablePolicy extends AdditionalRatePolicy {
    private double taxRate;
 
    @Override
    protected Money afterCalculated(Money fee) {
        return fee.plus(fee.times(taxRate));
    }
}
 
public class RateDiscountablePolicy extends AdditionalRatePolicy {
    private Money discountAmount;
 
    @Override
    protected Money afterCalculated(Money fee) {
        return fee.minus(discountAmount);
    }
}

이제 정책을 런타임에 조립한다. 상속에서 클래스 하나였을 조합이, 생성자 인자로 감싸는 한 줄이 된다.

// 일반 요금 + 기본요금 할인 + 세금 — 감싸는 순서가 곧 적용 순서다
Phone phone = new Phone(
    new TaxablePolicy(0.05,
        new RateDiscountablePolicy(Money.wons(1000),
            new RegularPolicy(Money.wons(10), Duration.ofSeconds(10)))));

왜 합성이 이기는가

flowchart LR
    Phone -->|ratePolicy| TaxablePolicy
    TaxablePolicy -->|next| RateDiscountablePolicy
    RateDiscountablePolicy -->|next| RegularPolicy
    subgraph RatePolicy 인터페이스
        TaxablePolicy
        RateDiscountablePolicy
        RegularPolicy
    end

PhoneRatePolicy 하나만 안다. 그 뒤에 무엇이 몇 겹 감싸여 있는지는 모른다. TaxablePolicynextRegularPolicy인지 또 다른 데코레이터인지 모른 채, 그저 RatePolicy에게 위임하고 결과에 세금을 얹는다. 모두가 같은 인터페이스로 협력하므로 조합이 클래스 수를 늘리지 않는다 — 기본 정책 m개와 부가 정책 n개면 클래스는 m+n개인데 만들 수 있는 조합은 곱절로 늘어난다. 상속이었다면 조합 수만큼 클래스가 필요했다.

판단 기준: “부모의 구현을 물려받아야 하는가, 아니면 인터페이스 뒤의 협력자를 갈아 끼우고 싶은가.” 후자라면 합성이다. 합성은 구현이 아니라 인터페이스(공개된 것)에만 의존하므로 취약한 기반 클래스 문제가 없다. 함정: 합성은 위임 호출이 한 겹 늘고, 객체를 조립하는 코드가 어딘가 필요하다(9장의 팩토리·DI가 여기서 쓰인다). 그럼에도 조합과 변경 앞에서 상속보다 거의 언제나 유리하다 — “상속보다 합성”이 기본값인 이유다.

클래스 폭발의 산술

“합성은 클래스가 합으로, 상속은 곱으로 는다”는 말을 숫자로 확인하자. 기본 정책이 3가지(일반, 심야, 고정), 부가 정책이 2가지(세금, 기본요금 할인)라 하자. 부가 정책은 켜거나 끌 수 있고, 둘을 함께 적용할 수도 있으니 부가 조합은 2^2 = 4가지(없음, 세금만, 할인만, 둘 다)다.

상속으로 모든 조합을 클래스로 만들면 3 × 4 = 12개의 클래스가 필요하다. RegularPhone, TaxableRegularPhone, RateDiscountableRegularPhone, TaxableAndRateDiscountableRegularPhone, 그리고 심야·고정에 대해 같은 것을 반복 — 부가 정책이 하나 더 늘면 3 × 2^3 = 24개로 두 배가 된다. 부가 정책 n개마다 클래스가 지수로 폭발한다.

// 상속: 3 × 2^2 = 12 클래스, 부가 정책 하나 추가 시 24, 그다음 48 …
public class TaxableAndRateDiscountableNightlyDiscountPhone
        extends TaxableNightlyDiscountPhone { ... } // 이런 클래스가 12개

합성이라면 기본 정책 3개 + 부가 정책 2개 = 5개 클래스로 끝이다. 조합은 클래스가 아니라 런타임 객체 조립으로 만든다.

// 합성: 3 + 2 = 5 클래스, 부가 정책 추가 시 6, 7 … 선형으로만 는다
new TaxablePolicy(0.05,
    new RateDiscountablePolicy(Money.wons(1000),
        new NightlyDiscountPolicy(...))); // "심야 + 할인 + 세금" 조합을 조립으로

판단 기준: 조합 가능한 정책이 늘어날 가능성이 있다면 상속은 처음부터 부적합하다 — 3 × 2^n의 지수 곡선을 사람이 유지보수할 수 없다. 합성의 m + n 선형 비용이 유일하게 지속 가능한 선택이다.

10장의 세 취약함을 합성으로 되푼다

10장에서 상속이 깨뜨린 세 사례 — InstrumentedHashSet, Stack extends Vector, Properties extends Hashtable — 가 합성에서 어떻게 안전해지는지 하나씩 본다. 공통 처방은 같다. 상속을 인스턴스 변수로 바꾸고, 노출할 것만 위임한다.

// 1) InstrumentedHashSet — 이중 계수가 사라진다
public class InstrumentedSet<E> {
    private final Set<E> set = new HashSet<>(); // 상속 대신 '가진다'
    private int addCount = 0;
 
    public boolean add(E e) {
        addCount++;
        return set.add(e);       // set 의 내부 self-use 와 무관하다
    }
 
    public boolean addAll(Collection<? extends E> c) {
        addCount += c.size();
        return set.addAll(c);    // set.addAll 이 내부에서 add 를 부르든 말든
    }                            // 그 add 는 우리 것이 아니라 HashSet 의 것 — 이중 계수 없음
    public int getAddCount() { return addCount; }
}

핵심은 set.addAll이 내부에서 add를 부르더라도, 그 addHashSet 자신의 add 이지 우리 InstrumentedSet.add가 아니라는 것이다. 상속이었다면 동적 바인딩으로 우리 것이 불렸지만, 합성에서는 set이 자기 메서드를 부를 뿐 우리에게 되돌아오지 않는다. 부모의 self-use 패턴이 우리에게 새지 않으므로 이중 계수가 원천적으로 불가능하다.

// 2) Stack — Vector 의 위험한 메서드를 아예 노출하지 않는다
public class Stack<E> {
    private final List<E> elements = new ArrayList<>(); // Vector 를 물려받지 않는다
 
    public void push(E e) { elements.add(e); }
    public E pop() { return elements.remove(elements.size() - 1); }
    // add(int, E), remove(int) 같은 임의 위치 메서드는 위임하지 않는다 → 노출 안 됨
}
 
// 3) Properties — String 규약을 자식이 강제한다
public class Properties {
    private final Map<String, String> map = new HashMap<>();
 
    public String setProperty(String key, String value) { return map.put(key, value); }
    public String getProperty(String key) { return map.get(key); }
    // put(Object, Object) 이 없으므로 정수 키/값은 컴파일 단계에서 막힌다
}

Stackpush/pop만 위임하고 add(int, E)를 뚫지 않았으므로 LIFO 규약을 깰 방법이 없다. PropertiessetProperty(String, String)만 노출하므로 put("key", 42)는 컴파일조차 안 된다. 판단 기준: 합성이 안전한 근본 이유는 인터페이스를 자식이 선택하기 때문이다. 상속은 부모의 인터페이스를 강제로 전부 물려받지만, 합성은 감춰야 할 위험한 메서드를 위임 목록에서 빼면 그만이다. 함정: 이 안정성은 “노출할 것만 위임한다”를 지킬 때만 얻는다. IDE의 “delegate methods”로 부모의 모든 메서드를 기계적으로 뚫어 버리면 상속과 다를 바 없어진다.

믹스인 — 상속과 합성 사이

상속은 컴파일 시점에 굳고, 합성은 런타임에 조립된다. 그 사이에 믹스인(mixin) 이라는 제3의 선택지가 있다. 믹스인은 여러 클래스에 걸쳐 기능을 “섞어 넣는” 코드 조각으로, 상속처럼 컴파일 시점에 결합하되 여러 개를 자유롭게 쌓을 수 있다. 스칼라의 trait가 대표적이다.

// 스칼라 trait — 기본 요금제에 부가 정책을 '섞어' 넣는다
abstract class BasicRatePolicy { def calculateFee(phone: Phone): Money }
 
trait TaxablePolicy extends BasicRatePolicy {
  override def calculateFee(phone: Phone): Money = {
    val fee = super.calculateFee(phone) // super 가 '섞인 순서상 앞'을 가리킨다
    fee + fee * 0.05
  }
}
 
// 필요한 정책만 골라 섞는다 — 조합마다 클래스를 만들지 않는다
class RegularPhone extends BasicRatePolicy with RateDiscountablePolicy with TaxablePolicy

여기서 super는 상속 계층의 고정된 부모가 아니라, 믹스인이 섞인 순서상 바로 앞의 것을 가리킨다(선형화). 그래서 데코레이터가 런타임에 하던 “감싼 순서대로 위임”을 컴파일 시점에 선언적으로 표현한다. 판단 기준: 조합을 런타임에 바꿀 필요가 없고 컴파일 시점에 고정해도 된다면, 믹스인은 데코레이터의 조립 코드 없이 같은 조합 표현력을 준다. 함정: 자바에는 믹스인이 없다(디폴트 메서드가 부분적으로 흉내 낼 뿐 상태를 못 가진다). 그래서 자바에서 조합 문제의 실용적 답은 이 장의 합성·데코레이터다. 믹스인은 “상속의 코드 재사용 + 합성의 조합 자유”를 언어 차원에서 노린 설계라는 개념으로 알아 두면 된다.

정리

  • 상속을 합성으로 바꾸는 절차: 상속 관계를 인스턴스 변수로 → 필요한 기능은 위임 → 노출할 인터페이스는 자식이 선택.
  • 상속은 조합에서 m × 2^n로 클래스가 폭발한다(기본 3 × 부가 2 = 12). 합성은 m + n(=5) 선형으로 끝난다.
  • 10장의 세 취약함이 합성에서 사라진다: InstrumentedSet은 부모의 self-use가 새지 않아 이중 계수 없음, Stack·Properties는 위험한 메서드를 위임하지 않아 규약이 안전하다.
  • 요금 계산을 RatePolicy로 뽑아 Phone가지고, 기본 정책은 BasicRatePolicy, 부가 정책은 AdditionalRatePolicy 데코레이터로 감싼다. 조립은 런타임 생성자로 겹쳐 쌓고, 감싼 순서가 적용 순서다.
  • 믹스인(스칼라 trait)은 상속의 재사용과 합성의 조합 자유를 언어 차원에서 노린 제3의 길이지만, 자바의 실용적 답은 합성·데코레이터다.

다음장으로 12장