본문으로 건너뛰기
안동민 개발노트 아이콘

안동민 개발노트

본문 시작
8장 : 상속과 다형성

상속보다 조합을 선택하기

코드 재사용만을 위해 상속하면 하위 클래스가 상위 구현 세부 사항에 강하게 묶입니다.

조합은 필요한 객체를 필드로 가지고 공개 동작을 위임해 협력 관계를 만듭니다.

실행 중 구현을 교체하거나 테스트 대역을 넣기도 쉽습니다.

하위 타입 관계가 거짓인데도 extends를 쓰면 상위 API가 불필요하게 노출됩니다.

이 절에서는 is-a와 has-a 질문으로 두 설계를 비교합니다.


is-a가 참일 때만 상속을 검토한다

모든 하위 객체가 상위 타입의 의미를 지켜야 합니다.

Stack이 Vector의 모든 변경 연산을 자연스럽게 제공하지 못하는 사례처럼 단순 구현 재사용은 충분한 이유가 아닙니다.


조합은 필요한 동작만 바깥에 공개한다

내부 객체가 가진 많은 메서드 가운데 현재 클래스의 책임에 맞는 것만 위임합니다.

내부 구현 타입을 바꿔도 외부 계약을 유지할 수 있습니다.

CompositionStrategy.java에서는 서비스가 계산 규칙을 상속받지 않고 전략 객체로 보유합니다. 서비스의 정체성은 유지한 채 규칙만 바꿀 수 있는지가 조합의 이점을 판단하는 관찰점입니다.

price -> ... 문법은 함수형 인터페이스 구현을 짧게 만드는 람다이며 14장에서 자세히 다룹니다. 지금은 Discount의 apply(int) 메서드 하나를 구현한 객체를 Checkout 생성자에 전달한다고 읽으면 됩니다.

CompositionStrategy.java
public class CompositionStrategy {
    interface Discount {
        int apply(int price);
    }

    static final class Checkout {
        private final Discount discount;
        Checkout(Discount discount) { this.discount = discount; }
        int total(int price) { return discount.apply(price); }
    }

    public static void main(String[] args) {
        Checkout regular = new Checkout(price -> price);
        Checkout event = new Checkout(price -> price * 80 / 100);
        System.out.println(regular.total(10_000));
        System.out.println(event.total(10_000));
    }
}

조합한 객체는 내부 전략을 사용하지만 전략의 모든 메서드를 바깥에 노출하지 않습니다. 호출자는 완성된 서비스의 동작만 보므로 내부 구현을 바꾸어도 공개 API와 상속 계층을 함께 흔들 필요가 없습니다.

10000
8000

Checkout 코드는 바꾸지 않고 생성자에 전달한 Discount 전략만 교체했습니다.


전략 객체로 변화 방향을 분리한다

계산 방식이나 출력 방식처럼 자주 바뀌는 동작을 인터페이스 필드로 받으면 클래스 계층을 늘리지 않고 교체할 수 있습니다.

같은 서비스에 할인 전략 두 개를 차례로 주입해 결과만 비교해 보세요. 조건문을 서비스 안에 계속 추가하지 않고 새 전략 클래스를 만드는 방식은 변화가 잦은 규칙을 독립적으로 교체하고 시험하게 해 줍니다.

비교 1
// 잘못된 is-a 관계
class PriceCalculator { int total(int price) { return price; } }
class DiscountedCheckout extends PriceCalculator {
    @Override int total(int price) { return price * 80 / 100; }
}
비교 2
// has-a 관계는 필요한 역할만 보유한다.
final class Notifier {
    private final java.util.function.Consumer<String> sender;
    Notifier(java.util.function.Consumer<String> sender) { this.sender = sender; }
    void notify(String message) { sender.accept(message); }
}

상속은 타입 관계를, 조합은 협력 관계를 표현하며 재사용은 그 선택의 결과일 뿐 출발점이 아닙니다.