상속보다는 컴포지션을 사용하라
상속은 코드를 재사용하는 강력한 수단이지만, 항상 최선은 아니다. 잘못 사용하면 오류를 내기 쉬운 소프트웨어를 만들게 된다. 다른 패키지의 구체 클래스를 상속하는 일은 위험하다. 여기서 말하는 상속은 클래스가 다른 클래스를 확장하는 구현 상속을 말한다. 이번 아이템에서의 상속은 클래스가 인터페이스를 구현하거나 인터페이스가 다른 인터페이스를 확장하는 인터페이스 상속과는 무관하다.
상속은 캡슐화를 깨뜨린다.
상위 클래스가 어떻게 구현되느냐에 따라 하위 클래스의 동작에 이상이 생길 수 있다. 상위 클래스는 릴리스마다 내부 구현이 달라질 수 있으며, 그 여파로 하위 클래스가 오동작할 수 있다.
상속의 잘못된 예
public class InstrumentedHashSet<E> extends HashSet<E> {
// 추가된 원소의 수
private int addCount = 0;
public InstrumentedHashSet() {
}
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;
}
public static void main(String[] args) {
InstrumentedHashSet<String> s = new InstrumentedHashSet<>();
s.addAll(List.of("틱", "탁", "딱"));
System.out.println(s.getAddCount()); // 6
}
}
main 메서드의 getAddCount 메서드를 호출 하면 3을 반환해야 하는데 6을 반환한다. 그 원인은 HashSet의 addAll 메서드가 add 메서드를 사용해 구현된 데 있다.
InstrumentedHashSet의 addAll은 addCount에 3을 더한 후 HashSet의 addAll 구현을 호출했다. HashSet의 addAll은 각 원소를 add 메서드를 호출해 추가하는데, 이때 불리는 add는 InstrumentedHashSet에서 재정의한 메서드다. 그래서 addAll로 추가한 원소 하나당 2씩 늘어났다.
하위 클래스에서 addAll 메서드를 재정의하지 않으면 문제를 고칠 수 있다. 하지만 당장은 제대로 동작할지 모르나, HashSet의 addAll이 add메서드를 이용해 구현했음을 가정한 해법이라는 한계를 지닌다.
상속으로 인한 하위 클래스
addAll 메서드 수정하여 HashSet의 addAll을 호출하지 않음으로 위 문제를 해결할 수 있지만 상위 클래스의 메서드 동작을 다시 구현하는 이 방식은 어렵고, 시간도 들고, 오류를 내거나 성능을 떨어뜨릴 수 있다. 또한 하위 클래스에서는 접근할 수 없는 private 필드를 써야 하는 상황이라면 이 방식으로는 구현 자체가 불가능하다.
상위 클래스에 새로운 원소 추가 메서드가 만들어 진다면, 하위 클래스에서 재정의하지 못한 그 새로운 메서드를 사용해 허용되지 않은 원소를 추가할 수 있게 된다. 실제로 Hashtable과 Vector를 컬렉션 프레임워크에 포함시켰을 때 이런 보안 이슈가 발생했다.
메서드를 재정의하는 대신 새로운 메서드를 추가하면 훨씬 안전하지만, 운없게 하위 클래스에 추가한 메서드와 시그니처가 같고 반환 타입은 다르다면 해당 클래스는 컴파일조차 되지 않는다. 혹 반환타입 마저 같다면 상위 클래스의 새 메서드를 재정의한 꼴이니 앞의 문제와 똑같아진다. 그리고 상위 클래스의 메서드가 요구하는 규약을 만족하지 못할 가능성도 크다.
컴포지션
기존 클래스를 확장하는 대신, 새로운 클래스를 만들고 private 필드로 기존 클래스의 인스턴스를 참조하게 하자. 새 클래스의 인스턴스 메서드들은 기존 클래스의 대응하는 메서드를 호출해 그 결과를 반환한다. 이 방식을 전달(forwarding)이라 하며, 새 클래스의 메서드들을 전달 메서드(forwarding method)라 부른다.
래퍼 클래스
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;
}
}
전달 클래스
public class ForwardingSet<E> implements Set<E> {
private final Set<E> s;
public ForwardingSet(Set<E> s) { this.s = s; }
public void clear() { s.clear(); }
public boolean contains(Object o) { return s.contains(o); }
public boolean isEmpty() { return s.isEmpty(); }
public int size() { return s.size(); }
public Iterator<E> iterator() { return s.iterator(); }
public boolean add(E e) { return s.add(e); }
public boolean remove(Object o) { return s.remove(o); }
public boolean containsAll(Collection<?> c)
{ return s.containsAll(c); }
public boolean addAll(Collection<? extends E> c)
{ return s.addAll(c); }
public boolean removeAll(Collection<?> c)
{ return s.removeAll(c); }
public boolean retainAll(Collection<?> c)
{ return s.retainAll(c); }
public Object[] toArray() { return s.toArray(); }
public <T> T[] toArray(T[] a) { return s.toArray(a); }
@Override public boolean equals(Object o)
{ return s.equals(o); }
@Override public int hashCode() { return s.hashCode(); }
@Override public String toString() { return s.toString(); }
}
컴포지션 방식은 한 번만 구현해두면 어떠한 Set 구현체라도 계측할 수 있으며, 기존 생성자들과도 함께 사용할 수 있다.
Set<Instant> times = new InstrumentedSet<>(new TreeSet<>(cmp));
Set<E> s = new InstrumentedSet<>(new HashSet<>(INIT_CAPACITY));
다른 Set 인스턴스를 감싸고(wrap) 있다는 뜻에서 InstrumentedSet 같은 클래스를 래퍼 클래스라 하며, 다른 Set에 계측 기능을 덧씌운다는 뜻에서 데코레이터 패턴이라고 한다. 컴포지션과 전달의 조합은 넓은 의미로 위임(delegation)이라고 부른다.
래퍼클래스와 콜백 프레임워크
래퍼 클래스는 콜백 프레임워크와는 어울리지 않는다. 콜백 프레임워크에서는 자기 자신의 참조를 다른 객체에 넘겨서 다음 콜백 때 사용하도록 한다. 내부 객체는 자신을 감싸고 있는 래퍼의 존재를 모르니 대신 자신의 참조를 넘기고, 콜백 때는 래퍼가 아닌 내부 객체를 호출하게 된다. 이를 SELF 문제라 한다. 전달 메서드가 성능에 주는 영향이나 래퍼 객체가 메모리 사용량에 주는 영향을 걱정하는 사람도 있지만, 실전에서는 둘다 별다른 영향이 없다고 밝혀졌다.
재사용할 수 있는 전달 클래스를 인터페이스당 하나씩만 만들어두면 원하는 기능을 덧씌우는 전달 클래스들을 아주 쉽게 구현할 수 있다. 좋은 예로, Guava는 모든 컬렉션 인터페이스용 전달 메서드를 전부 구현해뒀다.
컴포지션과 상속
상속은 클래스 B가 클래스 A와 is-a 관계일 때만 클래스 A를 상속해야 한다. 컴포지션을 써야 할 상황에서 상속을 사용하는 건 내부 구현을 불필요하게 노출하는 꼴이다. 그 결과 API가 내부 구현에 묶이고 그 클래스의 성능도 영원히 제한된다. 더 심각한 문제는 클라이언트가 노출된 내부에 직접 접근 할 수 있다는 점이다. 그리고 사용자를 혼란스럽게 할 수 있다.
확장하려는 클래스의 API에 결함이 있는 경우, 컴포지션으로는 이런 결함을 숨기는 새로운 API를 설계할 수 있지만, 상속은 상위 클래스 API의 결함까지 승계한다.
상속은 강력하지만 캡슐화를 해친다는 문제가 있다. 상속은 상위 클래스와 하위 클래스가 순수한 is-a 관계일 때만 써야 한다. is-a 관계일 때도 안심할 수만은 없는 게, 하위 클래스의 패키지가 상위 클래스와 다르고, 상위 클래스가 확장을 고려해 설계되지 않았다면 여전히 문제가 될 수 있다. 상속의 취약점을 피하려면 컴포지션과 전달을 사용하자.
Comments