제리 devlog

[이펙티브 자바] 상속보다는 컴포지션을 사용하라 본문

Java

[이펙티브 자바] 상속보다는 컴포지션을 사용하라

제리 . 2020. 12. 6. 15:47

상속은 코드를 재사용하는 강력한 수단이지만, 항상 최선은 아니다. 잘못 사용하면 오류를 내기 쉬운 소프트웨어를 만든다. 여기서 말하는 상속이란 클래스와 클래스 간에 상속(구현 상속)을 말하며 인터페이스를 구현하거나 인터페이스가 다른 인터페이스를 확장하는 상황과는 무관하다.

 

상속의 문제점

public class InstrumentedHashSet<E> extends HashSet<E> {
	
    //추가된 원소의 수
    private int addCount = 0;
    
    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<Stirng> s = new InsetrumentedHashset<>();
s.addAll(List.asList("a", "b", "c"));

위와 같이 새로운 리스트를 추가한다고 가정하자. 3개의 원소가 추가되었으니 getAddCount()는 3을 반환해야 한다.

그런데 실제로는 6을 반환한다.

 

HashSet의 addAll은 아래의 코드로 실행된다. add()메서드가 안에서 호출되는데, 재정의한 add()과 addAll() 모두 addCount를 증가시켜 6을 반환하게 된 것이다.

 

addAll메서드를 Overriding하지 않은 경우,

addAll메서드가 add를 통해 구현했음을 가정한 해법이라는 한계가 있다. addAll은 HashSet이 구현하는 메서드에 전적으로 달려있고 다음 릴리스에서 다르게 적용된다면 깨지기 쉽다.

 

addAll메서드를 Overriding한경우,

HashSet의 메서드를 더 이상 호출하지 않으니 addAll이 add를 사용하는지 상관없다. 그러나 상위 클래스의 메서드 동작을 다시 구현하는 것은 어렵거나 시간이 들고 오류와 성능 저하를 유발 할 수 있다. 만약, 하위 클래스에서 접근할 수 없는 private필드를 써야하는 상황이라면 구현자체가 불가능하다.

 

새로운 릴리스에 대응하기 어려움

다음 릴리스에서 상위클래스가 새로운 메서드를 추가하는 상황을 고려해보자. 상속받은 클래스에서 특정 조건을 만족해야 데이터를 추가할 수 있도록 재정의를 해놨다. 그런데 다음 릴리스에서 새로운 메서드가 만들어지고 클라이언트가 상위클래스의 메서드를 직접 호출하면 허용되지 않은 값이 추가될 수 있다. 실제로 컬렉션 프레임워크가 존재하기 전에 Vector와 HashTable을 컬렉션에 포함하자 이와 관련된 보안 구멍들을 수정해야하는 사태가 발생했다.

 

시그니처 중복

메서드를 아예 새롭게 만들면 위에 경우보다 안전하긴 하지만 역시 위험이 따른다. 다음 릴리스에 새로운 메서드가 추가된경우를 가정하자. 추가된 메서드가 내가 만든 메서드와 시그니처가 같고 반환 타입이 다르면 컴파일 에러가 발생한다. 반환 타입이 같다면 다시 메서드를 재정의하는 꼴이다. 새롭게 메서드를 만든 메서드는 상위 클래스의 메서드가 요구하는 규약을 만족하지 못할 가능성이 크다.

 

결함 허용

상속을 결정하기 전에 확장하려는 클래스의 API의 아무런 결함이 없는지 확인해야 한다. 컴포지션으로 이런 결함을 숨기는 새로운 API를 설계할 수 있지만, 상속은 상위 클래스의 API를 '그 결함까지도' 그대로 승계한다.

 

불필요한 내부 구현 노출

컴포지션을 써야할 상황에서 상속을 쓰는 것은 불필요하게 내부 구현을 노출하는 꼴이다. 그 결과 API가 내부 구현에 묶이고 클래스의 성능도 제한된다.

Properties p = new Properties();
p.get(key); //Object를 받음
p.getProperty(key); //String을 받음

get, getProperty는 각각 상위 클래스와 구현 클래스에 있는 메소드이다. 그런데 p.get(key), p.getProperty(key)는 결과가 다를 수 있다. 가장 심각한 문제는 클라이언트가 직접 상위클래스의 메서드를 호출하면 불변식을 깨버릴 수 있다. Properties는 키와 값으로 문자열만 허용하도록 설계하려 했으나 HashTable의 메서드를 직접호출하는 경우이다. 불변식이 한번 깨지면 load, store같은 다른 Propertie API는 더이상 사용할 수 없다. 이 문제가 밝혀졌을때 이미 수많은 사용자가 Propertiey의 키나 값으로 문자열 이외의 타입을 사용하고 있었다. 

컴포지션 

기존 클래스가 새로운 클래스의 구성 요소로 사용되는 설계 (composition) 기존 클래스를 확장(상속)하는 대신, 새로운 클래스를 만들고 private필드로 기존 클래스의 인스턴스를 참조하게 한다.

새 클래스의 메서드들은 기존 클래스의 대응하는 메서드를 호출해 결과를 반환한다(forwarding). 새로운 클래스는 기존 클래스의 내부 구현 방식의 영향에서 벗어나며 기존 크래스에 새로운 메서드가 추가되더라도 전혀 영향을 받지 않는다.

public class InstrumentedHashSet<E> extends ForwardingSet<E> {
	
    //추가된 원소의 수
    private int addCount = 0;
    
    public InstrumentedHashSet(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 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 addAll(Collection<? extends E> c) { return s.addAll(c); }
    
    ...

}

Set<Instant> times = new InstrumentedSet<>(new TreeSet<>(cmp)); //TreeSet을 감싸고 있음
Set<E> s = new Instrumented<>(new HashSet<>(INIT_CAPACITY));//HashSet을 감싸고 있음

 

InstrumentSet는 HashSet의 모든 기능을 정의한 Set인터페이스를 활용해 설계되어 견고하고 아주 유연하다. 구체적으로는 Set인터페이스를 ForwardingSet으로 구현했고 생성자에서 Set의 인스턴스를 받아 적용한다. 

s.addAll(List.asList("a", "b", "c"));

다시 위의 상황에서 addAll을 호출하면 InstrumentedHashSet은 ForwardSet의 addAll을 호출한다. ForwordSet의 addAll은 HashSet의 addAll을 호출한다. ForwordSet이 호출한 HashSet의 addAll은 InstrumentedSet의 add가 아닌 HashSet의 add를 사용한다.

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

 

다른 Set인스턴스를 감싸고 있다는 뜻에서 InstrumentedSet같은 클래스를 wrapper class라고 한다. 컴포지션과 전달의 조합은 넓은 의미로 위임(delegation)이라고 부른다. 단, 엄밀히 따지면 래퍼 객체가 내부 객체에 자기 자신의 참조를 넘기는 경우만 위임에 해당한다.

 

래퍼 클래스의 단점

래페 클래스의 단점은 거의 없지만 콜백 프레임 워크와는 어울리지 않는다는 점을 주의하면 된다. 콜백 프레임워크에서는 자기 자신의 참조를 다른 객체에 넘겨서 다음 호출때 사용하도록한다. 내부 객체는 자신을 감싸고 있는 래퍼의 존재를 모르니 대신 자신(this)의 참조를 넘기고 콜백 때는 래퍼가 아닌 내부 객체를 호출하게 된다. 이를 SELF문제라고 한다. 전달 메서드가 성능에 주는 영향이나 래퍼 객체가 메모리리 사용에 주는 영향을 걱정하는 사람도 있지만, 실전에서는 둘 다 별다른 영향을 주지 않았다.

 

상속을 사용해야 하는 경우

상속은 반드시 하위 클래스가 상위 클래스의 `진짜` 하위 타입인 상황에서만 사용해야 한다. 상위 클래스가 A, 하위 클래스가 B라면 B is a A 관계일 때만 사용해야 한다. 조건을 만족한다고 확신할 수 없다면 상속하지 말자. 이런 상황은 A를 private 인스턴스로 두고, A와는 다른 API를 제공해야 하는 상황이 대다수이다. A는 B의 필수 구성 요소가 아니라 구현하는 방법의 하나일 뿐이다.

 

*자바 플랫폼 라이브러리에서도 이 원칙을 위반한 대표적인 예시가 Stack, Properties이다. 스택은 벡터가 아니므로 벡터를 확장해서는 안 됐고, 속성 목록도 해시테이블이 아니므로 해시테이블을 확장해서는 안 됐다. 두 사례 모두 컴포지션을 사용했으면 더 좋았을 것이다.

 

-결론-

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

 

 

 

Comments