본문 바로가기
Language/Java

이펙티브 자바 - 계승대신 구성

by ocwokocw 2021. 11. 6.

- 이 글은 Effective Java 를 기반으로 작성되었습니다.

- 개요

계승(extends)은 코드 재사용을 돕는 강력한 도구이지만 잘못 사용하면 S/W 가 깨지기 쉽다. 계승을 사용하기전에는 아래와 같은 사항들을 고려해보는것이 좋다.

  • 단일 패키지내에서 상위 클래스와 하위 클래스 구현자가 같은 경우
  • 계승을 고려해 설계되었으며 문서를 갖춘 클래스인 경우

만약 객체 생성 가능 클래스라면 해당 클래스가 속한 패키지밖에서 계승을 시도하는것은 위험하다.


- 계승의 문제점

메소드 호출과 달리 계승은 캡슐화 원칙을 위반한다. 상위 클래스 A 와 이를 extends 한 하위 클래스 B가 있다고 가정해보자. 하위 클래스 B를 변경하지 않아도 release 가 거듭되면서 상위 클래스 A 의 동작이 변경되면 하위 클래스 B도 의도하지 않게 변경이 될 수 있다.

 

상위 클래스 작성자가 계승을 고려하지 않거나 문서를 작성해놓지 않으면 하위 클래스는 상위 클래스 동작에 의해 의도하지 않은 변경사항이 생길 수 있기 때문에 발맞추어 진화해야 한다.

 

public class InstrumentedHashSet<E> extends HashSet<E> {

	private int addCount = 0;

	public InstrumentedHashSet() {
		super();
	}

	public InstrumentedHashSet(int initialCapacity, float loadFactor) {
		super(initialCapacity, loadFactor);
	}

	@Override
	public boolean add(E e) {
		addCount += 1;
		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 은 현재까지 얼마나 많은 요소들이 추가되었는지를 측정하는 클래스이다. 요소를 추가하는 add 와 addAll 이 수행될때마다 원소의 수를 counting 한다.

 

public class TestMethod {

	public static void main(String[] args) {

		InstrumentedHashSet<String> testHashSet = 
			new InstrumentedHashSet<>();
		
		testHashSet.addAll(Arrays.asList("1", "2", "3"));
		
		System.out.println("getAddCount: " + testHashSet.getAddCount());
	}
}

================

getAddCount: 6

 

InstrumentedHashSet 에 addAll 메소드를 이용하여 3 개 원소를 추가하면 3 이 나와야 하지만 결과는 6 이 나온다. 상속하는 HashSet 에서 addAll 기능은 add 메소드를 통해 구현되어있기 때문이다. 그리고 이런 해당내용은 addAll 메소드에 언급되어있지 않다.

 

하위 클래스(InstrumentedHashSet) 에서 addAll 을 삭제하면 이 문제를 해결할 수 있지만 깔끔한 해결법은 아니다. 이 해결법을 적용하려면 addAll 은 add 메소드를 통해서 구현되어있다는 전제하에 적용해야 한다. 모든 자바플랫폼에 똑같이 구현되어있다고 가정할 수 없으며 추후 릴리즈가 있으면 변경될 수 있는 사항이다.

 

만약 신규 메소드를 추가하는 경우에도 문제가 생길 수 있다. 어떤 클래스가 컬렉션에 원소를 삽입하기 전에 Predicate 를 만족해야 한다고 가정해보자. 그리고 이를 상속하면 하위 클래스에서는 원소를 삽입하는 모든 메소드에 Predicate 를 만족하는지 검사해야 한다. 이 상태에서 상위 클래스에 신규 메소드가 추가되면 하위 클래스에서도 재정의를 해주어야 한다. 만약 그렇지 않으면 잘못된 객체를 넣을 수 있게 된다.

 

2 가지 문제는 모두 메소드 재정의 때문에 발생한다. 기존 메소드 대신 새 메소드를 추가하는 방법을 사용할 수 있지만 완벽하지는 않다.

 

만약 신규 릴리즈에서 상위 클래스에 추가한 메소드가 하위 클래스와 반환형을 제외한 메소드 시그니처가 같다면 컴파일이 되지 않는다. 또한 반환형까지 같으면 앞의 2 가지 문제가 발생하게 된다.


- 해결법

앞의 언급한 문제들을 피하기 위해서는 계승 대신 구성(Composition)을 사용해야 한다. 새로운 클래스에 기존 클래스 객체를 참조하는 private 변수를 두는것이다.

 

새로운 클래스에 포함된 각 메소드는 기존 클래스에 있는 메소드 가운데 필요한 것만 호출해서 결과를 그대로 반환하면 된다. 이런 구현기법을 forwarding 이라고 하고, forwarding 기법을 사용해 구현된 메소드를 forwarding method 라고 한다. 

 

말이 어려운데 예제 코드를 보면 그렇게 어렵지 않다. InstrumentedHashSet 클래스를 구성과 forwarding 기법을 사용해 수정해보자. InstrumentedHashSet 클래스 자신과 재사용이 가능한 forwarding 클래스 2 부분으로 나눌 수 있다. forwarding 클래스는 모든 forwarding 메소드를 포함할뿐 특별한점은 없다.

 

public class ForwardingSet<E> implements Set<E>{

	private final Set<E> s;
	
	public ForwardingSet(Set<E> s) {
		this.s = s;
	}

	@Override
	public int size() {
		return s.size();
	}

	@Override
	public boolean isEmpty() {
		return s.isEmpty();
	}

	@Override
	public boolean contains(Object o) {
		return s.contains(o);
	}

.............................

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 += 1;
		return super.add(e);
	}

	@Override
	public boolean addAll(Collection<? extends E> c) {
		addCount += c.size();
		return super.addAll(c);
	}

	public int getAddCount() {
		return addCount;
	}
}

 

우선 ForwardingSet 은 재사용이 가능한 forwarding 클래스를 나타낸것이다. Set 인터페이스로 참조할 수 있는 클래스를 구성(Composition) 하고 있다. 그리고 forwarding 메소드에서는 Set 구현체의 기능을 그대로 호출한다.

 

기존의 InstrumentedHashSet 은 Set 의 특정 구현체였던 HashSet 을 상속하고 있는데 ForwardingSet 을 상속하므로 클래스이름도 InstrumentedSet 으로 변경해주는것이 좋다. 그리고 ForwardingSet 을 상속한다. 여기에서 생성자의 인자가 Set 이므로 HashSet 뿐만 아니라 TreeSet 과 같이 Set 인터페이스를 구현하는 구현체를 유연하게 사용할 수 있게 되었다.

소스를 위와 같이 구성하면 중복 카운팅이 되지 않는다. 여기서 왜 그렇게 되지 않는지 헷갈릴수가 있으니 클래스 다이어그램을 그리면서 알아보자.

왼쪽 부분이 HashSet 을 그대로 상속해서 사용한 클래스 다이어그램이고, 오른쪽 부분은 forwarding 과 구성을 이용하여 새롭게 작성한 클래스의 다이어그램이다.

 

InstrumentedHashSet 에서는 HashSet 의 addAll 과 add 를 재정의하였기 때문에 addAll 을 호출하면 InstrumentedHashSet 의 add 도 호출된다. (HashSet 은 addAll 에서 add 를 호출하는 방식이기 때문이다.)

 

하지만 InstrumentedSet 에서는 addAll 을 호출하여도 구성(Composition)이 참조하는 private 변수 s (HashSet을 참조하는) 의 addAll 이 호출되기 때문에 s 의 add 가 호출되어서 중복 카운팅이 발생하지 않게 된다.


- 계승과 구성의 판단기준

계승은 하위 클래스가 상위 클래스의 서브타입이 확실할 때 사용해야 한다. 서브타입이 확실하다고 판단되면 계승을 애매하거나 아니라고 판단이 된다면 구성을 사용해야 한다.

 

서브타입 관계가 확실하게 성립한다고 하더라도 다른 패키지에 있거나 상위 클래스 작성자가 계승을 고려하여 설계하지 않았다면 다시 생각해보는것이 좋을 수 있다.

댓글