독서이야기/이펙티브 자바 3 edition

[아이템18] 상속보다는 컴포지션을 사용하라

사랑꾼이야 2022. 9. 18. 13:55
반응형

이 내용은 이펙티브 자바 Effective Java 3/E 를 읽으면서 정리한 내용을 포함하고 있습니다.

상속은 코드를 재사용하는 강력한 수단이지만, 항상 최선은 아닙니다. 잘못 사용하면 오류를 내기 쉬운 소프트웨어를 만들게 됩니다. 상위 클래스와 하위 클래스를 모두 같은 프로그래머가 통제하는 패키지 안에서라면 상속도 안전한 방법입니다.

상속의 문제점

메소드 호출과 달리 상속은 캡슐화를 깨뜨리게 됩니다. 상위 클래스가 어떻게 구현되느냐에 따라 하위 클래스의 동작에 이상이 생길 수 있습니다.

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;
    }
}

위 클래스에서 addAll 메서드의 단위 테스트를 작성하였습니다. 원소 3개를 저장하고 그 길이를 출력하는 단순한 테스트입니다.

해당 테스트를 실행하면 성공할 것 같지만 실패를 하게 됩니다. 무엇이 문제일까요?

@Test
void 원소_저장시_카운트가_중복으로_저장이_된다() {

    InstrumentedHashSet<String> result = new InstrumentedHashSet<>();
    result.addAll(List.of("틱", "틱틱", "펑"));

  assertThat(result.getAddCount()).isEqualTo(3);
}
  • HashSet의 addAll 메소드가 add 메서드를 사용해 구현된데 있습니다.
  • 따라서, add를 통해서 addCount에 값이 중복해서 더해져 최종값이 6으로 늘어났습니다.

컴포지션으로 해결

구현을 둘로 나눴습니다. 하나는 InstrumentedSet 집합 클래스 자신이고, 다른 하나는 ForwardingSet 전달 메서드만으로 이루어진 재사용 가능한 전달 클래스입니다.

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 addAll(Collection<? extends E> c) {
        addCount += c.size();
        return super.addAll(c);
    }

    public int getAddCount() {
        return addCount;
    }
}
  • InstrumentedSet 은 HashSet의 모든 기능을 정의한 set 인터페이스를 활용해 설계되어 견고하고 아주 유연합니다.
  • set 인터페이스를 구현했고, set의 인스턴스를 인수로 받는 생성자를 하나 제공합니다.
  • 임의의 set에 기능을 덧씌워 새로운 set으로 만드는 것이 핵심입니다.
  • 한번만 구현해두면 어떠한 set 구현체라도 사용할 수 있으며, 기존 생성자들과도 함께 사용할 수 있습니다.
  • Guava 는 모든 컬렉션 인터페이스용 전달 메서드를 전부 구현하였습니다.

상속을 반드시 사용한다면

상속은 반드시 하위 클래스가 상위 클래스의 진짜 하위 타입인 상황에서만 쓰여야 합니다. 다르게 말하면, 클래스 B가 클래스 A와 is-a 관계일 때만 클래스 A를 상속해야 합니다. 클래스 A를 상속하는 클래스 B를 작성하려 한다면 B가 정말 A인가라는 질문에 확신할 수 없다면 B는 A를 상속해서는 안됩니다. 그럴때는 A를 private 인스턴스로 두고, A와는 다른 API를 제공해야 하는 상황이 좋은 선택일 수 있습니다. 즉, A는 B의 필수 구성요소가 아니라 구현하는 방법 중 하나일뿐입니다.

자바에서 아쉬운 사례

  1. 스택은 벡터가 아니므로 Stack은 Vector를 확장한 부분
  2. 속성 목록도 해시테이블이 아니므로 Properties도 Hashtable을 확장한 부분

컴포지션 대신 상속을 사용하기로 결정하기 전에 확장하려는 클래스의 API에 아무런 결함이 없는지, 결함이 있다면, 이 결함이 클래스의 API까지 전파돼도 괜찮은지, 컴포지션으로는 이런 결함을 숨기는 새로운 API를 설계할 수 있지만, 상속은 상위 클래스의 API를 그 결함까지 그대로 승계합니다.

정리

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