Java/Effective Java

[Effective Java] 18.상속보다는 컴포지션을 사용하라

통통푸딩 2022. 3. 12. 23:06
반응형

상속은 코드를 재사용하는 강력한 수단이나 항상 최선은 아니다.

다른 패키지의 구체 클래스를 상속하는 일은 위험하다.

 

캡슐화를 깨뜨리는 상속

상위 클래스가 어떻게 구현되느냐에 따라 하위 클래스의 동작에 이상이 생길 수 있음

설계자가 확장을 충분히 고려하지 않으면, 하위 클래스는 상위 클래스 변화에 맞춰 수정되어야만 한다.

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("짝", "짝짝", "짝짝짝"));

getAddCount()를 호출하면 3이 반환되어야 하는데 6이 반환된다.

InstrumentedHashSet의 addAll()은 addCount에 3을 더한후 HashSet의 addAll()을 호출한다.

HashSet의 addAll()은 각 원소를 add()로 추가하는데, 이 때 이 add()는 InstrumentedHashSet에서 재정의한 메서드가 호출된다. 그래서 값이 중복으로 더해져서 6이 반환되는 것이다.

 

 

이는 다음과 같은 방법으로 문제를 해결할 수 있다.

  • 재정의 하지 않는 경우 (HashSet의 addAll() 을 사용하는 경우)
    • HashSet의 addAll 메서드가 add 메서드를 이용해 구현했다는 것을 가정한다는 한계를 가진다.
    • 현재 addAll 메서드의 구조에만 의존하게 되는 것 → 구조 변화가 일어나면 문제가 생길 것
  • 다른 식의 재정의를 하는 경우 (InstrumentedHashSet에서 아예 새롭게 addAll() 을 재정의 하는 경우)
    • 상위 클래스 메서드와 똑같이 동작하도록 구현해야 하는데, 이 방식은 어려울 수도 있으며, 시간도 더 들고, 오류 및 성능하락의 문제를 가져올 수 있다.

이는 메서드 재정의에서 문제가 일어났다.

그렇다면 메서드를 재정의하는 대신 새로운 메서드를 추가한다면?

다음 릴리스에서 상위 클래스에 새 메서드가 추가됐는데, 하위 클래스에서 추가한 메서드와 시그니처가 같고

반환 타입은 다르면 컴파일 오류가 난다.

—> 즉, 상위 클래스와 하위 클래스가 서로 너무 강하게 결합되어 있다.

이러한 문제들을 다 피해갈 수 있는 방법은 상속 대신 컴포지션을 사용하는 것이다.

 

 

컴포지션

새로운 클래스를 만들고 private 필드로 기존 클래스의 인스턴스를 참조하게 하는 것을 말한다.

새 클래스의 인스턴스 메서드들은 기존 클래스의 대응하는 메서드를 호출해 결과를 반화받는다.

이 방법은 새로운 기존 클래스의 내부 구현 방식의 영향에서 벗어난다.

위에 상속으로 구현했던 예제를 컴포지션으로 바꿔보자.

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 인스턴스로 생성
    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(); }
}

Set 인스턴스를 감싸고 있는 InstrumentedSet 클래스를 래퍼 클래스(Wrapper class)라 한다.

다른 Set에 기능을 덧씌운다는 뜻에서 데코레이터 패턴(Decorator pattern)이라고 한다.

 

 

결론

상속은 강력하지만 캡슐화를 해친다.

그래서 반드시 클래스 간에 is-a 관계일때만 사용해야한다.

컴포지션을 사용해야할 상황에서 상속을 사용하는건 내부 구현을 불필요하게 노출하는 것이다.

클라이언트에서 상위 클래스를 직접 수정하여 하위 클래스의 불변식을 해칠 수도 있다.

웬만하면 상속 대신 컴포지션과 전달을 사용하자.

반응형