ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • Item 18. 상속보다는 컴포지션을 사용하라
    백수의 개발/이펙티브 자바 2019. 10. 4. 12:42

    상속

    상속은 코드를 재사용하는 강력한 수단이다. 하지만, 잘못 사용하면 오류를 내기 쉬운 소프트웨어를 만들게 된다.

     

    이 장에서의 상속은 클래스가 다른 클래스를 확장하는 구현 상속을 말하는 것이며, 클래스가 인터페이를 구현하거나 인터페이스가 다른 인터페이스를 확장하는 인터페이스 상속에 대한 이야기는 아니다.

     

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

     

    오류 가능성이 있는 상속

    아래 HashSet을 상속하는 InstrumentedHashSet에 대한 코드를 살펴보자.

    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(List.of("틱", "탁탁", "펑"));

    위와 같이 InstrumentedHashSet을 구현하고, addAll을 해주면 getAddCount()의 결과가 3을 반환하리라 생각하겠지만 6을 반환한다.

    그 이유는 기존 HashSet의 addAll메서드 내부에서 add메서드를 호출한다는 것이다.

    그 결과 addAll에서 3이 증가하고, 각 원소 마다 add가 호출되어 총 3번의 add가 실행되고 총 6이 되는 것이다.

     

    그렇다고 addAll을 상위 HashSet의 addAll을 호출하지 않고 별도로 구현하여 이를 방지할 수 있을 것이다.

    그러나 이러한 방식은 어렵고, 시간도 더 소요될 뿐더러 자칫 오류를 내거나 성능을 저하시킬 수 있다.

     

    위의 문제는 재정의에서 문제가 발생하였다. 그렇다면 재정의 대신 새로운 메서드를 추가하면 괜찮을까?

    훨씬 안전한 방식은 맞지만, 위험은 여전히 있다.

    다음 릴리스에서 상위 클래스에 새 메서드가 추가되었는데, 우연히도 하위 클래스에 추가한 메서드와 동일하지만 반환 타입이 다르다면 컴파일조차 되지 않을 것이다. 이 외에도 다양하게 추가한 메서드는 상위 클래스의 메서드가 요구하는 규약을 만족하지 못할 가능성이 크다.

     

    이러한 문제를 피하기 위한 컴포지션에 대해 알아보자.

     

    상속을 대신한 컴포지션

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

    컴포지션을 통해 새 클래스의 인스턴스 메서드들은 기존 클래스에 대응하는 메서드를 호출해 그 결과를 반환하게하자.

    이러한 방식을 전달(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 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;
        }
    }

    위와 같이 구현하면 InstrumentedSet은  HashSet의 모든 기능을 정의한 Set 인터페이스를 활용해 설계되어 견고하고, 유연성이 높다.

     

    위 코드에서 Set인터페이스를 구현했고, Set의 인스턴스를 인수로 받는 생성자를 하나 제공한다. 임의의 Set에 계측 기능을 덧씌워 새로운 Set으로 만드는 것이 이 클래스의 핵심이다.

     

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

     

    래퍼 클래스는 단점이 거의 없다. 단 콜백 프레임워크와는 어울리지 않으니 이 점만 참고하자.

    그 이유는 자신의 참조를 다른 객체에 넘겨서 다음 호출(콜백) 때 사용하도록 한 콜백 프래임워크에서 내부 객체는 자신을 감싸고 있는 래퍼의 존재를 모르니 자신의 참조를 넘기고, 콜백 때는 래퍼가 아닌 내부 객체를 호출하게 되기 때문이다.

     

    상속을 쓰기전에

    컴포지션 대신 상속을 사용하기로 했다면 몇가지 자문을 해보자.

    1. 확장하려는 클래스의 API에 아무런 결함이 없는가?
    2. 결함이 있다면, 이 결함이 새로운 클래스의 API까지 전파돼도 괜찮은가?

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

     

    마무리

    상속은 강력하지만 캡슐화를 해친다는 문제가 있다. 상속은 상위 클래스와 하위클래스가 순수한 is-a 관계일 때만 써야한다.

    is-a 관계일지라도 하위 클래스의 패키지가 상위 클래스와 다르고, 상위 클래스가 확장이 고려되있지 않았다면 여전히 문제가 생길 수 있다.

    이러한 상속의 문제점들에서 벗어나기 위해 컴포지션과 전달을 사용하자. 래퍼 클래스는 하위 클래스를 보다 견고하고 강력하다.

    댓글

Designed by Tistory.