DEV ℧ Developer Diary

[EffectiveJava] item18 - 상속보다는 컴포지션을 사용하라

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

상속과 컴포지션은 상속 (Is-A)과 컴포지션 (Has-A) 를 참고해주시기 바랍니다.

상속의 캡슐화 이상

상속은 코드를 재사용하는 강력한 수단이지만, 항상 최선은 아니다. 메서드 호출과 달리 상속은 캡슐화를 깨뜨린다. 다시 말하자면 상위 클래스가 어떻게 구현되느냐에 따라 하위 클래스의 동작에 이상이 생길 수 있다.

릴리스마다 상위클래스의 내부 구현이 달라 질 수 있으며, 코드 한줄 건드리지 않은 하위 클래스가 이상이 생길 수 있다.

HashSet을 상속받은 클래스를 예시로 사용해 잘못된 예시를 알아보자.

/* 잘못된 예 - 상속을 잘못 사용했다. */
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;
    }
}

해당 클래스는 잘 구현된 것처럼 보이지만 제대로 작동하지 않는다.

InstrumentedHashSet<String> set = new InstrumentedHashSet<>();
set.addAll(List.of("apple", "banana", "cat"));
System.out.println("set size : " + set.getAddCount());

해당 코드를 이용해서 생성한 Set의 사이즈를 가져오자.

set size : 6

HashSet을 상속 받아 구현한 set의 size가 3이 아닌 6의 결과가 반환되는 것을 볼 수 있다.

어디가 잘못되었길래 잘못된 값이 구현된걸까?

그 원인은 HashSetaddAll 메서드가 add메서드를 사용해 구현된 데 있다. InstrumentedHashSetaddAll은 addCount에 3을 더한 후 HashSetaddAll 구현을 호출해 addCount 값이 중복되서 더해진다. 따라서 원소를 추가할때마다 2씩 늘어나서 최종 값이 6으로 늘어난것이다.

이 경우 하위 클래스에서 addAll 메서드를 재정의하지 않으면 문제를 고칠 수 있다. 하지만 당장은 제대로 동작할지 모르나 한계를 지닌다.

컴포지션의 사용

해당 문제를 회피 할 수 있는 방법은 무엇일까?

기존 클래스를 확장하는 대신, 새로운 클래스를 만들고 private 필드로 기존 클래스의 인스턴스를 참조하도록 하자. 기존 클래스가 새로운 클래스의 구성요소로 쓰인다는 뜻에서 이러한 설계를 컴포지션(composition; 구성)이라 한다. 새 클래스의 인스턴스 메서드들은 (private 필드로 참조하는) 기존 클래스의 대응하는 메서드를 호출해 그 결과를 반환한다. 이 방식을 전달(forwarding)이라 하며, 새 클래스의 메서드들을 전달 메서드(forwarding method)라 부른다.

그 결과 새로운 클래스는 기존 클래스의 내부 구현방식의 영향에서 벗어나며, 심지어 기존 클래스에 새로운 메서드가 추가되더라도 전혀 영향 받지 않는다.

기존에 작성한 Set의 구현을 컴포지션을 이용해 다시 재구성 해보자.

우선 재사용 할 수 있는 전달 클래스 ForwardingSet을 만들어 준다. private final을 통해 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); }
    public boolean isEmpty()          { return s.isEmpty();   }
    public int size()                 { return s.size();      }
    public Iterator<E> iterator()     { return s.iterator();  }
    public boolean add(E e)           { return s.add(e);      }
    public boolean remove(Object o)   { return s.remove(o);   }
    public boolean containsAll(Collection<?> c)
    { return s.containsAll(c); }
    public boolean addAll(Collection<? extends E> c)
    { return s.addAll(c);      }
    public boolean removeAll(Collection<?> c)
    { return s.removeAll(c);   }
    public boolean retainAll(Collection<?> c)
    { return s.retainAll(c);   }
    public Object[] toArray()          { return s.toArray();  }
    public <T> T[] toArray(T[] a)      { return s.toArray(a); }
    @Override
    public boolean equals(Object o)
    { return s.equals(o);  }
    @Override
    public int hashCode()    { return s.hashCode(); }
    @Override
    public String toString() { return s.toString(); }
}

이후 전달 클래스 ForwardingSet를 사용해 Set을 구현해준다.

/* 컴포지션을 사용한 Set 구현 */
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;
    }
}

InstrumentedSetHashSet의 모든 기능을 정의한 Set 인터페이스를 활용해 설계되어 견고하고 아주 유연하다. 구체적으로는 Set인터페이스를 구현했고, Set 인스턴스를 인수로 받는 생성자 하나를 제공한다.

임의의 Set에 계측 기능을 덧씌워 새로운 Set으로 만드는게 이클래스의 핵심이다.

컴포지션을 이용해 구현한 InstrumentedSet으로 다시 테스트를 진행해보자.

Set<String> set2 = new InstrumentedSet<>(new HashSet(List.of("apple", "banana", "cat")));
System.out.println("set size : " + set2.size());
set size : 3

size는 3으로 정상적으로 출력되는걸 볼 수 있다.

해당 컴포지션 방식은 한 번만 구현해둔다면 어떠한 Set 구현체라도 기존 생성자들과 함께 사용이 가능하다.

Set<Instant> times = new InstrumentedSet<>(new TreeSet<>(cmp));
Set<E> s = new InstrumentedSet<>(new HashSet<>(INIT_CAPACITY));

해당 방식은 다른 Set 인스턴스를 감싸고(wrap) 있다는 뜻에서 InstrumentedSet 같은 클래스를 래퍼 클래스라 하며, 다른 Set에 계측 기능을 덧씌운다는 뜻에서 데코레이터 패턴(Decorator pattern) 이라고 한다. 컴포지션과 전달 조합의 넓은 의미로 위임(delegation)이라고 부른다.

래퍼클래스

래퍼클래스는 단점이 거의 없다. 한가지, 래퍼 클래스가 콜백(callback) 프레임워크와는 어울리지 않는다는 점만 주의 하면 된다.

콜백 프레임워크에서는 자기 자신의 참조를 다른 객체에 넘겨서 다음 호출(콜백)때 사용하도록 한다. 내부객체는 자신을 감싸고 있는 래퍼의 존재를 모르니 대신 자신(this)의 참조를 넘기고, 콜백때는 래퍼가 아닌 내부 객체를 호출하게되는데, 이를 SELF문제 라고 한다.

상속을 사용하는 경우는?

상속은 반드시 하위 클래스가 상위 클래스의 ‘진짜’ 하위 타입인 상황에서만 쓰여야한다. 즉, 클래스 B가 클래스 A와 is-a 관계일 때만 클래스 A를 상속해야 한다.

해당 경우가 아니라면 A는 private 인스턴스로 두고, A와는 다른 API를 제공해야 하는 상황이 대다수 이다.

컴포지션에 써야 할 상황에 상속을 사용하는 건 내부 구현을 불필요하게 노출하는 꼴이다. 그결과 API가 내부 구현에 묶이고 그 클래스의 성능도 영원히 제한 될 수 있다.

컴포지션 대신 상속을 사용하기로 결정하기 전에 몇가지 에대해 질문을 해보자.

  1. 확장하려는 클래스에 아무런 결함이 없는가?
  2. 결함이 있다면 이결함이 타 클래스의 API 까지 전파되어도 괜찮은가?

그 이유는, 컴포지션으로는 이런 결함을 숨기는 새로운 API를 설계할 수 있지만, 상속은 상위 클래스의 API를 ‘결함’까지 그대로 상속받아 구현하기 때문이다.