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

들어가며

메소드 호출과 달리 상속은 캡슐화를 깨트린다. 다르게 말하면 상위 클래스가 어떻게 구현되느냐에 따라 하위 클래스의 동작에 이상이 생긴다. 다음 코드를 보자.

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
33
34
35
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 class main {
public static void main(String[] args) {
InstrumentedHashSet<String> s = new InstrumentedHashSet<>();
s.addAll(List.of("tic", "tac", "toe"));

System.out.println(s.getAddCount());
}
}

결과는 다음과 같다.

1
6

왜그럴까? 원인은 HashSet의 addAll 메소드가 add 메소드를 사용한다는데 있다.

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
/**
* {@inheritDoc}
*
* <p>This implementation iterates over the specified collection, and adds
* each object returned by the iterator to this collection, in turn.
*
* <p>Note that this implementation will throw an
* <tt>UnsupportedOperationException</tt> unless <tt>add</tt> is
* overridden (assuming the specified collection is non-empty).
*
* @throws UnsupportedOperationException {@inheritDoc}
* @throws ClassCastException {@inheritDoc}
* @throws NullPointerException {@inheritDoc}
* @throws IllegalArgumentException {@inheritDoc}
* @throws IllegalStateException {@inheritDoc}
*
* @see #add(Object)
*/
public boolean addAll(Collection<? extends E> c) {
boolean modified = false;
for (E e : c)
if (add(e))
modified = true;
return modified;
}

옳지 못한 해결 방안

이 경우 하위 클래스에서 addAll 메소드를 재정의하지 않는다면 문제는 해결된다. 당장은 해결될지도 모르나 이는 HashSet의 addAll이 add 메소드를 이용해 구현했음을 가정한 해법이라는거에 지나치지 않는다. 이 정책은 다음 릴리즈때 어떻게 될지 예측할 수 없다.

다른 방법도 있다. addAll 메소드를 주어진 컬렉션을 순회하며 원소 하나당 add 메소드를 호출하는 것이다. 이 방식은 HashSet의 addAll을 호출하지 않으니 addAll이 어떻게 구현되었든간에 이와 상관 없다는 점에서 조금 더 옳을지도 모른다. 하지만 단점은 남아있다. 상위 클래스의 메소드 동작을 다시 구현하는 이 방법은 어렵고, 시간도 많이 들고, 오류를 내거나 성능을 떨어트릴 수 있다.

다른 문제도 있다. 다음 릴리즈에서 상위 클래스에 새로운 메소드를 추가한다면 어떨까? 보안 때문에 컬렉션에 추가된 모든 원소가 특정 조건을 만족해야 하는 프로그램을 생각해보자. 그 컬렉션을 상속하여 원소를 추가하는 모든 메소드를 재정의해 조건을 먼저 검사하면 될 것 같다. 이 방식이 허용하는 범위는 차후 릴리즈에 새로운 원소 추가 메소드가 생기기 전 까지이다. 새로운 재정의하지 못한 원소 추가 메소드를 통해 허용하지 않은 원소를 삽입할 수 있게 된다.

문제의 해결

이를 해결하는 묘안이 있다. 컴포지션을 사용하면 된다. 즉 새로운 클래스를 만들고 private 필드로 기존 클래스의 인스턴스를 참조하게 하면 된다. 새 클래스의 대응하는 메소드들은 기존 클래스의 대응하는 메소드를 호출해 그 결과를 반환한다. 이를 forwarding이라 한다.

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
33
34
35
36
public class ForwardingSet<E> implements Set<E> {
private final Set<E> set;

public ForwardingSet(Set<E> set) { this.set = set; }

public void clear() { set.clear(); }
public boolean isEmpty() { return set.isEmpbty(); }
public int size() { return s.size(); }
public boolean add(E e) { return set.add(e); }
public boolean addAll(Collection<? extends E> c) { return set.addAll(c); }
// ... 생략
}

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> collection) {
addCount = addCount + collection.size();
return super.addAll(collection);
}

public int getAddCount() {
return addCount;
}
}

임의의 Set에 계측 기능을 덧씌워 새로운 Set으로 만든 것이 InstrumentedSet의 핵심이다. 이 컴포지션 방식은 한 번만 구현해두면 어떠한 Set 구현체라도 계측할 수 있으며 기존 생성자들과도 함께 사용할 수 있다. 다른 Set 인스턴스를 감싸고 있다는 의미에서 이를 래퍼 클래스라고 하며 다른 Set에 계측 기능을 덧씌운다는 의미에서 데코레이터 패턴이라고 한다. 컴포지션과 전달의 조합은 넓은 의미로 위임이라고 부른다.

래퍼 클래스는 단점이 거의 없다. 콜백 프레임워크만 제외한다면 말이다. 콜백 프레임워크에서는 자기 자신의 참조를 다른 객체에 넘겨서 다음 콜백 때 사용하도록 한다. 내부 객체는 자신을 랩핑하고 있는 래퍼의 존재를 모르니 대신 this의 참조를 넘기고 콜백 때는 래퍼가 아닌 내부 객체를 호출하게 된다. 이를 SELF 문제라고 한다.

상속 확인 사항

상속은 반드시 하위 클래스가 상위 클래스의 진짜 하위 타입인 상황에서만 쓰여야 한다. 상속을 할 때는 항상 물어보자. 정말로 is-A인가? 확장하려는 클래스의 API에 아무런 결함이 없는가? 결함이 있다면 이 결함이 여러분 클래스의 API까지 전파돼도 괜찮은가?

컴포지션은 이런 결함을 숨기는 새로운 API를 설계할 수 있지만 상속은 상위 클래스의 API의 결함까지도 그대로 상속한다.

Author: Song Hayoung
Link: https://songhayoung.github.io/2020/08/08/Languages/Effective%20JAVA/item18/
Copyright Notice: All articles in this blog are licensed under CC BY-NC-SA 4.0 unless stating additionally.