GoF가 책 서두에 제시한 2대 설계 원칙 중 하나 — "클래스 상속보다 객체 합성을 선호하라".
(다른 하나는 "구현이 아니라 인터페이스에 프로그래밍하라".)
왜 상속이 위험한가
- ① 컴파일 타임에 고정된다 — 런타임에 바꿀 수 없다
- ② 캡슐화를 깬다 — 하위가 상위의 구현 세부에 의존한다
- ③ 조합이 폭발한다 — 축이 둘이면 클래스가 곱해진다
- ④ 상위 변경이 모든 하위를 깨뜨린다 (fragile base class)
// ✗ 상속 — 커피 옵션 조합마다 클래스가 필요하다
class CoffeeWithMilk extends Coffee {}
class CoffeeWithMilkAndSugar extends CoffeeWithMilk {} // 조합 폭발
// ✓ 합성 — 런타임에 감싼다 (Decorator)
Beverage b = new Sugar(new Milk(new Coffee()));
패턴들이 이 원칙의 구현이다
- Decorator — 상속 대신 감싸서 기능 추가
- Strategy — 상속 대신 알고리즘 객체 교체
- Bridge — 상속 대신 구현 객체 참조 → 곱셈을 덧셈으로
- Composite — 상속 대신 자식 목록 보유
반대로 상속을 쓰는 패턴도 있다
- Template Method — 골격을 상위에 고정하고 훅만 하위가 채운다
- 변하는 부분이 '메서드 일부' 로 작을 때는 상속이 간결하다
상속이 맞는 경우
-
진짜 is-a 관계다 (하위가 상위를 완전히 대체 가능 — 리스코프 치환)
-
상위가 상속을 위해 설계·문서화되어 있다
-
같은 패키지 안이라 상위 변경을 통제할 수 있다
-
판별 "B 는 A 다" 가 어색하면 상속이 아니라 합성이다
- Stack 이 Vector 를 상속한 것이 대표적 실패 사례 (Stack 은 Vector 가 아니다)
면접 함정
- ❌ "상속은 나쁘다" → 나쁜 게 아니라 결합이 강하다. Template Method처럼 적합한 자리가 있다.
- ❌ 코드 재사용 목적의 상속 → 재사용은 합성으로 한다. 상속은 타입 계층을 만들 때 쓴다.
상속이 캡슐화를 깨는 구체적 예
class CountingSet<E> extends HashSet<E> {
private int addCount;
public boolean add(E e) { addCount++; return super.add(e); }
public boolean addAll(Collection<? extends E> c) {
addCount += c.size();
return super.addAll(c); // 💥 내부에서 add() 를 호출한다
}
}
new CountingSet<>().addAll(List.of(1,2,3)); // addCount 가 6 이 된다
상위 클래스의 구현 세부(addAll이 add를 부른다)에 의존하게 됐다. 그 구현이 바뀌면 하위가 조용히 깨진다.
위임 보일러플레이트
합성의 대가는 위임 코드다 — Set 의 메서드 15개를 전부 넘겨 써야 한다
- 완화 — IDE 의 delegate 생성 · 추상 위임 클래스 재사용(ForwardingSet)
- 코틀린은 by 키워드로 언어가 지원한다
결정 순서
- ① is-a 가 성립하는가? (하위를 상위 자리에 넣어도 되는가) 아니오 → 합성
- ② 상위가 상속을 위해 설계·문서화되었는가? — 아니오 → 합성
- ③ 변하는 축이 둘 이상인가? — 예 → 합성 (Bridge)
- ④ 골격은 같고 일부만 다른가? — 예 → 상속 OK (Template Method)