컴포지션
합성(Composition)
마지막 업데이트: 2026-06-08 · 24 min read
컴포지션(Composition, 합성/조합)은 객체가 다른 객체를 내부에 들고 있다가 필요한 일을 그 객체에게 위임하는 방식이다.
객체지향에서는 보통 상속과 대비된다.
짧게 말하면 이렇다.
방식 | 뜻 |
|---|---|
상속 | 부모 클래스의 타입 관계와 구현을 물려받는다 |
컴포지션 | 다른 객체를 부품처럼 조립하고 일을 맡긴다 |
상속이 대체로 is-a 관계를 표현한다면, 컴포지션은 has-a, part-of, uses-a 관계를 표현한다.
문장 | 보통 더 자연스러운 설계 |
|---|---|
| 상속 후보 |
| 컴포지션 후보 |
| 컴포지션 후보 |
| 컴포지션 후보 |
다만 is-a와 has-a는 문법 공식이 아니다. 일반적으로 설명을 편하게 하려고 핵심만 남긴 것이지. 공식적인 설명은 아니다.
개인 메모: 사실 이런 것은 흔하다. 특정 정보를 전달할때 이해하기 쉽게 일부 정보를 제거하는데, 이 과정에서 정보 손실이 일어나고, 정보가 왜곡되고 또 밈화 된다.
"A는 B다"라고 말할 수 있어도 실제 코드에서는 컴포지션이 더 나을 때가 많다. 중요한 건 어떻게 변경할 것인가, 즉 변경 축이다.
용어 구분
이 문서에서 말하는 컴포지션은 기본적으로 객체지향 컴포지션이다. 함수형 프로그래밍에서 말하는 함수 합성, 즉 f(g(x)) 또는 f ∘ g와는 다른 말이다.
둘 다 "작은 것을 엮어 큰 것을 만든다"는 느낌은 같지만, 다루는 대상이 다르다는 점을 유의해야한다.
또 하나 구분할 점이 있다. 컴포지션과 인터페이스 기반 다형성은 같은 개념이 아니다.
이름 | 뜻 | 얻는 것 |
|---|---|---|
구체 객체 컴포지션 |
| 코드 분리, 책임 분리 |
인터페이스 기반 컴포지션 |
| 교체 가능성, 테스트 용이성, 런타임 정책 변경 |
컴포지션 자체는 has-a 구조로 많이 표기된다.
객체지향에서 넓은 의미의 컴포지션은 한 객체가 다른 객체를 참조하거나 소유하거나 사용하며 협력하는 구조다.
좁은 의미의 UML composition은 강한 소유/생명주기 종속을 뜻하지만, 실무에서 “상속보다 컴포지션”이라고 말할 때는 대개 delegation, aggregation, dependency injection까지 넓게 포함한다.
다형성은 interface, abstract class, trait, function pointer 같은 대체 가능한 계약에서 나온다. 실무에서 "상속보다 컴포지션"이라고 말할 때는 대개 둘을 합친 인터페이스 기반 컴포지션을 가리킨다.
개인 메모: 인터페이스 기반 컴포지션이라는 건 흔히 현장에선 꽤 쓰이지만 실제 학술적 용어는 없고, 다형적 위임이나 인터페이스를 통한 위임으로 쓴다.
왜 상속보다 컴포지션이라는 말이 나오는가
초기 객체지향에서는 상속이 매우 매력적이었다. 클
래스 계층을 만들면 현실 세계 분류도 표현할 수 있고, 부모 클래스의 코드도 재사용할 수 있었다. Animal -> Dog, Vehicle -> Car, Order -> DiscountOrder 같은 구조는 처음에는 자연스럽게 보인다.
문제는 시간이 지나면서 여러 문제점이 자주 나타났다.
1. 상속은 여러 의미를 한 번에 묶는다
Taivalsaari는 1996년 ACM Computing Surveys 논문에서 상속이 객체지향을 구분하는 중요한 개념이지만, 그 의미와 사용법에 대해 연구자들조차 합의가 잘 되지 않는다고 정리했다.1
상속은 코드 재사용, 타입 계층, 개념 분류, 차이 기반 프로그래밍을 하나의 문법 안에 묶어 놓는다.
이 네 가지가 항상 같으면 문제가 없지만, 대부분은 프로그램이 커짐에 따라 코드 재사용의 이유, 타입 관계의 이유, 도메인 분류의 이유, 변경의 이유가 서로 어긋나기 시작한다.
상속을 쓰는 이유 | 실제로 같이 따라오는 것 |
|---|---|
코드를 재사용하고 싶다 | 타입 관계까지 생긴다 |
도메인 분류를 표현하고 싶다 | 부모 구현에 묶인다 |
다형성을 쓰고 싶다 | 행동 계약을 지켜야 한다 |
일부 기능만 바꾸고 싶다 | override 순서와 내부 호출에 의존한다 |
사실 이게 컴포지션을 이해하는데 핵심이다. 상속이 나쁘다는 뜻이 아니다만은, 상속은 너무 많은 의미를 한 문법에 묶는다.
컴포지션은 그중 재사용, 역할 조립, 정책 교체를 타입 계층에서 떼어내는 방법이다.
2. 상속은 캡슐화를 약하게 만들 수 있다
Alan Snyder는 1986년 OOPSLA 논문에서 많은 객체지향 언어가 데이터 추상화를 제공하지만, 상속이 들어오면 캡슐화의 장점이 심하게 약해진다고 지적했다.2
자식 클래스는 부모의 protected 필드, 내부 호출 순서, virtual 메서드 호출 타이밍, 생성자 규칙을 알아야 하는 경우가 많다. 이게 왜 나쁘냐고 하는 사람들은 많은데, OOP에 많은 영향을 끼친 파르나스의 정보 은닉 이론에 따르면 이것은 일종의 정보 은닉의 실패라고 볼 수 있다.
즉 상속은 "공개 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에도 유지되어야 한다고 설명한다.3
이 말은 현장에서는 이렇게 바뀐다.
자식 클래스는 부모 클래스처럼 보이는 것만으로 충분하지 않다. 부모 클래스가 약속한 행동까지 지켜야 한다.
단, 이건 상속만의 문제가 아니다. 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 | 부분-전체 관계를 객체 조합으로 표현한다 |

(Effective Java 3판 item 18)
Joshua Bloch도 Effective Java에서 "상속보다 컴포지션을 선호하라"를 별도 항목으로 다룬다.5 특히 public class를 상속해서 확장하면 부모의 내부 구현 변경에 자식이 깨지기 쉽다는 점을 강조한다.
컴포지션의 기본 구조
컴포지션은 보통은 다음과 같이 3단계로 행해진다.
변하는 행동을 찾는다.
그 행동을 작은 객체로 뺀다.
본체 객체는 그 객체에게 일을 맡긴다.
기본형은 구체 객체 위임이다.
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);
컴포지션은 "내가 직접 내부를 뒤지지 않고, 가까운 협력 객체에게 일을 맡긴다"는 점에서 디미터의 법칙과 연결된다.
하지만 이것이 자동으로 좋은 설계가 되는 것은 아니다. 객체를 너무 잘게 쪼개면 오히려 위임 홉이 늘고, a.GetB().GetC().Do() 같은 train-wreck이 생길 수 있다.
따라서 디미터의 법칙의 핵심은 점 개수를 세는 것이 아니다. 외부 코드가 내부 구조를 알아야만 판단할 수 있는 상태를 줄이는 것이다.
언제 컴포지션을 쓰는가
다음 질문에 "예"가 많으면 컴포지션을 먼저 고려해본다.
이 동작을 런타임에 바꿔야 하는가?
이 동작이 여러 클래스에서 반복되는가?
상속하면 자식 클래스 수가 조합 폭발을 일으키는가?
부모 클래스의 내부 구현을 몰라도 협력할 수 있는가?
테스트에서 이 부분만 가짜 객체로 바꾸고 싶은가?
정책, 전략, 어댑터, 저장소, 렌더러처럼 교체 가능한 역할인가?
특히 다음 이름이 보이면 컴포지션 후보로 할 근거가 충분하다.
다만 이름은 근거가 아니라 냄새다. 프로그래머 감각이라고 해야하나. 작업해서 겪는 상황 그 자체들이 쌓여서 코드 냄새가 된다.
Policy라는 이름을 붙였다고 자동으로 분리할 가치가 생기지는 않는다.
이름 | 보통 의미 |
|---|---|
| 판단 규칙 |
| 교체 가능한 알고리즘 |
| 외부 인터페이스 변환 |
| 출력 형식 변환 |
| 표시 형식 변환 |
| 검증 규칙 |
| 저장소 경계 |
| 외부 값 제공 |
| 실행 시점 결정 |
| 데이터 직렬화 |
이런 것들은 대개 객체의 정체성이라기보다 객체가 사용하는 역할이다.
언제 상속이 더 낫나
컴포지션이 항상 정답은 아니다. 상속이 자연스러운 경우도 있다.
진짜 하위 타입 관계가 안정적일 때
부모 클래스가 확장을 위해 명시적으로 설계되어 있을 때
템플릿 메서드처럼 알고리즘의 뼈대는 고정하고 일부 단계만 바꿀 때
프레임워크가 상속 기반 확장점을 요구할 때
타입 계층 자체가 도메인 모델의 핵심일 때
같은 패키지/모듈 안에서 부모와 자식을 같은 팀이 함께 관리할 때
심각한 성능이 필요한 내부 루프에서 가상 호출과 포인터 추적을 피해야 할 때
문제는 상속 자체가 아니라 상속을 구현 재사용의 지름길로 쓰는 습관이다. 부모 클래스가 확장용 API로 설계되어 있지 않으면, 자식 클래스는 부모의 내부 사정에 매달리게 된다.
개인 메모: 상속은 가끔 "공짜 재사용"처럼 보이는데, 실제로는 부모 클래스의 인생사까지 물려받는 경우가 많다. 부모가 돈이 많다면 자식이 기꺼이 물려받겠지만, 가난할 경우 그 부모를 벗어나고 싶듯이, 상속도 좋은 부모한테 물려받을 게 있을때만 받는 것이 좋다.
상속에서 특히 자연스러운 : open recursion
상속이 컴포지션보다 자연스러운 대표 지점은 open recursion이다. 부모 메서드가 this.SomeStep()을 호출했을 때, 실제 런타임 타입의 override로 디스패치되는 성질이다.
Cook, Hill, Canning의 Inheritance Is Not Subtyping도 상속과 서브타이핑을 구분해 다룬다.7
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
그래서 게임 엔진, 시뮬레이션, 고성능 금융 시스템처럼 많은 데이터를 매 프레임/매 틱 순회하는 영역에서는 객체 참조 기반 컴포지션보다 SoA, ECS, Job 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로 시작한다.구체 클래스를 바로 쓴다.
중복이 두세 번 보일 때까지 기다린다.
변경 축이 실제로 드러난 뒤 정책 객체로 뺀다.
이건 설계 원칙을 모르는 것이 아니고, 아직 모르는 변경 축에 돈을 쓰지 않는 것이다.
개인 메모: 스타트 업 프로그래머는 단순하고 빠르게 MVP로 하드코딩 해도 된다. 하지만 규모를 키우거나 나같이 여러곳에 납품해야하는 프로그래머일 수록 템플릿화와 추상화 해야한다.
3. 복잡성은 사라지지 않고 이동한다
상속은 복잡성을 클래스 계층에 숨긴다. 컴포지션은 복잡성을 객체 조립 과정으로 옮긴다.
방식 | 복잡성이 숨어 있는 곳 |
|---|---|
상속 | 부모/자식 계층, override 순서, protected 상태 |
컴포지션 | 생성자 주입, DI 컨테이너, 런타임 구현체 선택 |
작은 객체가 많아지면 테스트는 쉬워질 수 있지만, 실제 런타임에 어떤 구현체가 들어왔는지 추적하기는 어려워진다. 특히 DI 설정이 여러 모듈에 흩어져 있으면 코드를 읽는 사람은 "이 인터페이스의 실제 구현이 뭐지?"부터 찾아야 한다. 너무 잘게 쪼개면 하나의 정책 변경이 여러 객체와 DI 설정을 동시에 건드리는 형태가 되고, 결과적으로 샷건 수술(Shotgun Surgery)과 같은 안티 패턴 냄새가 생길 수 있다.
4. 디미터의 법칙의 남용
디미터의 법칙(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 메서드 폭발이 생긴다.
이 딜레마를 줄이는 방법 중 하나는 'Tell, Don't Ask(묻지 말고 시켜라)' 원칙을 적용하는 것이다. 객체 내부의 데이터를 꺼내와서(Ask) 밖에서 로직을 처리하려 하지 말고, 데이터를 가장 잘 아는 객체에게 계산이나 행위를 위임(Tell)해야 한다.
그러면 래퍼 폭발 없이도 자연스럽게 캡슐화가 유지되고 컴포지션의 책임을 나눌 수 있다.
5. 프레임워크 훅과 내부 루프
프레임워크가 특정 base class의 hook method를 요구하면 상속이 현실적인 답일 수 있다. UI 프레임워크, 게임 엔진, 테스트 프레임워크, ORM 일부 확장점은 "이 메서드를 override하라"는 계약으로 만들어져 있다. 특히 C#에서 자주 쓰이는 대표적 게임 엔진인 Unity가 그렇다.
또 성능이 중요한 내부 루프에서는 컴포지션을 걷어내야 할 수 있다. 인터페이스로 잘게 나눈 정책 객체는 바깥 경계에서는 좋지만, 수백만 번 반복되는 계산 안에서는 직접 함수, struct, 배열, SIMD, SoA 구조가 더 맞을 수 있다.
좋은 컴포지션은 변경 가능성이 확인된 축을 분리한다. 나쁜 컴포지션은 아직 생기지도 않은 미래를 위해 단순한 코드를 DI 컨테이너 미로로 만든다.
내 기준
내가 컴포지션을 판단할 때 쓰는 기준은 보통은 이렇다.
이것은 본질인가, 역할인가?
이 변화 축은 다른 변화 축과 독립적으로 바뀌는가?
이걸 상속으로 만들면 클래스 수가 조합 폭발하는가?
테스트에서 이 부분을 갈아끼우고 싶은가?
위임 관계가 너무 많아져서 오히려 흐름이 안 보이지는 않는가?
이 코드가 내부 루프인가, 바깥 정책 경계인가?
지금 이 추상화가 제품 속도를 실제로 돕는가?
요약하자면
상속은 "정체성"을 표현할 때 조심해서 쓰고, 컴포지션은 "역할"과 "정책"을 조립할 때 먼저 고려한다.
다만 핫 루프와 초기 제품에서는 유연성보다 단순함과 데이터 locality가 먼저일 수 있다.
같이 보기
객체지향_프로그래밍
프로그래밍 패턴
전략 패턴
데코레이터 패턴
어댑터 패턴
캡슐화
상속
다형성
의존성 역전
데이터 지향 프로그래밍
참고문헌
Ralph E. Johnson; Brian Foote. "Designing Reusable Classes". Journal of Object-Oriented Programming, 1(2), 22-35, 1988. 재사용 가능한 클래스 설계와 프레임워크 논의.
Ulrich Drepper. "What Every Programmer Should Know About Memory". 2007. CPU cache, locality, memory hierarchy를 이해하기 위한 고전적 참고 자료.
각주
- Antero Taivalsaari. "On the Notion of Inheritance". ACM Computing Surveys, 28(3), 438-479, 1996. DOI: 10.1145/243439.243441. https://courses.cs.umbc.edu/331/resources/papers/Inheritance.pdf ↩
- Alan Snyder. "Encapsulation and Inheritance in Object-Oriented Programming Languages". OOPSLA 1986. DOI: 10.1145/28697.28702. 상속이 캡슐화의 장점을 약화할 수 있다는 초기 논의. ↩
- 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. ↩
- Erich Gamma, Richard Helm, Ralph Johnson, John Vlissides. Design Patterns: Elements of Reusable Object-Oriented Software. Addison-Wesley, 1994. 온라인 서지: [InformIT](https://www.informit.com/store/design-patterns-elements-of-reusable-object-oriented-softwar ↩
- Joshua Bloch. Effective Java, 3rd edition. Addison-Wesley, 2018. Item 18: "Favor composition over inheritance." ↩
- 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 ↩
- William R. Cook; Walter L. Hill; Peter S. Canning. "Inheritance Is Not Subtyping". POPL 1990. DOI: 10.1145/96709.96721. 상속과 서브타이핑을 같은 것으로 보면 안 된다는 고전적 논의. ↩
- Martin Hirzel et al. "Data layouts for object-oriented programs". SIGMETRICS 2007. 객체지향 프로그램의 객체/포인터 의존성과 cache/TLB miss 문제를 다룬 연구. ↩
- Trishul M. Chilimbi; Mark D. Hill; James R. Larus. "Making Pointer-Based Data Structures Cache Conscious". IEEE Computer, 33, 67-74, 2000. 포인터 기반 구조의 locality 개선 논의. ↩
- Shawn M. Harris. "Implementation and Analysis of the Entity Component System Architecture". California Polytechnic State University, 2022. ECS를 데이터 지향 컴포지션 패턴으로 보고 객체지향 구조와 성능을 비교한 석사 논문. ↩