컴포지션
컴포지션(Composition, 합성/조합)은 객체가 다른 객체를 내부에 들고 있다가 필요한 일을 그 객체에게 위임하는 방식이다.
객체지향에서는 보통 상속과 대비된다.
짧게 말하면 이렇다.
방식 | 뜻 |
|---|---|
상속 | 부모 클래스의 타입 관계와 구현을 물려받는다 |
컴포지션 | 다른 객체를 부품처럼 조립하고 일을 맡긴다 |
상속이 대체로 is-a 관계를 표현한다면, 컴포지션은 has-a, part-of, uses-a 관계를 표현한다.
문장 | 보통 더 자연스러운 설계 |
|---|---|
| 상속 후보 |
| 컴포지션 후보 |
| 컴포지션 후보 |
| 컴포지션 후보 |
다만 is-a와 has-a는 문법 공식이 아니다. 일반적으로 설명을 편하게 하려고 핵심만 남긴 것이지. 공식적인 설명은 아니다.
"A는 B다"라고 말할 수 있어도 실제 코드에서는 컴포지션이 더 나을 때가 많다. 중요한 건 현실 언어의 문장이 아니라 변경 축이다.
용어 구분
이 문서에서 말하는 컴포지션은 기본적으로 객체지향 컴포지션이다. 함수형 프로그래밍에서 말하는 함수 합성, 즉 f(g(x)) 또는 f ∘ g와는 다른 말이다. 둘 다 "작은 것을 엮어 큰 것을 만든다"는 감각은 같지만, 다루는 대상이 다르다.
또 하나 구분할 점이 있다. 컴포지션과 인터페이스 기반 다형성은 같은 개념이 아니다.
이름 | 뜻 | 얻는 것 |
|---|---|---|
구체 객체 컴포지션 |
| 코드 분리, 책임 분리 |
인터페이스 기반 컴포지션 |
| 교체 가능성, 테스트 용이성, 런타임 정책 변경 |
컴포지션 자체는 has-a 구조다. 다형성은 interface, abstract class, trait, function pointer 같은 대체 가능한 계약에서 나온다. 실무에서 "상속보다 컴포지션"이라고 말할 때는 대개 둘을 합친 인터페이스 기반 컴포지션을 가리킨다.
왜 상속보다 컴포지션이라는 말이 나왔나
초기 객체지향에서는 상속이 매우 매력적이었다. 클래스 계층을 만들면 현실 세계 분류도 표현할 수 있고, 부모 클래스의 코드도 재사용할 수 있었다. Animal -> Dog, Vehicle -> Car, Order -> DiscountOrder 같은 구조는 처음에는 자연스럽게 보인다.
문제는 시간이 지나면서 반복적으로 드러났다.
1. 상속은 여러 의미를 한 번에 묶는다
Taivalsaari는 1996년 ACM Computing Surveys 논문에서 상속이 객체지향을 구분하는 중요한 개념이지만, 그 의미와 사용법에 대해 연구자들조차 합의가 잘 되지 않는다고 정리했다.[3] 상속은 코드 재사용, 타입 계층, 개념 분류, 차이 기반 프로그래밍을 동시에 떠안는다.
이 네 가지가 항상 같은 방향을 가리키면 문제가 없다. 하지만 실무에서는 자주 어긋난다.
상속을 쓰는 이유 | 실제로 같이 따라오는 것 |
|---|---|
코드를 재사용하고 싶다 | 타입 관계까지 생긴다 |
도메인 분류를 표현하고 싶다 | 부모 구현에 묶인다 |
다형성을 쓰고 싶다 | 행동 계약을 지켜야 한다 |
일부 기능만 바꾸고 싶다 | override 순서와 내부 호출에 의존한다 |
이게 이 문서의 중심이다. 상속이 나쁘다는 뜻이 아니다. 상속은 너무 많은 의미를 한 문법에 묶는다. 컴포지션은 그중 재사용, 역할 조립, 정책 교체를 타입 계층에서 떼어내는 방법이다.
2. 상속은 캡슐화를 약하게 만들 수 있다
Alan Snyder는 1986년 OOPSLA 논문에서 많은 객체지향 언어가 데이터 추상화를 제공하지만, 상속이 들어오면 캡슐화의 장점이 심하게 약해진다고 지적했다.[1] 자식 클래스는 부모의 protected 필드, 내부 호출 순서, virtual 메서드 호출 타이밍, 생성자 규칙을 알아야 하는 경우가 많다.
즉 상속은 "공개 API만 보고 협력한다"가 아니라 "부모의 내부 사정을 알고 확장한다"가 되기 쉽다.
public abstract class Order
{
// protected는 public은 아니지만, 자식에게 내부 상태를 노출한다.
// 자식 클래스가 이 필드에 의존하기 시작하면 부모 구현을 바꾸기 어려워진다.
protected decimal price;
public decimal FinalPrice()
{
// 부모는 이 순서를 내부 구현이라고 생각할 수 있다.
// 하지만 자식이 CalculateDiscount()를 override하면 이 순서도 사실상 계약이 된다.
return price - CalculateDiscount();
}
protected abstract decimal CalculateDiscount();
}
public sealed class PremiumOrder : Order
{
protected override decimal CalculateDiscount()
{
// 자식은 부모가 price를 언제, 어떤 단위로 채우는지 알아야 한다.
// 이 순간 부모의 내부 표현이 자식 코드에 새어 들어온다.
return price * 0.2m;
}
}
이 예제에서 진짜 문제는 Template Method 자체가 아니다. 문제는 두 가지가 한꺼번에 섞인 것이다.
부모가
protected price로 내부 상태를 자식에게 노출한다.부모의
FinalPrice()가 override 가능한CalculateDiscount()를 호출하므로, 부모의 실행 순서가 사실상 자식과의 계약이 된다.
부모가 price를 Money 값 객체로 바꾸거나, 세금 계산 순서를 바꾸거나, 할인 계산을 다른 단계로 옮기면 자식 클래스들이 영향을 받는다. 상속이 캡슐화를 깨뜨리는 전형적인 지점이다.
3. 하위 타입은 행동까지 지켜야 한다
상속은 단순한 코드 재사용이 아니다. 보통 타입 관계를 만든다. Liskov와 Wing의 1994년 논문은 subtype이 supertype을 대체할 수 있으려면 supertype에 대해 증명된 성질이 subtype에도 유지되어야 한다고 설명한다.[2]
이 말은 실무적으로 이렇게 바뀐다.
자식 클래스는 부모 클래스처럼 보이는 것만으로 충분하지 않다. 부모 클래스가 약속한 행동까지 지켜야 한다.
단, 이건 상속만의 문제가 아니다. IList를 구현한 ReadOnlyList가 Add()에서 예외를 던져도 같은 문제가 생긴다. 인터페이스 구현체도 서브타입이기 때문이다.
그래서 LSP는 "컴포지션이 상속보다 우월하다"의 직접 근거가 아니다. 더 정확한 결론은 이렇다.
상속이든 인터페이스든 subtype 관계를 만들면 행동 계약을 지켜야 한다.
읽기 전용 컬렉션이 필요하다면
IList가 아니라 더 작은IReadOnlyList같은 계약을 노출해야 한다.컴포지션이 도움이 되는 지점은 subtype 관계를 억지로 만들지 않고, 내부 구현을 감춘 채 더 작은 API를 제공할 수 있다는 데 있다.
4. 변화 축이 하나의 상속 트리에 갇힌다
상속 트리는 기본적으로 한 방향의 분류에 강하다. 하지만 실무 객체는 여러 이유로 바뀐다.
결제 예시를 보자.
상속으로 밀어붙이면 Payment, CardPayment, BankTransferPayment,CouponCardPayment, PremiumCouponCardPayment,InternationalPremiumCouponCardPayment처럼 이름이 점점 길어진다.
처음에는 CardPayment와 BankTransferPayment만 있었을 수 있다. 이후 쿠폰, 회원 등급, 국가 정책이 붙으면 클래스 이름이 점점 조합식이 된다. 결제 수단, 할인, 사용자 등급, 국가라는 서로 다른 변화 축이 하나의 상속 트리에 들어간 것이다.
컴포지션은 축을 나눈다.
Payment가 조립하는 역할 | 바뀌는 이유 |
|---|---|
| 카드, 계좌이체, 포인트 같은 결제 수단 |
| 쿠폰, 이벤트, 회원 등급 할인 |
| 일반, 프리미엄, 기업 고객 정책 |
| 국내, 해외, 세금, 통화 정책 |
이러면 결제 수단을 바꾸는 일과 할인 정책을 바꾸는 일이 서로 덜 충돌한다.
GoF가 정리한 방향
GoF의 Design Patterns는 1994년에 "class inheritance"보다 "object composition"을 선호하라는 원칙을 소개했다.[4] 이 조언은 상속을 금지하라는 뜻이 아니다.
더 정확히는 다음에 가깝다.
구현 재사용만을 위해 상속하지 말라.
런타임에 바뀌어야 하는 행동은 객체로 분리하라.
클래스 계층으로 조합 폭발이 생기면 객체 조합으로 축을 나눠라.
부모 클래스 내부 구현을 알아야 한다면 위험 신호로 봐라.
그래서 GoF 패턴 상당수는 컴포지션을 쓴다.
패턴 | 컴포지션이 쓰이는 방식 |
|---|---|
전략 패턴 | 알고리즘을 객체로 빼서 갈아끼운다 |
데코레이터 패턴 | 기존 객체를 감싸서 기능을 덧붙인다 |
어댑터 패턴 | 외부 인터페이스를 내부 인터페이스로 변환한다 |
Bridge | 추상과 구현을 분리해 각각 바꾼다 |
Composite | 부분-전체 관계를 객체 조합으로 표현한다 |
Joshua Bloch도 Effective Java에서 "상속보다 컴포지션을 선호하라"를 별도 항목으로 다룬다.[5] 특히 public class를 상속해서 확장하면 부모의 내부 구현 변경에 자식이 깨지기 쉽다는 점을 강조한다.
컴포지션의 기본 구조
컴포지션은 다음 세 단계로 보면 된다.
변하는 행동을 찾는다.
그 행동을 작은 객체로 뺀다.
본체 객체는 그 객체에게 일을 맡긴다.
기본형은 구체 객체 위임이다.
public sealed class TaxCalculator
{
public decimal Calculate(decimal price)
{
return price * 0.1m;
}
}
public sealed class Order
{
private readonly TaxCalculator taxCalculator;
public Order(TaxCalculator taxCalculator)
{
// Order는 세금 계산식을 직접 품지 않는다.
// 세금 계산 책임을 별도 객체에 맡긴다.
this.taxCalculator = taxCalculator;
}
public decimal TotalPrice(decimal price)
{
return price + taxCalculator.Calculate(price);
}
}
이 구조는 책임을 나눈다. 하지만 TaxCalculator를 다른 구현체로 쉽게 갈아끼우는 다형성은 없다. 교체 가능성까지 필요하면 인터페이스 기반 컴포지션으로 간다.
예를 들어 할인 정책은 주문의 본질이 아니라 주문이 사용하는 정책이다.
// 할인 정책은 "주문이 어떤 할인 규칙을 쓸 것인가"를 표현한다.
// 주문 자체와 할인 알고리즘을 분리하기 위한 인터페이스다.
public interface IDiscountPolicy
{
decimal CalculateDiscount(decimal price);
}
public sealed class NoDiscountPolicy : IDiscountPolicy
{
public decimal CalculateDiscount(decimal price)
{
// 할인 없음도 하나의 정책으로 둔다.
// null 체크나 if 분기를 Order 안에 흩뿌리지 않기 위해서다.
return 0;
}
}
public sealed class RateDiscountPolicy : IDiscountPolicy
{
private readonly decimal rate;
public RateDiscountPolicy(decimal rate)
{
// rate는 이 정책의 불변 설정값이다.
// Order는 이 값이 10%인지 20%인지 알 필요가 없다.
this.rate = rate;
}
public decimal CalculateDiscount(decimal price)
{
return price * rate;
}
}
public sealed class Order
{
private readonly IDiscountPolicy discountPolicy;
public Order(IDiscountPolicy discountPolicy)
{
// Order는 구체 할인 정책을 만들지 않는다.
// 바깥에서 정책을 넣어주면 Order는 그 정책을 사용만 한다.
this.discountPolicy = discountPolicy;
}
public decimal FinalPrice(decimal price)
{
// Order의 책임은 최종 가격 계산 흐름을 잡는 것이다.
// 할인 세부 규칙은 discountPolicy에게 위임한다.
var discount = discountPolicy.CalculateDiscount(price);
return price - discount;
}
}
이 구조에서 Order는 할인 방식이 정률인지, 쿠폰인지, 이벤트인지 모른다. 할인 정책 객체만 바꾸면 된다.
여기서 유연성은 두 요소가 합쳐져 생긴다.
컴포지션:
Order가 할인 정책 객체를 내부에 들고 위임한다.인터페이스:
IDiscountPolicy구현체를 바꿀 수 있다.
둘을 구분해야 한다. 컴포지션만으로는 책임 분리가 생기고, 인터페이스까지 붙으면 교체 가능한 다형성이 생긴다.
여러 변화 축을 조합하기
컴포지션의 힘은 변화 축이 둘 이상일 때 더 잘 보인다.
public interface IPaymentMethod
{
void Pay(decimal amount);
}
public interface IShippingFeePolicy
{
decimal CalculateShippingFee(decimal orderPrice);
}
public interface IDiscountPolicy
{
decimal CalculateDiscount(decimal orderPrice);
}
public sealed class CheckoutService
{
private readonly IPaymentMethod paymentMethod;
private readonly IShippingFeePolicy shippingFeePolicy;
private readonly IDiscountPolicy discountPolicy;
public CheckoutService(
IPaymentMethod paymentMethod,
IShippingFeePolicy shippingFeePolicy,
IDiscountPolicy discountPolicy)
{
// 결제 수단, 배송비 정책, 할인 정책은 서로 다른 변경 축이다.
// 하나의 상속 트리에 넣지 않고 독립적으로 조립한다.
this.paymentMethod = paymentMethod;
this.shippingFeePolicy = shippingFeePolicy;
this.discountPolicy = discountPolicy;
}
public void Checkout(decimal orderPrice)
{
var discount = discountPolicy.CalculateDiscount(orderPrice);
var shippingFee = shippingFeePolicy.CalculateShippingFee(orderPrice);
var finalAmount = orderPrice - discount + shippingFee;
// 결제 방식은 카드, 계좌이체, 포인트 결제 등으로 바뀔 수 있다.
// CheckoutService는 결제 방식의 내부 구현을 모른다.
paymentMethod.Pay(finalAmount);
}
}
상속으로 만들면 CardPremiumFreeShippingCheckout, BankCouponNormalShippingCheckout 같은 조합이 생긴다. 컴포지션은 결제, 할인, 배송비를 각각 바꾸게 해준다.
Law of Demeter와의 연결
Law of Demeter는 "낯선 객체와 너무 깊게 대화하지 말라"는 식으로 요약된다. Lieberherr와 Holland의 1989년 논문 Assuring Good Style for Object-Oriented Programs에서 정리된 원칙이다.[6]
나쁜 예:
// OrderService가 customer의 wallet 내부 creditCard까지 직접 타고 들어간다.
// 내부 구조를 너무 많이 안다.
var cardNumber = order.Customer.Wallet.CreditCard.Number;
더 나은 예:
// OrderService는 고객 내부 구조를 캐지 않는다.
// 결제 가능 여부 판단은 customer 또는 paymentMethod에게 맡긴다.
var canPay = order.Customer.CanPay(order.TotalPrice);
컴포지션은 "내가 직접 내부를 뒤지지 않고, 가까운 협력 객체에게 일을 맡긴다"는 점에서 Law of Demeter와 연결된다. 하지만 자동으로 좋은 설계가 되는 것은 아니다. 객체를 너무 잘게 쪼개면 오히려 위임 홉이 늘고, a.GetB().GetC().Do() 같은 train-wreck이 생길 수 있다.
따라서 Law of Demeter의 핵심은 점 개수를 세는 것이 아니다. 외부 코드가 내부 구조를 알아야만 판단할 수 있는 상태를 줄이는 것이다.
언제 컴포지션을 쓰는가
다음 질문에 "예"가 많으면 컴포지션을 먼저 본다.
이 동작을 런타임에 바꿔야 하는가?
이 동작이 여러 클래스에서 반복되는가?
상속하면 자식 클래스 수가 조합 폭발을 일으키는가?
부모 클래스의 내부 구현을 몰라도 협력할 수 있는가?
테스트에서 이 부분만 가짜 객체로 바꾸고 싶은가?
정책, 전략, 어댑터, 저장소, 렌더러처럼 교체 가능한 역할인가?
특히 다음 이름이 보이면 컴포지션 후보가 많다. 다만 이름은 근거가 아니라 냄새다. Policy라는 이름을 붙였다고 자동으로 분리할 가치가 생기지는 않는다.
이름 | 보통 의미 |
|---|---|
| 판단 규칙 |
| 교체 가능한 알고리즘 |
| 외부 인터페이스 변환 |
| 출력 형식 변환 |
| 표시 형식 변환 |
| 검증 규칙 |
| 저장소 경계 |
| 외부 값 제공 |
| 실행 시점 결정 |
| 데이터 직렬화 |
이런 것들은 대개 객체의 정체성이라기보다 객체가 사용하는 역할이다.
언제 상속이 더 낫나
컴포지션이 항상 정답은 아니다. 상속이 자연스러운 경우도 있다.
진짜 하위 타입 관계가 안정적일 때
부모 클래스가 확장을 위해 명시적으로 설계되어 있을 때
템플릿 메서드처럼 알고리즘의 뼈대는 고정하고 일부 단계만 바꿀 때
프레임워크가 상속 기반 확장점을 요구할 때
타입 계층 자체가 도메인 모델의 핵심일 때
같은 패키지/모듈 안에서 부모와 자식을 같은 팀이 함께 관리할 때
심각한 성능이 필요한 내부 루프에서 가상 호출과 포인터 추적을 피해야 할 때
문제는 상속 자체가 아니라 상속을 구현 재사용의 지름길로 쓰는 습관이다. 부모 클래스가 확장용 API로 설계되어 있지 않으면, 자식 클래스는 부모의 내부 사정에 매달리게 된다.
개인 메모: 상속은 가끔 "공짜 재사용"처럼 보이는데, 실제로는 부모 클래스의 인생사까지 물려받는 경우가 많다. 부모가 빚을 졌으면 자식도 같이 갚는다.
상속만 자연스럽게 주는 것: open recursion
상속이 컴포지션보다 자연스러운 대표 지점은 open recursion이다. 부모 메서드가 this.SomeStep()을 호출했을 때, 실제 런타임 타입의 override로 디스패치되는 성질이다. Cook, Hill, Canning의 Inheritance Is Not Subtyping도 상속과 서브타이핑을 구분해 다룬다.[12]
Template Method가 상속을 원하는 이유가 여기에 있다.
public abstract class ImportJob
{
public void Run()
{
// Run()은 부모에 있지만, Parse()와 Save()는 자식 override로 열린다.
// 이 self dispatch가 open recursion이다.
var rows = Parse();
Save(rows);
}
protected abstract IReadOnlyList<Row> Parse();
protected abstract void Save(IReadOnlyList<Row> rows);
}
컴포지션으로도 비슷하게 만들 수는 있다. 하지만 내부 객체에게 위임하면 그 객체의 this는 래퍼가 아니라 내부 객체 자신이다. 래퍼로 다시 되올라오는 self dispatch가 공짜로 생기지 않는다. 그래서 프레임워크 훅, UI lifecycle, 테스트 fixture, import/export pipeline처럼 알고리즘 뼈대를 고정하고 일부 단계를 열어두는 구조에서는 상속이 더 간단할 수 있다.
단, 이때도 부모의 protected 상태를 많이 열면 앞에서 본 캡슐화 문제가 돌아온다. 좋은 Template Method는 상태 공유를 최소화하고, override해야 할 단계의 계약을 좁게 만든다.
컴포지션의 현실적 제약
컴포지션도 공짜가 아니다. 상속의 문제를 없애는 것이 아니라, 복잡성을 다른 위치로 옮기는 경우가 많다.
1. 물리적 메모리 비용
컴포지션은 객체 참조를 늘린다. Order가 IDiscountPolicy를 들고 있고, 그 정책이 다시 다른 객체를 들고 있으면 실행 흐름은 깔끔해 보이지만 메모리 접근은 흩어진다.
CPU 관점에서는 다음 비용이 생길 수 있다.
힙에 작은 객체가 많이 생긴다.
한 객체에서 다른 객체로 포인터를 타고 이동한다.
데이터가 연속된 배열에 있지 않아 spatial locality가 약해진다.
interface call 또는 virtual dispatch가 내부 루프에서 반복된다.
cache miss, TLB miss, branch prediction 비용이 커질 수 있다.
객체지향 프로그램은 객체와 포인터에 많이 의존하기 때문에 cache/TLB miss에 취약할 수 있다는 연구도 있다.[8] 포인터 기반 자료구조의 locality를 개선하려는 연구가 따로 있는 것도 같은 이유다.[9]
그래서 게임 엔진, 시뮬레이션, 고성능 금융 시스템처럼 많은 데이터를 매 프레임/매 틱 순회하는 영역에서는 객체지향 컴포지션보다 데이터 지향 프로그래밍이나 ECS(Entity Component System)가 더 맞을 수 있다.[10]
나쁜 뜻의 컴포지션 예:
foreach (var particle in particles)
{
// 루프마다 객체 참조를 따라가고, interface dispatch를 수행한다.
// 데이터 수가 작으면 문제 없지만 수십만 개를 매 프레임 돌리면 병목이 될 수 있다.
particle.Integrator.Update(particle, deltaTime);
}
핫 루프에서는 이런 식의 구조가 더 나을 수 있다.
for (var i = 0; i < count; i++)
{
// 필요한 데이터를 연속된 배열에서 읽는다.
// 정책 객체를 갈아끼우는 유연성은 줄지만, 메모리 접근은 훨씬 예측 가능하다.
velocityX[i] += accelerationX[i] * deltaTime;
positionX[i] += velocityX[i] * deltaTime;
}
즉 컴포지션은 설계 유연성을 산다. 대신 데이터 locality를 팔 수 있다. 이 비용은 웹 CRUD에서는 거의 안 보이지만, 내부 루프에서는 바로 보인다.
2. 제품 생애주기 비용
"변하는 행동을 인터페이스로 뺀다"는 말은 맞다. 하지만 MVP 단계에서는 무엇이 변할지 모르는 경우가 많다.
처음부터 결제, 배송, 할인, 재고, 알림을 전부 인터페이스로 쪼개고 DI 컨테이너로 조립하면 개발 속도가 떨어진다. 아직 시장 반응도 없는데 미래의 확장성만 보고 구조를 키우는 셈이다.
초기 제품에서는 다음 선택이 더 나을 때도 있다.
단순한
if로 시작한다.구체 클래스를 바로 쓴다.
중복이 두세 번 보일 때까지 기다린다.
변경 축이 실제로 드러난 뒤 정책 객체로 뺀다.
이건 설계 원칙을 모르는 것이 아니다. 아직 모르는 변경 축에 돈을 쓰지 않는 것이다.
3. 복잡성은 사라지지 않고 이동한다
상속은 복잡성을 클래스 계층에 숨긴다. 컴포지션은 복잡성을 객체 조립 과정으로 옮긴다.
방식 | 복잡성이 숨어 있는 곳 |
|---|---|
상속 | 부모/자식 계층, override 순서, protected 상태 |
컴포지션 | 생성자 주입, DI 컨테이너, 런타임 구현체 선택 |
작은 객체가 많아지면 테스트는 쉬워질 수 있지만, 실제 런타임에 어떤 구현체가 들어왔는지 추적하기는 어려워진다. 특히 DI 설정이 여러 모듈에 흩어져 있으면 코드를 읽는 사람은 "이 인터페이스의 실제 구현이 뭐지?"부터 찾아야 한다.
4. Law of Demeter 남용
Law of Demeter는 내부 구조를 줄이라는 원칙이지, 모든 접근을 포워딩 메서드로 감싸라는 뜻이 아니다.
나쁜 남용 예:
public sealed class Order
{
private readonly Customer customer;
public string GetCustomerName()
{
// 단순 데이터를 꺼내기 위한 포워딩만 늘어난다.
// 이런 메서드가 수십 개 생기면 객체는 캡슐화가 아니라 중계소가 된다.
return customer.Name;
}
}
order.Customer.Name이 항상 나쁜 것은 아니다. 문제는 외부 코드가 order.Customer.Wallet.CreditCard.Provider.RetryPolicy처럼 깊은 내부 구조와 정책 결정까지 알아야 하는 경우다. 단순 조회까지 전부 감추려고 하면 wrapper 메서드 폭발이 생긴다.
5. 프레임워크 훅과 내부 루프
프레임워크가 특정 base class의 hook method를 요구하면 상속이 현실적인 답일 수 있다. UI 프레임워크, 게임 엔진, 테스트 프레임워크, ORM 일부 확장점은 "이 메서드를 override하라"는 계약으로 만들어져 있다.
또 성능이 중요한 내부 루프에서는 컴포지션을 걷어내야 할 수 있다. 인터페이스로 잘게 나눈 정책 객체는 바깥 경계에서는 좋지만, 수백만 번 반복되는 계산 안에서는 직접 함수, struct, 배열, SIMD, SoA 구조가 더 맞을 수 있다.
좋은 컴포지션은 변경 가능성이 확인된 축을 분리한다. 나쁜 컴포지션은 아직 생기지도 않은 미래를 위해 단순한 코드를 DI 컨테이너 미로로 만든다.
내 기준
내가 컴포지션을 판단할 때 쓰는 기준은 이렇다.
이것은 본질인가, 역할인가?
이 변화 축은 다른 변화 축과 독립적으로 바뀌는가?
이걸 상속으로 만들면 클래스 수가 조합 폭발하는가?
테스트에서 이 부분을 갈아끼우고 싶은가?
위임 관계가 너무 많아져서 오히려 흐름이 안 보이지는 않는가?
이 코드가 내부 루프인가, 바깥 정책 경계인가?
지금 이 추상화가 제품 속도를 실제로 돕는가?
한 줄로 줄이면:
상속은 "정체성"을 표현할 때 조심해서 쓰고, 컴포지션은 "역할"과 "정책"을 조립할 때 먼저 고려한다. 다만 핫 루프와 초기 제품에서는 유연성보다 단순함과 데이터 locality가 먼저일 수 있다.
같이 보기
객체지향_프로그래밍
프로그래밍 패턴
전략 패턴
데코레이터 패턴
어댑터 패턴
캡슐화
상속
다형성
의존성 역전
데이터 지향 프로그래밍
참고문헌
[1] Alan Snyder. "Encapsulation and Inheritance in Object-Oriented Programming Languages". OOPSLA 1986. DOI: 10.1145/28697.28702. 상속이 캡슐화의 장점을 약화할 수 있다는 초기 논의.
[2] Barbara Liskov; Jeannette Wing. "A Behavioral Notion of Subtyping". ACM Transactions on Programming Languages and Systems, 16(6), 1811-1841, 1994. DOI: 10.1145/197320.197383.
[3] Antero Taivalsaari. "On the Notion of Inheritance". ACM Computing Surveys, 28(3), 438-479, 1996. DOI: 10.1145/243439.243441.
[4] Erich Gamma, Richard Helm, Ralph Johnson, John Vlissides. Design Patterns: Elements of Reusable Object-Oriented Software. Addison-Wesley, 1994. 온라인 서지: InformIT
[5] Joshua Bloch. Effective Java, 3rd edition. Addison-Wesley, 2018. Item 18: "Favor composition over inheritance."
[6] Karl J. Lieberherr; Ian M. Holland. "Assuring Good Style for Object-Oriented Programs." IEEE Software, 1989. 참고: Law of Demeter bibliography, Law of Demeter: Principle of Least Knowledge
[7] Ralph E. Johnson; Brian Foote. "Designing Reusable Classes". Journal of Object-Oriented Programming, 1(2), 22-35, 1988. 재사용 가능한 클래스 설계와 프레임워크 논의.
[8] Martin Hirzel et al. "Data layouts for object-oriented programs". SIGMETRICS 2007. 객체지향 프로그램의 객체/포인터 의존성과 cache/TLB miss 문제를 다룬 연구.
[9] Trishul M. Chilimbi; Mark D. Hill; James R. Larus. "Making Pointer-Based Data Structures Cache Conscious". IEEE Computer, 33, 67-74, 2000. 포인터 기반 구조의 locality 개선 논의.
[10] Shawn M. Harris. "Implementation and Analysis of the Entity Component System Architecture". California Polytechnic State University, 2022. ECS를 데이터 지향 컴포지션 패턴으로 보고 객체지향 구조와 성능을 비교한 석사 논문.
[11] Ulrich Drepper. "What Every Programmer Should Know About Memory". 2007. CPU cache, locality, memory hierarchy를 이해하기 위한 고전적 참고 자료.
[12] William R. Cook; Walter L. Hill; Peter S. Canning. "Inheritance Is Not Subtyping". POPL 1990. DOI: 10.1145/96709.96721. 상속과 서브타이핑을 같은 것으로 보면 안 된다는 고전적 논의.
분류: 프로그래밍 패턴 | 객체지향_프로그래밍 | 디자인 패턴 | WIKI