1. 상속을 고려한 설계와 문서화
상속용 클래스는 재정의할 수 있는 메서드들을 내부적으로 어떻게 이용하는지 문서로 남겨야 한다. 예를 들어, 클래스의 공개된 메서드에서 클래스 자신의 또 다른 메서드를 호출하는데, 그 메서드가 자식 클래스에서 재정의가 되었다면 클라이언트는 의도치 않은 결과를 가져올 수 있다. 즉 어떤 순서로 호출하는지, 각각의 호출 결과가 이어지는 처리에 어떤 영향을 주는지 담아야한다. 또한 재정의 가능 메서드가 호출될 수 있는 모든 상황에 대해서도 남겨주어야 한다.

java.util.AbstractCollection 의 remove 메서드에서 @implSpec 태그를 통해 내부 동작 방식을 설명하고 있다. (@inheritDoc 에 커서를 갖다대면 더 자세한 내용이 나온다) 설명에 따르면, 반복자의 remove 메서드를 사용해 컬렉션에서 제거한다는 내용으로 보아 iterator 메서드를 재정의 했을 때, remove 메서드 동작에 영향이 갈 수 있다는 것을 알 수 있다. HashSet 에는 add 를 재정의 했을 때 addAll 에 영향을 준다는 내용이 없었는데 이와 대조된다.
2. 훅의 protected 메서드
그렇다면, 재정의 할 가능성이 있는 메서드에 @implSpec 태그를 통해 내부 메커니즘을 문서화하는 것이 상속을 위한 설계의 전부일까? 그렇진 않다. 클래스의 내부 동작 과정 중, 끼어들 수 있는 훅을 잘 선별하여 protected 메서드 형태로 공개해야 할 수도 있다. clear 메서드에서 사용되는 java.util.AbstractCollection 의 removeRange 메서드를 살펴보자.

clear 메서드의 최종 사용자는 removeRange 에 관심도 없지만, 아래 사진에서 ListIterator.remove 가 선형시간이 걸린다면, 이 구현의 성능은 제곱에 비례한다는 정보를 주었다. 결국, removeRange 메서드가 존재하지 않고 하위 클래스에서 clear 메서드를 호출한다면 제곱에 비례해 성능이 악화된다는 사실을 알 수 있다.

그렇다면 상속용 클래스를 설계할 때, 어떤 메서드를 protected 로 노출해야하는가? 최대한 많은 구현 정보를 노출해야 하는가? 안타깝게도 정답은 없다. 하위 클래스를 만들어 실험해보는 것이 최선이며, 너무 많이 노출해서도, 너무 적게 노출해서도 안된다. 단지 구현한 protected 메서드와 필드를 영원히 책임져야함을 인식하며, 상속용으로 설계한 클래스는 배포 전에 반드시 하위 클래스를 만들어 검증하자.
3. 상속을 허용하는 클래스가 지켜야할 제약
3-1. 상속용 클래스의 생성자는 재정의 가능 메서드를 호출해선 안된다.
이러한 상황이 발생한다면, 상위 클래스와 하위 클래스 중 상위 클래스의 생성자가 더 먼저 실행되므로 하위 클래스에서 재정의한 메서드가 하위 클래스의 생성자보다 먼저 호출이 된다. 이 때, 재정의 한 메서드가 하위 클래스의 생성자의 초기화에 의존하고 있다면, 원하는 바가 나타나지 않을 것이다. 아래 예시를 살펴보자.
public class Super {
public Super() {
overrideMe(); // 1
}
public void overrideMe() { ... }
}
public final class Sub extends Super {
private final Instant instant;
Sub() {
instant = Instant.now(); // 3
}
@Override
public void overrideMe() {
System.out.println(instant); // 2 // 4
}
public static void main(String[] args) {
Sub sub = new Sub();
sub.overrideMe();
}
}
이 경우, 순서는 1,2,3,4 대로 흘러간다. 부모 클래스의 생성자를 호출했을 때, 재정의한 하위클래스의 메서드를 불러보지만, instant 엔 담겨있는 게 없기 때문에 null 이 출력되고, 이후 하위 클래스의 생성자를 호출해야 비로소 instant 가 초기화되어 원하는 바가 출력된다. 만약 출력이 아니라, instnat 객체의 메서드를 호출하려 했다면? NPE 가 발생했을 것이다.
private, final, static 메서드는 재정의가 불가능하니, 생성자에서 안심하고 호출해도 된다.
위 케이스로, 실제 clone 과 readObject 메서드 또한 생성자와 비슷한 효과를 내기 때문에 두 메서드 모두 재정의 메서드를 호출해서는 안되는 케이스다. readObject 의 경우, 하위 클래스의 상태가 미처 다 역직렬화되기 전에 (이 과정을 하위 클래스의 생성자에서 했겠죠) 재정의한 메서드부터 호출하게 된다.
3-2 결국
클래스를 상속용으로 설계하기 위해선 엄청난 노력이 들고, 그 클래스에 안기는 제약도 상당하다. 만약, 그 외 일반적인 구체 클래스는 어떨까? 결론은 상속용으로 설계하지 않은 클래스는 상속을 금지하는 것이 가장 좋다. 클래스를 final 로 선언하던지, 모든 생성자를 private 이나 package-private 으로 선언하고 public 정적 팩터리를 만들어주는 방법이다. 그래도 상속을 굳이굳이 해야겠다면, 재정의 가능 메서드를 사용하지 않게 만들고, 이 사실을 문서로 남기자. 그래도 굳이굳이 재정의 가능 메서드를 사용해야겠다면, private 메서드로 빼서 재정의 가능 메서드를 바라 보고 있던 곳을 직접 private 메서드를 호출하도록 수정해주자.
'1️⃣ 백앤드 > 이펙티브 자바' 카테고리의 다른 글
| [아이템21] 인터페이스는 구현하는 쪽을 생각해 설계해라 (2) | 2024.10.14 |
|---|---|
| [아이템20] 추상 클래스보다는 인터페이스를 우선하라 (0) | 2024.10.14 |
| [아이템18] 상속보다는 컴포지션을 사용해라 (0) | 2024.10.09 |
| [아이템17] 변경 가능성을 최소화하라 (1) | 2024.10.06 |
| [아이템16] public 클래스에서는 public 필드가 아닌 접근자 메서드를 사용하라 (0) | 2024.10.05 |