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

2021-02-15

상속은 코드를 재사용하는 강력한 수단이지만, 항상 최선은 아니다. 잘못 사용하면 오류를 내기 쉬운 소프트웨어를 만들게 된다.

이번 포스팅에서 논하는 문제는 인터페이스 상속과는 무관하다.

상속은 아이러니하게도 캡슐화 원칙을 위반한다. 상위 클래스가 어떻게 구현되느냐에 따라 하위 클래스의 동작에 이상이 생길 수 있다. 상위 클래스는 릴리스마다 내부 구현이 달라질 수 있으며, 그 여파로 코드 한 줄 건드리지 않은 하위 클래스가 오작동할 수 있다는 말이다.

HashSet에서는 현재 원소가 몇개 있는지 알수있는 size() 메소드가 있는데, 그 외에도 객체가 원소를 몇번 삽입했는지 추적할 수 있는 InstrumentedSet 클래스를 만들어 보자.

InstrumentedSet

/**
 * HashSet을 상속받아 HashSet객체가 생성된 이후에
 * 얼마나 많은 요소가 추가되었는지 확인하는 클래스
 */
public class InstrumentedSet<E> extends HashSet<E> {
    //삽입 횟수
    private int addCount = 0;
    public InstrumentedSet() {
        super();
    }
    public InstrumentedSet(int initialCapacity, float loadFactor) {
        super(initialCapacity, 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;
    }
}

테스트하기

이 코드의 테스트코드를 작성해보자.

class Rule16Tests extends Specification {
    def "HashSet이 생성된 후 얼마나 많은 요소가 추가되었는지 확인하는 테스트"() {
        given:
        List<Integer> addAll당할리스트 = Arrays.asList(1, 5, 3)
        HashSet<Integer> 규칙16커스텀해쉬셋 = new InstrumentedSet<>();
        when:
        규칙16커스텀해쉬셋.addAll(addAll당할리스트)
        // 총 3개 원소를 집어 넣었어요.
        // 그럼 삽입횟수는 3개겠죠?
        then:
        println("삽입횟수 : " + 규칙16커스텀해쉬셋.getAddCount())
        규칙16커스텀해쉬셋.getAddCount() == 3
    }
}

ArrayList에 addAll에 사용될 리스트를 만들고 .addAll()를 호출해서 전부다 요소를 넣어보자.

예상된 삽입 횟수는 3개의 요소를 넣었으니 3개를 넣었다고 생각하지만 실제의 삽입 횟수는 6이 호출된다.

결과

삽입횟수 : 6
Condition not satisfied:
규칙16커스텀해쉬셋.getAddCount() == 3
|          |             |
[1, 3, 5]  6             false

무엇이 문제였을까? 결과가 이렇게 나온 이유를 알기 위해서는 HashSet의 구현체의 코드를 들여다봐야 한다. HashSet의 addAll()은 내부적으로 HashSet의 add() 함수를 이용하기 때문에 이런 결과가 나온 것이다. 제대로 동작하기 위해서는 하위 클래스에서 작성했던 addAll() 함수를 삭제해서 교정을 해주면 된다.그러나 이 HashSet 함수가 릴리즈를 거듭나면서 바뀔 가능성이 있기 때문에, 따라서 우리가 구현한 InstrumentedSet 클래스는 깨지기 쉬운(fragile) 클래스일 수 밖에 없다.

어떻게할까?

이 문제를 피하기 위해서는 기존 클래스를 상속하는데, 새로운 클래스에 기존 클래스 객체를 참조하는 private 필드를 하나 두면 됩니다. 이런 설계 기법을 구성(composition)이라고 부르는데, 기존 클래스가 새 클래스의 일부가 되기 때문이다. 새 클래스는 기존클래스에필요한 메소드만 호출해서 그 결과를 반환하면 되는데, 이런 기법을 전달이라고 하고, 전달 기법을 사용해 구현된 메서드를 전달 메서드라고 부른다. 구성 기법을 통해 구현된 클래스는 기존 클래스의 구현 세부사항에 종속되지 않기 때문에 굉장히 견고하다.

InstrumentedSet

/**
 * HashSet을 상속받아 HashSet객체가 생성된 이후에
 * 얼마나 많은 요소가 추가되었는지 확인하는 클래스
 */
public class InstrumentedSet<E> extends ForwardingSet<E> {
    //삽입 횟수
    private int addCount = 0;
    public InstrumentedSet(Set<E> s) {
        super(s);
    }
    @Override
    public boolean add(Object e) {
        addCount++;
        return super.add(e);
    }
    @Override
    public boolean addAll(Collection c) {
        addCount += c.size();
        return super.addAll(c);
    }
    public int getAddCount() {
        return addCount;
    }
}

ForwardingSet

package com.donghyeon.effectivejava.rule16;
import java.util.Collection;
import java.util.Iterator;
import java.util.Set;
/**
 * 전달 클래스!
 */
public class ForwardingSet<E> implements Set {
    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 iterator() {
        return s.iterator();
    }
    @Override
    public Object[] toArray() {
        return s.toArray();
    }
    @Override
    public boolean add(Object o) {
        return s.add((E) o);
    }
    @Override
    public boolean remove(Object o) {
        return s.remove(o);
    }
    @Override
    public boolean addAll(Collection c) {
        return s.addAll(c);
    }
    @Override
    public void clear() {
        s.clear();
    }
    @Override
    public boolean removeAll(Collection c) {
        return s.removeAll(c);
    }
    @Override
    public boolean retainAll(Collection c) {
        return s.retainAll(c);
    }
    @Override
    public boolean containsAll(Collection c) {
        return s.containsAll(c);
    }
    @Override
    public Object[] toArray(Object[] a) {
        return s.toArray();
    }
}

InstrumentedSet을 이렇게 설계할 수 있는 것은 HashSet이 제공해야할 기능을 규정하는 Set이라는 인터페이스가 있기 때문이다. 이런 설계는 안정적일 뿐 아니라 유연성도 아주 높다.

InstrumentedSet 클래스는 Set 인터페이스를 구 현하며 Set 객체를 인자로 받는 생성자를 하나 갖고 있다. 결국 이 클래스는 어떤 Set 객체를 인자로 받아, 필요한 기능을 갖춘 다른 Set 객체로 변환하는 구실을 한다.

상속을 이용한 접근법은 한 클래스에만 적용이 가능하고, 상위 클래스 생성자마다 별도의 생성자를 구현해야 한다. 하지만 이런 기법을 사용하면 어떤 Set 구현도 원하는 대로 수정할 수 있고, 이미 있는 생성자도 그대로 사용할 수 있다.

Set<Date> s = new InstrumentedSet<Date>(new TreeSet<Date>)(cmp));

InstrumentedSet과 같은 클래스를 포장 클래스(wrapper class)라고 부는데, 다른 Set 객체를 포장하고 있기 때문이다. 또한 이런 구현 기법은 장식자(decorator) 패턴이라고도 부르는데, 기존 Set 객체에 기능을 덧 붙여 장식하는 구실을 하기 때문이다. 때로는 구성과 전달 기법을 아울러서 막연하게 위임(delegation)이라고 부르기도 한다.

그런데 기술적으로 보자면, 포장 객체가 자기 자신을 포장된 객체에 전달하지 않으면 위임이라고 부를 수 없기 때문에, 컴포지션은 위임이라고 할 수 없다.

마무리

상속은 강력한 도구이지만 캡슐화 원칙을 침해하므로 문제를 발생시킬 소지가 있다 . 상위 클래스와 하위 클래스 사이에 IS-A 관계가 있을 때만 사용하는 것이 좋다.

IS-A 관계가 성립해도, 하위 클래스가 상위 클래스와 다른 패키지에 있거나 상속을 고려해 만들어진 상위 클래스가 아니라면 하위클래스는 깨지기 쉽다. 이런 문제를 피하려면 구성과 전달 기법을 사용하는 것이 좋다.