춤추는 개발자

[item 18] 상속보다는 컴포지션을 사용하라 본문

Developer's_til/Effective Java

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

Heon_9u 2022. 11. 2. 19:22
728x90
반응형

 

 

 상속은 코드를 재사용하는 강력한 수단이지만, 항상 최선은 아니다. 잘못 사용하면 오류를 내기 쉬운 소프트웨어를 만들게 된다. 일반적인 구체 클래스를 패키지 경계를 넘어, 즉 다른 패키지의 구체 클래스를 상속하는 일은 위험하다.

 

 메서드 호출과 달리 상속은 캡슐화를 깨트린다. 다르게 말하면, 상위 클래스가 어떻게 구현되느냐에 따라 하위 클래스의 동작에 이상이 생길 수 있다. 이러한 이유로 상위 클래스 설계자가 확장을 충분히 고려하고 문서화도 제대로 해두지 않으면 하위 클래스는 상위 클래스의 변화에 발맞춰 수정돼야만 한다.

 

[ HastSet을 사용한 예시 ]

public class InstrumentedHastSet<E> extends HastSet<E> {
    private int addCount = 0;

    public InstrumentedHastSet() {}

    public InstrumentedHastSet(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개를 더했다고 해보자. getAddCount 메서드를 호출하면 3을 기대하겠지만, 실제로는 6을 반환한다. 그 원인은 HashSet의 addAll메서드가 add 메서드를 사용해 구현된데 있다.

 

 이처럼 자신의 다른 부분을 사용하는 '자기사용(self-use)' 여부는 해당 클래스의 내부 구현 방식에 해당하여 상속하는 클래스 입장에서는 모르고 있을 가능성이 크다.

 

 이러한 문제를 피해가는 묘안으로, 기존 클래스를 확장하는 대신, 새로운 클래스를 만들고 private 필드로 기존 클래스의 인스턴스를 참조하게 하자. 기존 클래스가 새로운 클래스의 새로운 구성요소로 쓰인다는 뜻에서 이러한 설계를 컴포지션(composition)이라 한다.

 

[ 컴포지션과 Set을 활용한 예시 ]

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); }
    // ...

    @Override
    public boolean equals(Object o) {return s.equals(o); }
    
    @Override
    public int hashCode() { return s.hashCode(); }

    @Override
    public String toString() { return s.toString(); }
}
public class InstrumentedHastSet<E> extends ForwardingSet<E> {
    private int addCount = 0;

    public InstrumentedHastSet(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;
    }
}

 InstrumentedSet은 HashSet의 모든 기능을 정의한 Set 인터페이스를 활용해 설계되어 견고하고 아주 유연하다. 구체적으로는 Set 인터페이스를 구현했고, Set의 인스턴스를 인수로 받는 생성자를 하나 제공한다. 임의의 Set에 계측 기능을 덧씌워 새로운 Set으로 만드는 것이 이 클래스의 핵심이다.

 

Set<Object> times = new InstrumentedHastSet<>(new TreeSet<>());
Set<E> s = new InstrumentedHastSet<>(new HashSet<>(INIT_CAPACITY));

 InstrumentedSet을 이용하면 대상 Set 인스턴스를 특정 조건하에서만 임시로 계측할 수 있다. InstrumentedSet 클래스는 Set 객체를 인자로 받아 다른 기능들을 추가한 새로운 Set으로 변환하며 이런 식으로 다른 Set 인스턴스를 감싸고 있는 클래스를 래퍼 클라스라 한다. 또한, 다른 Set에 계측 기능을 덧씌운다는 뜻에서 데코레이터 패턴이라고 한다.

 

상속은 반드시 하위 클래스가 상위 클래스의 '진짜' 하위 타입인 상황에서만 쓰여야 한다. 다르게 말하면, 클래스 B가 클래스 A와 is-a 관계일 때만 클래스 A를 상속해야 한다. 클래스 A를 상속하는 클래스 B를 작성하려 하나면 "B가 정말 A인가?"라고 자문해보자.

 컴포지션을 써야 할 상황에서 상속을 사용하는 건 내부 구현을 불필요하게 노출하는 꼴이다. 예컨대 Properties의 인스턴스인 p가 있을 때. p.getProerty(key)와 p.get(key)는 결과가 다를 수있다. 전자가 Properties의 기본 동작인데 반해, 후자는 Properties의 상위 클래스인 Hashtable로부터 물려받은 메서드이기 때문이다. Properties는 키와 값으로 문자열만 허용하도록 설계하려 했으나, 상위 클래스인 Hashtable의 메서드를 직접 호출하면 이 불변식을 깨버릴 수있다.

 

 

 

728x90
반응형