오브젝트 실전 강의 교재¶
11장 — 합성과 유연한 설계¶
원서: 조영호 『오브젝트』 대상: Java/Spring 백엔드 입문~중급 수강생 형식: 개념 → 비유 → 예시 → 핵심 교훈 → 현업 예제 → 함정 → 체크리스트 → 퀴즈(정답 분리)
0. 이 장을 시작하기 전에¶
0.1 학습 목표¶
- 10장 상속 함정을 합성 으로 해결.
- 핸드폰 과금 시스템의 상속 조합 폭증 → 합성으로 해결.
- 믹스인 (Mixin) — 상속과 합성의 중간.
- Effective Java Item 18·리팩터링 12.10 의 살아있는 사례.
0.2 큰 그림 — 상속 vs 합성¶
[ 상속 (extends) ] [ 합성 (composition) ]
컴파일타임 고정 런타임 교체
부모-자식 강 결합 필드로 들고 위임
1:1 (자바) N:M (여러 위임 객체)
정적 동적
→ "is-a" → "has-a"
비유 — "혈연 vs 계약"
상속 = 혈연 (못 끊음, 부모 변화에 영향). 합성 = 계약 (필요 시 갈아탐, 독립). 안정적 부모면 상속, 변화 잦으면 합성.
0.3 현업에서 왜 중요한가¶
- Effective Java Item 18 ("상속보다 컴포지션") 의 가장 중요한 OO 원칙.
- 도메인 모델링의 대부분이 합성 — Order has-a Customer, has-a List
. - Strategy 패턴이 합성의 정형.
1. 상속을 합성으로 변경하기¶
1.1 불필요한 인터페이스 상속 문제 — Stack¶
// ❌ Stack extends Vector — Vector 모든 메서드 노출
public class Stack<E> extends Vector<E> { ... }
// ✅ Stack 이 Vector 를 합성
public class Stack<E> {
private final Vector<E> elements = new Vector<>();
public void push(E e) { elements.add(e); }
public E pop() { return elements.remove(elements.size() - 1); }
}
→ Stack 이 필요한 메서드만 노출. LIFO 보장.
1.2 메서드 오버라이딩의 오작용 문제 — InstrumentedHashSet¶
// ❌
public class InstrumentedHashSet<E> extends HashSet<E> {
private int addCount = 0;
@Override public boolean add(E e) { addCount++; return super.add(e); }
@Override public boolean addAll(Collection<? extends E> c) {
addCount += c.size();
return super.addAll(c); // 내부적으로 add 호출 → 중복 카운트
}
}
// ✅ 합성
public class InstrumentedSet<E> implements Set<E> {
private final Set<E> s;
private int addCount = 0;
public InstrumentedSet(Set<E> s) { this.s = s; }
@Override public boolean add(E e) { addCount++; return s.add(e); }
@Override public boolean addAll(Collection<? extends E> c) {
addCount += c.size();
return s.addAll(c); // s 내부 호출은 우리 add 안 부름
}
// 나머지 Set 메서드는 위임만
}
→ 부모 구현 디테일 의존 X. Effective Java Item 18 의 핵심 예제.
1.3 부모-자식 동시 수정 문제 — PersonalPlaylist¶
부모에 새 메서드 추가 시 자식도 같이 봐야 (오버라이드 필요 가능성).
→ 합성은 위임만, 부모 메서드 추가가 자식 영향 X.
2. 상속으로 인한 조합의 폭발¶
2.1 핸드폰 과금 — 기본 정책 + 부가 정책¶
기본 정책 (2종): RegularPhone·NightlyDiscountPhone. 부가 정책 (2종): 세금·기본 요금 할인.
조합: 2 × 2 = 4 자식 클래스.
RegularPhone
RegularPhoneWithTax
RegularPhoneWithBasicDiscount
RegularPhoneWithTaxAndBasicDiscount
NightlyDiscountPhone
NightlyDiscountPhoneWithTax
...
부가 정책 1개 추가 → 자식 클래스 2배 폭증.
2.2 상속으로 기본 정책 구현하기¶
public abstract class Phone { ... }
public class RegularPhone extends Phone { ... }
public class NightlyDiscountPhone extends Phone { ... }
2.3 세금 추가하기 (상속)¶
public class TaxableRegularPhone extends RegularPhone { ... }
public class TaxableNightlyDiscountPhone extends NightlyDiscountPhone { ... }
→ 2 자식 추가.
2.4 기본 요금 할인 추가 (상속)¶
public class RateDiscountableRegularPhone extends RegularPhone { ... }
public class RateDiscountableNightlyDiscountPhone extends NightlyDiscountPhone { ... }
public class TaxableAndRateDiscountableRegularPhone extends TaxableRegularPhone { ... }
public class TaxableAndRateDiscountableNightlyDiscountPhone extends TaxableNightlyDiscountPhone { ... }
→ 4 자식 추가. 폭증.
2.5 중복 코드의 덫¶
각 조합 클래스에 세금 계산·할인 계산 중복. 부가 정책 변경 시 모든 자식 수정.
3. 합성 관계로 변경하기¶
3.1 기본 정책 합성하기¶
public class Phone {
private final RatePolicy ratePolicy; // 합성
public Phone(RatePolicy ratePolicy) { this.ratePolicy = ratePolicy; }
public Money calculateFee() { return ratePolicy.calculateFee(this); }
}
public interface RatePolicy {
Money calculateFee(Phone phone);
}
public class RegularPolicy implements RatePolicy { ... }
public class NightlyDiscountPolicy implements RatePolicy { ... }
3.2 부가 정책 적용하기¶
부가 정책도 RatePolicy 구현 + 다른 정책을 합성:
public abstract class AdditionalRatePolicy implements RatePolicy {
private final RatePolicy next;
public AdditionalRatePolicy(RatePolicy next) { this.next = next; }
@Override
public Money calculateFee(Phone phone) {
Money fee = next.calculateFee(phone); // 다음 정책 위임
return afterCalculated(fee); // 추가 처리
}
protected abstract Money afterCalculated(Money fee);
}
public class TaxablePolicy extends AdditionalRatePolicy {
private final double rate;
@Override
protected Money afterCalculated(Money fee) {
return fee.plus(fee.times(rate)); // 세금 추가
}
}
public class BasicDiscountPolicy extends AdditionalRatePolicy {
private final Money discount;
@Override
protected Money afterCalculated(Money fee) {
return fee.minus(discount);
}
}
3.3 조합 — 데코레이터 패턴¶
Phone phone = new Phone(
new TaxablePolicy(0.05,
new BasicDiscountPolicy(Money.won(1000),
new RegularPolicy(...)
)
)
);
→ 자식 클래스 0. 정책 객체의 체인 으로 모든 조합 표현.
3.4 새 정책 추가¶
새 부가 정책 = 새 AdditionalRatePolicy 클래스 1개. 기존 무변경. OCP 충족.
3.5 객체 합성이 클래스 상속보다 더 좋다¶
| 상속 | 합성 | |
|---|---|---|
| 결합 시점 | 컴파일타임 | 런타임 |
| 결합 정도 | 강 (구현 디테일) | 약 (인터페이스) |
| 새 조합 추가 | 자식 클래스 폭증 | 객체 체인 |
| 정책 교체 | 불가 (객체 새로 생성) | 가능 |
| 다중 결합 | 단일 부모 | N개 합성 |
4. 믹스인 (Mixin)¶
4.1 정의¶
다른 클래스에 행동을 섞어 넣는 메커니즘. 상속과 합성의 중간.
- Java 에는 직접 없음 — interface default method 가 유사.
- Scala 의 trait, Kotlin 의 delegation 이 정형.
4.2 예: Java default method¶
public interface Loggable {
default void log(String msg) {
System.out.println("[" + getClass().getSimpleName() + "] " + msg);
}
}
public class OrderService implements Loggable { ... }
→ Loggable 의 기본 구현을 가져옴 — 다중 상속 제한 우회.
4.3 한계¶
- Java 의 default method 는 필드 못 가짐 — 진짜 믹스인은 아님.
- 다중 default method 충돌 시 명시 해결 필요.
핵심 교훈¶
- 합성 > 상속 — 결합도 낮음·교체 자유·조합 자유.
- 상속의 조합 폭증 = 거의 항상 합성으로 해결.
- 데코레이터 패턴 = 합성의 정형 — 정책 체인으로 모든 조합.
- OCP 충족 — 새 정책 = 새 클래스 1개.
- 믹스인 = 상속·합성의 중간 — Java default method 로 일부 표현.
현업 예제 — Spring 의 합성¶
정책 체인 (인터셉터·필터)¶
// Spring Security FilterChain — 합성 체인
http
.addFilter(new JwtAuthFilter())
.addFilterAfter(new RoleCheckFilter(), JwtAuthFilter.class)
.addFilterAfter(new LoggingFilter(), RoleCheckFilter.class);
→ 새 필터 = 새 클래스, 기존 무변경. 11장 합성 그대로.
Strategy 주입¶
@Service
public class OrderService {
private final DiscountPolicy discountPolicy; // 합성
private final PaymentProcessor paymentProcessor;
// 정책 객체 주입 → 교체 자유
}
→ 합성 + DI = Spring DI 의 핵심.
함정 / 주의¶
- 합성도 과하면 추적 어려움 — 정책 체인이 5단계 넘으면 디버깅 비용.
extends무지성 금지 도그마 — Template Method 같은 정당한 상속은 OK.- 순환 합성 — A → B, B → A 는 의존성 사이클 (악취).
체크리스트¶
- 자식 클래스 폭증 (조합 폭발) 이 있는가 → 합성 검토
- 자식이 부모 구현 디테일에 의존하는가 → 합성으로 격리
- 정책 교체가 런타임에 필요한가 → 합성 (상속은 컴파일타임 고정)
- 데코레이터 패턴이 정책 조합에 적합한가
- Spring AOP 가 더 적합한 횡단 관심사인가 (로깅·트랜잭션)
퀴즈¶
- 합성이 상속보다 좋은 결정적 이유?
- 핸드폰 과금에서 자식 클래스 폭증이 일어난 직접 원인?
- 데코레이터 패턴 이 어떻게 조합 폭증을 해결하는가?
- Effective Java Item 18 ("상속보다 컴포지션") 이 11장과 같은 메시지인 이유?
- 믹스인 이 상속·합성의 중간인 이유?
정답·해설¶
- 런타임 교체 자유 + 약한 결합. 상속은 컴파일타임 고정 + 부모 구현 의존, 합성은 인터페이스에만 의존 + 정책 객체 교체 자유. 결과: OCP·DIP·테스트 용이성 모두 합성 승.
- N × M 조합을 자식 클래스로 표현. 기본 정책 2 × 부가 정책 2 = 4 자식. 부가 정책 1개 추가 시 자식 2배. 상속 = 컴파일타임 고정이라 모든 조합을 미리 만들어야.
- 정책 객체 체인 으로 표현 — 자식 클래스 X.
new Taxable(new Discount(new Regular(...)))같이 런타임 조합. 새 부가 정책 = 새 클래스 1개. OCP. - 상속의 함정 (취약한 기반·구현 의존·조합 폭증) 을 컴포지션으로 회피. EJ Item 18 의 Stack·Properties 사례가 11장의 핸드폰 과금과 같은 구조 — 상속 → 합성 전환.
- 상속처럼 행동을 가져옴 + 합성처럼 다중 가능. Java default method 는 일부 표현 (필드 X). Scala trait, Kotlin delegation 이 정형. 상속 단일성·합성 명시성의 트레이드오프 중간.
다음 장 예고 — 12장: 다형성¶
상속·합성을 통한 코드 재사용을 다뤘으니, 12장은 다형성의 메커니즘 깊이 — 업캐스팅·동적 바인딩·메시지 위임·self vs super. 다형성이 OO 의 강력함을 만드는 이유.