JAVA

Java 상속 vs 조합(Composition) — 실무에서의 선택 가이드

IT Lab 2026. 2. 16. 10:00

Java에서 extends 상속이 실무에서 왜 위험해질 수 있는지, 조합 우선 원칙과 상속을 써야 할 때의 기준을 예제 코드로 정리합니다.

 

기능이 비슷해 보여서 extends로 빠르게 붙였는데, 몇 달 뒤 “이 클래스만 예외 처리” 같은 요구가 들어오면서 수정이 폭발한 경험 있으실 거예요. 상속은 한 번 얽히면 풀기 어렵고, 특히 라이브러리 클래스 상속은 예상 못한 동작 변경으로 이어지기도 합니다. 그래서 실무에서는 “상속보다 조합(Composition) 우선”을 자주 원칙으로 둡니다.

핵심 개념: Java에서 extends가 위험해지는 지점과 조합 우선 원칙

상속(is-a)과 조합(has-a)을 대비해 보여주는 간단한 객체 관계 다이어그램

상속은 is-a 관계를 코드로 고정하는 도구입니다. 문제는 요구사항이 바뀌면 is-a가 쉽게 무너진다는 점이에요. 예를 들어 “알림”이 처음엔 이메일만 있었는데, 나중엔 슬랙/푸시가 추가되면 “이메일 알림 is a 알림” 같은 계층이 급격히 복잡해집니다.

실무에서 extends가 함정이 되는 대표 포인트는 아래 3가지입니다.

  1. 상위 클래스 변경의 파급(Fragile Base Class)
    상위 클래스의 내부 구현이 바뀌면, 하위 클래스는 컴파일은 되는데 런타임 동작이 바뀌는 경우가 생깁니다. 특히 protected 필드/메서드에 기대는 하위 클래스가 많을수록 위험해요.
  2. 캡슐화 약화
    상속은 “재사용”이 아니라 “결합”입니다. 하위 클래스는 상위 클래스의 규칙과 생명주기에 끌려가고, 상위 클래스의 내부 상태를 알아야 올바르게 동작하는 구조가 되기 쉽습니다.
  3. LSP(리스코프 치환 원칙) 위반이 잦음
    겉으론 is-a처럼 보이지만, 실제로는 동작 규약이 달라서 “부모 타입으로 다룰 때” 문제가 터집니다. 대표적으로 컬렉션을 상속해서 일부 메서드를 막거나 의미를 바꾸는 경우가 그렇습니다.

반대로 조합(Composition) 은 “has-a 관계”를 사용합니다. 즉, 기능을 가진 객체를 필드로 들고 위임(delegate) 하는 방식이에요. 조합이 실무에서 강한 이유는 다음과 같습니다.

  • 내부 구현 교체가 쉬움(의존성 주입/테스트 더블도 쉬움)
  • 상위 클래스 변경에 덜 흔들림(인터페이스/역할 중심)
  • 기능을 “쌓아 올리는” 확장이 쉬움(데코레이터/전략 패턴과 궁합 좋음)

선택 기준 한눈에 보기

아래 표는 실무에서 상속/조합을 고를 때 자주 쓰는 체크리스트입니다.

기준 상속(extends)이 더 적합 조합(Composition)이 더 적합
관계 진짜로 is-a이고, 앞으로도 유지될 가능성이 높음 has-a이거나 요구 변경 가능성이 큼
확장 방식 상위 클래스의 “템플릿”을 고정하고 일부만 커스터마이즈 기능을 교체/추가/조립하고 싶음
캡슐화 상위 클래스가 잘 설계되어 있고(훅 메서드 등) 규약이 명확 내부 구현을 숨기고 역할만 노출하고 싶음
테스트 상속 체인이 길어질수록 테스트가 어려워짐 구성 요소를 목/스텁으로 대체 쉬움
대표 패턴 Template Method Strategy / Decorator / Adapter
flowchart TD
  A["\"Need reuse?\""] --> B["\"Is it a true is-a relationship?\""]
  B -->|Yes| C["\"Is parent behavior stable and designed for inheritance?\""]
  C -->|Yes| D["\"Use inheritance carefully\""]
  C -->|No| E["\"Prefer composition\""]
  B -->|No| E["\"Prefer composition\""]

상속을 고려하기 전에 “진짜 is-a인가?”와 “상속을 위해 설계된 부모인가?”를 먼저 확인하는 흐름도입니다.

 

코드 예제: 상속의 함정(깨진 캡슐화) vs 조합으로 안전하게 위임하기

아래 코드는 “카운팅 가능한 Set”을 만들고 싶을 때 흔히 저지르는 실수를 보여줍니다. HashSet을 상속해서 add()에서 카운트를 올리면 될 것 같지만, addAll()이 내부에서 add()를 호출하는 방식(구현 세부사항)에 따라 카운트가 중복되거나, 반대로 카운트가 누락될 수 있습니다. 즉, 부모 구현에 종속되는 순간 깨지기 쉬운 코드가 됩니다.

반면 조합은 Set을 감싸고(delegate) “우리가 정의한 규칙”으로만 카운트를 관리합니다.

import java.util.Collection;
import java.util.HashSet;
import java.util.Set;

public class InheritanceVsCompositionDemo {

    // ❌ 상속의 함정: 부모 구현에 기대면 쉽게 깨집니다.
    // (HashSet의 addAll 구현이 add를 호출하는지 여부 같은 내부 구현에 종속)
    static class CountingHashSet<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(); // addAll 내부에서 add를 호출하면 중복 카운트 위험
            return super.addAll(c);
        }

        public int getAddCount() {
            return addCount;
        }
    }

    // ✅ 조합: Set을 감싸고, 외부에 노출할 동작을 명확히 통제합니다.
    static class CountingSet<E> {
        private final Set<E> delegate;
        private int addCount = 0;

        public CountingSet(Set<E> delegate) {
            this.delegate = delegate;
        }

        public boolean add(E e) {
            boolean added = delegate.add(e);
            if (added) addCount++; // "실제로 추가된 경우만" 카운트
            return added;
        }

        public boolean addAll(Collection<? extends E> c) {
            boolean changed = false;
            for (E e : c) {
                changed |= add(e); // 규칙을 한 곳에 모음
            }
            return changed;
        }

        public boolean contains(E e) {
            return delegate.contains(e);
        }

        public int size() {
            return delegate.size();
        }

        public int getAddCount() {
            return addCount;
        }
    }

    public static void main(String[] args) {
        // 상속 버전
        CountingHashSet<String> bad = new CountingHashSet<>();
        bad.addAll(Set.of("A", "B", "C"));
        System.out.println("[Inheritance] size=" + bad.size() + ", addCount=" + bad.getAddCount());

        // 조합 버전
        CountingSet<String> good = new CountingSet<>(new HashSet<>());
        good.addAll(Set.of("A", "B", "C"));
        System.out.println("[Composition] size=" + good.size() + ", addCount=" + good.getAddCount());

        // 중복 추가 상황에서도 조합은 의도대로 동작하기 쉬움
        good.add("A");
        System.out.println("[Composition after duplicate] size=" + good.size() + ", addCount=" + good.getAddCount());
    }
}

실무 팁

💡 실무에서는: “상속을 써도 되는” 조건을 체크리스트로 고정해 두세요

  • 상위 클래스가 상속을 고려해 설계되어 있나요? (문서화된 훅 메서드, final로 막을 건 막음, 불변식 명확)
  • 하위 클래스가 상위 클래스의 내부 상태/호출 순서를 추측하지 않나요?
  • “부모 타입으로 치환해서 써도” 의미가 유지되나요(LSP)?
    이 3개 중 하나라도 애매하면, 대부분 조합이 더 싸고 안전합니다.

💡 실무에서는: 라이브러리/프레임워크 클래스 상속은 특히 조심하세요
예를 들어 컬렉션 구현체(ArrayList, HashSet)를 상속해서 동작을 바꾸는 건 유지보수 비용이 큽니다. 대신 List, Set 같은 인터페이스에 의존하고, 감싸기(Wrapper)나 데코레이터로 확장하면 버전업/교체에 강해집니다. (필요하면 Collections.unmodifiableList 같은 표준 래퍼도 적극 활용해 보세요.)


핵심 요약: 상속은 재사용이 아니라 결합이며, 부모 구현 변경에 취약해지기 쉽습니다.
조합은 역할 중심으로 위임해 캡슐화를 지키고 변경에 강한 구조를 만듭니다.
상속은 “진짜 is-a + 상속을 위해 설계된 부모”일 때만 제한적으로 쓰는 게 안전합니다.

다음 글: #09 인터페이스와 추상 클래스 실전 구분법