2 minute read

item 18. 상속보다는 컴포지션을 사용하라

  • 일반적인 구체 클래스를 패키지 경계를 넘어, 다른 패키지의 구체 클래스를 상속하는 일은 위험하다.

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

  • 보다 정확히 말하자면, 상위 클래스 구현에 따라 하위 클래스의 동작이 달라질 수 있으며 이에 따라 하위 클래스가 오동작할 수 있다.
    • 상위 클래스는 릴리즈마다 내부 구현이 달라질 수 있고, 이에 따라 하위 클래스가 오작동할 수 있다.
    • 상위 클래스 설계자가 확장을 충분히 고려하고 문서화하지 않으면, 하위 클래스는 상위 클래스의 변화에 일일히 따라 수정되어야 한다.
public class InstrumentedHashSet<E> extends HashSet<E> {
	private int addCount = 0;

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

이 클래스는 잘 구현된 것처럼 보이지만, 그렇지 않다.

InstrumentedHashSet<String> s = new InstrumentedHashSet<>();
s.addAll(List.of("틱", "탁탁", "펑"));
  • getAddCount를 호출하면 3이 반환될 것 같지만 실제로는 6을 반환한다.
  • HashSet의 addAll 은 각 원소마다 add 를 호출하게 구현이 되어있다.
  • 이때 InstrumentedHashSet에서 재정의한 add메서드가 있으므로 InstrumentedHashSet의 add 가 호출되게 되고, 결국 count의 증가가 중복으로 일어나게 된다.
//  `HashSet`의 `addAll` 메소드 (상위 클래스)
public boolean addAll(Collection<? extends E> c) {  
    boolean modified = false;  
    for (E e : c)  
        if (add(e))  
            modified = true;  
    return modified;  
}

위와 같은 경우, addAll 메서드를 재정의하지 않거나, 다른 방식의 addAll 재정의를 통해 문제를 해결할 수 있다.

  • 재정의 하지 않는 경우 (HashSet의 addAll 을 사용하는 경우)
    • HashSetaddAll메서드가 add메서드를 이용해 구현했다는 것을 가정한다는 한계를 가진다.
    • 즉, 현재 addAll 메서드의 구조에만 의존하게 되는 것이다. → 구조 변화가 일어나면 문제가 생길 것
  • 다른 식의 재정의를 하는 경우 (InstrumentedHashSet에서 아예 새롭게 addAll 을 재정의 하는 경우)
    • 상위 클래스 메서드와 똑같이 동작하도록 구현해야 한다.
    • 이는 적용하기 어려울 수도 있으며, 시간이 더 들고, 오류 및 성능하락의 문제를 가져올 수 있다.
    • 하위 클래스에서 접근 불가능한 private 필드를 사용해야 한다면 이 방식으로는 구현 자체가 불가능하다.

컴포지션 사용하기

  • 컴포지션이란, 새로운 클래스를 만들고 private 필드로 기존 클래스의 인스턴스를 참조하게 하는 방법이다.
    • 기존 클래스가 새로운 클래스의 구성요소로 쓰인다.
  • 전달(forwarding)
    • 새 클래스의 인스턴스 메서드들(= 전달 메서드(forwarding method) )은 (private 필드로 참조) 기존 클래스에서 이에 대응하는 메서드를 호출해 그 결과를 반환한다.
public class ForwardingSet<E> implements Set<E> {
// 이하는 forwarding method들

    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 class InstrumentedSet<E> extends ForwardingSet<E> {
// Wrapper class -- called Decorator pattern
// Wrapper는 자신이 받는 모든 요청을 대상 객체에 위임한다.
    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();
    }
}

위와 같이 컴포지션을 구현한다면,

  1. 개발자가 원하는 메서드만 클라이언트에게 공개할 수 있다.
  2. 상위 클래스의 내부 구현을 숨길 수 있다.
  3. 상위 클래스에서 제공하는 메서드를 더 나은 버전으로 개선해 제공할 수 있다.
  4. 참조하는 인스턴스 변수를 변경해 프로그램을 동적으로 변경할 수 있다.
  5. 상위 클래스의 메서드 형태와 관계없이, 유연하게 하위 클래스의 메서드를 정의할 수 있다.

상속을 사용해야 한다면, 다음과 같은 문제에 대해 충분히 고민하고 적용하자.

  1. 상위 클래스와 하위 클래스가 완벽한 Is-a 관계인가?
  2. 상위 클래스의 API에 결함이 없는지 확인하고, 이 결함이 전파되어도 괜찮은지 확인해보자.

Leave a comment