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

2021. 11. 26. 01:13Programming Languages/Effective Java

상속은 코드를 재사용하는 강력한 수단이지만 잘못 사용하면 오류를 만들어내기 쉬운 소프트웨어가 된다. 슈퍼 클래스와 서브 클래스가 같은 프로그래머에 의해 다루어지며 통제하는 경우는 안전하다. 확장할 목적으로 잘 설계되었고 문서화도 잘 된 클래스 또한 안전하다. 그러나 외부 패키지에서 이를 상속하는 경우 문제가 발생한다.

메서드 호출과 달리 상속은 캡슐화를 깨뜨린다. 다르게 말하면 슈퍼 클래스가 어떻게 구현되었느냐에 따라 서브 클래스의 동작에 문제가 생길 수 있다. 슈퍼 클래스는 버전마다 내부 구현이 달라질 수 있다. 이때문에 확장을 충분히 고려하여 설계하지 않거나 문서화가 부족한 경우 이를 상속하는 서브 클래스들은 이에 발맞춰서 코드를 수정해야 하는 불상사가 생긴다.

구체적인 예제를 살펴보자. HashSet을 사용하여 내부의 원소 개수의 상태를 가지고 있는 클래스를 만든다면 다음과 같이 만들 수 있다.

public class InstrumentedHashSet<E> extends HashSet<E> {

    private int count = 0;
    
    public InstrumentedHashSet() {}
    
    public InstrumentedHashSet(int initCap, float loadFactor) {
        super(initCap, loadFactor);
    }
    
    @Override
    public boolean add(E e) {
        count++;
        super.add(e);
    }
    
    @Override
    public boolean addAll(Collection<? extends E> c) {
        count += c.size();
        super.addAll(c);
    }
    
    public int getCount() {
        return count;
    }
}

그리고 이를 사용한 클라이언트 코드를 보자.

InstrumentedHashSet<String> s = new InstrumentedHashSet<>();
s.addAll(List.of("하나", "둘", "셋"));

우리의 예상대로라면 getCount() 호출 시 반환값은 3일 것이다. 그러나 실제로 이를 테스트해보면 6이라는 결과가 반환된다. 왜 이런 것일까? HashSet의 addAll()의 실제 동작은 각 원소에 대해 add()를 반복해서 수행하는 것이기 때문이다. HashSet을 구현한 InstrumentedHashSet에서 addAll()을 호출하면 먼저 매개변수의 크기인 3을 count에 더한다. 그리고 나서 add()를 원소만큼 돌면서 수행하는데 여기서 인스턴스가 InstrumentedHashSet이기 때문에 오버라이딩된 add()가 호출되면서 각 원소만큼 count를 한 번씩 더 더하는 것이다. 그렇다면 서브 클래스에서 기능을 재정의하면 되지 않느냐고 생각할 수 있다. 그러나 이런 방법은 상속을 제대로 활용하는 방법이 아니며 확장성도 현저히 떨어진다. 또한 add()와 비슷한 기능을 가진 메서드가 추가되면 이 또한 서브 클래스에서 제대로 동작하지 않을 확률이 크다. 충분히 상속을 고려하지 않은 문제점이 무엇인지 느꼈을 것이다.

그렇다면 이러한 문제는 어떻게 해결해야 할까? 컴포지션을 이용하면 슬기롭게 이 문제를 해결할 수 있다. 새 클래스의 메서드 동작을 컴포지션한 클래스의 메서드를 호출하여 반환하도록 구성한다. 이 방식을 포워딩(forwarding)이라고 하며 새 클래스의 메서드들을 포워딩 메서드라고 부른다. 이렇게 하면 새로운 클래스는 기존 클래스의 내부 구현 방식의 영향에서 벗어나며 심지어 기존 클래스에 새로운 메서드가 추가되더라도 전혀 영향을 받지 않는다. 코드로 예시를 살펴보자.

public class InstrumentedHashSet<E> extends ForwardingSet<E> {

    private int count = 0;
    
    public InstrumentedHashSet(Set<E> s) {
        super(s);
    }
    
    @Override
    public boolean add(E e) {
        count++;
        super.add(e);
    }
    
    @Override
    public boolean addAll(Collection<? extends E> c) {
        count += c.size();
        super.addAll(c);
    }
    
    public int getCount() {
        return count;
    }
}
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 e) { return s.remove(e); }
    public boolean addAll(Collection<? extends E> c) {
        return s.addAll(c);
    }
    public boolean containsAll(Collection<? extends E> c) {
        return s.containsAll(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(); }

}

이런 식으로 InstrumentedHashSet은 필요한 기능을 구현하면서 ForwardingSet은 Set을 구현한 인스턴스를 컴포지션한다. 여기에 아까처럼 HashSet을 넣는다면 동작이 어떻게 바뀌었는지 보자. 다시 InstrumentedHashSet의 addAll()을 호출한다. 먼저 count가 3이 된다. 그리고 super.addAll()이 호출된다. 이때 super.addAll()은 컴포지션으로 참조한 HashSet의 add()가 호출되므로 앞서 발생한 문제가 해결된다. 뿐만 아니라 Set을 구현한 어떠한 인스턴스로도 기능을 구현할 수 있는 확장성까지 얻게 되었다. 이처럼 상속보다 컴포지션을 활용한다면 상속의 문제를 유려하게 해결할 수 있으며 확장성 또한 얻게 된다.

상속을 사용하려면 반드시 A is a B의 관계가 만족하는지 따져봐야 한다. 그렇다고 확신하지 못하면 상속을 사용해서는 안 된다. 또한 상속은 내부 구현을 외부에 공유하는 꼴이기 때문에 기능이 묶이고 성능 또한 제한된다. 더 큰 문제는 클라이언트에 혼란을 줄 수 있다는 점이다. 예를 들어 Properties의 메서드로 p.getProperty(key)와 p.get(idx)의 결과는 다를 수 있다. Properties는 Hashtable을 구현하기 때문에 p.get(idx)을 호출하면 사용자 입장에서 예상되는 property가 반환되는 것이 아니라 인덱스에 해당하는 값이 반환될 것이다. 따라서 상속을 사용하려면 그 당위성을 면밀히 따져보아야 하며 부족한 경우 컴포지션을 활용하는 것이 좋다.