본문 바로가기
Language/Java

이펙티브 자바 - 계승을 위한 설계, 문서화

by ocwokocw 2021. 11. 21.

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

- 개요

이전의 '계승대신 구성하라'는 규칙에서는 계승을 고려한 설계나 문서화가 되지 않은 클래스에 대해 하위 클래스 생성시 문제점을 살펴보았다. 그렇다면 '계승을 고려한 설계나 문서화'의 의미란 무엇인가?


- 재정의 가능 메서드 문서 작성

재정의 가능 메서드는 non-final 인 public 이나 protected 접근제어자를 가진 메서드나 생성자를 말한다. 이들에 대해 재정의 가능 메서드의 호출 순서나 호출 결과를 문서로 남겨야 한다. 관습적으로 주석 맨 마지막 부분에 작성하며 '이 구현은~'으로 시작한다. 

 

/**
 * {@inheritDoc}
 *
 * <p>This implementation iterates over the collection looking for the
 * specified element.  If it finds the element, it removes the element
 * from the collection using the iterator's remove method.
 *
 * <p>Note that this implementation throws an
 * <tt>UnsupportedOperationException</tt> if the iterator returned by this
 * collection's iterator method does not implement the <tt>remove</tt>
 * method and this collection contains the specified object.
 *
 * @throws UnsupportedOperationException {@inheritDoc}
 * @throws ClassCastException            {@inheritDoc}
 * @throws NullPointerException          {@inheritDoc}
 */
public boolean remove(Object o) {

 

위의 코드는 AbstractCollection 의 remove 메서드 주석이다. iterator의 remove 메서드를 통해서 컬렉션의 원소를 삭제한다고 명시되어있어서 iterator 구현을 변경하면 remove 메서드도 영향을 받음을 알 수 있다.

 

사실 이 클래스를 계승해서 새로운 하위 클래스를 만들것이 아니라 단순히 API 를 사용하는 사용자에게는 해당 주석의 세부사항 정보는 과다한면이 있다. 이는 문서화의 기본인 이 메서드가 어떻게(how)보다 무엇(what)을 하는지 기술해야하는 기본사항을 위반한것이다. 그렇지만 계승 자체가 캡슐화를 위반하기 때문에 어쩔수 없이 하위 클래스를 만드는 사용자를 위해 구현 세부사항을 기술해주어야 한다.


- protected 메서드 제공

단순히 문서만 잘 작성한다고 해서 계승에 적합한 설계가 되지는 않는다. 클래스 내부동작에 개입할 수 있는 훅(hooks)을 신중하게 고른 protected 메서드 형태로 제공해야 한다.

 

 

/**
 * Removes from this list all of the elements whose index is between
 * {@code fromIndex}, inclusive, and {@code toIndex}, exclusive.
 * Shifts any succeeding elements to the left (reduces their index).
 * This call shortens the list by {@code (toIndex - fromIndex)} elements.
 * (If {@code toIndex==fromIndex}, this operation has no effect.)
 *
 * <p>This method is called by the {@code clear} operation on this list
 * and its subLists.  Overriding this method to take advantage of
 * the internals of the list implementation can <i>substantially</i>
 * improve the performance of the {@code clear} operation on this list
 * and its subLists.
 *
 * <p>This implementation gets a list iterator positioned before
 * {@code fromIndex}, and repeatedly calls {@code ListIterator.next}
 * followed by {@code ListIterator.remove} until the entire range has
 * been removed.  <b>Note: if {@code ListIterator.remove} requires linear
 * time, this implementation requires quadratic time.</b>
 *
 * @param fromIndex index of first element to be removed
 * @param toIndex index after last element to be removed
 */
protected void removeRange(int fromIndex, int toIndex) {
    ListIterator<E> it = listIterator(fromIndex);
    for (int i=0, n=toIndex-fromIndex; i<n; i++) {
        it.next();
        it.remove();
    }
}
 

위의 코드는 AbstractList 의 removeRange 메서드이다. 2번째 단락에 기술되어있듯이 this 리스트와 부분 리스트에 대한 clear 연산 성능을 올릴 수 있다. 이 메서드가 없으면 하위 클래스에서 부분 리스트에 대한 clear 메서드 성능이 리스트 길이의 제곱이 비례하여 나빠지는점을 감안하고 사용하거나 subList 매커니즘을 처음부터 끝까지 다시 작성해야 한다.

계승을 고려한 설계시 protected 멤버를 선정하는 절대적인 기준은 존재하지 않는다. 그저 신중하게 생각하고 Test 를 하면서 가능한 protected 개수를 줄이는것 뿐이다. 그렇다고 너무 protected 개수가 적으면 계승해서 사용하기에 매우 불편한 클래스가 된다.


- 계승을 위한 Test

계승을 위한 클래스를 Test 해볼 유일한 방법은 직접 만들어보는 수 밖에 없다. 대체적으로 하위 클래스를 3 개 정도 만들어보면된다. 만약 하위 클래스를 몇개 만들어보아도 사용할일이 없는 protected 변수를 발견했다면 private 로 변경한다.

 

가능한 protected 를 private 로 변경해야 하는 이유는 내부호출을 문서에 명시하거나 protected 필드나 메서드들은 한번 정하면 영원히 고수해야하기 때문이다. 그래서 릴리즈전에 이 멤버나 변수를 protected 로 설정해도 괜찮은지 Test 를 해보고 결정해야 한다.

 

계승용 주석(ex - 재정의 가능 메서드의 구현 세부사항)은 하위 클래스 생성자에게 유용하지만 일반 API 사용자가 보기에는 추상화되지 않은 너무 복잡한 정보라고 할 수 있다. 하지만 해당 주석이 없다면 하위 클래스 생성자가 잘못 사용할 우려가 있기 때문에 감수해야할 부분이다.


- 계승에 따르는 추가 제약사항

생성자는 간접적이든 직접적이든 재정의 가능 메서드를 호출하면 안된다. 상위 클래스의 생성자는 하위 클래스 생성자보다 먼저 호출되기 때문에 하위 클래스에서 재정의한 메서드는 하위 클래스 생성자보다 먼저 호출된다.

 

하위 클래스에서 재정의한 메서드가 하위 클래스 생성자에 의존하는 경우(ex - 생성자에거 멤버 변수 초기화) 메서드가 원하는대로 동작하지 않는다.

 

public class Super {

	public Super() {
		System.out.println("Super call overrideTest()");
		this.overrideTest();
	}
	
	protected void overrideTest() {}
}

public class Sub extends Super{

	private final Date date;
	
	public Sub() {
		date = new Date();
	}
	
	@Override
	protected void overrideTest() {
		System.out.println(date);
	}

	public static void main(String[] args) {

		Sub sub = new Sub();
		sub.overrideTest();
		
	}
}

........

Super call overrideTest()
null
Sun Nov 21 11:44:24 KST 2021

 

위의 Super 와 Sub 클래스 예제에서 사용자는 Date 값이 2번 출력될거라고 기대하지만 Super 생성자에서 overrideTest 메서드를 호출하기 때문에 사용자의 기대와는 다르게 동작한다. Sub 의 overrideTest 에서는 단순하게 date 값을 출력하니 오류가 나지는 않지만 만약 date 값의 속성을 사용하거나 메서드를 사용하려고 하면 NPE 가 발생한다.

 

Cloneable과 Serialzable 의 경우 계승용 클래스 설계가 더 까다로운데 클래스를 계승할 프로그래머에게 과도한 책임을 지우기 때문이다. 그래도 사용해야 한다면 clone 과 readObject 메서드도 생성자와 비슷하게 동작하므로 비슷한 규칙을 따라야 한다.


- 마치면서

위의 규칙들에서 계승을 위한 클래스를 설계하려면 상당한 제약이 가해진다는 사실을 알 수 있다. 일반적인 객체 생성가능 클래스들은 final 로 선언되거나 하위 클래스를 위한 문서화도 되어있지 않기 때문에 이런 클래스들의 하위 클래스를 만드는것은 위험하다.

 

이런 문제를 해결하기 위한 가장 좋은 방법은 계승에 맞도록 설계하고 문서화 하지 않은 클래스에 대한 하위 클래스를 만들지 않는것이다. 생성자를 private나 package-private 로 생성하고 public 정적 팩토리 메소드를 제공하거나 클래스를 final 로 설정하여 하위 클래스 생성자체를 금지할 수도 있다.

 

만약 필수적으로 하위 클래스 생성을 제공해야 한다면 재정의 가능 메서드를 내부적으로 호출하지 않고, 이를 문서에 남겨야 한다. 그렇게하면 최소한 메서드를 재정의해도 다른 메서드에 영향은 주지 않기 때문이다.

 

재정의 가능 메서드를 제거할때에는 호출되는 재정의 가능 메서드 로직을 helper 메서드로 옮기고, 해당 재정의 가능 메서드를 호출한 메서드가 helper 메서드를 호출하도록 변경하면 된다.

댓글