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

    핵심 정리

    • 패키지 경계를 넘어 다른 패키지의 구체 클래스를 상속하는 일은 위험하다.
      • 아래 두 가지 경우가 발생하면, 상속에서 구현한 메서드가 의도한 대로 동작하지 않을 수 있다.
        • 상위 클래스에서 제공하는 메서드 구현이 바뀌는 경우
        • 상위 클래스에서 새로운 메서드가 생기는 경우
    • 상속보다는 컴포지션을 활용하라.
    • 컴포지션 (Composition)
      • 새로운 클래스를 만들고 private 필드로 기존 클래스의 인스턴스를 참조 (상속대신에 필드로 가진다)
      • 새 클래스의 인스턴스 메서드들은 기존 클래스에 대응하는 메서드를 호출해 그 결과를 반환한다.  (deligation 한다)
      • 컴포지션을 이용하면, 기존 클래스의 구현이 바뀌거나 새로운 메서드가 생기더라도 아무런 영향을 받지 않는다.

     

    들어가기 전

    이번 글에서는 상속을 사용했을 때 발생하는 상속의 단점을 아래와 같이 살펴볼 것이다.

    • 어떻게 캡슐화가 깨지는지.
    • 어떻게 상위 클래스의 내부 구현이 하위 클래스에 영향을 미치는지 (하위 클래스는 바뀌어야 함)
    • 상위 클래스의 내부 정보가 상속으로 인해 노출되었을 때 발생하는 문제 

    상속은 캡슐화를 깨뜨리고, 확장이 어려운 코드를 가져온다.

    아래 코드를 살펴보자.

    • HashSet을 상속한 클래스다
    • HashSet이 가진 기능을 사용하면서, 원소가 추가될 때 마다 원소의 갯수를 새는 기능을 추가한 것이다. 
    public class InstrumentedHashSet <E> extends HashSet<E> {
        // 추가된 원소의 수
        private int addCount = 0;
        public InstrumentedHashSet(){}
    
        public InstrumentedHashSet(int initialCapacity, float loadFactor) {
            super(initialCapacity, 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<String>();
            s.addAll(List.of("틱", "탁탁", "펑"));
            System.out.println(s.getAddCount());
        }
    
    }

    그런데 위 코드를 바탕으로 main() 메서드를 실행하면 결과는 어떻게 될까? 3이라는 결과가 나오길 기대했지만, 실제로 나오는 값은 6이다. 왜 아래와 같은 문제가 발생할까?

    // 실행 결과
    6

    다음과 동작 때문에 위 문제가 발생한다

    1. addAll()을 하면, 우리가 구현한 클래스 내부에서 우선 리스트의 사이즈만큼 값을 addCount에 추가함 (0 → 3)
    2. addAll()은 HashSet의 addAll()을 호출한다. 이 때, addAll()은 각 원소에 대해서 add()를 하나씩 호출한다. 이 때 호출되는 add()는 우리가 자식 클래스에서 구현한 add()다. 
    3. 자식에서 구현된 add()가 3번 호출되니 addCount는 증가한다 (3→6)

    그런데 이 동작을 알게 되면서 문제가 되는 부분은 무엇일까? 

    문제를 해결하는 과정에서 디버깅을 통해서 상위 클래스가 어떻게 돌아가는지에 대해서 조사했다. 하위 클래스를 구현하면서 상위 클래스의 내부 구현을 알게 되었다. 상위 클래스의 구현이 하위 클래스로 노출된 것이고 이것은 캡슐화가 깨진 것을 의미한다.  (addAll은 내부적으로 add를 호출함)

    아래에서는 이게 왜 문제인지 살펴본다.

     

    상속 문제점1 : 상위 클래스 내부 구현 노출에 의존하면, 상위 클래스 변화에 취약하다.

    원소가 추가될 때 마다 세는 기능을 구현하려고 했다. 그렇지만 구현하고 보니 상위 클래스의 내부 구현에 영향을 받아서, 상위 클래스와 함께 동작할 수 있도록 코드를 수정했다. 그런데 나중에 상위 클래스의 내부 구현이 쥐도 새도 모르게 바뀔 수 있다. 이 경우 하위 클래스에서 우리가 정의한 기능은 제대로 동작하지 않을 수 있다. 

    예를 들어 addAll()을 호출하면, 각각 add()를 호출해줄 것을 기대하고 기능을 구현했다. 하지만 addAll()이 더 이상 add()를 호출하지 않고, 다른 메서드를 호출하게 되면 우리가 정의한 기능은 동작하지 않을 것이다. 


    상속 문제점2 : 상위 클래스에 새 메서드가 추가되는 경우, 적절한 대응이 안된다. 

    우리는 인스턴스가 추가될 때 마다 addCount를 세고자 한다. 그런데 이 때 상위 클래스(HashSet)에 인스턴스를 추가하는 메서드가 하나 더 추가되었다고 가정해보자. 우리는 그 기능이 추가되었는지 모른다. 왜냐하면 컴파일 에러가 나오지 않기 때문에 ChangeLog를 꾸준히 살펴보지 않는 이상 알 수 없다. 

    그런데 누군가는 HashSet에 추가된 그 기능을 알고, 원소를 추가하기 위해서 자식 클래스에서 그 메서드를 사용했다. 우리는 새롭게 만들어진 메서드에 addCount 하는 기능을 추가하지 않았기 때문에 원소는 추가되었으나 addCount는 올라가지 않는다. 즉, addCount가 알려주는 값은 잘못된 값이 될 수도 있다.

     

    상속 문제점3 : 상위 클래스에서 새롭게 정의하면, 자식 클래스의 메서드가 깨진다. 

    자식 클래스에만 정의한 메서드가 나중에 상위 클래스에서 더 넓은 Scope으로 생성되면, 자식 클래스의 메서드가 깨질 수 있다. 

    // 자식 클래스
    private int getAddCount();
    
    // 부모 클래스
    public int getAddCount();

    상속 대신 컴포지션

    상속은 부모와 자식이 강하게 결합한다. 따라서 부모 클래스의 변화에 자식 클래스는 영향을 많이 받고, 그에 따라서 불완전한 방법이 된다. 따라서 상속 보다는 컴포지션을 사용하는 것을 추천한다. 

    컴포지션은 사용하고 싶은 기능을 가진 모든 인스턴스를 private 필드로 선언하고, 모든 메서드들이 그 필드를 통해서 실행되도록 하는 것이다. 예를 들면 아래와 같은 것이다.

    public class ForwardingSet<E> implements Set<E> {
        private final Set<E> s;
        public int size() { 
        	return s.size();}
    	...
    }

     

    컴포지션을 이용해서 추가 기능을 구현한 코드는 아래에서 볼 수 있다. 


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

    아래는 컴포지션에서 재사용 될 코드다. 

    • ForwardingSet은 생성될 때, Set을 멤버 변수로 전달받는다
      • ForwadingSet에서 정의된 메서드는 멤버 변수 Set의 메서드를 대신 호출한다. (위임한다)
    • 아래 코드는 Set 인터페이스를 구현한다.
      • Set 인터페이스에는 Set이 가져야 하는 인터페이스 규약이 존재한다.
      • 따라서 Set의 구현체가 무엇이고, 내부 구현이 어떻게 바뀌든지 ForwadingSet은 고민하지 않아도 된다.
      • 내부 구현을 몰라도 되는 것이기 때문에 이전에 캡슐화가 깨지던 문제가 해결된다.
    • Set 인터페이스를 구현하기 때문에 Set에 새로운 메서드가 추가되어도 컴파일 에러로 금새 알아챌 수 있다. 
      • 주입되는 구현체에만 있는 특정 메서드가 추가되더라도 무시할 수 있게 된다. 왜냐하면 Set 인터페이스와 관련없는 녀석이기 때문이다.

    상속에서 발생하던 문제는 컴포지션을 통해서 해결할 수 있게 되었다. 인터페이스를 구현하고, 인터페이스 타입으로 변수를 받은 다음에 기능을 위임시키면 상위 클래스의 세부 구현이 무엇인지 이해 할 필요도 없어진다. 따라서 캡슐화가 깨지던 문제가 해결되고, 인터페이스에 새로운 메서드가 추가되더라도 바로 알 수 있게 되었다. 

    // 코드 18-3 재사용할 수 있는 전달 클래스 (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 Objects.hash(s);
        }
        @Override public String toString() { return s.toString();}
    }

     

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

    재사용할 수 있는 전달 클래스는 '다른 클래스가 사용할 것'을 가정하고 만든 클래스다. 다른 클래스들은 전달 클래스를 상속하고, 필요한 기능을 재정의한 메서드에 추가하기만 하면 된다. 예를 들어 아래에 있는 InstrumentedSet 클래스를 살펴보자. 

    • InstrumentedSet은 ForwadingSet을 상속했다.
    • ForwadingSet은 이미 컴포지션 된 클래스이고, 내부에 필요한 객체를 가지고 그 객체에게 기능을 위임하는 역할을 한다.
    • InstrumentedSet이 ForwadingSet을 상속했기 때문에 메서드를 호출하면, ForwardingSet에 정의된 기능이 호출된다.
    • 위와 다른 부분은 다음과 같다.
      • InstrumentedHashSet 클래스는 addAll()을 호출하면 HashSet의 addAll()을 호출했고, 이미 구현된 클래스의 내부 구현에 의존하게 되었다.
      • InstrumentedSet 클래스는 addAll()을 호출하면 ForwardingSet의 addAll()을 호출하고,  ForwadingSet의 addAll은 내부적으로 가지고 있는 변수의 addAll()을 호출한다.
      • 즉, InstrumentedHashSet은 부모 클래스를 호출하기 때문에 자식 클래스의 구현까지 영향을 받는 것이고, 이번에 구현한 것은 HashSet이 부모 클래스가 아니라 단순 멤버 변수로 기능만 위임하기 때문에 영향을 주지 않게 되는 것이다.

     

    // 코드 18-2 래퍼 클래스 - 상속 대신 컴포지션을 사용했다. (117-118쪽)
    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());
        }
    
    }

     

     


    정리

    • 상속을 하면 상위 클래스의 내부 구현이 노출되어 캡슐화가 깨질 수 있다. 이것은 하위 클래스가 상위 클래스의 변화에 영향을 많이 받고, 단단하지 못한 코드가 되는 것을 의미한다. 
    • 상속 대신 컴포지션을 통해 인터페이스 구현 + 기능을 위임하면 상위 클래스의 내부 구현을 몰라도 된다. 이를 통해 단단한 코드를 작성할 수 있게 된다. 

    댓글

    Designed by JB FACTORY