소프트웨어 설계에서 클래스 간의 관계를 맺을 때, 우리는 흔히 상속(Inheritance)이라는 강력한 도구를 접합니다. 하지만 10년의 개발 경력을 통해 뼈저리게 느낀 점은, 상속은 종종 '깨지기 쉬운 기반 클래스(Fragile Base Class)' 문제를 야기하며 유연성을 심각하게 저해한다는 사실입니다. 상속이 "Is-a(A는 B이다)" 관계를 강제한다면, 컴포지션은 "Has-a(A는 B를 가지고 있다)" 관계를 통해 객체를 조립합니다. 이는 현대적인 대규모 서비스에서 요구되는 복잡한 비즈니스 로직을 모듈 단위로 분리하고, 테스트 가능성을 극대화하는 핵심 설계 기법입니다.
개념 소개: 상속의 굴레에서 벗어나기
상속은 하위 클래스가 상위 클래스의 구현을 재사용하게 해주지만, 클래스 계층 구조가 깊어질수록 부모 클래스의 변경이 자식 클래스 전체에 영향을 미치는 결합도 문제를 발생시킵니다. 컴포지션은 서로 다른 클래스를 객체의 내부 멤버 변수로 포함하여 기능을 확장합니다. 2026년 현재, 마이크로서비스 아키텍처와 함수형 프로그래밍의 영향으로 많은 프레임워크가 상속보다는 인터페이스를 통한 컴포지션과 전략 패턴을 혼합한 형태를 표준으로 삼고 있습니다. 이는 객체의 행동을 런타임에 동적으로 변경할 수 있다는 결정적인 장점을 가집니다.
핵심 원리: 레고 블록과 조립 라인
상속을 '거대한 단일 조각상을 깎는 과정'이라고 한다면, 컴포지션은 '레고 블록으로 성을 쌓는 과정'입니다.
- 상속 (is-a): 당신이 '전기차' 클래스를 만들기 위해 '차' 클래스를 상속받는다면, '차' 클래스에 정의된 모든 부품과 동작을 강제로 떠안아야 합니다.
- 컴포지션 (has-a): '전기차' 객체 안에 '배터리 모듈', '모터 모듈', '차체 모듈'을 주입(Inject)합니다. 만약 배터리 성능을 개선하고 싶다면 전체 구조를 바꿀 필요 없이 '배터리 모듈'만 새로운 구현체로 교체하면 됩니다.
이는 다이어그램으로 나타내면, 상속은 위에서 아래로 흐르는 수직적인 트리 구조를 그리지만, 컴포지션은 중앙의 핵심 객체를 여러 독립적인 서비스 객체가 둘러싸고 있는 방사형 구조를 가집니다.
코드 예제: 컴포지션의 힘
예제 1: 단순한 기능 결합 (Python)
아래 예제는 상속 없이 특정 기능을 객체 내부에 포함하여 재사용하는 방식을 보여줍니다.
class EmailSender:
def send(self, message):
print(f"이메일 발송: {message}")
class Logger:
def log(self, message):
print(f"로그 기록: {message}")
class NotificationService:
def __init__(self, sender, logger):
# 필요한 기능을 컴포지션으로 포함
self.sender = sender
self.logger = logger
def notify(self, message):
self.logger.log(message)
self.sender.send(message)
# 사용 시점에 필요한 기능을 조합
service = NotificationService(EmailSender(), Logger())
service.notify("안녕하세요, 컴포지션입니다.")
예제 2: 런타임 행동 변경 (JavaScript)
컴포지션은 객체의 행동을 실행 도중에 교체하기에 매우 유리합니다.
class PaymentProcessor {
constructor(strategy) {
this.strategy = strategy; // 실행 전략을 컴포지션으로 주입
}
execute(amount) {
return this.strategy.pay(amount);
}
}
const creditCardPayment = { pay: (amt) => `신용카드로 ${amt}원 결제` };
const cryptoPayment = { pay: (amt) => `암호화폐로 ${amt}원 결제` };
const processor = new PaymentProcessor(creditCardPayment);
console.log(processor.execute(1000)); // 신용카드 결제
// 런타임에 전략 교체
processor.strategy = cryptoPayment;
console.log(processor.execute(5000)); // 암호화폐 결제
실무 적용 사례
실무에서는 주로 '의존성 주입(Dependency Injection)'과 결합하여 사용됩니다. 예를 들어, 사용자의 인증 정보를 처리하는 AuthService를 구현할 때, DB 접속 객체, 토큰 생성 객체, 로깅 객체를 컴포지션으로 주입받습니다. 이렇게 설계하면 단위 테스트 시 DB 접속 객체를 'Mock(가짜 객체)'으로 쉽게 갈아끼울 수 있어 테스트 효율이 비약적으로 상승합니다. 상속 관계였다면 부모 클래스의 DB 의존성까지 모두 테스트 환경에 맞추어 세팅해야 하는 번거로움이 발생했을 것입니다.
자주 하는 실수와 해결법
가장 흔한 실수는 컴포지션을 모든 곳에 적용하려는 강박입니다. 컴포지션은 객체 간의 관계를 수평적으로 나열하므로, 객체의 깊이가 지나치게 깊어지면 코드의 가독성이 떨어지는 '객체 폭발(Object Explosion)' 현상이 발생할 수 있습니다.
- 해결책: 계층 구조가 단순히 공통 동작의 나열이라면
Mixin이나Interface를 적극적으로 활용하세요. 컴포지션은 '기능의 조합'이 복잡하고 '행동의 교체'가 빈번할 때 그 진가를 발휘합니다. 또한, 과도한 의존성 주입은 오히려 코드를 복잡하게 만드니, 필요한 최소한의 인터페이스만 정의하는 인터페이스 분리 원칙(ISP)을 병행하세요.
더 공부할 리소스 추천
- "Design Patterns: Elements of Reusable Object-Oriented Software" (GoF의 디자인 패턴): 객체 지향 설계의 바이블입니다. 컴포지션의 실질적인 구현인 전략 패턴과 데코레이터 패턴을 깊이 있게 다룹니다.
- "Effective Java" (Joshua Bloch): 아이템 18 '상속보다는 컴포지션을 사용하라' 챕터는 필수 필독 구간입니다.
- Refactoring.guru: 웹사이트 내의 "Composition over Inheritance" 섹션을 통해 시각적인 비교 자료를 참고하세요.