Effective Java - Item 18

Updated:

Effective Java

Item 18. 상속보다는 컴포지션을 사용하라

상속(extends)의 한계

상속은 코드를 재사용하는 강력한 수단이지만, 항상 최선은 아니다. 특히 패키지 경계를 넘어, 다른 패키지의 클래스를 상속하는 일은 위험하다.
상속은 메서드 호출과 달리 하위 클래스의 캡슐화를 깨뜨릴 수 있다. 상위 클래스의 구현이 바뀌면 하위 클래스에도 영향이 있기 때문이다.

다음 코드는, HashSet 클래스를 상속받아, add()와 addAll() 메서드를 재정의하여 원소를 add 할 때마다 추가한 원소의 갯수를 세는 클래스를 작성하였다.

public class WrongSet<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 = addCount + c.size();
      return super.addAll(c);
  }

  public int getAddCount() {
      return addCount;
  }
}

하지만 제대로 동작하지 않는데, 이유는 HashSet의 addAll() 메서드의 구현을 보면 add()메서드를 매번 호출하여 원소를 추가한다.
따라서, addAll()에서 addCount를 원소 개수 만큼 증가시켰지만, 재정의한 add() 함수를 또 호출하여 원소를 추가한다.
이에따라, 추가로 원소 개수만큼 재정의한 add() 메서드의 addCount++가 실행되어 2배의 개수가 count 된다.

public boolean addAll(Collection<? extends E> c) {
  boolean modified = false;
  for (E e : c)
      if (add(e))
          modified = true;
  return modified;
}

addAll() 메서드를 재정의하지 않거나, 다른식으로 재정의 하여 해결할 수는 있지만 여전히 문제가 있다.
상위 클래스의 메서드 동작을 다시 구현하는 이 방식은 어렵고, 시간도 더 들고, 자칫 오류를 내거나 성능을 떨어뜨릴 수 있다.

이외에도 상속에 의해 하위 클래스가 깨지기 쉬운 이유는 더 있다.
상위 클래스에 새로운 메서드를 추가하면, 하위 클래스에서 미처 재정의를 하지 못하여 보안 구멍이 생기는 경우도 있다.
또한, 하위 클래스의 메서드가 상위 클래스의 새로 추가한 메서드와 시그니처가 같고 반환 타입이 달라 컴파일 조차 안되거나, 둘 다 같아버려 새 메서드를 재정의 해버리는 경우가 생긴다.

컴포지션

기존 클래스를 확장하는 대신, 새로운 클래스를 만들고 private 필드로 기존 클래스의 인스턴스를 참조하게 한다.
새 클래스의 인스턴스 메서드들은 기존 클래스에 대응하는 메서드를 호출하여 그 결과를 바로 반환한다.
이 방식을 전달(forward)라고 하며, 이 메서드들을 전달 메서드(forward method)라고 한다.

public class GoodSet<E> extends ForwardingSet<E>  {
    private int addCount = 0;

    public GoodSet(Set<E> set) {
        super(set);
    }

    @Override
    public boolean add(E e) {
        addCount++;
        return super.add(e);
    }

    @Override
    public boolean addAll(Collection<? extends E> collection) {
        addCount = addCount + collection.size();
        return super.addAll(collection);
    }

    public int getAddCount() {
        return addCount;
    }
}
// 전달 클래스
public class ForwardingSet<E> implements Set<E> {
    private final Set<E> set;         // 상속 대신 인스턴스를 참조한다.
    public ForwardingSet(Set<E> set) { this.set = set; }
    public void clear() { set.clear(); }
    public boolean isEmpty() { return set.isEmpty(); }
    public boolean add(E e) { return set.add(e); }
    public boolean addAll(Collection<? extends E> c) { return set.addAll(c); }
    // ... 생략
}

이 결과 새로운 클래스는 기존 클래스의 내부 구현 방식의 영향에서 벗어나며, 새로운 메서드가 추가되더라도 전혀 영향받지 않는다.
Set 인스턴스를 감싸고 있다는 뜻에서 GoodSet과 같은 클래스를 Wrapper Class라고 하며, Set에 계측 기능을 덧씌운다는 뚯에서 데코레이터 패턴이라고 한다.

상속이 쓰이는 경우

상속은 반드시 상위 / 하위 클래스가 순수한 is-a 관계일때만 사용해야한다.

is-a : ~은 ~이다.
학생은 사람이다.

상위 클래스를 A, 하위 클래스를 B라고 하자,
B 가 정말 A 인가?를 생각하여, 맞으면 상속 아니면 private 인스턴스로 둔다.
즉 A가 B의 필수 구성요소가 아니면, 그저 구현하는 방법으로 A를 사용한다고 생각한다.

정리

상속은 강력하지만 캡슐화를 해친다는 문제가 있다.
상속은 상위 클래스와 하위 클래스가 순수한 is-a 관계일 때만 써야한다.
is-a 관계일때도 안심할 수 없는게, 하위 클래스의 패키지가 상위 클래스ㅇ와 다르고, 상위 클래스의 확장을 고려하지 않았다면 여전히 문제가 될 수 있다.
상속의 취약점을 피하려면 상속 대신 컴포지션과 전달을 사용한다.

출처

Joshua Bloch. Effective Java 3/E. n.p.: 인사이트, 2018년 11월 1일.

Reference

https://madplay.github.io/post/favor-composition-over-inheritance