→ 변경에 유연한 코드가 좋다는 관점 하에 객체 합성이 클래스 상속보다 좋은 방법이다.
상속과 합성은 코드를 재사용한다는 점에서 같으나 재사용의 대상이 다르다.
상속을 합성으로 변경하면 구현에 대한 의존은 인터페이스에 대한 의존으로 변경 가능하다.
→ 즉, 클래스 간의 높은 결합도를 객체 간의 낮은 결합도로 대체 가능하다.
💡 상속은 부모의 내부구조를 알아야 하므로 화이트박스 재사용에 해당하며, 합성은 인터페이스만 알면 되므로 블랙박스 재사용에 해당함.
10장에서 등장한 Properties, Stack의 불필요한 인터페이스 상속 문제를 해결하기 위해서
→ 상속 관계를 합성 관계로 변경한다.
클라이언트는 Hashtable 오퍼레이션을 직접 사용하지 않게 된다.
Vector, Stack도 마찬가지이다.
즉, 합성을 통해 불필요한 오퍼레이션이 퍼블릭 오퍼레이션에 스며드는 것을 방지한다.
Set, InstrumentedHashSet의 메서드 오버라이딩 오작용 문제를 해결하기 위해서…
HashSet에 대한 구현 결합도는 제거하면서도 퍼블릭 인터페이스는 그대로 상속받을 수 있는 방법은?
→ 상위 인터페이스를 구현하여 내부에선 합성으로 노출시킬 행위를 정의한다.
10장의 PersonalPlaylist 동시 수정 문제를 합성으로 대체해도 동시 수정 문제를 곧바로 해결할 수 없다.
그러나, 상속보다는 합성이 좋다. 그 이유는 파급효과를 캡슐화할 수 있기 때문이다.
상속으로 인해 결합도가 높아지면 코드 수정에 필요한 작업량이 과도하게 늘어나는 경우가 있다.
예를 들어 작은 기능을 조합하여 더 큰 기능을 수행할 시
→ 합성 사용 시 상속으로 인한 클래스 증가 및 중복 코드 문제를 간단히 해결할 수 있다.
부가 정책이라는 요구사항이 추가됐다.
상속을 이용해서 기본 정책과 부가 정책을 구현한다.
기본 정책은 Phone 추상 클래스를 루트로 삼는 기존의 상속 계층을 그대로 이용할 것이다.
결합도를 낮추기 위해서 자식 클래스가 부모 클래스의 메서드를 호출하지 않도록 부모 클래스에 추상 메서드를 제공한다.
그러나 추상 메서드를 제공하더라도 단일 상속으로 인한 코드 중복을 막기는 어렵다.
아래와 같은 코드에서 TaxableRegularPhone
과 TaxableNightlyDiscountPhone
은 부모는 다르지만 내부 구현은 거의 같다.
기본 요금 할인정책을 추가한 뒤에도 RateDiscountableRegularPhone
과 RateDiscountableNightlyDiscountPhone
은 마찬가지로 상속받는 부모의 이름만 다를 뿐 코드 자체는 거의 같다.
부가 정책은 자유롭게 조합할 수 있어야 하며 적용 순서 역시 임의로 결정할 수 있어야 한다.
상속을 이용한 해결 방법은 모든 가능한 조합 별로 자식 클래스를 하나씩 추가하는 것이다.
이는 복잡성보다도 큰 문제가 있다. 새로운 정책을 추가하기 위해서 불필요하게 많은 수의 클래스를 상속 계층 안에 추가해야 하기 때문이다.
💡 클래스 폭발 문제 or 조합의 폭발 문제
상속의 남용으로 하나의 기능을 추가하기 위해 필요 이상으로 많은 수의 클래스를 추가해야 하는 경우
-> 이 문제를 해결할 수 있는 최선의 방법은 상속을 포기하는 것이다.
상속 관계는 컴파일타임에 결정되고 고정되므로 코드 실행 중 변경할 수 없다. 따라서 여러 기능을 조합해야 하는 설계에 상속을 이용하면 모든 조합 가능한 경우 별로 클래스를 추가한다 (클래스 폭발 문제)
→ 합성을 사용하면 컴파일타임 관계를 런타임 관계로 변경하여 문제를 해결할 수 있다.
합성을 사용하면 구현 시점에 정책들의 관계를 고정시킬 필요가 없으며 정책들의 관계를 유연하게 변경할 수 있다.
상속이 조합 결과를 개별 클래스 안으로 밀어 넣는 방법이라면 합성은 조합을 구성하는 요소들을 개별 클래스로 구현한 후 실행 시점에 인스턴스를 조립하는 방법을 사용하는 것이다.
컴파일 의존성에 속박되지 않고 다양한 방식의 런타임 의존성을 구성할 수 있다는 것이 합성이 제공하는 가장 커다란 장점이다.
가장 먼저, 각 정책을 별도의 클래스로 구현한다. 분리된 정책들을 연결할 수 있도록 합성 관계를 이용해서 구조를 개선한다.
아래의 RatePolicy는 기본 정책과 부가 정책을 포괄하는 인터페이스이다.
public interface RatePolicy {
Money calculateFee(Phone phone);
}
기본 정책을 구성하는 일반 요금제, 심야 할인 요금제는 개별 요금을 계산하는 방식을 제외한 전체 처리 로직이 거의 동일하다. 이를 담을 추상클래스를 추가한다.
public abstract class BasicRatePolicy implements RatePolicy {
@Overried
public Money calculateFee(Phone phone) {
Money result = Money.ZERO;
for(Call call : phone.getCalls()) {
result.plus(calculateCallFee(call));
}
return result;
}
protected abstract Money calculateCallFee(Call call);
}
(이후 코드 생략)
Phone의 경우처럼 다양한 종류의 객체와 협력하기 위해 합성 관계를 사용하는 경우 합성하는 객체의 타입을 인터페이스나 추상 클래스로 선언하고 의존성 주입을 사용해 런타임에 필요한 객체를 설정할 수 있도록 구현하는 것이 일반적이다.
public class Phone {
private **RatePolicy ratePolicy;** // 이게 합성
private List<Call> calls = new ArrayList<>();
...
}
다음과 같이 사용하고 싶은 요금제 규칙에 따라 인스턴스를 합성하면 된다
new Phone(new RegularPolicy(Money.wons(10), Duration.ofSeconds(10));
new Phone(new NightlyDiscoountPolicy(Money.wons(10), Money.wons(10), Duration.ofSeconds(10));
부가 정책은 다음 두가지 제약을 기반으로 구현해야 한다.
public abstact class AdditionalRatePolicy implements RatePolicy {
private RatePolicy next;
public AdditionalRatePolicy(RatePolicy next) {
this.next = next;
}
@Override
public Money calculateFee(Phone phone) {
Money fee = next.calculateFee(phone);
return afterCalculated(fee);
}
abstract protected Money afterCalculated(Money fee);
}
앞서 기본 정책과 부가 정책 클래스의 구현이 완료되었으니 구현된 정책들을 합성해본다.
원하는 정책의 인스턴스를 생성한 뒤에 의존성 주입을 통해서 다른 정책의 인스턴스에 전달하기만 하면 된다.
합성 방식의 구현에서 순서를 바꾸거나 동일한 정책을 다른 기본 정책에 적용하는 것은 굉장히 간단하다.
Phone phone = new Phone(
new TaxablePolicy(0.05,
new RateDiscoountablePolicy(Money.wons(1000),
new RegularPolicy(...)));
합성의 진가는 새로운 클래스를 추가하거나 수정하는 시점이 되면 비로소 알 수 있다.
상속은 코드 재사용을 위한 우아한 해결책은 아니다.
상속은 부모 클래스의 세부적인 구현에 자식 클래스를 강하게 결합시키기 때문에 코드 진화를 방해한다.
합성은 코드를 재사용하면서도 건전한 결합도를 유지할 수 있다. 상속이 구현을 재사용하는 데 비해 합성은 객체의 인터페이스를 재사용한다.
그렇다면 상속은 사용하면 안 되는 것인가? 상속을 사용해야 하는 경우는 언제인가?
→ 상속을 구현 상속과 인터페이스 상속 두 가지로 나눠야 한다. (이번 장은 구현 상속에만 해당한다)