티스토리 뷰

반응형

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

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

 

캡슐화를 깨뜨리는 상속

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

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

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 관계일때만 사용해야한다.

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

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

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

반응형
댓글
반응형
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday