[Effective Java] 16. 상속 대신 구성하라

3 분 소요

상속은 코드 재활용을 위한 강력한 방법이지만 적절하게 사용하지 않은 경우 깨지기 쉬운 소프트웨어가 된다.

1. 상속이 안전한 경우

  1. 상위 클래스와 하위 클래스 구현을 같은 개발자가 통제하는 단일 패키지 내부인 경우

  2. 상속을 충분히 고려해 설계되고 이에 맞는 문서를 갖춘 클래스인 경우

2. 상속의 단점

상속은 캡슐화 원칙을 위배한다. 하위 클래스의 구현은 상위 클래스의 구현에 의존할 수 밖에 없다.
또한 부모 클래스는 릴리즈되면서 변경될 수 있는데 그러다보면 하위 클래스 코드가 수정 없이도 망가질 수 있다.

2.1. 상속을 받아 문제가 되는 경우 1

예를 들어 아래 HashSet을 상속받은 예이다. 이는 하위 클래스가 상위 클래스 구현에 의존하여 발생하는 문제점을 설명한다.

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;
	}
}

위와 같이 HashSet을 상속받은 클래스를 만들고 추가된 전체 개수를 리턴하는 기능을 만들었다. 이 때 아래 코드를 실행한다.

InstrumentedHashSet<String> s = new InstrumentedHashSet<String>();
s.addAll(Arrays.asList("A", "B", "C"));

System.out.println("결과: " + s.getAddCount());

최종결과는 3을 기대하지만 6이 리턴된다.
HashSet 클래스의 addAll()이 내부적으로 add()메서드를 통해 구현되어 있기 때문이다. 문제는 이러한 사실이 HashSet 문서에 명시되어 있지 않다는 것이다.

물론 하위 클래스에 Override한 addAll()을 제거하면 현재 버그는 수정할 수 있다. 하지만 이러한 것은 addAll()이 add()를 통해 구현되어 있다라는 사실에 의존하는 것이고 이는 HashSet의 구현 세부사항이라 모든 자바 플랫폼에서 동일하다고 보장할 수 없다.

2.2. 상속을 받아 문제가 되는 경우 2

상위 클래스에 새로운 메서드가 추가되고 해당 메서드에 구현 세부사항이 연관되는 경우 문제가 될 수 있겠다.

예를 들어 정합성 검사를 리턴하는 isCollect() 라는 메서드가 상위 클래스에 추가되었다. 그리고 상위 클래스는 parameter로 받는 원소를 컬렉션에 Add하기 전에 이를 호출하여 정합성 검사를 한다.

하위 클래스는 이를 override하여 특정 상태에 대한 정합성 결과를 리턴해 줘야 한다.

하지만 상위 클래스의 신규 구현 사항이라 이를 상속받은 하위 클래스에서 추가된 메서드를 override 하지 않았다면? 수정된 상위클래스가 기대하는 정합성 검사를 적절하게 하지 못할것이고, 이는 또 다른 문제를 발생시킬 수 있을 것이다.

즉, 하위 클래스의 수정 없이 상위클래스가 수정된 것만으로도 소프트웨어가 깨지게 된다.

2.3. 상속을 받아 문제가 되는 경우 3

기존 메서드의 override없이 하위 클래스에서 새로운 메서드를 추가하면 어떻게 될까.
이런 확장법이 상위 클래스의 메서드를 Override 하는것보다는 안전하다.

하지만 만약 새로운 릴리즈의 부모클래스에서 추가된 신규 메서드가 하필 하위클래스가 따로 정의한 메서드와 동일한 이름을 사용한다면? 경우에 따라 컴파일 에러가 날 것이고, 더 심각한 경우 이를 캐치하지 못해 에러가 나지도 않는데 이상한 동작을 하는 경험을 할 수 있다.

3. 구성(composition)

위와 같은 상속의 문제를 피할 수 있는 방법이 구성(composition)이다. 즉, 새로운 클래스를 만들고 기존 클래스 객체를 참조하는 private 필드를 하나 두는 것이다.

구성을 통해 구현된 클래스는 기존 클래스의 구현 세부사항에 종속되지 않기 때문에 견고하다.

3.1. 상속대신 구성을 사용한 예

// 상속 대신 구성을 사용하는 Wrapper class
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;
	}
}

// 재사용 가능한 forwarding class
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 boolean add(E e) { s.add(e); }
	public boolean addAll(Collection<? extends E> c) {
		return s.addAll(c); 
	}
	....

}

InstrumentedSet을 이렇게 설계할 수 있는 것은 HashSet이 제공해야 할 기능을 규정하는 Set interface가 있기 때문이다.

위와 같은 설계는 안정적이고 유연성도 높다.
Wrapper class 기법을 쓰면 어떤 set 구현을 원하는대로 수정할 수 있다.

Set<Date> s = new InstrumentedSet<Date>(new TreeSet<Date>(cmp));
Set<E> s2 = new InstrumentedSet<E>(new HashSet<E>(capacity));

InstrumentedSet와 같은 class를 Wrapper class라고 부르는 이유는 다른 Set 객체를 포장하고 있기 때문이다. 이런 구현 기법은 장식자(decorator) 패턴이라고도 부른다.

참고로 ForwardingSet과 같은 구현 기법을 forwarding 이라고 하고 이러한 메서드를 forwarding method 라고 부른다.

4. 상속을 구현할때 고려할 부분

상속은 하위클래스가 상위클래스의 subtype이 확실한 경우에만 바람직하다. 즉, “IS-A” 관계가 성립할때만 상속을 사용해야 한다.

또한, 상속할 상위 클래스의 api에 문제가 있는지 확인하고 문제가 있다면 이러한 문제가 하위 클래스의 일부가 되어도 상관이 없는지 확인해야 한다.
상속은 상위 클래스 문제를 하위 클래스에 전파시키기 때문이다.

댓글남기기