본문 바로가기
Computer Sience/Java

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

by 제우제우 2024. 11. 5.

상속(Inheritance)의 위험성

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

상위 클래스와 하위 클래스가 모두 같은 개발자가 통제하는 패키지 안에서라면 상속도 안전한 방법이다.

확장할 목적으로 설계되었고 문서화가 잘 된 클래스라면 마찬가지로 안전하다. 

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

 

왜 위험할까?

 

메소드 호출과 달리 상속은 캡슐화를 깨트린다. 

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

@Getter
public class InstrumentedHashSet<E> extends HashSet<E> {
    private int addCount = 0;
    public InstrumentedHashSet() {
    }
    @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);
    }
}

해당 클래스는 HashSet 클래스를 상속 받은 InstrumentedHashSet 이다. 

이 HashSet은 처음 생성된 이후 원소가 몇 개 더해졌는지 알려고 만든 클래스이다.

그래서 내부에 addCount 변수를 추가하고 add, addAll 메소드에 원소의 개수를 더하는 로직을 추가했다.  

 

잘 동작할 것 같지만 제대로 동작하지 않는다. 

public class Client {
    public static void main(String[] args) {
        InstrumentedHashSet<Integer> set = new InstrumentedHashSet<>();
        set.addAll(List.of(1, 2, 3));
        System.out.println(set.getAddCount()); // 6
    }
}

 

우리가 기대한 값은 3이지만 실제 addCount를 호출하면 6이 나온다. 

 

그 이유는 상위 클래스인 HashSet의 addAll()에서 InstrumentedHashSet에서의 add() 메소드를 호출하기 때문이다. 

그래서 처음 사이즈를 3 늘리고 추가적으로 add()가 3번 호출되어서 총 addCount가 6이 된다. 

 

이 경우 하위 클래스인 InstrumentedHashSet에서 addAll() 메소드를 재정의하지 않으면 문제를 고칠 수 있다. 

하지만 당장은 제대로 동작하지만 HashSet의 addAll이 add메소드를 이용해 구현했음을 가정한 해법이라는 한계를 지닌다.

 

addAll 메소드에서 상위 클래스의 addAll 메소드를 호출하지 않고 주어진 컬렉션을 순회하면서 원소 하나당 add 메소드를 한 번만 호출하는 방법도 있지만 이 방식은 상위 클래스의 메소드 동작을 다시 구현하니 어렵고, 오류를 내거나 성능을 떨어뜨릴 수도 있다.

 

조합(Composition)

기존 클래스를 확장하는 대신, 새로운 클래스를 만들고 private 필드에 기존 클래스의 인스턴스를 참조하게 하자. 

기존 클래스가 새로운 클래스의 구성요소로 쓰인다는 뜻에서 이러한 설계를 컴포지션(composition)이라 한다.

 

새 클래스의 인스턴스 메소드들은 private 필드로 참조하는 기존 클래스의 대응하는 메소드를 호출해 그 결과를 반환한다.

이 방식을 전달(forwarding)이라고 하며, 새 클래스의 메소드들을 전달 메소드(forwaring method)라고 한다. 

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

 

 ForwardingSet<E> implements Set<E>

public class ForwardingSet<E> implements Set<E> {
    private final Set<E> set;
    public ForwardingSet(Set<E> set) {
        this.set = set;
    }
    @Override
    public Spliterator<E> spliterator() {
        return set.spliterator();
    }
    @Override
    public int size() {
        return set.size();
    }
    @Override
    public boolean isEmpty() {
        return set.isEmpty();
    }

    @Override
    public boolean contains(Object o) {
        return set.contains(o);
    }

    @Override
    public Iterator<E> iterator() {
        return set.iterator();
    }

    @Override
    public Object[] toArray() {
        return set.toArray();
    }

    @Override
    public <T> T[] toArray(T[] a) {
        return set.toArray(a);
    }
    @Override
    public boolean add(E e) {
        return set.add(e);
    }
    @Override
    public boolean remove(Object o) {
        return set.remove(o);
    }
    @Override
    public boolean containsAll(Collection<?> c) {
        return set.containsAll(c);
    }
    @Override
    public boolean addAll(Collection<? extends E> c) {
        return set.addAll(c);
    }
    @Override
    public boolean retainAll(Collection<?> c) {
        return set.retainAll(c);
    }
    @Override
    public boolean removeAll(Collection<?> c) {
        return set.removeAll(c);
    }
    @Override
    public void clear() {
        set.clear();
    }
}

 

Set 인터페이스의 API를 사용하는 전달용 Set 클래스이다. 

이 Set 클래스는 재사용이 가능하다.

 

 InstrumentedHashSet<E> extends ForwardingSet<E>

@Getter
public class InstrumentedHashSet<E> extends ForwardingSet<E> {
    private int addCount = 0;
    public InstrumentedHashSet(Set<E> set) {
        super(set);
    }
    @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);
    }
}

 

InstrumentSet은 Set인터페이스의 모든 기능을 전달하는 ForwardingSet을 상속 받는 클래스이다. 

Set 인스턴스를 받는 생성자를 제공한다.

 

InstrumentSet은 FowardingSet에서의 추가적인 기능인 addCount 로직을 추가했다.

이는 디자인 패턴인 데코레이터 패턴과 똑같다.

 

컴포지션과 전달의 조합은 위임(delegation)이라고 부른다. 

그럼 언제 상속을 사용하고 언제 조합을 사용할까?

상속은 반드시 하위 클래스가 상위 클래스의 진짜 하위 타입인 상황에서만 쓰여야 한다.

즉 is-a 관계일 때만 클래스를 상속으로 설계해야 한다. (포우류 - 사자)

 

또한 is-a 관계여도 하위 클래스와 상위 클래스의 패키지가 다르고 상위 클래스가 확장을 고려해 설계되지 않았다면 

여전히 문제가 될 수 있다.

이런 문제점들이 보인다면 컴포지션과 전달 조합을 사용하자. 

 

특히 래퍼 클래스로 구현할 적당한 인터페이스가 있다면 더욱 그렇다.

래퍼 클래스는 하위 클래스보다 견고하고 강력하다.