Study/이펙티브 자바 / / 2023. 9. 16. 20:02

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

💥 개요

상속은 코드를 재사용하는 강력한 수단이지만, 항상 최선은 아닙니다. 

패키지 안에서라면 상속도 안전하지만 잘못 사용하면 오류를 내기 쉬운 소프트웨어를 만들게 됩니다.

 

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

상위 클래스가 어떻게 구현되느냐에 따라 하위 클래스의 동작에 이상이 생길 수 있습니다. 상위 클래스는 내부 구현이 달라지게 되면 하위 클래스에 이상이 생길 수 있습니다.

HashSet을 사용하는 프로그램이 있고, 성능을 높이려면 이 HashSet은 처음 생성된 이후 몇개의 원소가 더해졌는지 알 수 있어야 한다고 가정하여 아래 코드와 같이 변수와 접근자 메서드를 추가하고 add와 addAll을 재정의 한 경우를 확인해 보겠습니다.

// 코드 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());
    }
}

이 클래스는 상속을 잘못하여 addAll 메서드로 원소 3개를 더하면 getAddCount를 print했을 때 3이 출력 될거라 생각하겠지만, 실제로 6이 출력됩니다. 원인은 addAll 메서드가 add 메서드를 사용해 구현되었기 때문입니다. addAll은 addCount에 3을 더한 후 HashSet에 addAll 구현을 호출했습니다. hashSet의 addAll은 각 원소를 add 메서드를 호출해 추가하는데 이때 불리는 add는 재정의한 add기 때문에

재정의한 addAll에서 3이 추가되고, super.addAll에서 각각의 원소마다 재정의한 add가 3번 호출되어 3이 추가되어 6이 출력 되었습니다.

이처럼 상위 클래스의 메서드 동작을 어렵고, 시간이 많이 들고 오류를 내거나 성능을 떨어뜨릴 수 있습니다. 또한 하위 클래스에서 접근할 수 없는 private 필드를 사용하는 경우 이 방식으로 구현 자체가 불가능합니다.

 

다음 릴리스에 상위 클래스에 새로운 메서드를 추가하는 경우

보안 때문에 컬렉션에 추가된 모든 원소가 특정 조건을 만족해야 하는 클래스라고 가정할 때 그 컬렉션을 상속해 원소를 추가하는 모든 메서드를 재정의해서 필요한 조건을 먼저 검사하면 될 것 같지만, 이 방식은 상위 클래스에 또 다른 원소 추가 메서드가 만들어지면 문제가 발생합니다. 하위 클래스에서 재정의하지 못한 그 새로운 메서드를 사용해 허용되지 않은 원소를 추가할 수 있게 되는 보안 구멍들이 생기게 됩니다.

만약 하위 클래스에 추가한 메서드와 상위 클래스에 새롭게 추가된 메서드의 시그니처가 같고 반환 타입이 다르다면 컴파일조차 되지 않습니다.

하위 클래스에 메서드를 작성할 때 상위 클래스의 메서드는 존재하기 이전의 시점이니, 하위 클래스의 메서드는 상위 클래스에 새롭게 추가된 메서드가 요구하는 규약을 지키지 못할 가능성이 큽니다.

 

👍 문제를 해결하는 방법

기존 클래스를 확장하는 대신, 새로운 클래스를 만들고 private 필드로 기존 클래스의 인스턴스를 참조하게 만들면 됩니다. 기존 클래스가 새로운 클래스의 구성요소로 쓰인다는 뜻에서 이러한 설계를 컴포지션이라고 합니다.

새 클래스의 인스턴스의 메서드는 기존 클래스의 대응하는 메서드를 호출해 그 결과를 반환합니다. 이 방식을 전달이라고 하며, 새 클레스의 메서드들은 전달 메서드라고 부릅니다. 그 결과 새로운 클래스는 기존 클래스의 내부 구현 방식의 영향에서 벗어나게 됩니다.

 

래퍼 클래스 - 상속 대신 컴포지션을 사용했다.

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

 

재사용할 수 있는 전달 클래스

public class ForwardingSet<E> implements Set<E> {
    private final Set<E> s;

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

    public int size() {
        return 0;
    }

    public boolean isEmpty() {
        return s.isEmpty();
    }

    public boolean contains(Object o) {
        return s.contains(o);
    }

    public Iterator<E> iterator() {
        return s.iterator();
    }

    public Object[] toArray() {
        return s.toArray();
    }

    public <T> T[] toArray(T[] a) {
        return s.toArray(a);
    }

    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 retainAll(Collection<?> c) {
        return s.retainAll(c);
    }

    public boolean removeAll(Collection<?> c) {
        return s.removeAll(c);
    }

    public void clear() {
        s.clear();
    }

    @Override
    public boolean equals(Object o) {
        return s.equals(o);
    }

    @Override
    public int hashCode() {
        return s.hashCode();
    }

    @Override
    public String toString() {
        return s.toString();
    }
}

InstrumentedHashSetUseComposition은 HashSet의 모든 기능을 정의한 Set 인터페이스를 활용해 설계되어 견고하고 아주 유연합니다.

임의의 Set에 계측 기능을 덧씌워 새로운 Set으로 만드는게 이 클래스의 핵심 입니다.

상속 방식은 구체 클래스 각각을 따로 확장하고, 지원하고 싶은 상위 클래스의 생성자 각각에 대응하는 생성자를 별도로 정의해줘야 합니다. 하지만 컴포지션 방식은 한번만 구현해두면 어떤 Set이라도 계측할 수 있으며, 기존 생성자들과도 함께 사용이 가능합니다.

다른 Set 인스턴스를 감싸고 있다는 뜻에서 InstrumentedSet같은 클래스를 래퍼 클래스라고 하며, 다른 Set에 계측 기능을 덧씌운다는 뜻에서 데코레이터 패턴이라고 합니다. 컴포지션과 전달의 조합은 넓은 의미로 위임이라 부릅니다. 래퍼 클래스는 콜백 프레임워크와 어울리지 않는다는 점만 주의하면 단점은 거의 없습니다.

 

상속과 is-a

상속은 반드시 하위 클래스가 상위 클래스의 '진짜' 하위 타입인 상황에서만 쓰여야 합니다. 즉 클래스 B가 클래스 A와 is-a 관계일 때만 클래스 A를 상속해야 합니다. 클래스 A를 상속하는 클래스 B를 작성하기 전 'B가 정말 A인가?'라는 생각을 갖고 상속해야 합니다. 만약 아니라면 상속이 아닌 컴포지션을 사용해야 합니다.

 

상속시 주의점

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

그 결과 API가 내부 구현에 묶이고 클래스의 성능도 영원히 제한됩니다.

더 심각한 문제는 클라이언트가 노출된 내부에 직접 접근이 가능해집니다.

추가로 사용자를 혼란스럽게 만들 수 있습니다. Properties 클래스의 인스턴스 p가 있을 때 p.getProperty(key)와 p.get(key)는 결과가 다를 수 있습니다. 전자는 Properties의 기본 동작인데 후자는 상위 클래스 HashTable에 메서드입니다.

가장 심각한 건 상위 클래스를 직접 수정해 하위 클래스의 불변식을 해칠 수 있습니다.

상속은 상위 클래스의 결함까지도 그대로 승계되어 주의가 필요합니다.

 

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

 

❤️ 스터디 질답 정리

Q. 데코레이터 패턴은 어떤 경우에 사용하는지?
A. 한번 잘 설계된 클래스를 감싸 기존 코드에 영향을 주지 않고, 새로운 기능을 추가하고 싶은 경우(ex.로깅)
데코레이터 패턴은 new InstrumentedSet<>(new HashSet<>()); 처럼 한번 감싸 새로운 기능을 추가한다. 

  • 네이버 블로그 공유
  • 네이버 밴드 공유
  • 페이스북 공유
  • 카카오스토리 공유