이펙티브 자바 완벽 공략 2부
아이템 18. 상속보다는 컴포지션을 사용하라
아이템 18. 상속보다는 컴포지션을 사용하라
- 아이템 18. 상속보다는 컴포지션을 사용하라
- 아이템 18. 핵심 정리
- 아이템 18. 상속보다는 합성을 사용하라
- 구현 상속과 인터페이스 상속은 다르다
- 구현 상속이 문제가 되는 이유
- 예제 : InstrumentedHashSet
- 예상한 결과
- 실제 결과
- HashSet 내부 구현 때문이다
- 상속 때문에 내부 구현을 알게 된다
- 캡슐화가 깨진 것이다
- 상위 클래스 구현이 변경되면?
- 새로운 메서드가 추가되면?
- 기능 누락이 발생할 수 있다
- 메서드 이름 충돌 문제
- 이러한 문제의 원인
- 해결책은 합성(Composition)
- 모든 기능을 위임한다
- ForwardingSet의 역할
- InstrumentedSet은 ForwardingSet을 상속한다
- 왜 이제는 3이 나올까?
- 합성이 캡슐화를 지켜준다
- 새로운 메서드가 추가되어도 안전하다
- 상속보다 합성이 더 안전한 이유
- 핵심 정리
- 한 줄 요약
- 아이템 18. 핵심 정리
아이템 18. 핵심 정리
아이템 18. 상속보다는 합성을 사용하라
이번 아이템에서는 객체지향 설계에서 가장 많이 들어본 원칙 중 하나인 “상속(Inheritance)보다는 합성(Composition)을 사용하라”를 다룬다.
이 문장은 많이 들어봤지만 왜 상속이 문제가 되고, 합성이 어떻게 그 문제를 해결하는지를 제대로 이해하는 것이 중요하다.
이 아이템은 단순히 “상속을 쓰지 말라”는 이야기가 아니다.
구현 상속(Implementation Inheritance) 이 가지는 구조적인 문제를 설명하고, 그 대안으로 합성(Composition) 과 위임(Delegation) 을 사용하는 설계를 소개한다.
구현 상속과 인터페이스 상속은 다르다
먼저 이 아이템에서 말하는 상속은 구현 상속이다.
즉, 구체적인 클래스를 extends 하는 경우를 의미한다.
class InstrumentedHashSet extends HashSet<E> {
}
반면 인터페이스를 구현하는 것은 이 아이템에서 말하는 상속 문제가 아니다.
class MemberServiceImpl implements MemberService {
}
또는
interface B extends A {
}
처럼 인터페이스를 구현하거나 인터페이스끼리 상속하는 것은 별개의 이야기이다.
따라서 이 아이템은 구현 클래스를 상속할 때 발생하는 문제점을 설명하는 내용이라고 이해하면 된다.
구현 상속이 문제가 되는 이유
구현 상속의 가장 큰 문제는 상위 클래스의 내부 구현에 하위 클래스가 의존하게 된다는 것이다.
즉,
- 상위 클래스의 내부 구현을 알아야 하고
- 내부 구현이 변경되면 하위 클래스도 영향을 받는다.
결국 캡슐화가 깨지게 된다.
예제 : InstrumentedHashSet
책에서는 다음과 같은 예제를 사용한다.
class InstrumentedHashSet<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);
}
}
의도는 단순하다.
요소가 추가될 때마다 개수를 세고 싶은 것이다.
예상한 결과
예를 들어
set.addAll(List.of("A", "B", "C"));
를 호출했다면
추가된 요소는
3개
이므로
addCount = 3
이 되어야 할 것 같다.
실제 결과
하지만 실제 결과는
6
이 나온다.
왜 그럴까?
HashSet 내부 구현 때문이다
원인은 HashSet 내부 구현에 있다.
addAll() 내부에서는
add()
를 반복 호출한다.
즉,
addAll()
↓
add()
↓
add()
↓
add()
라는 구조인 것이다.
상속 때문에 내부 구현을 알게 된다
우리는 단순히
super.addAll(c);
만 호출했을 뿐인데
실제로는
super.addAll()
↓
add()
↓
오버라이딩된 add()
↓
addCount++
가 다시 실행된다.
결국
addAll()
↓
+3
↓
add() 3번
↓
+3
이 되어
6
이 출력된다.
캡슐화가 깨진 것이다
이 문제의 핵심은
상위 클래스의 내부 구현을 알아야 한다는 것
이다.
즉,
HashSet.addAll()
↓
내부적으로 add()를 호출한다.
는 사실을 알아야 올바르게 구현할 수 있다.
이것은 상위 클래스의 내부 구현이 외부에 노출된 것과 다름없다.
즉,
캡슐화가 깨진 것이다.
상위 클래스 구현이 변경되면?
더 큰 문제는 미래에 발생한다.
예를 들어 HashSet의 내부 구현이 변경되어
add()
를 사용하지 않고
새로운 방식으로 구현되었다고 가정해 보자.
그러면
InstrumentedHashSet
도 함께 수정해야 한다.
즉,
상위 클래스의 내부 구현 변경이
하위 클래스의 수정으로 이어진다.
새로운 메서드가 추가되면?
또 다른 문제도 있다.
우리는
모든 요소 추가
를 카운트하고 싶다.
그래서
add()
addAll()
을 모두 오버라이딩했다.
그런데 미래에 HashSet에
addRange()
같은 메서드가 새로 생겼다고 해보자.
그러면 어떻게 될까?
우리는 그것도 오버라이딩해야 한다.
하지만 그 사실을 모를 수도 있다.
기능 누락이 발생할 수 있다
새로운 메서드를 오버라이딩하지 않았다면
add()
↓
카운트 증가
는 되지만
addRange()
에서는
카운트가 증가하지 않는다.
즉,
일부 경로에서만 부가 기능이 수행되는
버그가 생길 수 있다.
메서드 이름 충돌 문제
또 다른 문제도 있다.
하위 클래스에서
private int getAddCount()
같은 메서드를 만들었다고 해보자.
그런데 미래에
상위 클래스에도
public int getAddCount()
가 추가되었다면?
메서드 충돌이 발생할 수 있으며,
접근 제한자 문제나 API 변경으로 인해 코드가 깨질 수도 있다.
이러한 문제의 원인
결국 모든 문제는 하나의 원인에서 시작된다.
하위 클래스가
상위 클래스의 구현에 의존하기 때문
이다.
상속은
API뿐 아니라
구현까지 함께 물려받는다.
그래서
상위 클래스 내부 구현 변경이
하위 클래스에 영향을 준다.
해결책은 합성(Composition)
이 문제를 해결하기 위해 Effective Java는
합성(Composition) 을 권장한다.
상속 대신
필드로 객체를 보관하는 것이다.
class ForwardingSet<E> {
private final Set<E> set;
public ForwardingSet(Set<E> set) {
this.set = set;
}
}
즉,
기존 클래스를 상속하는 것이 아니라
멤버 변수로 가지고 있는 것이다.
모든 기능을 위임한다
ForwardingSet은
자신이 직접 구현하지 않고
모든 기능을 내부 객체에 전달한다.
public boolean add(E e) {
return set.add(e);
}
public boolean addAll(Collection<? extends E> c) {
return set.addAll(c);
}
이처럼
모든 메서드를
내부 객체에게 위임(Delegation) 한다.
ForwardingSet의 역할
이 클래스는 흔히
- Forwarding Class
- Wrapper Class
라고 부른다.
또한 디자인 패턴에서는
Decorator Pattern의 대표적인 형태이기도 하다.
InstrumentedSet은 ForwardingSet을 상속한다
이제 InstrumentedSet은
HashSet을 상속하지 않는다.
대신
ForwardingSet을 상속한다.
class InstrumentedSet<E> extends ForwardingSet<E> {
private int addCount;
@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);
}
}
여기서
super.addAll()
은
HashSet의 구현을 호출하는 것이 아니라
ForwardingSet의 메서드를 호출한다.
왜 이제는 3이 나올까?
호출 구조가 완전히 달라졌기 때문이다.
InstrumentedSet
↓
ForwardingSet
↓
HashSet
addAll()은
ForwardingSet
↓
HashSet.addAll()
로 전달된다.
더 이상
오버라이딩된 add()를 다시 호출하지 않는다.
따라서
+3
만 수행되고
결과는
3
이 된다.
합성이 캡슐화를 지켜준다
합성을 사용하면
HashSet 내부 구현이 어떻게 바뀌든
전혀 영향을 받지 않는다.
왜냐하면 우리가 의존하는 것은
HashSet 구현이 아니라
Set 인터페이스의 계약이기 때문이다.
즉,
HashSet 내부 구현
↓
변경
↓
ForwardingSet
↓
영향 없음
캡슐화가 유지된다.
새로운 메서드가 추가되어도 안전하다
HashSet에만
새로운 메서드가 추가된다면
우리 코드는 영향을 받지 않는다.
반대로
Set 인터페이스에 메서드가 추가된다면
ForwardingSet이 컴파일 오류를 발생시킨다.
즉,
개발자가 반드시 변경 사항을 인지하게 된다.
그 결과
필요한 부가 기능을
명시적으로 추가할 수 있다.
상속보다 합성이 더 안전한 이유
합성을 사용하면
- 상위 클래스 내부 구현을 몰라도 된다.
- 내부 구현 변경의 영향을 받지 않는다.
- 캡슐화가 유지된다.
- 예상하지 못한 부작용(Side Effect)이 줄어든다.
- 기능 확장이 훨씬 안전하다.
즉,
구현에 의존하지 않고
인터페이스의 계약만 의존하는 구조가 된다.
핵심 정리
- 이 아이템은 인터페이스 상속이 아니라 구현 상속의 문제점을 다룬다.
- 구현 상속은 상위 클래스의 내부 구현에 의존하게 되어 캡슐화를 깨뜨린다.
- 상위 클래스의 내부 구현이 변경되면 하위 클래스도 영향을 받는다.
- 새로운 메서드가 추가되면 기능 누락이나 버그가 발생할 수 있다.
- 합성은 객체를 멤버 변수로 보관하고 필요한 기능을 위임하는 방식이다.
ForwardingSet처럼 위임을 사용하면 상위 클래스 구현 변경의 영향을 받지 않는다.- 합성은 인터페이스의 계약에만 의존하므로 상속보다 훨씬 유연하고 안전한 설계를 만들 수 있다.
한 줄 요약
구현 상속은 상위 클래스의 내부 구현까지 함께 상속받기 때문에 캡슐화를 깨뜨릴 수 있으며, 객체를 포함하고 기능을 위임하는 합성(Composition)이 더 유연하고 안전한 설계 방법이다.