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

클래스와 인터페이스, 네 번째 아이템

Posted by SungBeom on March 26, 2020 · 10 mins read

Intro

상속(클래스가 다른 클래스를 확장하는 구현 상속)은 코드를 재사용하는 강력한 수단이지만, 항상 최선은 아니다. 상위 클래스와 하위 클래스를 모두 같은 프로그래머가 통제하는 패키지 안에서나, 확장할 목적으로 설계되었고 문서화도 잘 된 클래스라면 안전하다. 하지만 다른 패키지의 구체 클래스를 상속하는 일은 위험하다.

상속의 문제점

메소드 호출과 달리 상속은 캡슐화를 깨뜨린다. 상위 클래스가 어떻게 구현되느냐에 따라 하위 클래스의 동작에 이상이 생길 수 있다. 구체적인 예로 HashSet을 사용하는 프로그램을 살펴보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
/*
 * 상속을 잘못 사용한 예
 */
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;
    }
}
cs

이 클래스는 잘 구현된 것처럼 보이지만 제대로 작동하지 않는다. 이 클래스의 인스턴스에 addAll 메소드로 원소 3개를 더하고, getAddCount 메소드를 호출하면 3이 아닌 6을 반환한다. 그 원인은 HashSet의 addAll 메소드가 add 메소드를 사용해 구현되었는데, 이때 불리는 add는 InstrumentedHashSet에서 재정의한 메소드다. 따라서 addCount에 값이 중복해서 더해져, 추가한 원소 하나당 2씩 늘어나 최종값이 6이 된 것이다.

하위 클래스에서 addAll 메소드를 재정의하지 않으면 문제를 고칠 수 있으나, HashSet의 addAll이 add 메소드를 이용해 구현했음을 가정한 해법이라는 한계를 지닌다. 또한 addAll 메소드를 다른 식으로 재정의할 수도 있지만, 상위 클래스의 메소드 동작을 다시 구현하는 방식이므로 어렵고, 시간도 더 들고, 자칫 오류를 내거나 성능을 떨어뜨릴 수도 있다. 게다가 하위 클래스에서는 접근할 수 없는 private 필드를 써야 하는 상황이라면 이 방식으로는 구현 자체가 불가능하다.

상위 클래스가 변경되었을 때, 하위 클래스에서 재정의하지 못한 새로운 메소드를 사용해 '허용되지 않은' 원소를 추가할 수 있게 되는 것도 하위 클래스가 깨지기 쉬운 이유이다. 메소드를 재정의하는 대신 새로운 메소드를 추가하는 방식도 이후에 상위 클래스에 새로운 메소드가 추가될 때 문제가 생길 위험이 있다. 게다가 하위 클래스의 상위 클래스의 새로운 메소드가 요구하는 규약을 만족하지 못할 가능성이 크다.

컴포지션과 전달 클래스

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/*
 * 래퍼 클래스 - 상속 대신 컴포지션을 사용했다.
 */
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;
    }
}
cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
/*
 * 재사용할 수 있는 전달 클래스
 */
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(); }
}
cs

InstrumentedSet은 HashSet의 모든 기능을 정의한 Set 인터페이스를 활용해 설계되어 견고하고 아주 유연하다. 임의의 Set에 계측 기능을 덧씌워 새로운 Set으로 만드는 것이 이 클래스의 핵심이다. 컴포지션 방식은 한 번만 구현해두면 어떠한 Set 구현체라도 계측할 수 있으며, 기존 생성자들과도 함께 사용할 수 있다.

다른 Set 인스턴스를 감싸고(wrap) 있다는 뜻에서 InstrumentedSet 같은 클래스를 래퍼 클래스라 하며, 다른 Set에 계측 기능을 덧씌운다는 뜻에서 데코레이터 패턴(Decorator pattern)이라고 한다. 컴포지션과 전달의 조합은 넓은 의미로 위임(delegation)이라고 부른다(엄밀히 따지면 래퍼 객체가 내부 객체에 자기 자신의 참조를 넘기는 경우만 위임에 해당한다).

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

상속과 컴포지션의 적합한 사용처

상속은 반드시 하위 클래스가 상위 클래스의 '진짜' 하위 타입인 상황에서만 쓰여야 한다. 다르게 말하면, 클래스 B가 클래스 A와 is-a 관계일 때만 클래스 A를 상속해야 한다. 컴포지션을 써야 할 상황에서 상속을 사용하는 건 내부 구현을 불필요하게 노출하는 꼴이다. 그 결과 API가 내부 구현에 묶이고 그 클래스의 성능도 영원히 제한된다. 더 심각한 문제는 클라이언트가 노출된 내부에 직접 접근할 수 있다는 점이다.

컴포지션 대신 상속을 사용하기로 결정하기 전에 마지막으로 자문해야 할 질문이 있다. 확장하려는 클래스의 API에 아무런 결함이 없는가? 결함이 있다면, 이 결함이 여러분 클래스의 API까지 전파돼도 괜찮은가? 컴포지션으로는 이런 결함을 숨기는 새로운 API를 설계할 수 있지만, 상속은 상위 클래스의 API를 '그 결함까지도' 그대로 승계한다.


핵심 정리

상속은 강력하지만 캡슐화를 해친다는 문제가 있다. 상속은 상위 클래스와 하위 클래스가 순수한 is-a 관계일 때만 써야 한다. is-a 관계일 때도 안심할 수만은 없는 게, 하위 클래스의 패키지가 상위 클래스와 다르고, 상위 클래스가 확장을 고려해 설계되지 않았다면 여전히 문제가 될 수 있다. 상속의 취약점을 피하려면 상속 대신 컴포지션과 전달을 사용하자. 특히 래퍼 클래스로 구현할 적당한 인터페이스가 있다면 더욱 그렇다. 래퍼 클래스는 하위 클래스보다 견고하고 강력하다.