여기서 말하는 상속이란?

- 클래스가 다른 클래스를 확장하는 구현 상속

- 클래스가 인터페이스를 구현하거나 인터페이스가 다른 인터페이스를 확장하는 인터페이스 상속과는 무관하다.

메서드 호출과 달리 상속은 캡슐화를 깨뜨린다.

예제

- 추가된 원소수를 저장하는 변수와 접근자 메서드를 추가한 HashSet을 사용하는 프로그램

- 하위 클래스에서 상위 클래스의 메서드 재정의 시 상위 클래스에서의 동작에 따라 결과가 달라진다.

public class InstrumentedHashSet<E> extends HashSet<E> {
  // 추가된 원소 수
  private int addCount = 0;
  ...
  @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); // 각 원소를 add메서드를 사용해서 추가
  }

  public int getAddCount() {
    return addCount;
  }
}

아래 Hashset의 addAll함수에서  add()가 안에서 호출되므로,재정의한 add()와 addall()이 3씩 증가시켜서 6이

반환된다. 아래코드의 add(e)는 InstrumentedHashSet에서 재정의한 메서드다.

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

 

addAll메서드를 Overriding하지 않은 경우 문제를 고칠수 있지만..

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

 

addAll메서드를 Overriding한경우,(주어진 컬렉션을 순회하며 원소 하나당 add메서드 한번만호출)

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

 

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

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

 

시그니처 중복

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

 

결함 허용

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

 

불필요한 내부 구현 노출

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

Properties p = new Properties();
p.get(key); //Object를 받음
p.getProperty(key); //String을 받음
@SuperessWarnings("unchecked")
public synchronized V get(Object key){
Entry<?,?> tab[] = table;
int hash=key.hashCode();
int index= (hash & 0x7FFFFFFF) % tab.length;
for(Entry<?,?> e = tab[index] ; e!=null ; e=e.next){
if((e.hash=hash)&&e.key.equals(key)){
return (V)e.value;
}
}
return null;
}

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

}

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

+ Recent posts