-
Notifications
You must be signed in to change notification settings - Fork 2
Description
Chapter : 4. 클래스와 인터페이스
Item : 18. 상속보다는 컴포지션을 사용하라
Assignee : youngkimi
🍑 서론
이 책에서의 상속은 클래스가 다른 클래스를 확장하는 구현 상속을 말한다. 클래스가 인터페이스를 구현하거나 인터페이스가 다른 인터페이스를 확장하는 인터페이스 상속과는 무관하다.
상속은 코드를 재사용하는 강력한 수단이지만, 잘못 사용하면 오류를 내기 쉬운 소프트웨어를 만들게 된다. 상위 클래스와 하위 클래스를 모두 같은 프로그래머가 통제하는 패키지 내부라면 안전할 수 있으나, 패키지 경계를 넘어 다른 패키지의 구체 클래스를 상속하는 일은 위험하다.
🍑 본론
메서드 호출과 달리 상속은 캡슐화를 깨트린다.
- 상위 클래스의 구현 방법에 따라 하위 클래스 동작에 이상이 생길 수 있다.
- 상위 클래스는 릴리즈마다 내부 구현이 달라질 수 있으며, 이로 인해 코드 한 줄 변경되지 않은 하위 클래스가 오작동할 수도 있다.
- 상위 클래스 구현자가 확장이나 문서화에 대해 고려해두지 않았다면, 하위 클래스는 상위 클래스 수정에 맞추어 수정되어야 한다.
HashSet의 예시
public class MyHashSet<E> extends HashSet<E> {
private int addCount = 0;
public MyHashSet() {
}
@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;
}
}HashSet 을 상속받는 MyHashSet을 구현했다. 성능상의 이점을 위해 add 시 원소가 더해진 개수(addCount)를 기록해둔다.
public class Main {
public static void main(String[] args) {
MyHashSet<Integer> myHashSet = new MyHashSet<>();
myHashSet.addAll(List.of(1, 2, 3));
System.out.println(myHashSet.getAddCount());
}
}반환값은 어떻게 될까? 우리는 3이라 생각하겠지만 실제로는 6의 값이 반환된다.
그 원인은 HashSet의 addAll 메서드가 add 메서드를 사용해 구현되었기 때문이다. 이러한 내부 구현 방식은 HashSet 문서에는 적혀있지 않다.
addAll 시 각 원소를 add 하는데, add는 우리가 재정의한 메서드이다. 따라서 각각의 원소를 add 하면서 addCount가 증가하고, 전체 원소의 개수만큼 중복으로 증가하게 된다.
이 경우에는 addAll이 add를 이용해 구현했음을 가정하고 addAll에서 별도의 count를 증가시키지 않는 방향으로 문제를 해결할 수 있다. 하지만 이는 가정이다. 자신의 내부 다른 부분을 재사용하는 자기사용(self-use) 여부는 해당 클래스의 내부 구현 방식에 해당하며, 이것이 자바 플랫폼 전반적인 정책인지, 다음 릴리즈까지 유지되는지, 혹은 수정될 예정인지 알 수 없다.
물론 addAll에서 상위 addAll을 호출하지 않으면 문제를 해결할 수는 있으나, 상위 메서드의 구현 방식을 재구현하는 것은 어렵고, 시간도 더 들고, 자칫 오류를 내거나 성능을 떨어트릴 수 있다. 또한 하위 클래서에서 접근 불가능한 경우도 있어 이 방식은 구현 자체가 불가능할 가능성이 높다.
또한, 상위 클래스에 메서드가 추가되는 경우에는 하위 클래스에서 재정의하지 않는 메서드를 호출하게 되어 허용되지 않은 원소를 추가할 수도 있다. 실제로 HashTable과 Vector를 컬렉션 프레임워크에 추가하자 이와 관련한 보안 구멍을 수정하는 사태가 발생했다.
위 두 사례에서는 모두 메서드 재정의 (override)가 원인이었다. 메서드를 재정의하는 대신, 새로운 메서드를 추가하면 괜찮을까? 물론 메서드 재정의에 비해서 훨씬 안전한 방식인 것은 맞지만 위험이 없는 것은 아니다. 상위 클래스에서 메서드가 추가되었는데 재수 없게 하위 클래스에서 새로 생성한 메서드와 메서드와 시그니쳐가 같고 반환 타입이 다르다면? 컴파일 조차 되지 않을 것이고, 반환 타입이 같다면 상위 클래스의 메서드를 재정의한 꼴이므로 위 문제와 동일한 문제가 발생한다.
상위 클래스를 상속받아 하위 클래스를 구현하는 것은 위험하다.
기존의 클래스를 확장하지 말고, 새 클래스를 만들고 private 필드로 기존 클래스의 인스턴스를 참고하라.
컴포지션 (Composition)
기존 클래스가 새로운 클래스의 구성 요소로 사용되는 설계 방식
기존 클래스의 내부 구현 방식의 영향에서 벗어나며, 기존 클래스에 메서드가 추가되어도 영향받지 않는다.
전달 (forwarding) : 새 클래스의 인스턴스 메서드에서 기존 클래스에 대응하는 메서드를 호출해 결과 반환하는 것
전달 메서드 (forwarding method) : 새 클래스의 메서드
래퍼 클래스 - 상속 대신 컴포지션 활용
public class MyHashSet<E> extends ForwardingSet<E> {
private int addCount = 0;
public MyHashSet(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(); }
}이 경우, MyHashSet는 HashSet의 모든 기능을 정의한 Set 인터페이스를 활용해 설계되어 견고하고 유연하다.
Set의 인터페이스를 구현하고, Set의 인스턴스를 인수로 받는 생성자를 하나 제공한다. 임의의 Set에 계측 기능을 덧 씌워 새로운 Set으로 만드는 것이 클래스의 핵심이다.
상속 시에는 구체 클래스 각각을 따로 확장해야 하며, 상위 클래스의 생성자 각각에 대응하는 생성자를 별도로 정의해줘야 한다. 하지만 지금 선보인 컴포지션 방식은 한 번만 구현해드면 어떠한 Set 구현체라도 계측할 수 있으며 기존 생성자들과도 함께 사용할 수 있다.
static void walk(Set<Dog> dogs {
MyHashSet<Dog> iDogs = new MyHashSet<>(dogs);
... // 이 메서드에서는 dogs 대신 iDogs 사용.
}다른 Set 인스턴스를 감싸고 있다는 점에서 MyHashSet와 같은 클래스를 래퍼(Wrapper) 클래스라고 부르고, 다른 Set에 계측 기능을 덧씌운다는 뜻에서 데코레이터 패턴이라고 한다. 컴포지션과 전달의 조합은 넓게 위임 (delegation)이라고 부른다.
엄밀히 따지면 래퍼 객체가 내부 객체의 자기 자신의 참조를 넘기는 경우만 위임에 해당한다.
래퍼 클래스는 단점이 거의 없다. 래퍼 클래스가 콜백(Callback) 프레임워크와 어울리지 않는다는 점만 주의하면 된다. 콜백 프레임워크에서는 자기 자신의 참조를 다른 객체에 넘겨서 다음 호출에 사용하려 한다. 내부 객체는 자신을 감싼 래퍼의 존재를 모르니 자신의 참조를 넘기고, 콜백 때는 래퍼가 아닌 내부 객체를 호출하게 된다. 이를 SELF 문제라고 부른다.
전달 메서드를 작성하는 것이 지루하겠지만, 재사용할 수 있는 전달 클래스를 인터페이스당 하나씩만 만들어두면 원하는 기능을 덧씌우는 전달 클래스들을 손쉽게 구현할 수 있다. [위 재사용 가능한 전달 클래스 참조]
🍑 결론
클래스 B가 클래스 A와 is-a 관계일 때만 클래스 A를 상속해야 한다. 그렇지 않다면 A를 private 인스턴스로 두고, A와는 다른 API를 제공해야 하는 상황이 대다수이다. 이 경우, A는 B의 필수 구성 요소가 아니라 구현 방법 중 하나일 뿐이다.
상속은 하위 클래스가 상위 클래스의 "진짜" 하위 타입인 상황에서만 쓰여야 한다. 컴포지션을 사용해야 할 상황에서 상속을 구현하는 것은 내부 구현을 불필요하게 노출하는 꼴이고, 그 결과 API가 내부 구현에 묶이고 클래스의 성능도 영원히 제한된다.
더 큰 문제는 클라이언트가 노출된 내부에 직접 접근할 수 있다는 점이다. 이는 사용자를 혼란스럽게 할 수 있다. 이 경우 클라이언트가 상위 클래스를 수정하여 하위 클래스의 불변식 을 해칠 수도 있다.
상속은 강력하나, 캡슐화를 해친다. 상속은 상위 클래스와 하위 클래스가 is-a 관계일 때만 사용해야 한다. is-a관계라 하더라도 패키지가 다르거나 확장을 고려하여 설계되지 않은 경우에는 컴포지션을 사용하는 것이 바람직하다.