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

상속은 is-a 관계를 코드로 고정하는 도구입니다. 문제는 요구사항이 바뀌면 is-a가 쉽게 무너진다는 점이에요. 예를 들어 “알림”이 처음엔 이메일만 있었는데, 나중엔 슬랙/푸시가 추가되면 “이메일 알림 is a 알림” 같은 계층이 급격히 복잡해집니다.
실무에서 extends가 함정이 되는 대표 포인트는 아래 3가지입니다.
- 상위 클래스 변경의 파급(Fragile Base Class)
상위 클래스의 내부 구현이 바뀌면, 하위 클래스는 컴파일은 되는데 런타임 동작이 바뀌는 경우가 생깁니다. 특히protected필드/메서드에 기대는 하위 클래스가 많을수록 위험해요. - 캡슐화 약화
상속은 “재사용”이 아니라 “결합”입니다. 하위 클래스는 상위 클래스의 규칙과 생명주기에 끌려가고, 상위 클래스의 내부 상태를 알아야 올바르게 동작하는 구조가 되기 쉽습니다. - 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 인터페이스와 추상 클래스 실전 구분법
'JAVA' 카테고리의 다른 글
| Java 블로그 로드맵 — Java 17 기준 40편 커리큘럼 한눈에 보기 (0) | 2026.02.16 |
|---|---|
| Java 인터페이스 vs 추상 클래스 실전 구분법 (default 메서드, 다중 구현 패턴까지) (0) | 2026.02.16 |
| Java 접근 제어자와 캡슐화 — public/private/protected/default 제대로 쓰기 (0) | 2026.02.15 |
| Java 메서드 잘 만드는 법: 파라미터 설계부터 반환 타입, 오버로딩, 길이 원칙까지 (0) | 2026.02.14 |
| Java 클래스와 객체 — 왜 나눠야 할까? (설계 기초부터 생성자·this·네이밍까지) (0) | 2026.02.14 |