본문 바로가기
책/effective java

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

by 2245 2023. 8. 7.

결론

상속은 강력하지만 캡슐화를 깨뜨린다는 단점이 있습니다.
대안책으로 컴포지션과 전달을 사용할 수 있습니다. 
특히 래퍼 클래스로 구현할 적당한 인터페이스가 있다면 더욱 좋습니다. 

 

핵심 코드

public class CustomHashSetByComposition<E> {

    private final HashSet<E> hashSet;
    private int addCount = 0;

    public CustomHashSetByComposition(HashSet<E> hashSet) {
        this.hashSet = hashSet;
    }

    public boolean add(E e) {
        addCount++;
        return hashSet.add(e);
    }

    public boolean addAll(Collection<? extends E> c) {
        addCount += c.size();
        return hashSet.addAll(c);
    }

    public int getAddCount() {
        return addCount;
    }
}

 

 

설명

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

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

상기하자면, 이 책에서의 상속은 (클래스가 다른 클래스를 확장하는) 구현 상속을 말합니다.

 

상속의 위험성

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

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

상위 클래스는 릴리스마다 내부 구현이 달라질 수 있으며, 그 여파로 코드 한 줄 건드리지 않은 하위 클래스가 오작동할 수 있습니다. 

 

상속을 잘못 사용한 예시: HashSet

우리는 이 HashSet이 처음 생성된 이후 원소가 몇 개 더해졌는지 알고 싶습니다. 

(HashSet의 현재 크기와는 다른 개념입니다. 현재 크기는 제거되면 줄어듭니다.)

따라서 다음과 같이 추가된 원소의 수를 저장하는 변수와 접근자 메서드를 추가했습니다. 

그런 다음, HashSet에 원소를 추가하는 메서드인 add와 addAll을 재정의했습니다. 

 

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());	//3을 기대했으나, 6출력 
    }
}

 

문제점: 3을 기대했으나, 6을 출력

원인은, HashSet의 addAll 메서드가 add 메서드를 사용해 구현된 데 있습니다.

이런 내부 구현 방식은 HashSet 문서에는 (당연히) 쓰여있지 않습니다.

 

  1. InstrumentedHashSet의 addAll은 addCount에 3을 더한 후 HashSet의 addAll을 호출합니다. 
  2. HashSet의 addAll은 각 원소를 add 메서드를 호출해 추가하는데, 이 때 불리는 add는 InstrumentedHashSet에서 재정의한 메서드입니다. 
  3. 따라서, addCount에 값이 중복해서 더해져, 최종값이 6으로 늘어납니다. 

 

 

해결책?

addAll 메서드를 재정의하지 않는다.

하위 클래스에서 addAll 메서드를 재정의하지 않으면, addCount에 3을 더하지 않고 HashSet의 addAll이 호출되므로 문제를 고칠 수 있습니다.

하지만, HashSet의 addAll이 add 메서드를 이용해 구현했음을 가정한 해법이라는 한계를 지닙니다.

 

이처럼 자신의 다른 부분을 사용하는 '자기사용(self-use)' 여부는 해당 클래스의 내부 구현 방식에 해당하며, 자바 플랫폼 전반적인 정책인지, 그래서 다음 릴리스에서도 유지될지 알 수 없기 때문에 이런 가정에 기댄 InstrumentedHashSet도 깨지기 쉽습니다. 

 

 

addAll 메서드를 다른 식으로 재정의한다.

예를 들어, 주어진 컬렉션을 순회하며 원소 하나당 add 메서드를 한 번만 호출하는 것입니다. 

더이상 HashSet의 addAll을 호출하지 않으니 addAll의 add를 사용하는지와 상관없이 결과가 옳다는 점에서 조금은 나은 해법입니다.

 

하지만, 상위 클래스의 메서드 동작을 다시 구현하는 이 방식은 어렵고, 시간도 더 들고, 자칫 오류를 내거나 성능을 떨어뜨릴 수도 있습니다. 또한, 하위 클래스에서는 접근할 수 없는 private 필드를 써야 한다면 이 방식으론 구현 자체가 불가능합니다.

 

 

또 다른 문제점: 상위 클래스의 새로운 메서드 추가

하위 클래스가 깨지기 쉬운 이유는 더 있습니다. 다음 릴리스에서 상위 클래스에 새로운 메서드를 추가한다면 어떻게 될까요?

 

한 컬렉션에 추가된 모든 원소가 특정 조건을 만족하는지 검사해야 한다고 가정해봅시다. 

그 컬렉션을 상속하여 원소를 추가하는 모든 메서드를 재정의해 필요한 조건을 먼저 검사하게끔 하면 될 것 같습니다. 

 

하지만 만약, 상위 클래스에서 추가 메서드를 정의한다면 하위 클래스에서 재정의되지 못한 그 새로 추가된 메서드를 사용해 '허용되지 않은' 원소를 추가할 수 있게 됩니다.  

실제로 컬렉션 프레임워크 이전부터 존재하던 Hashtable과 vector를 컬렉션 프레임워크에 포함시키자 이와 관련된 보안 구멍들을 수정해야 하는 사태가 벌어졌습니다.

 

 

또 다른 해결책? 재정의하지 말고 새로운 메서드를 추가하자.

이상의 두 문제 모두 재정의가 원인이었습니다. 따라서 클래스를 확장하더라도 메서드를 재정의하는 대신 새로운 메서드를 추가하면 괜찮으리라 생각할 수도 있습니다.

이 방식이 훨씬 안전한 것은 맞지만, 위험이 전혀 없는 것은 아닙니다.

 

상위 클래스에 새 메서드가 추가됐는데, 운 없게도 하필 하위 클래스에 추가한 메서드와 시그니처가 같고 반환 타입은 다르다면 여러분이 만든 클래스는 컴파일조차 되지 않습니다. 

반환 타입마저 같다면 재정의한 것과 다름이 없습니다.

문제는 여기서 그치지 않고, 여러분이 이 메서드를 작성할 때는 상위 클래스의 메서드가 존재하지도 않았으니, 여러분이 만든 메서드는 요구하는 규약을 만족하지 못할 가능성이 큽니다. 

 

 

 

유일한 해결책: 컴포지션(Composition)

기존 클래스를 확장하는 대신, 새로운 클래스를 만들고 private 필드로 기존 클래스의 인스턴스를 참조하게 합시다. 

기존 클래스가 새로운 클래스의 구성요소로 쓰인다는 뜻에서 이러한 설계를 컴포지션(composition: 구성)이라 합니다.

 

참고 전달(forwarding), 전달 메서드(forwarding method)
새 클래스의 인스턴스 메서드들은 (private 필드로 참조하는) 기존의 클래스의 대응하는 메서드를 호출해 그 결과를 반환합니다.
이 방식을 전달(forwarding)이라 하며, 새 클레스의 메서드들을 전달 메서드(forwarding method)라 부릅니다.

 

그 결과 새로운 클래스는 기존 클래스의 내부 구현 방식의 영향에서 벗어나며,

심지어 기존 클래스에 새로운 메서드가 추가되더라도 전혀 영향받지 않습니다. 

 

구체적인 예시를 위해 InstrumentedHashSet을 컴포지션과 전달 방식으로 다시 구현한 코드를 살펴봅시다. 

 

재사용할 수 있는 전달 클래스 (p.118)

전달 메서드로만 이뤄졌습니다. 

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 int size()                 { return s.size();      }
    public Iterator<E> iterator()     { return s.iterator();  }
    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 removeAll(Collection<?> c)
                                   { return s.removeAll(c);   }
    public boolean retainAll(Collection<?> c)
                                   { return s.retainAll(c);   }
    public Object[] toArray()          { return s.toArray();  }
    public <T> T[] toArray(T[] a)      { return s.toArray(a); }
    @Override public boolean equals(Object o)
                                       { return s.equals(o);  }
    @Override public int hashCode()    { return s.hashCode(); }
    @Override public String toString() { return s.toString(); }
}

 

집합 클래스 (상속 대신 컴포지션을 사용했다.)

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());	//3 출력 (상속을 한게 아니므로, 오버라이딩된 add가 아닌 HashSet의 add가 호출됩니다.)
    }
}

 

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

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

 

Set<Instant> times = new InstrumentedSet<>(new TreeSet<>(cmp));
Set<E> s = new InstrumentedSet<>(new HashSet<>(INIT_CAPACITY));

 

또한, 대상 Set 인스턴스를 특정 조건하에서만 임시로 계측할 수 있습니다.

 

static void walk(Set<Dog> dogs) {
	InstrumentedSet<Dog> iDogs = new InstrumentedSet<>(dogs);
    ... //이 메서드에서는 dogs 대신 iDogs를 사용한다.
}

 

참고 래퍼 클래스, 데코레이터 패턴(Decorator pattern)
다른 Set 인스턴스를 감싸고(wrap) 있다는 뜻에서 InstrumentedSet 같은 클래스를 래퍼 클래스라 하며, 
다른 Set에 계측 기능을 덧씌운다는 뜻에서 데코레이터 패턴이라고 합니다. 

 

 


출처

https://www.yes24.com/Product/Goods/65551284

 

이펙티브 자바 Effective Java 3/E - 예스24

자바 플랫폼 모범 사례 완벽 가이드 - Java 7, 8, 9 대응자바 6 출시 직후 출간된 『이펙티브 자바 2판』 이후로 자바는 커다란 변화를 겪었다. 그래서 졸트상에 빛나는 이 책도 자바 언어와 라이브

www.yes24.com