아이템18 상속보다는 컴포지션을 사용하라

Effective Java 3e 아이템 18를 요약한 내용 입니다.

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

메서드 호출과 달리 상속은 캡슐화를 깨뜨린다. 다르게 말하면, 상위 클래스가 어떻게 구현 되느냐에 따라 하위 클래스의 동작에 이상이 생길 수 있다.

public class InstrumentedHashSet<E> extends HashSet<E> {
    private int addCount = 0;

    @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을 반환한다.

그 원인은 HashSetaddAll 메서드가 add 메서드를 사용해 구현된 데 있다. 이런 내부 구현 방식은 HahSet 문서에는 (당연히) 쓰여 있지 않다.

이처럼 자신의 다른 부분을 사용하는 '자기사용(self-use)' 여부는 해당 클래스의 내부 구현 방식에 해당하며 다음 릴리즈에도 유지될지는 알 수 없다.

자기 사용이란?

하위 클래스가 깨지기 쉬운 이유는 더 있다.

다음 릴리스에서 상위 클래스에 새로운 메서드를 추가한다면 어떨까?

다음 릴리스에서 우려한 일이 생기면 하위 클래스에서 재정의하지 못한 그 새로운 메서드를 사용해 '허용되지 않은'원소를 추가할 수 있게 된다. 실제로도 컬렉션 프레임워크 이전부터 존재하던 HashtableVector를 컬렉션 프레임워크에 포함시키자 이와 관련한 보안 구멍들을 수정해야 하는 사태가 벌어졌다.

Hashtable과 Vector의 보안 사례는 어떤것이 있는가?

다행히 이상의 문제를 모두 피해 가는 묘안이 있다. 기존 클래스가 새로운 클래스의 구성 요소로 쓰인다는 뜻에서 이러한 설계를 컴포지션이라 한다. 새 클래스의 인스턴스 메서드들은 (private 필드로 참조하는) 기존 클래스의 대응하는 메서드를 호출해 그 결과를 반환한다. 이 방식을 전달(forwarding)이라 하며, 새 클래스의 메서드들을 전달 메서드(forwarding method)라 부른다.

컴포지션과 전달의 조합은 넓은 의미로 위임(delegation)이라고 부른다.

상속은 반드시 하위 클래스가 상위 클래스의 '진짜'하위 타입인 상황에서만 쓰여야 한다. 다르게 말하면, 클래스 B가 클래스 A와 is-a 관계일 때만 클래스 A를 상속해야 한다. = 리스코프 치환 법칙

컴포지션을 써야 할 상황에서 상속을 사용하는 건 내부 구현을 불필요하게 노출하는 꼴이다. 그 결과 API가 내부 구현에 묶이고 그 클래스의 성능도 영원히 제한된다. 더 심각한 문제는 클라이언트가 노출된 내부에 직접 접근할 수 있다는 점이다. 가장 심각한 문제는 클라이언트에서 상위 클래스를 직접 수정하여 하위 클래스의 불변식을 해칠 수 있다는 사실이다.

불변식을 해치는 사례는?

컴포지션 대신 상속을 사용하기로 결정하기 전에 마지막으로 자문해야 할 질문을 소개한다.

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

정리

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

Last updated