10장은 상속의 두 함정에서 끝났다 — 취약한 기반 클래스, 그리고 조합에 대한 무력함. 세금과 심야 할인을 함께 적용하려는 순간 TaxableNightlyDiscountPhone 같은 클래스가 조합 수만큼 필요해진다. 이 장의 답은 합성(composition) 이다. 상속이 “무엇을 물려받는가(is-a)“라면 합성은 “무엇을 가지고 협력하는가(has-a)“다. 물려받는 대신 인터페이스 뒤의 객체를 가지고, 그 객체를 런타임에 갈아 끼우고 겹겹이 감싼다. 상속의 컴파일 시점 결합이 실행 시점 조립으로 바뀐다.
상속 폭발에서 합성으로
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
Phone은 RatePolicy 하나만 안다. 그 뒤에 무엇이 몇 겹 감싸여 있는지는 모른다. TaxablePolicy는 next가 RegularPolicy인지 또 다른 데코레이터인지 모른 채, 그저 RatePolicy에게 위임하고 결과에 세금을 얹는다. 모두가 같은 인터페이스로 협력하므로 조합이 클래스 수를 늘리지 않는다 — 기본 정책 m개와 부가 정책 n개면 클래스는 m+n개인데 만들 수 있는 조합은 곱절로 늘어난다. 상속이었다면 조합 수만큼 클래스가 필요했다.
판단 기준: “부모의 구현을 물려받아야 하는가, 아니면 인터페이스 뒤의 협력자를 갈아 끼우고 싶은가.” 후자라면 합성이다. 합성은 구현이 아니라 인터페이스(공개된 것)에만 의존하므로 취약한 기반 클래스 문제가 없다. 함정: 합성은 위임 호출이 한 겹 늘고, 객체를 조립하는 코드가 어딘가 필요하다(9장의 팩토리·DI가 여기서 쓰인다). 그럼에도 조합과 변경 앞에서 상속보다 거의 언제나 유리하다 — “상속보다 합성”이 기본값인 이유다.
정리
- 상속은 정책 조합 앞에서 클래스가 폭발한다. 합성은 인터페이스 뒤의 객체를 갈아 끼워 이를 없앤다.
- 요금 계산을
RatePolicy로 뽑고,Phone은 그것을 가진다. 계산은 정책에 위임한다. - 기본 정책은
BasicRatePolicy를 상속하고, 부가 정책은AdditionalRatePolicy데코레이터로 다른 정책을 감싼다. - 조립은 런타임에 생성자로 겹쳐 쌓는다. 감싼 순서가 적용 순서이며, 클래스 수는 조합이 아니라 합으로 는다.
다음장으로 12장