Posts [이펙티브자바] 아이템18-상속보다는 컴포지션을 사용하라
Post
Cancel

[이펙티브자바] 아이템18-상속보다는 컴포지션을 사용하라

위 포스팅에서 언급하는 상속은 클래스가 인터페이스를 구현하거나 인터페이스가 다른 인터페이스를 확장하는 인터페이스 상속과는 무관하다.

상속은 코드를 재사용하는 강력한 수단이지만, 잘 못 사용하면 오류를 내기 쉽다.

  • 같은 프로그래머가 통제하는 패키지 안에서라면 상속도 안전한 방법이다.
  • 확장할 목적으로 설계되었고 문서화도 잘 된 클래스(아이템19)도 마찬가지로 안전하다.
  • 하지만 일반적인 구체 클래스를 패키지 경계를 넘어, 즉 다른 패키지의 구체 클래스를 상속하는 일은 위험하다.

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

  • 상위 캡슐화를 어떻게 구현되느냐에 따라 하위 클래스의 동작에 이상이 생길 수 있다.
  • 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
29
30
31
32
// 코드 18-1 잘못된 예 - 상속을 잘못 사용했다! (114쪽)
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;
    }

    public static void main(String[] args) {
        InstrumentedHashSet<String> s = new InstrumentedHashSet<>();
        s.addAll(List.of("틱", "탁탁", "펑"));
        System.out.println(s.getAddCount());
    }
}
  • 위 코드를 실행하면 addCount는 3이 아닌 6이 된다.
  • 왜냐하면 Set의 addAll메서드는 각 원소를 add 메서드를 호출하여 추가하는데, 이 떄 불리는 add 메서드는 InstrumentedHashSet에서 재정의한 메서드이기에 중복해서 더해진 것이다.
  • 이를 해결하기위 addAll 을 호출시 직접 루프를 돌며 add 메서드를 한 번 씩 호출해줄 수 있다. 하지만 이 방식은 어렵고, 시간도 더 들고, 자칫 오류를 내거나 성능 문제를 야기할 수 있다.
  • 또한 하위 클래스에선 접근할 수 없는 private 필드를 써야 하는 상황이라면 이 방식으로 구현 자체가 불가능하다.
  • 그렇다면 메서드를 재정의하는 대신 새로운 메서드를 추가하는 방식으로 접근하면 어떨까?
    • 이 방식이 훨씬 안전하긴 하지만, 위험이 전혀 없는 것은 아니다.
    • 다음 릴리스에서 상위 클래스에 새 메서드가 추가됐는데 운 없게도 하필 하위 클래스에 추가한 메서드와 시그니처가 같고 반환 타입은 다르다면 하위 클래스는 컴파일조차 되지 않을 것이다.. 즉 좋은 방법이라 할 수 없다.

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

  • 기존 클래스가 새로운 클래스의 구성요소로 쓰인다는 뜻에서 이러한 설계를 컴포지션(Composition: 구성)이라 한다.
  • 새 클래스의 인스턴스 메서드들은 (private 필드로 참조하는) 기존 클래스의 대응하는 메서드를 호출해 그 결과를 반환한다. 이 방식을 전달(forwarding)이라 하며, 새 클래스의 메서드들은 전달 메서드(forwarding method)라 부른다.
  • 그 결과 새로운 클래스는 기존 클래스의 내부 구현 방식의 영향에서 벗어나게 되며, 심지어 기존 클래스에 메서드가 추가되더라도 전혀 영향 받지 않는다.
  • 먼저 위의 문제가 되는 예시 코드를 개선한 코드를 살펴보자.
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
// 코드 18-3 재사용할 수 있는 전달 클래스 (118쪽)
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(); }
}
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
// 코드 18-2 래퍼 클래스 - 상속 대신 컴포지션을 사용했다. (117-118쪽)
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 static void main(String[] args) {
        InstrumentedSet<String> s = new InstrumentedSet<>(new HashSet<>());
        s.addAll(List.of("틱", "탁탁", "펑"));
        System.out.println(s.getAddCount());
    }
}
  • 위의 코드에서 임의의 Set에 계측 기능을 덧씌워 새로운 Set으로 만드는 것이 이 클래스의 핵심이다.
  • 아래 예시 코드처럼 한 번만 구현해두고 나서 어떠한 Set 구현체라도 계측할 수 잇으며, 기존 생성자들과도 함께 사용할 수 있다.
1
2
Set<Instant> times = new InstrumentedSet<>(new TreeSet<>(cmp));
Set<E> s = new InstrumentedSet<>(new HashSet<>(INIT_CAPACITY));
  • 다른 Set인스턴스를 감싸고(wrap) 있다는 뜻에서 InstrumentedSet 같은 클래스를 래퍼 클래스라 하며, 다른 Set에 계측 기능을 덧씌운다는 뜻에서 데코레이터 패턴이라 한다.
  • 컴포지션과 전달의 조합은 넓은 의미로 위임(delegation)이라고 부른다.
  • 래퍼 클래스는 단점이 거의 없다. 래퍼 클래스가 콜백(callback) 프레임워크와는 어울리지 않는다는 점만 주의하면 된다.

Note: 위 처럼 전달 메서드들을 작성하는게 지루하겠지만, 재사용할 수 있는 전달 클래스(코드 18-3)를 인터페이스당 하나씩만 만들어두면 원하는 기능을 덧씌우는 전달 클래스들을 아주 손쉽게 구현할 수 있다. 좋은 예로 구아바(Guava)는 모든 컬렉션 인터페이스용 전달 메서드를 전부 구현해뒀다.

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

  • 클래스B가 클래스A와 is-a 관계일 때만 클래스 A를 상속해야 한다.

자바 플랫폼 라이버르리에서도 위 원칙을 명히 위반한 클래스들을 찾아볼 수 있다.

1) 스택은 벡터가 아니므로 StackVector를 확장해선 안됐다.

2) 마찬가지로, 속성 목록도 해시 테이블이 아니므로 PropertiesHashTable을 확장해선 안됐다.

위의 두 사례 모두 컴포지션을 사용했따면 더 좋았을 것이다.

컴포지션을 써야 할 상황에서 상속을 사용하는 건 내부 구현을 불필요하게 노출하는 꼴이다.

  • 위처럼 한다면 API가 내부 구현에 묶이고 그 클래스의 성능도 영원히 제한된다.
  • 더 심각한 문제는 클라이언트가 노출된 내부에 직접 접근할 수 있다는 점이다.
  • 예를 들어, Properties의 인스턴스인 p가 있을 때, p.getProperty(key)p.get(key)는 결과가 다를 수 있다.
    • getProperty()는 Properties의 기본 동작인데 반해, get()은 HashTable로부터 물려받은 메서드이기 때문이다.
    • 가장 심각한 문제는 클라이언트에서 상위 클래스를 직접 수정하여 하위 클래스의 불변식을 해칠 수 있다는 사실이다. 예컨대 Properties는 키와 값으로 문자열만 허용하도록 설계하려 했으나, 상위 클래스인 HashTable의 메서드를 직접호출하면 불변식을 깨버릴 수 있다.

컴포지션 대신 상속을 이용하려 결정하기 전에 마지막으로 자문해야 할 질문은 아래와 같다.

  • 확장하려는 클래스의 API에 아무런 결함이 없는가?
  • 이 결함이 상속 받은 클래스의 API까지 전파돼도 괜찮은가?
  • 컴포지션으로는 이런 결함을 숨기는 새로운 API를 설계할 수 있지만, 상속은 상위 클래스의 API를 ‘그 결함까지도’ 그대로 승계한다.

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

This post is licensed under CC BY 4.0 by the author.

[이펙티브자바] 아이템17-변경 가능성을 최소화하라

[이펙티브자바] 아이템19-상속을 고려해 설계하고 문서화하라. 그러지 않았다면 상속을 금지하라