티스토리 뷰

(Effective Java) 규칙18. 상속보다는 컴포지션을 사용하라


계승(Inheritance)


  • 계승(Inheritance)은 재사용을 돕는 강력한 도구지만, 항상 최선이라고 할 순 없다.

    • 계승을 적절히 사용하지 못한 소프트웨어는 깨지기 쉬움.
  • 계승은 상위 클래스와 하위 클래스 구현을 같은 프로그래머가 통제하는 단일 패키지 안에 있을 경우 안전하다.

  • 일반 객체 생성 클래스(concrete class)라면, 해당 클래스가 속한 패키지 밖에서 계승을 시도하는 것은 위험하다.

    • 이 책에서는 계승(Inheritance)이라는 용어를 구현 계승 (implementation Inheritance)의 의미로 사용
    • 즉 한 클래스가 다른 클래스를 'extends' 한다는 소리, 인터페이스 끼리의 계승은 해당하지 않음

 

계승의 문제


  • 메서드 호출과 달리, 계승은 캡슐화(encapsulation) 원칙을 위반한다.

    • 하위 클래스가 정상 동작하기 위해서는 상위 클래스의 구현에 의존할 수 밖에 없음
    • 상위 클래스가 수정됨에 따라 하위 클래스 코드는 영향을 받아 망가질 수 있음
  • 하위 클래스 구현을 망가뜨릴 수 있는 요인

    • 상위 클래스의 구현을 알지 못하여 생기는 문제
      • 예) HashSet을 사용하는 프로그램
        • 프로그램의 성능을 높이기 위해서 HashSet 객체가 생성된 이후 얼마나 많은 요소가 추가되었는지 질의해야 한다고 가정
        • 계승을 이용해 HashSet에 삽입된 요소의 수를 추적하는 필드와 그 필드에 대한 접근자를 갖는 클래스
          //계승을 잘못 사용한 사례!
          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;
              	  }
           }
        • 이 코드는 제대로 동작하지 않음
            InstrumentedHashSet<String> s = new InstrumentedHashSet<>();
            s.addAll(Arrays.asList("first", "second", "third"));
          • 위 코드를 실행 하면 getAddCount가 3을 반환 할 것이라고 예상이 되지만 실제로 6을 반환함
          • 그 이유는 HashSet의 addAll 메서드는 add메서드를 통해 구현이 되어 있기 때문임
            • HashSet 문서에는 이런 사실이 명시되어 있지 않음
          • 이 클래스가 정상 동작한다는 것은 HashSet의 addAll 메서드가 add 위에서 구현되었다는 사실에 의존함
          • 즉, InstrumentedHashSet 클래스는 깨지기 쉬운(fragile) 클래스일 수 밖에 없음
    • 다음 릴리즈에 상위 클래스에 새로운 메서드가 추가될 수 있는 문제
      • 상위 클래스에 컬렉션에 요소를 넣을 수 있는 메서드가 추가되면 하위 클래스에서 재정의하지 않은 새 메서드를 호출하여 "잘못된(illegal)" 객체를 컬렉션에 넣을 수 있게 됨
  • 앞서 살펴본 문제들은 메서드 재정의 때문에 발생한 문제이다.

    • 메서드 재정의 대신 새 메서드로 만들어 넣으면 계승이 괜찮을 거라고 생각할 수 있지만, 위험성이 완전히 사라지는 것은 아님
      • 예) 새 릴리스에 추가된 상위 클래스 메서가 재수 없게도 하위 클래스에 정의한 메서드와 같은 시그니처인데 반환 값만 다른 경우...

계승 대신 구성(Composition)


  • 지금껏 봐왔던 문제들을 모두 피할 수 있는 방법

    • 기존 클래스를 계승 하는 대신, 새로운 클래스에 기존 클래스 객체를 참조하는 private 필드를 하나 두는 것
    • 이런 설계 기법은 구성(composition)이라고 하는데 기존 클래스가 새 클래스의 일부(component)가 되기 때문임
  • 새로운 클래스에 포함된 각각의 메서드는 기존 클래스에 있는 메서드 가운데 필요한 것을 호출해서 그 결과를 반환

    • 이런 구현 기법은 전달(forwarding)이라고 하고 전달 기법을 사용해 구현된 메서드를 전달 메서드라고 부름
  • 구성 기법으로 구현된 클래스는 견고하다.

    • 기존 클래스에 또 다른 메서드가 추가되더라도, 새로 만든 클래스에는 영향이 없음
    • 예) 앞선 예제의 구성 기법 적용
      //계승 대신 구성을 사용하는 포장(wrapper) 클래스
      public class InstrumentedSet<E> extends ForawrdingSet<E> {
        //요소를 삽입하려한 횟수
        private int addCount = 0;
      
        public InstrumentedSet() {
        }
      
        public InstrumentedSet(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;
        }
      }
      //재사용 가능한 전달(forwarding) 클래스
      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 c.containsAll(c); }
        ....
      }
    • InsteumentedSet을 이렇게 설계할 수 있는 것은, HashSet이 제공해야 할 기능을 규정하는 Set이라는 인터페이스가 있기 때문
      • 이런 설계는 안정적이고 유연성이 아주 높음
      • 포장(wrapper class) 기법을 쓰면 어떤 Set 구현도 원하는 대로 수정할 수 있고, 이미 있는 생성자도 그대로 사용 가능
        Set<Data> s = new InstrumentedSet<Date>(new TreeSet<Date>(cmp)); Set<E> s2 = new InstrumentedSet<E>(new HashSet<E>(capacity));
      • 심지어 이미 사용 중인 객체에 일시적으로 원하는 기능을 넣는데도 활용 가능
        static void walk(Set<Dog> dogs) { 
            InstrumentedSet<Dog> iDogs = new InstrumentedSet<Dog>(dogs);
            ... //이 메서드 안에서는 dogs 대신 iDogs를 사용 }
        }
    • InsteumentedSet과 같은 클래스를 포장 클래스라고 부르는데 다른 Set 객체를 포장하고 있기 때문임
      • 이런 구현 기법은 장식자(decorator) 패턴이라고 부름
  • 포장 클래스에는 단점이 별로 없지만 역호출(callback) 프레임워크와 함께 사용하기에는 적절치 않다.

    • 역호출 프레임워크에서는 객체는 자기 자신에 대한 참조를 다른 객체에 넘겨, 나중에 필요할 때 역호출(callback)하도록 요청함
    • 포장된(wrapped) 객체는 포장 객체에 대해서는 모르기 때문에, 자기 자신에 대한 참조(this)를 전달할 것임
    • 역호출 과정에서 포장 객체는 제외 되게 됨
  • 전달 메서드 호출과 과정에서 성능이 저하되거나, 포장객체 때문에 메모리 요구량이 늘어나지 않을까 걱정하지만 실제로는 큰 영향이 없는 것으로 나타났다.

  • 전달 메서드 코딩은 지루한 작업이지만 인터페이스별로 한 번씩만 구현하면 되고 인터페이스와 같은 패키지에 이미 들어있는 경우도 있다.

정리하며


  • 계승은 하위 클래스가 상위 클래스의 하위 자료형(subtype)이 확실한 경우에만 바람직하다.

    • 클래스 B는 클래스 A와 "IS-A" 관계가 성립할 때만 A를 계승해야함
    • 스스로에게 물어본 뒤 "그렇다' 답할 수 없다면 계승하면 안됨
    • "아니다" 라고 답했다면, B 안에 A객체를 참조하는 private 필드를 두고 B에는 더 작고 간단한 API를 구현해야 함
      • A는 B의 핵심적 부분이 아니며 B의 구현 사항에 불과함
  • 자바 플랫폼 라이브러리에는 이 원칙을 분명히 위반하는 사례들이 많다.

    • 예) 스택(stack)은 벡터(vector)가 아니므로 Stack은 Vector를 계승하면 안됨 (Properties, Hashtable 역시)
      • 두 경우 모두 구성 기법이 더 적합함
  • 구성 기법이 적절한 곳에 계승을 사용하면 구현 세부사항이 쓸데없이 노출된다.

    • 그런 API는 원래 구현에서 벗어날 수 없게 되며, 클래스의 성능을 개선하기 어려워짐
    • 더 심각하게는 클라이언트가 내부 구현 세부사항에 접근할 수도 있게됨
      • 예) Properties의 경우 설계자의 의도는 키와 값으로 문자열을 사용하는 것이었지만, 상위 클래스인 Hashtable에 직접 접근할 수 있으로 이 불변식을 깰 수가 있음
        • 불변식이 깨지고 나면 API의 다른 부분 (load, store)은 사용할 수 없게 됨
  • 구성 대신 계승을 사용하려 할 때 반드시 물어야 하는 질문은 "계승할 클래스의 API에 문제가 있는가?"

    • "그렇다면 그 문제들이 계속 새 API의 일부가 되어도 상관없겠는가?"
    • 계승은 상위 클래스의 문제를 하위 클래스에 전파시키지만 구성은 그런 약점을 감추는 새로운 API를 설계하도록 도와줌

결론


  • 계승은 강력한 도구이지만 캡슐화 원칙을 침해하므로 문제를 발생시킬 소지가 있다.

    • 상위 클래스와 하위 클래스 사이에 IS-A 관계가 있을 때만 사용하는 것이 좋음
    • 설사 관계가 성립해도 하위 클래스가 상위 클래스와 다른 패키지에 있거나 계승을 고려하지 않은 상위 클래스라면 하위 클래스는 깨지기 쉬움
  • 이런 문제를 피하려면 구성과 전달 기법을 사용하는 것이 좋다.

    • 포장 클래스 구현에 적당한 인터페이스가 있다면 더더욱
반응형
댓글