컴포지션과 상속 (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-ahas-a
결합 시점컴파일 타임에 고정실행 시점에 조립 가능
의존 대상부모의 내부 구현까지공개 인터페이스만
캡슐화깨지기 쉬움유지됨
재사용 범위단일 부모 (자바는 단일 상속)여러 객체 조합 가능
단점취약한 기반 클래스 문제전달 메서드 작성 비용

실무 지침

상속을 허용할 클래스라면 어떤 메서드가 내부적으로 어떤 메서드를 호출하는지 문서화하고 확장 지점을 명확히 설계해야 하며, 그렇게 할 자신이 없으면 클래스를 final로 선언하거나 생성자를 감춰 상속을 막는 것이 안전하다. Java 17부터는 sealed 클래스로 상속 가능한 자식을 명시적으로 제한할 수도 있다. 다형성이 필요하면 클래스 상속 대신 인터페이스 구현으로 타입 계층을 만들고, 구현 재사용은 컴포지션과 위임으로 해결하는 것이 현대 자바의 일반적인 설계 방향이다. 스프링(Spring) 프레임워크의 의존성 주입 역시 실행 시점에 부품을 갈아 끼우는 컴포지션 사상의 연장선에 있다.

면접에서 이렇게 물어본다

  • 상속보다 컴포지션을 우선하라는 원칙의 근거를 구체적인 사례와 함께 설명해 보라.
  • 취약한 기반 클래스 문제가 무엇인지, 왜 상속에서 발생하는지 설명해 보라.
  • is-a 관계와 has-a 관계를 어떻게 구분하며, 리스코프 치환 원칙과는 어떤 관련이 있는가.
  • 자바 표준 라이브러리에서 상속을 잘못 사용한 예를 들고 무엇이 문제인지 말해 보라.
  • 데코레이터 패턴이 컴포지션과 어떤 관계인지 설명해 보라.
Java 문서 전체 보기

이 개념, 내 코드에서는 어떻게 물어볼까요

데브핏(DevFit)에 채용공고 URL을 붙여 넣고 레포지토리를 연결하면, 내가 쓴 커밋에서 예상 질문과 어필 포인트를 만듭니다. 하루 10회까지 무료입니다. 합불 판정이나 지원자 랭킹은 만들지 않고, 최종 판단은 사람이 합니다.

공고 넣고 시작하기