본문 바로가기

프로그래밍(TA, AA)/JVM 언어

[자바] 계승과 구성 방식에 관하여

이전에 쓰레드관련 포스팅에서 객체합성이 상속보다 더 낫다라는 얘기를 잠깐 꺼낸적이 있습니다. 자바 이펙티브 서적에서 관련 내용을 다루고 있어 이 포스팅에서 주요 내용들을 정리해 보려 합니다. 실제 대규모 프로젝트를 접하다 보면, 하나의 객체가 만들어지기까지 몇번의 합성을 거치는 경우를 많이 보게 됩니다. 그 이유가 늘 궁금했는데, 그 질문에 대한 답을 차근히 써내려 가겠습니다.

 

상속은 자바에서 재사용을 돕는 강력한 도구이지만, 최선이라고 할 수 없습니다. 왜냐하면 상속을 적절히 사용하지 못하면 깨지기 쉬운 상태의 소프트웨어가 되기 때문입니다. 보통 상속은 상위 클래스와 하위 클래스 구현을 같은 프로그래머가 통제하는 단일 패키지 안에서 사용하면 안전합니다. 애초에 상속을 고려해 설계되고 그에 맞는 문서를 갖춘 클래스에 사용하는 것도 안전합니다.

 

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

 

 

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

하위 클래스가 정상 동작하기 위해서는 상위 클래스의 구현에 의존할 수 밖에 없습니다. 상위 클래스의 구현은 릴리스(release)가 거듭되면서 바뀔 수 있는데, 그러다보면 보면 하위 클래스 코드는 수정된 적이 없어도 망가질 수 있습니다.

 

// 계승을 잘못 사용한 사례!
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;
  }
}

괜찮아 보이지만, 제대로 동작하지는 않습니다. 가령 이 클래스로 객체를 만들어 addAll 메서드를 통해 세 개 원소를 집어넣는다고 해보겠습니다.

 

InstrumentedHashSet<String> s = new InstrumentedHashSet<String>();
s.addAll(Arrays.asList("Snap", "Crackle", "Pop"));

getAddCount 메서드가 3을 반환할 것이라고 기대하지만, 실제로는 6을 반환합니다. 무엇이 잘못된 것일까요? 사실 HashSet의 addAll 메서드는 add 메서드를 통해 구현되어 있습니다. 그리고 HashSet 문서에는 그런 사실이 명시되어 있지 않습니다. InstrumentedHashSet에 정의된 addAll 메서드는 addCount에 3을 더하고 상위 클래스인 HashSet의 addAll 메서드를 super.addAll과 같이 호출하는데, 이 메서드는 InstrumentedHashSet에서 재정의한 add 메서드를 삽입할 원소마다 호출하게 됩니다. 각각의 add 메서드가 호출될 때마다 addCount는 1씩 증가할 것이므로, 총 6만큼 증가하게 되는 것입니다. addAll 메서드로 추가한 요소는 중복 계산하게 되는 것입니다.

 

하위 클래스에서 재정의한 addAll 메서드를 삭제하면 이 문제를 "교정(fix)"할 수는 있는데, 이 클래스가 정상 동작한다는 것은 HashSet의 addAll 메서드가 add 위에서 구현되었다는 사실에 의존합니다. 그러나 이것은 HashSet의 구현 세부사항이라, 모든 자바 플랫폼에 똑같이 구현되었으리라 볼 수는 없습니다. 릴리스가 거듭되면서 바뀔 가능성도 있고, 따라서 InstrumentedHashSet 클래스는 깨지기 쉬운(fragile) 클래스일 수 밖에 없습니다.

 

이와 관련해서 하위 클래스 구현을 망가뜨릴 수 있는 또 한 가지 요인은, 다음 릴리스에는 상위 클래스에 새로운 메서드가 추가될 수 있다는 것입니다. 가령 어떤 프로그램의 보안이 어떤 컬렉션에 추가되는 모든 원소가 특정 술어(predicate)를 만족한다는 사실에 근거한다고 해보겠습니다. 그 술어를 계속 만족시키려면 컬렉션의 하위 클래스에서는 원소를 추가할 수 있는 모든 메서드를 재정의해서, 컬렉션에 원소를 넣기 전에 술어가 만족되는지 검사해야 합니다. 이 방법은 컬렉션에 요소를 넣을 수 있는 새로운 메서드가 상위 클래스에 추가되기 전까지는 제대로 동작할 것이나, 일단 그런일이 벌어지면 하위 클래스에서 재정의하지 않은 새 메서드를 호출해서 "잘못된(illegal)" 객체를 컬렉션에 넣을 수 있게 됩니다. 단순한 이론적 문제가 아닙니다. Hashtable과 Vector를 컬렉션 프레임워크에 넣는 과정에서 실제로 이런 보안 문제가 발생되어 수정해야만 했습니다.

 

위에 언급한 두 가지 문제는 메서드 재정의 때문에 발생한 것입니다. 기존 메서드를 재정의하는 대신 새 메서드로 만들어 넣으면 계승해도 괜찮을 거라 생각할 수도 있습니다. 그런 확장법이 좀 더 안전한 것은 사실이나, 위험성이 완전히 사라지는 것은 아닙니다. 새 릴리스에 추가된 상위 클래스 메서드가 재수 없게도 하위 클래스에 정의한 메서드와 같은 시그니처인데 반환값 자료형만 다를 경우, 여러분이 만든 하위 클래스는 더이상 컴파일 되지 않을 것이며, 반환값 자료형까지도 같은 경우에는 앞서 설명한 두 가지 문제가 일어나기 시작할 것입니다. 게다가, 새로운 상위 클래스 메서드의 일반 규약이 기존 하위 클래스 메서드와 맞을지 알 수 없다는 문제도 있습니다. 하위 클래스에 메서드를 추가할 당시에 만들어진 규약이 아니기 때문입니다.

 

하지만, 기존 클래스를 상속받는 대신, 새로운 클래스에 기존 클래스 객체를 참조하는 private 필드를 하나 두는 것으로 위와 같은 문제를 예방할 수 있습니다. 이런 설계 기법을 구성(Composition)이라고 부르는데, 기존 클래스가 새 클래스의 일부(component)가 되기 때문입니다. 새로운 클래스에 포함된 각각의 메서드는 기존 클래스에 있는 메서드 가운데 필요한 것을 호출해서 그 결과를 반환하면 됩니다. 이런 구현 기법을 전달(forwarding)이라고 부릅니다. 구성 기법을 통해 구현된 클래스는 견고합니다. 기존 클래스의 구현 세부사항에 종속되지 않기 때문입니다. 기존 클래스에 또 다른 메서드가 추가되더라도, 새로 만든 클래스에는 영향이 없을 것입니다. 앞서 보였던 InstrumentedHashSet 클래스를 구성과 전달 기법을 사용해 수정해서, 방금 설명한 내용을 좀 더 구체적으로 살펴보겠습니다. 구현 결과는 클래스 자신과, 재사용이 가능한 전달 클래스(forwarding class)의 두 부분으로 나뉩니다. 전달 클래스는 모든 전달 메서드를 포함하고, 다른 것은 없습니다.

 

// 계승 대신 구성을 사용하는 포장(wrapper) 클래스
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;
  }
  
}

// 재사용 가능한 전달(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(); }
}

InstrumentedSet을 이렇게 설계할 수 있는 것은, HashSEt이 제공해야할 기능을 규정하는 Set이라는 인터페이스가 있기 때문입니다. 이런 설계는 안정적일 뿐아니라 유연성도 아주 높습니다. InstrumentedSet 클래스는 Set 인터페이스를 구현하며, Set 객체를 인자로 받는 생성자를 하나 갖고 있씁니다. 결국 이 클래스는 어떤 Set 객체를 인자로 받아, 필요한 기능을 갖춘 다른 Set 객체로 변환하는 구실을 합니다. 계승을 이용한 접근법은 한 클래스에만 적용이 가능하고, 상위 클래스 생성자마다 별도의 생성자를 구현해야 합니다. 하지만 포장 클래스(wrapper class) 기법을 쓰면 어떤 Set 구현도 원하는 대로 수정할 수 있고, 이미 있는 생성자도 그대로 사용할 수 있습니다.

 

Set<Data> s = new InstrumentedSet<Data>(new TreeSet<Data>(cmp));
Set<E> s2 = new InstrumentedSet<E>(new HashSet<E>(capacity));

심지어 이미 사용 중인 객체에 일시적으로 원하는 기능을 넣는 데도 사용할 수 있습니다.

 

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

 

InstrumentedSet과 같은 클래스를 포장 클래스라고 부르는데, 다른 Set 객체를 포장하고 있기 때문입니다. 또한 이런 구현 기법은 decorator 패턴이라고 부르는데, 기존 Set 객체에 기능을 덧붙여 장식하는 구실을 하기 때문입니다. 때로는 구성과 전달 기법을 아울러서 막연하게 위임(delegation)이라고 부르기도 합니다. 그런데 기술적으로 보자면, 포장객체가 자기 자신을 포장된 객체에 전달하지 않으면 위임이라고 부를 수 없습니다.

 

포장 클래스에는 단점이 별로 없으나, 역호출(callback) 프레임워크와 함께 사용하기에는 적합하지 않습니다. 역호출 프레임워크에서 객체는 자기 자신에 대한 참조를 다른 객체에 넘겨, 나중에 필요할 때 역호출하도록 요청합니다. 포장된 객체는 포장 객체에 대해서는 모르기 때문에, 자기 자신에 대한 참조를 전달할 것입니다. 따라서 역호출 과정에서 포장 객체는 제외됩니다. 이 문제는 SELF 문제로 알려져 있습니다. 어떤 사람들은 전달 메서드 호출 과정에서 성능이 저하되거나, 포장 객체 때문에 메모리 요구량이 늘어나지 않을까 걱정하기도 합니다. 하지만 실제로는 큰 영향이 없는 것으로 판명되었습니다. 전달 메서드 코딩은 지루한 작업이지만 전달 클래스는 인터페이스별로 한번씩만 구현하면 되고, 인터페이스와 같은 패키지에 이미 들어 있는 경우도 있습니다.

 

계승은 하위 클래스가 상위 클래스의 하위 자료형(subtye)이 확실한 경우에만 바람직합니다. 다시 말해서, 클래스 B는 클래스 A와 "IS-A" 관계가 성립할 때만 A를 계승해야 합니다. A를 확실하게 계승하고 있다면 상속을 사용해도 되지만, 확실하지 않다면 B안에 A 객체를 참조하는 private 필드를 두고, B에는 더 작고 간단한 API를 구현해야 합니다. A는 B의 핵심적 부분이 아니며, B의 구현 세부사항에 불과합니다.

 

구성 기법이 적절한 곳에 계승을 사용하면 구현 세부사항이 쓸데없이 노출됩니다. 그렇게 API를 만들면 원래 구현에서 벗어날 수 없게 되며, 클래스의 성능을 개선하기 어려워집니다. 더 심각한 이슈는, 클라이언트가 내부 구현 세부사항에 접근할 수 있다는 것입니다. 적어도, 의미가 혼란스러운 프로그램이 만들어진다는 것만큼은 분명합니다. 예를 들어 Properties 객체에 대한 참조 변수 p가 있을 때, p.getProperty(key)와 p.get(key)는 다른 결과를 반환할 수도 있습니다. 앞 메서드는 기본값(default)을 고려하지만, Hashtable에서 계승된 두 번째 메소드는 그렇지 않기 때문입니다. 가장 심각한 문제는 클라이언트가 상위 클래스 상태를 직접 변경하여 하위 클래스의 불변식을 깰 수 있다는 것입니다. Properties의 예를 보면, 클래스 설계자의 의도는 키와 값으로 문자열을 사용하는 것이었지만, 상위 클래스인 Hashtable에 직접 접근할 수 있으므로 이 불변식은 깰 수 있씁니다. 일단 불변식이 깨지고 나면 Properties API의 다른 부분(load와 store)은 사용할 수 없게 됩니다. 그러나 이 문제는 발견하고서도 수정할 수가 없었는데, 문자열이 아닌 무엇을 키와 값으로 사용하는 클라이언트 코드들이 작성된 후였기 때문입니다.

 

구성 대신 계승을 사용하려 할 때 만드시 물어야할 마지막 질문들은 이런 것입니다. 계승할 클래스의 API에 문제가 있는가? 그렇다면, 그 문제들이 계속 새 API의 일부가 되어도 상관없겠는가? 계승 메커니즘은 상위 클래스의 문제를 하위 클래스에 전파시킵니다. 반면 구성 기법은 그런 약점을 감추는 새로운 API를 설계할 수 있도록 해줍니다.

 

요약하자면, 계승은 강력한 도구이지만 캡슐화 원칙을 침해하므로 문제를 발생시킬 소지가 있다는 것입니다. 상위 클래스와 하위 클래스 사이에 IS-A 관계가 있을 때만 사용하는 것이 좋습니다. 설사 IS-A 관계가 성립해도, 하위 클래스가 상위 클래스와 다른 패키지에 있거나 계승을 고려해 만들어진 상위 클래스가 아니라면, 하위 클래스는 깨지기 쉽습니다. 이런 문제를 피하려면 구성과 전달 기법을 사용하는 것이 좋습니다. 포장 클래스 구현에 적당한 인터페이스가 있다면 더욱 그렇습니다. 포장 클래스는 하위 클래스보다 견고할 뿐 아니라, 강력합니다.