컴포지션과 상속 (Composition vs Inheritance)
한눈에 보기
컴포지션은 객체를 필드로 소유하고 위임해 재사용하는 기법이고 상속은 구현을 물려받는 기법이다. 취약한 기반 클래스 문제와 is-a, has-a 판단 기준으로 컴포지션 우선 원칙을 설명한다.
최종 업데이트 2026.09.17
컴포지션(Composition)은 다른 객체를 필드로 소유하고 그 기능을 위임(delegation)해서 재사용하는 설계 기법이고, 상속(Inheritance)은 부모 클래스를 확장해 구현을 물려받는 기법이다. 두 방식 모두 코드 재사용을 목적으로 하지만, 상속은 부모의 내부 구현에 강하게 결합되는 반면 컴포지션은 공개된 인터페이스에만 의존하므로 변경에 훨씬 유연하다. 그래서 객체지향 설계에서는 순수한 is-a 관계가 아니라면 상속보다 컴포지션을 우선하라는 원칙이 널리 통용된다.
상속의 문제: 캡슐화가 깨진다
상속은 extends 한 줄로 부모의 모든 공개 기능을 물려받는 강력한 도구지만, 자식이 부모의 내부 구현 방식에 의존하게 만든다는 근본적인 약점이 있다. 부모 클래스가 다음 버전에서 내부 구현을 바꾸면, 부모 코드는 한 줄도 건드리지 않았는데 자식 클래스가 오동작할 수 있다. 이를 취약한 기반 클래스 문제(fragile base class problem)라 한다.
대표적인 사례가 자기 사용(self-use) 문제다. 아래처럼 HashSet을 상속해 추가된 원소 수를 세는 클래스를 만들면, addAll이 내부적으로 add를 호출하는 구현 세부 사항 때문에 카운트가 두 배로 늘어난다.
class CountingSet<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를 다시 호출한다
}
}
CountingSet<String> set = new CountingSet<>();
set.addAll(List.of("a", "b", "c"));
// addCount는 3이 아니라 6이 된다
이 버그는 HashSet의 문서에 없는 내부 동작에 의존했기 때문에 생겼고, 부모의 구현이 바뀌면 고쳐 놓은 코드가 다시 깨질 수도 있다.
컴포지션으로 바꾸면
같은 요구사항을 컴포지션으로 풀면, 기존 Set을 필드로 소유하고 모든 호출을 그대로 전달하는 전달 클래스(forwarding class)를 만든 뒤 필요한 메서드만 확장한다. 이렇게 기존 객체를 감싸는 구조를 래퍼(wrapper)라 하고, 기능을 덧붙인다는 의미에서 데코레이터 패턴(Decorator Pattern)이라고도 부른다.
class CountingSet<E> implements Set<E> {
private final Set<E> inner; // 구현이 아니라 인터페이스에 의존한다
private int addCount = 0;
CountingSet(Set<E> inner) { this.inner = inner; }
@Override
public boolean add(E e) {
addCount++;
return inner.add(e);
}
@Override
public boolean addAll(Collection<? extends E> c) {
addCount += c.size();
return inner.addAll(c); // inner가 내부적으로 무엇을 하든 무관하다
}
public int getAddCount() { return addCount; }
// 나머지 Set 메서드는 inner에 위임한다
}
inner의 addAll이 내부에서 무엇을 호출하든 바깥 클래스의 카운트에는 영향이 없다. 게다가 생성자에 HashSet이든 TreeSet이든 어떤 Set 구현체라도 넣을 수 있어, 하나의 래퍼로 모든 구현체에 기능을 덧붙일 수 있다. 상속이었다면 구현체마다 자식 클래스를 따로 만들어야 했을 것이다.
is-a와 has-a로 판단하기
상속을 써도 되는 경우는 자식이 부모의 완전한 하위 타입, 즉 is-a 관계가 의미적으로 성립할 때뿐이다. 부모가 쓰이는 모든 자리에 자식을 넣어도 프로그램의 의미가 유지되어야 한다는 리스코프 치환 원칙(Liskov Substitution Principle)이 그 기준이다. 반면 한 객체가 다른 객체를 부품으로 갖는 has-a 관계라면 컴포지션이 맞다. 자동차는 엔진을 상속하는 것이 아니라 엔진을 소유한다.
자바 표준 라이브러리에도 이 기준을 어긴 사례가 남아 있다. Stack이 Vector를 상속한 탓에 스택인데도 중간 삽입이 가능하고, Properties가 Hashtable을 상속한 탓에 문자열이 아닌 키를 넣을 수 있다. 둘 다 is-a가 성립하지 않는 관계를 상속으로 묶어 타입 규약이 깨진 예로 자주 인용된다.
상속과 컴포지션 비교
| 구분 | 상속 | 컴포지션 |
|---|---|---|
| 관계 | is-a | has-a |
| 결합 시점 | 컴파일 타임에 고정 | 실행 시점에 조립 가능 |
| 의존 대상 | 부모의 내부 구현까지 | 공개 인터페이스만 |
| 캡슐화 | 깨지기 쉬움 | 유지됨 |
| 재사용 범위 | 단일 부모 (자바는 단일 상속) | 여러 객체 조합 가능 |
| 단점 | 취약한 기반 클래스 문제 | 전달 메서드 작성 비용 |
실무 지침
상속을 허용할 클래스라면 어떤 메서드가 내부적으로 어떤 메서드를 호출하는지 문서화하고 확장 지점을 명확히 설계해야 하며, 그렇게 할 자신이 없으면 클래스를 final로 선언하거나 생성자를 감춰 상속을 막는 것이 안전하다. Java 17부터는 sealed 클래스로 상속 가능한 자식을 명시적으로 제한할 수도 있다. 다형성이 필요하면 클래스 상속 대신 인터페이스 구현으로 타입 계층을 만들고, 구현 재사용은 컴포지션과 위임으로 해결하는 것이 현대 자바의 일반적인 설계 방향이다. 스프링(Spring) 프레임워크의 의존성 주입 역시 실행 시점에 부품을 갈아 끼우는 컴포지션 사상의 연장선에 있다.
면접에서 이렇게 물어본다
- 상속보다 컴포지션을 우선하라는 원칙의 근거를 구체적인 사례와 함께 설명해 보라.
- 취약한 기반 클래스 문제가 무엇인지, 왜 상속에서 발생하는지 설명해 보라.
- is-a 관계와 has-a 관계를 어떻게 구분하며, 리스코프 치환 원칙과는 어떤 관련이 있는가.
- 자바 표준 라이브러리에서 상속을 잘못 사용한 예를 들고 무엇이 문제인지 말해 보라.
- 데코레이터 패턴이 컴포지션과 어떤 관계인지 설명해 보라.
Java에서 같이 읽기
- 오토 박싱과 오토 언박싱 (Auto Boxing and Unboxing)오토 박싱은 기본형 값을 래퍼 클래스 객체로, 오토 언박싱은 그 반대로 자동 변환하는 기능이다. Integer 캐시, == 비교 함정, NullPointerException 위험과 성능 비용을 JVM 메모리 관점에서 정리한다.
- Call by Value와 Call by Reference자바는 기본형이든 참조형이든 언제나 값에 의한 호출만 사용하며, 참조형은 객체가 아니라 참조값이 복사되어 전달된다. swap 실패 예제와 스택 프레임 관점으로 그 근거를 설명한다.
- 캐스팅 (Casting), 업캐스팅과 다운캐스팅캐스팅은 값이나 참조를 다른 타입으로 취급하는 변환이다. 기본형 확대와 축소 변환, 업캐스팅과 다형성, 다운캐스팅과 instanceof 패턴 매칭, ClassCastException 예방법을 다룬다.
- 자바 컴파일 과정 (Java Compile Process)javac가 소스를 바이트코드로 바꾸고 JVM이 로딩, 링킹, 초기화 후 인터프리터와 JIT로 실행하는 전체 흐름을 정리한다. 클래스 파일 구조와 부모 위임 모델, 계층형 컴파일까지 다룬다.
이 개념, 내 코드에서는 어떻게 물어볼까요
데브핏(DevFit)에 채용공고 URL을 붙여 넣고 레포지토리를 연결하면, 내가 쓴 커밋에서 예상 질문과 어필 포인트를 만듭니다. 하루 10회까지 무료입니다. 합불 판정이나 지원자 랭킹은 만들지 않고, 최종 판단은 사람이 합니다.
공고 넣고 시작하기