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

2021. 8. 15. 13:03책/이펙티브자바

들어가기

상속보다는 컴포지션을 사용해래

컴포지션이 상속받을 클래스를 필드로 넣어서 사용하는걸 컴포지션이라고 부르는거 같던데 맞나 ?

일단 상속보다 컴포지션이 좋은점이 있으니까 그러라고 하는거겠지 ?

왜 그런지 알아보자.

상속의 단점

  1. 상속은 캡슐화를 깨뜨린다.
    1. 상위 클래스가 어떻게 구현하느냐에 따라 하위클래스의 동작에 이상이 생길 수 있다.
    2. 상위 클래스는 릴리스마다 내부 구현이 달라질 수 있는데, 그 여파로 코드를 안건드린 하위 클래스가 오동작 할 수 있다는 것이다.

예를들어 HashSet 을 사용하는 프로그램이 있는데 여기서 추가적으로 처음 생성된 이후 원소가 몇 개 더해져있는지 알수있어야 하는 클래스가 필요하다고 할게.

그래서 HashSet을 상속받은 InstrumentedHashSet 을 만들어서 사용할거야 .

// InsturmentedHashSet.class
public class InsturmentedHashSet<E> extends HashSet<E> {
    // 추가된 원소의 수
    private int addCount = 0;

    // 생성자
    public InstrumentedHashSet() {}
    public InstrumentedHashSet(int initCap, float loadFactor) {
        super(initCap, loadFactor);
    }

    public int getAddCount() {
        return addCoount;
    }

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

이렇게 구현을 해서 보면 코드를 보면 뭐 문제없어보여 .

근데 실제 사용할때는 문제가 발생해.

InstrumentedHashSet<String> s = new InstrumentedHashSet<>();
s.addAll(List.of("틱", "틱틱", "틱틱틱"));

이렇게 addAll() 메소드를 호출하면 위에서 오버라이딩한 메소드 내부에서 c.size 를 더하잖아 ?

그러면 당연히 addCount 가 3이길 기대할거야.

근데 작동시켜보면 addCount가 실제로는 6이야. 왜일까 ???

 

이건 HashSet의 addAll() 내부를 들여다 보면 add()를 호출해서 작동해서 그래.

그러니까 내 InstrumentedHashSet.addAll() 을 호출하면 += c.size 로 3을 더하게 되고

또 내부에선 add() 를 호출하게 되니까 addCount++ 를 추가적으로 3번 하게되어서 6이 나오는거지.

// AbstractCollection.class
public boolean addAll(Collection<? extends E> c) {
  boolean modified = false;
  for (E e : c)
      if (add(e))
          modified = true;
  return modified;
}

아니 그러면 InstrumentedHashSet 에서 그냥 addAll 메서드를 재정의하지 않고 문제를 고치면 되지않나 ?

근데 이런식으로 고치면 당장은 제대로 동작할지 모르나, HashSet의 addAll()이 add()를 이용해 구현한 결과를 내가 이미 알고있으니까 이런 답안을 낼 수 있는거야. 만약 몰랐다면 이런 생각조차 못했겠지.

또 다음 릴리스에서도 이게 유지될지는 미지수잖아 ?

그래서 이런 가정에 기대서 만든 InstrumentedHashSet 도 결국엔 깨지기 쉽다는 거야.

 

아니면 addAll() 을 다른식으로 재정의하면 되지 않을까 ?

예를들어 컬렉션을 순회하며 원소 하나당 add()를 한번만 호출하는거야.

이렇게 하면 HashSet.addAll() 을 호출하는건 아니니까 addAll() 이 add()를 쓰던말던 상관없이 결과는 원하는 대로 나오겠지 ?

근데 이렇게 해도 문제는 남아있어.

상위 클래스의 메서드 동작을 다시 구현하는 이 방식은 어렵고, 시간도 많이 들고, 오류를 내거나 성능하락이 일어날수도있어. 또 하위클래스에서 접근이 불가능한 private 필드를 사용하는 경우라면 이렇게 구현은 불가능해.

 

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

만약 다음 릴리스에서 상위 클래스에 새 메서드가 추가된다면 어떨까 ?

 

보안 때문에 컬렉션에 추가된 모든 원소가 특정 조건을 만족해야만 하는 프로그램이 있다고 해볼게.

그럼 그 컬렉션을 상속하여 원소를 추가하는 모든 메서드를 재정의해 검사하는 코드를 추가하면 문제 없겠지 ?

근데 이 방식이 통하는건 상위 클래스에 또 다른 원소추가 메서드가 만들어지기 전까지야.

만약 다음 릴리스에서 원소추가 메서드가 만들어진다면 ? 하위 클래스에서 재정의하지 못한 그 새로운 메서드를 호출해서 검사되지않은 원소를 추가할수있게되는거야.

 

그러니까 클래스를 확장하더라도 메서드를 재정의하는 대신 새로운 메서드를 추가하면 괜찮을꺼라 생각할수있잖아 ?

이게 훨씬 안전한건 맞아.근데 완전하다곤 할수없지.

왜냐면 다음 릴리스에서 상위 클래스에 새 메서드가 추가됐는데, 하필 하위 클래스에서 추가한 메서드와 시그니처가 같고 반환타입이 다르면 내 클래스는 컴파일조차 안되버리는거지

// Father.class
public class Father {

    void cry() {
        System.out.println("cry ");
    }

        // error
    String cry2() {
        return "cry2";
    }
}

// Boy.class
public class Boy extends Father {

    @Override
    void cry() {
        super.cry();
    }

        // error
    void cry2() {
        System.out.println("cry2");
    }
}

Boy가 Father을 상속한 상태에서 void cry2() 를 추가해서 잘 쓰고 있었는데

다음 릴리스에서 Father이 갑자기 String cry2() 를 만들어버리면 ? 컴파일조차 안되어버리는거야.

추가적으로 Boy 에서 cry2() 를 쓸때는 Father cry2()는 없었잖아 ?

근데 나중에 Father cry2() 를 만들때 메서드가 요구하는 규약이 있으면 Boy는 만족하지 못할 가능성이 높다는거지.

상속 단점의 해결책

지금까지는 상속이 안좋은점에 대해서 설명한거 같네.

그러면 뭐 다른 해결책도 제시해주겠지 ?

 

책에서 위에서 적은 모든 문제를 다 피해가는 방법이 있다고 말해줘.

기존 클래스를 확장하는게 아니라 대신 새로운 클래스를 만들고 private 필드로 기존 클래스의 인스턴스를 참조하라는거야.

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

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

그 결과 새로운 클래스는 기존 클래스의 내부 구현 방식의 영향에서 벗어나고, 심지어 기존 클래스에 새로운 메서드가 추가되더라도 전혀 영향을 안받아.

// 18-2. Wrapperclass
public class InstrumentedSet<E> extends ForwardingSet<E> {
    private int addCount = 0;

    public InstrumentedSet(Set<E> s) {
        super(s);
    }

    public int getAddCount() {
        return addCoount;
    }

    @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);
    }
}
// 18-3. ForwardingSet
public class ForwardingSet<E> extends Set<E> {
  private final Set<E> s;
  public ForwardingSet(Set<E> s) { this.s = s; }

  @Override public int size() { return s.size(); }
  @Override public boolean isEmpty() { return s.isEmpty(); }
  @Override public boolean contains(Object o) { return s.contains(o); }
  @Override public Iterator<E> iterator() { return s.iterator(); }
  @Override public Object[] toArray() { return s.toArray(); }
  @Override public <T> T[] toArray(T[] a) { return s.toArray(a); }
  @Override public boolean add(E e) { return s.add(e); }
  @Override public boolean remove(Object o) { return s.remove(o); }
  @Override public boolean containsAll(Collection<?> c) { return s.containsAll(c); }
  @Override public boolean addAll(Collection<? extends E> c) { return s.addAll(c); }
  @Override public boolean retainAll(Collection<?> c) { return s.retainAll(c); }
  @Override public boolean removeAll(Collection<?> c) { return s.removeAll(c); }
  @Override public void clear() { s.clear(); }

  @Override public boolean equals(Obejct o) { return s.eqals(o); }
  @Override public int hashCode() { return s.hashCode(); }
  @Override public String toString() { return s.toString(); }
}

이렇게 되면 InstrumentedSet은 HashSet의 모든 기능을 정의한 Set 인터페이스를 활용해서 견고하고 유연해.

구체적으로보면 Set 인터페이스를 구현하고, Set의 인스턴스를 인수로 받는 생성자가 있어.

임의의 Set에 계측기능을 덧씌워 새로운 Set으로 만드는것이 InstrumentedSet의 핵심인거지.

이런 컴포지션 방식은 한번만 구현해두면 어떠한 Set 구현체라도 계측할 수 있으며, 기존 생성자들과도 함께 사용할수 있다.

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

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

컴포지션 + 전달 ⇒ 넓은 의미로 위임(delegation) 이라고 한다.

단, 엄밀히 말하면 래퍼 객체가 내부 객체에 자기 자신의 참조를 넘기는 경우만 위임이다.

is-a 관계

상속은 반드시 하위 클래스가 상위 클래스의 "진짜" 하위 타입인 상황에서만 쓰여야 한다.

말하자면 클래스 B가 클래스 A 와 is-a 관계일때만 A를 상속해야 한다.

 

클래스 A를 상속하는 클래스 B를 작성하려 한다면 "B가 진짜로 A 인가 ?" 를 자문해보자.

확신할수 없다면 상속해서는 안된다.

아니라면 A를 private 인스턴스로 두고, A와는 다른 API를 제공해야 하는 상황이 대다수이다.

즉, A는 B의 필수 구성요소가 아니라 구현하는 방법 중 하나일 뿐이다.

 

컴포지션을 써야되는데 상속을 하는건 내부 구현을 불필요하게 노출하는 꼴이다.

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

 

자바에서 이렇게 잘못된 예시로 속성 목록은 해시테이블이 아닌데 Properties 가 HashTable을 확장했다.

그 결과 사용자가 혼란스럽게 되었는데 Properties에서 getProperty(key) 와 get(key)는 결과가 다를 수 있다.

getProperty는 Properties의 기본동작인데, get은 HashTable의 메서드이기 때문이다.

이렇게되면 가장 큰 문제는 클라이언트가 상위 클래스를 수정하여 하위클래스의 불변식을 해칠수있다는 것이다.

예를들어 Properties는 키와 값으로 문자열만 허용하려고 했는데 HashTable의 메서드를 호출하면 이 불변식이 깨질수 있다. 한번 불변식이 깨지면 load, store 같은 Properties의 API는 더이상 사용할 수 없다.

상속을 사용할때

컴포지션이 아니고 상속을 사용할거라면 마지막으로 자문해야할 질문이 있다.

"확장하려는 클래스의 API에 아무런 결함이 없는가?"

"결함이 있다면 이 결함이 내 클래스의 API까지 전파돼도 괜찮은가?"

컴포지션으로는 이런 결함을 숨기는 새로운 API를 설계할 수 있지만, 상속은 상위 클래스의 API를 그 결함까지도 승계한다.

결론

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