📌 상속의 문제점
상속은 코드를 재사용하는 강력한 수단이지만, 항상 최선은 아니다.
같은 프로그래머가 통제하는 패키지 안에서라면 상속도 안전한 방법이다.
확장할 목적으로 설계되었고 문서화도 잘 된 클래스(Item 19)라면 마찬가지로 안전하다.
하지만 일반적인 구체 클래스를 패키지 경계를 넘어, 다른 패키지의 구체 클래스를 상속하는 일은 위험하다.
(참고로 여기서 '상속'은 클래스가 다른 클래스를 확장하는 구현 상속을 말하고, 인터페이스 상속과는 무관하다.)
1️⃣ 예제1
메서드 호출과 달리 상속은 캡슐화를 깨뜨린다.
상위 클래스는 릴리즈마다 내부 구현이 달라질 수 있으며, 그 여파로 하위 클래스는 아무것도 건드리지 않았는데 오동작할 수 있다.
만약, 상위 클래스 설계자가 확장을 충분히 고려하지 않고, 문서화도 제대로 해두지 않으면 하위 클래스는 상위 클래스에 발맞춰 수정되어야만 한다.
예시로 HashMap 기능을 사용하면서, 생성된 이후 몇 개의 원소가 더해졌는지 알 수 있는 기능을 추가한 클래스를 구현해보자.
public class InstrumentedHashSet<E> extends HashSet<E> {
private int addCount = 0;
public InstrumentedHashSet(int initCap, float loadFactor) {
super(initCap, loadFactor);
}
@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);
}
public int getAddCount() {
return addCount;
}
}
InstrumentedHashSet<String> s = new InstrumentedHashSet<>();
s.addAll(List.of("", "용용", "용용용"));
// java9 이전 버전은 List.of 대신 Arrays.asList() 사용
getAddCount를 호출하면 3이 반환되어야 할 것 같지만, 실제로는 6을 반환한다.
그 이유는 HashSet의 addAll 메서드가 각 원소에 대해 add 메서드를 호출하기 때문에 발생한다.
public boolean addAll(Collection<? extends E> c) {
boolean modified = false;
for (E e : c)
if (add(e))
modified = true;
return modified;
}
super.addAll()로 HashSet의 메서드를 호출해도 내부적으로 add를 호출하면 InstrumentedHashSet에서 재정의한 add 메서드가 호출되므로 count 증가가 중복되는 것이다.
이 문제를 임시 방편으로 해결하는 방법이 두 가지가 있다.
- HashSet의 addAll 메서드를 재정의하지 않는 경우
- HashSet의 addAll이 add 메서드를 이용해 구혔했음을 가정한다는 한계점이 있다. (현재 addAll 메서드의 구조에만 의존하고 있다. 구조 변화가 일어나면 반드시 문제가 생긴다.)
- 자신의 다른 부분을 사용하는 '자기사용(self-use)' 여부는 해당 클래스의 내부 구현 방식에 해당하며, 다음 릴리즈에서도 유지될 지 보장할 수 없다.
- addAll 메서드를 다른 식으로 재정의 하는 경우
- 주어진 컬렉션을 순회하며 원소 하나당 add 메서드를 한 번만 호출하게 할 수 있다.
- 하지만 상위 클래스 메서드와 똑같이 동작하도록 구현해야 하는데, 어려울 수 있으며, 시간 소모도 크고, 오류 및 성능 하락의 문제를 동반할 수 있다.
2️⃣ 예제2
보안 이슈로 인해서, 컬렉션의 모든 원소가 특정 조건을 만족해야만 하는 프로그램을 생각해보자.
해당 컬렉션을 상속하여 원소를 추가하는 모든 메서드를 재정의하고 필요한 조건을 먼저 검사하게끔 하면 지금 당장은 잘 동작할 것이다.
하지만, 다음 릴리즈에서 상위 클래스에 또 다른 원소 추가 메서드가 만들어지면, 하위 클래스에서 재정의 하지 못한 새로운 메서드를 사용해 허용되지 않은 원소를 추가할 수 있게 된다.
실제로 Hashtable과 Vector를 컬렉션 프레임워크에 포함시키면서 발생한 보안 구멍들을 모두 수정해야 하는 사태가 있었다.
📌 위 두 방식의 공통적인 문제점
두 방식 모두 메서드 재정의로 인한 문제점이 발생했다.
재정의 대신 새로운 메서드를 추가하더라도 보다 안전할 수는 있으나, 운이 안 좋게도 다음 릴리즈에서 상위 클래스에 추가된 새 메서드가 하위 클래스에 추가한 메서드와 시그니처가 같을 수 있다.
이 경우 컴파일이 안 될 뿐더러, 해당 메서드를 작성하는 시점만 해도 상위 클래스의 메서드는 존재하지 않았으니 당연히 규약을 만족하지 못할 가능성이 크다.
📌 컴포지션 (Composition)
컴포지션은 새로운 클래스를 만들고 private 필드로 기존 클래스의 인스턴스를 참조하게 하는 방법이다.
즉, 기존 클래스가 새로운 클래스의 구성 요소로써 쓰이게 되는 구조다.
새 클래스의 인스턴스 메서드들은 private 필드로 참조하는 기존 클래스의 대응하는 메서드를 호출해 그 결과를 반환한다.
이 방식을 전달(Fowarding)이라 하며, 새 클래스의 메서드들을 전달 메서드(Forwarding method)라 부른다.
public class ForwardingSet<E> implements Set<E> {
private final Set<E> s;
public ForwardingSet(Set<E> s) { this.s = s; }
@Override public int size() { return s.size(); }
@Override public boolean isEmpty() { return s.isEmpty(); }
@Override public boolean contains(Object o) { return s.contains(o); }
@Override public Iterator<E> iterator() { return s.iterator(); }
@Override public Object[] toArray() { return s.toArray(); }
@Override public <T> T[] toArray(T[] a) { return s.toArray(a); }
@Override public boolean add(E e) { return s.add(e); }
@Override public boolean remove(Object o) { return s.remove(o); }
@Override public boolean containsAll(Collection<?> c) { return s.containsAll(c); }
@Override public boolean addAll(Collection<? extends E> c) { return s.addAll(c); }
@Override public boolean retainAll(Collection<?> c) { return s.retainAll(c); }
@Override public boolean removeAll(Collection<?> c) { return s.removeAll(c); }
@Override public void clear() { s.clear(); }
}
public class InstrumentedSet<E> extends ForwardingSet<E> {
private int addCount = 0;
public InstrumentedSet(Set<E> s) {
super(s);
}
@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);
}
public int getAddCount(){ return addCount; }
}
임의의 Set에 계측 기능을 덧씌워 새로운 Set으로 만드는 것이 이 클래스의 핵심이다.
상속 방식은 구체 클래스 각각을 따로 확장해야 하고, 지원하고 싶은 상위 클래스의 생성자 각각에 대응하는 생성자를 별도로 정의해주어야 한다.
하지만 컴포지션은 한 번만 구현해두면 어떠한 Set 구현체라도 계측할 수 있으며, 기존 생성자들과도 함께 사용할 수 있다.
InstrumentedSet<String> is = new InstrumentedSet<>(new HashSet<>());
is.addAll(List.of("Effecive", "Java", "3/E"));
System.out.println(is.getAddCount());
InstrumentedSet<Integer> is = new InstrumentedSet<>(new TreeSet<>());
is.addAll(List.of(1,2,3));
System.out.println(is.getAddCount());
static void walk(Set<Dog> dogs) {
InstrumentedSet<Dog> iDogs = new InstrumentedSet<>(dogs);
...
}
다른 Set 인스턴스를 감싸고 있다는 뜻에서 InstrumentedSet 같은 클래스를 래퍼 클래스라 하며, 다른 Set에 계측 기능을 덧씌운다는 뜻에서 데코레이터 패턴(Decorator pattern)이라고도 한다.
컴포지션과 전달의 조합은 넓은 의미로 위임(delegation)이라고도 하는데, 엄밀히 따지면 래퍼 객체가 내부 객체에 자기 자신의 참조를 넘기는 경우에만 해당한다.
📌 컴포지션의 단점
래퍼 클래스는 단점이 거의 없으나 콜백 프레임 워크와는 어울리지 않는다.
콜백 프레임워크에서는 자기 자신의 참조를 다른 객체에 넘겨서 다음 호출 때 사용하도록 한다.
내부 객체는 자신을 감싸고 있는 래퍼의 존재를 모르니 this의 참조를 넘기고, 콜백 대 래퍼가 아닌 내부 객체를 호출하게 된다. (이를 SELF 문제라고 한다.)
다만, 전달 메서드가 성능에 주는 영향이나 래퍼 객체가 메모리 사용량에 주는 영향은 실전에서 별다른 영향이 없다고 밝혀졌다.
전달 메서드들을 작성하는 게 다소 지루할 수는 있으나, 재사용할 수 있는 전달 클래스를 인터페이스당 하나씩만 만들어두면 원하는 기능을 덧씌우는 전달 클래스들을 아주 손 쉽게 구현할 수 있다.
📌 컴포지션 대신 상속을 결정하기 전 고려할 점
상속은 반드시 하위 클래스가 상위 클래스의 진짜 하위 타입인 상황에서만 사용해야 한다.
클래스 B가 A를 상속하려 할 때, 클래스 B가 클래스 A라고 확신할 수 없다면 상속해서는 안 된다.
아예 대답이 "아니다"라고 확신이 든다면 A를 private 인스턴스로 두고, A와는 다른 API를 제공해야 하는 상황이 대다수다.
즉, A는 B의 필수 구성요소가 아니라 구현하는 방법 중 하나일 뿐이다.
(Vector를 확장한 Stack과 Hashtable을 확장한 Properties도 원칙을 위반한 클래스이다.)
컴포지션 대신 상속을 사용하기로 결정하기 전에 마지막으로 자문해야 할 질문들이 있다.
- 확장하려는 클래스의 API에 아무런 결함이 없는가?
- 결함이 있다면, 이 결함이 내 클래스 API까지 전파돼도 괜찮은가?
컴포지션으로는 이런 결함을 숨기는 새로운 API를 만들 수 잇지만, 상속은 상위 클래스의 API를 결함까지도 승계해버린다.