[Java] 컴포지션을 활용한 코드의 재사용 방법 (Effective Java 3rd_Item18)
상속은 상위 클래스의 코드를 재사용할 수 있도록 해주지만 캡슐화를 깨뜨립니다. 즉, 상위 클래스가 어떻게 구현되느냐에 따라서 하위 클래스의 동작에 영향을 미칠 수 있습니다. 그렇기 때문에 상위 클래스의 설계자는 확장을 충분히 고려하고, 문서화를 해두어야 합니다.
Effective Java 3rd 책의 예제 코드를 한 번 살펴보겠습니다.
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 this.addCount;
}
}
@Test
public void 잘못된_예제_테스트() {
InstrumentedHashSet<String> s = new InstrumentedHashSet<>();
s.addAll(Arrays.asList("하나", "둘", "셋"));
System.out.println("this count is : " + s.getAddCount());
}
위의 코드는 HashSet 을 확장하여 처음 생성된 이후로 원소가 몇 개가 추가되었는지 카운트하는 클래스입니다. 만약 위의 클래스에서 테스트 코드와 같이 String 타입의 원소를 3개 추가했다면 addCount는 몇이 될까요?? 저는 간단하게 1차원적으로 생각하여 3이 될거라 생각했습니다.
this count is : 6
하지만 결과는 6을 출력하였습니다. 처음에는 뭐지? 싶었습니다. 그 이유에 대한 내용을 살펴보니 addAll() 메서드는 내부적으로 add() 메서드를 이용한다는 것이었습니다. 즉, 처음 addCount += c.size() 로 인하여 3이 더해지고, 상위 클래스의 addAll() 메서드가 내부적으로 add() 메서드를 수행하여 addCount++ 가 3번 호출되어 총 6이 된 것입니다.
/**
* {@inheritDoc}
*
* <p>This implementation iterates over the specified collection, and adds
* each object returned by the iterator to this collection, in turn.
*
* <p>Note that this implementation will throw an
* <tt>UnsupportedOperationException</tt> unless <tt>add</tt> is
* overridden (assuming the specified collection is non-empty).
*
* @throws UnsupportedOperationException {@inheritDoc}
* @throws ClassCastException {@inheritDoc}
* @throws NullPointerException {@inheritDoc}
* @throws IllegalArgumentException {@inheritDoc}
* @throws IllegalStateException {@inheritDoc}
*
* @see #add(Object)
*/
public boolean addAll(Collection<? extends E> c) {
boolean modified = false;
for (E e : c)
if (add(e))
modified = true;
return modified;
}
HastSet의 추상하 클래스인 AbstractCollection 클래스에서 동작하는 addAll() 클래스입니다. addAll()을 보시면 반복문을 통해 add() 메서드가 호출되는 것을 확인할 수 있습니다.
이러한 문제를 피해가기 위해서 기존 클래스를 상속하는 대신, 컴포지션을 이용하여 기존 클래스의 인스턴스를 private 필드로 참조하게 하는 방법을 사용할 수 있습니다. 새로운 클래스의 인스턴스는 기존 클래스의 대응하는 메서드를 호출하여 그 결과를 반환하는 방식을 전달(forwarding) 이라 하며, 새 클래스의 메서드들을 전달 메서드(forwarding method) 라고 합니다. 이러한 방법은 새로운 클래스가 기존 클래스의 내부 구현 방식의 영향에서 벗어날 수 있도록 해줍니다.
위에서 보았던 InstrumentedSet 클래스에서 컴포지션과 전달(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 obj) {
return s.equals(obj);
}
@Override
public int hashCode() {
return s.hashCode();
}
@Override
public String toString() {
return s.toString();
}
}
우선 전달 클래스입니다. 전달 클래스는 Set 인터페이스를 구현하여 Set의 기능들을 정의하도록 하였고, 컴포지션을 이용하여 , Set의 구현체를 인스턴스로 참조하도록 private 필드를 정의하였습니다. 이 클래스는 Set의 기능들을 이용할 수 있도록 기능들을 전달하는 역할을 하고, Set에 또 다른 기능을 추가하여 새로운 Set을 구현할 수 있도록 해줍니다.
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;
}
}
@Test
public void 컴포지션을_활용한_예제() {
InstrumentedSet<String> s = new InstrumentedSet<>(new TreeSet<>());
s.add("하나");
s.add("둘");
s.add("셋");
s.addAll(Arrays.asList("넷", "다섯", "여섯"));
System.out.println("this count is : " + s.getAddCount());
}
this count is : 6
위에서 보았던 전달 클래스를 상속하여 확장한 래퍼 클래스입니다. 이 클래스에는 아까와 같이 원소가 몇 개가 추가되는지 카운트 하는 기능을 가지고 있습니다. 이러한 구조는 기존 클래스에 새로운 기능을 덧씌운다는 뜻에서 데코레이터 패턴이라고 부릅니다.
상속을 할 때는 반드시 하위 클래스가 상위 클래스와 is-a 관계인지 생각해봐야 합니다. 만약 그렇지 않다면 상속 대신 컴포지션과 전달 메서드를 이용하는 편이 좋습니다.
Decorator Pattern
참고 자료
Effective Java 3rd (인사이트 출판)