아이템 18. 상속보다는 컴포지션을 사용하라. - ksw6169/effective-java GitHub Wiki

상속은 잘못 사용하면 오류를 내기 쉬운 소프트웨어를 만들게 된다.

  • 상위/하위 클래스를 모두 같은 프로그래머가 통제하는 패키지 안에서라면 상속도 안전한 방법이다.
  • 확장할 목적으로 설계되었고 문서화도 잘 된 클래스도 마찬가지로 안전하다.
  • 하지만 다른 패키지의 구체 클래스를 상속하는 일은 위험하다.

상속은 캡슐화를 깨뜨린다.

  • 메소드 호출과 달리 상속은 캡슐화를 깨뜨린다. 다르게 말하면 상위 클래스는 릴리즈마다 내부 구현이 달라질 수 있으며, 그 여파로 코드 한 줄 건드리지 않은 하위 클래스가 오동작 할 수 있다.
  • 다음 코드는 HashSet을 상속하여 객체가 생성된 후에 추가된 원소의 수를 계산할 수 있는 Set이다.
/**
 * 잘 구현된 코드 같지만 상속을 잘못 사용하였다.
 */
public class InstrumentedHashSet<E> extends HashSet<E> {

    // 추가된 원소의 수
    private int addCount = 0;

    public InstrumentedHashSet() { }

    public InstrumentedHashSet(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;
    }
}
  • 이 클래스는 잘 구현된 것처럼 보이지만 제대로 작동하지 않는다. 이유는 HashSet의 addAll 메소드가 각 원소를 추가할 때 add 메소드를 호출하기 때문이다. 이 문제는 하위 클래스에서 addAll 메소드를 재정의하지 않으면 문제를 고칠 수 없다.
InstrumentedHashSet<String> s = new InstrumentedHashSet<>();
s.addAll(List.of("틱", "탁탁", "펑"));

// 3을 반환하지 않고 6을 반환하여 테스트 실패
assertEquals(3, s.size());

해결책 1 - 하위 클래스에서 addAll 메소드를 재정의한다.

  • addAll 메소드를 재정의하면 당장은 제대로 동작할지 모르나 HashSet의 addAll이 add 메소드를 이용해 구현했음을 가정한 해법이라는 한계를 지닌다.
  • 자신의 다른 부분을 사용하는 '자기사용(self-use)` 여부는 해당 클래스의 내부 구현에 해당하며, 다음 릴리즈에서도 유지될지는 알 수 없으므로 이런 가정에 깃댄 InstrumentedHashSet도 깨지기 쉽다.
  • 상위 클래스의 addAll 메소드 동작을 다시 구현하는 방식은 어렵고, 시간도 더 들고, 자칫 오류를 내거나 성능을 떨어뜨릴 수도 있다. 또한 하위 클래스에서는 접근할 수 없는 private 필드를 사용해야 하는 상황이라면 이 방식으로는 구현 자체가 불가능하다.
  • 또한 다음 릴리즈에서 상위 클래스에 새로운 메소드가 추가된다면 하위 클래스에서 미처 재정의하지 못한 새로운 메소드를 사용해 또 다른 문제를 야기할 수 있다.

해결책 2 - addAll을 재정의하지 않고 새로운 메소드를 추가한다.

  • 이 방식은 좀 나아보이지만, 여전히 문제를 발생시킨다. 예컨대 다음 릴리즈에서 상위 클래스에 새 메소드가 추가됐는데 내가 하위 클래스에 추가한 메소드와 시그니처가 같고 반환 타입은 다르다면 클래스가 컴파일조차 되지 않는다. 혹 반환 타입 마저 같다면 상위 클래스의 메소드를 재정의한 꼴이니 앞서의 문제와 똑같은 상황에 부닥친다.
  • 또한 새로 만든 메소드가 상위 클래스의 메소드가 요구하는 규약을 만족하지 못할 가능성도 크다.

진정한 해결책 - 기존 클래스를 확장하는 대신 새로운 클래스를 멤버로 추가한다.

  • 앞의 해결책들에서 발생한 문제를 모두 피해가는 묘안이 있다. 기존 클래스를 확장하는 대신 새로운 클래스를 만들고 private 필드로 기존 클래스의 인스턴스를 참조하는 것이다.
  • 기존 클래스가 새로운 클래스의 구성요소로 쓰인다는 뜻에서 이러한 설계를 컴포지션(composition: 구성) 이라 한다.
  • 새 클래스의 인스턴스 메소드들은 (private 필드로 참조하는) 기존 클래스의 대응하는 메소드를 호출해 그 결과를 반환한다. 이 방식을 전달(forwarding)이라 하며, 새 클래스의 메소드들은 전달 메소드(forwarding method)라 부른다.
  • 결과적으로 새로운 클래스는 기존 클래스의 내부 구현 방식의 영향에서 벗어나며, 심지어 기존 클래스에 새로운 메소드가 추가되더라도 전혀 영향받지 않는다.

예제 - 상속 대신 컴포지션을 사용한 래퍼 클래스

  • 상속 방식은 구체 클래스 각각을 따로 확장해야 하며, 지원하고 싶은 상위 클래스의 생성자 각각에 대응하는 생성자를 별도로 정의해줘야 한다. 하지만 컴포지션 방식은 한 번만 구현해두면 어떠한 Set 구현체라도 계측할 수 있으며, 기존의 생성자와도 함께 사용할 수 있다.
  • 다른 Set 인스턴스를 감싸고 있다는 뜻에서 InstrumentedSet 같은 클래스를 래퍼 클래스라 하며, 다른 Set에 계측 기능을 덧씌운다는 뜻에서 데코레이터 패턴(Decorator pattern) 이라고 한다.
  • 컴포지션과 전달의 조합은 넓은 의미로 위임(delegation) 이라고 부른다.
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;
    }
}
// 재사용 할 수 있는 전달 클래스
public class ForwardingSet<E> implements Set<E> {

    private final Set<E> s;

    public ForwardingSet(Set<E> s) {
        this.s = s;
    }
    
    // 전달 메소드(forwarding method)
    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); }
    public boolean equals(Object obj) { return s.equals(obj); }
    public int hashCode() { return s.hashCode(); }
    public String toString() { return s.toString(); }
}

래퍼 클래스 주의 사항

  • 래퍼 클래스는 콜백(callback) 프레임워크와는 어울리지 않는다는 점만 주의하면 된다.
  • 콜백 프레임워크에서는 자기 자신의 참조를 다른 객체에 넘겨서 다음 호출(콜백) 때 사용하도록 한다. 그런데 래퍼 클래스에서 감싸고 있는 내부 객체는 자신을 감싸고 있는 래퍼가 무엇인지 알 수가 없다. 따라서 내부 객체는 콜백을 위해 자기 자신(this)의 참조를 전달하게 되는데 이 경우 래퍼가 콜백으로 수행되는 것이 아니라, 래퍼가 감싸고 있는 내부 객체가 콜백으로 실행되게 된다. 따라서 콜백이 의도한대로 동작하지 못하게 되는데 이 문제를 SELF 문제라 한다.
/**
 * Reference : https://stackoverflow.com/questions/28254116/wrapper-classes-are-not-suited-for-callback-frameworks
 */
interface SomethingWithCallback {
    void doSomething();
    void call();
}

class WrappedObject implements SomethingWithCallback {
    private final SomeService service;

    WrappedObject(SomeService service) {
        this.service = service;
    }

    @Override
    public void doSomething() {
        /**
         * wrapper가 무엇인지 모르니(알 방법도 없음) 
         * service의 performAsync를 비동기적으로 수행시키기 위해
         * 자기 자신을 callback으로 넘김
         */
        service.performAsync(this);
    }

    @Override
    public void call() {
        System.out.println("WrappedObject callback!");
    }
}

class Wrapper implements SomethingWithCallback {
    private final WrappedObject wrappedObject;

    Wrapper(WrappedObject wrappedObject) {
        this.wrappedObject = wrappedObject;
    }

    @Override
    public void doSomething() {
        // 내부 객체의 doSomething을 호출
        wrappedObject.doSomething();
    }

    @Override
    public void call() {
        System.out.println("Wrapper callback!");
    }
}

final class SomeService {
    // callback을 전달 받아 callback.call() 호출
    void performAsync(SomethingWithCallback callback) {
        new Thread(() -> {
            perform();
            callback.call();
        }).start();
    }

    void perform() {
        System.out.println("Service is being performed.");
    }
}

public static void main(String[] args) {
    SomeService service = new SomeService();
    WrappedObject wrappedObject = new WrappedObject(service);
    Wrapper wrapper = new Wrapper(wrappedObject);
    wrapper.doSomething();
}

상속은 반드시 필요한 상황에서만 사용해야 한다.

  • 상속은 반드시 하위 클래스가 상위 클래스의 '진짜' 하위 타입인 상황에서만 쓰여야 한다.
  • 다르게 말하면, 클래스 B가 클래스 A와 is-a 관계일 때만 클래스 A를 상속해야 한다.
  • 만약 is-a 관계가 아니라면 A를 private 인스턴스로 두고 A와는 다른 API를 제공해야 하는 상황이 대다수다.
  • 즉, A는 B의 필수 구성요소가 아니라 구현하는 방법 중 하나일 뿐이다.

컴포지션 대신 상속을 사용할 때의 문제점

1. 내부 구현을 불필요하게 노출한다.

  • 컴포지션을 써야 할 상황에서 상속을 사용하는 건 내부 구현을 불필요하게 노출하는 꼴이다.
  • 그 결과 API가 내부 구현에 묶이고 그 클래스의 성능도 영원히 제한된다.
  • 더 심각한 문제는 클라이언트가 노출된 내부에 직접 접근할 수 있다는 점이다.

2. 사용자를 혼란스럽게 할 수 있다.

  • 예컨대 Properties의 인스턴스인 p가 있을 때, p.getProperty(key)와 p.get(key)는 결과가 다를 수 있다.
  • 전자가 Properties의 기본 동작인 데 반해, 후자는 Properties의 상위 클래스인 Hashtable로부터 물려받은 메소드이기 때문이다.

3. 클라이언트에서 상위 클래스를 직접 수정하여 하위 클래스의 불변식을 해칠 수 있다.

  • 예컨대 Properties는 키와 값으로 문자열만 허용하도록 설계하려 했으나, 상위 클래스인 Hashtable의 메소드를 직접 호출하면 이 불변식을 깨버릴 수 있다.
  • 불변식이 한번 깨지면 load와 store 같은 다른 Properties API는 더이상 사용할 수 없다.
// Hashtable의 put을 호출하여 key, value를 String 타입이 아닌 다른 타입으로 넣을 수 있게 된다. 
Properties properties = new Properties();
properties.put("key", 1);  // Hashtable의 put

String value = properties.getProperty("key");  // Properties의 getProperty
System.out.println(value);  // null 반환

참고 자료

  • Effective Java 3/E
⚠️ **GitHub.com Fallback** ⚠️