[Effective Java]상속을 고려해 설계하고 문서화하라. 그러지 않았다면 상속을 금지하라

클래스와 인터페이스, 다섯 번째 아이템

Posted by SungBeom on March 27, 2020 · 6 mins read

상속을 허용하는 클래스가 지켜야 할 제약

메소드를 재정의하면 어떤 일이 일어나는지를 정확히 정리하여 문서로 남겨야 한다. 달리 말하면, 상속용 클래스는 재정의할 수 있는 메소드들을 내부적으로 어떻게 이용하는지(자기사용) 문서로 남겨야 한다. 재정의가 가능한 메소드가 클래스 내부에서 호출되는지, 어떤 순서로 호출하는지, 각각의 호출 결과가 이어지는 처리에 어떤 영향을 주는지 등의 내용을 담아야 한다.

API 문서의 메소드 설명 끝에서 종종 "Implementation Requirements"로 시작하는 절이 그 메소드의 내부 동작 방식을 설명하는 곳이다. 이 절은 메소드 주석에 @implSpec 태그를 붙여주면 자바독 도구가 생성해준다. '어떻게'가 아닌 '무엇'을 설명하야 좋은 API 문서라는 말에 대치되지만, 상속이 캡슐화를 해치기 때문에 일어나는 안타까운 현실이다.

내부 메커니즘을 문서로 남기는 것뿐만 아니라, 효율적인 하위 클래스를 큰 어려움 없이 만들 수 있게 해야 한다. 그러기 위해선 클래스의 내부 동작 과정 중간에 끼어들 수 있는 훅(hook)을 잘 선별하여 protected 메소드 형태로 공개해야 할 수도 있다. 드물게는 protected 필드로 공개해야 할 수도 있다.

상속용 클래스를 설계할 때 어떤 메소드를 protected로 노출할지 결정하는 마법은 없다. 심사숙고해서 잘 예측해본 다음, 실제 하위 클래스를 만들어 시험해보는 것이 최선이다. protected 메소드 하나하나가 내부 구현에 해당하므로 그 수는 가능한 한 적어야 하고, 너무 적게 노출해서 상속으로 얻는 이점마저 없애지 않도록 주의해야 한다.

상속용 클래스를 시험하는 방법은 직접 하위 클래스를 만들어보는 것이 '유일'하다. 따라서 상속용으로 설계한 클래스는 배포 전에 반드시 하위 클래스를 만들어 검증해야 한다.

또 다른 제약으로 상속용 클래스의 생성자는 직접적으로든 간접적으로든 재정의 가능 메소드를 호출해서는 안 된다. 상위 클래스의 생성자가 하위 클래스의 생성자보다 먼저 실행되므로 하위 클래스에서 재정의한 메소드가 하위 클래스의 생성자보다 먼저 호출된다. 이때 그 재정의한 메소드가 하위 클래스의 생성자에서 초기화하는 값에 의존한다면 의도대로 동작하지 않을 것이다.

1
2
3
4
5
6
7
8
9
public class Super {
    // 잘못된 예 - 생성자가 재정의 가능 메소드를 호출한다.
    public Super() {
        overrideMe();
    }
 
    public void overrideMe() {
    }
}
cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public final class Sub extends Super {
    // 초기화되지 않은 final 필드. 생성자에서 초기화한다.
    private final Instant instant;
 
    Sub() {
        instant = Instant.now();
    }
 
    // 재정의 가능 메소드. 상위 클래스의 생성자가 호출한다.
    @Override public void overrideMe() {
        System.out.println(instant);
    }
 
    public static void main(String[] args) {
        Sub sub = new Sub();
        sub.overrideMe();
    }
}
cs

이 프로그램이 instant를 두 번 출력하리라 기대했겠지만, 첫 번째는 null을 출력한다. 상위 클래스의 생성자는 하위 클래스의 생성자가 인스턴스 필드를 초기화하기도 전에 overrideMe를 호출하기 때문이다. final 필드의 상태가 정상이라면 단 하나뿐이어야 하지만 이 프로그램에서는 두 가지임에 주목하자. private, final, static 메소드는 재정의가 불가능하니 생성자에서 안심하고 호출해도 된다.

Cloneable과 Serializable 인터페이스 중 하나라도 구현한 클래스를 상속할 수 있게 설계하는 것은 일반적으로 좋지 않은 생각이다. clone과 readObject 메소드는 생성자와 비슷한 효과(새로운 객체 생성)를 내기 때문에 구현할 때 따르는 제약도 생성자와 비슷하다. 즉, clone과 readObject 모두 직접적으로든 간접적으로든 재정의 가능 메소드를 호출해서는 안 된다.

마지막으로, Serializable을 구현한 상속용 클래스가 readResolve나 writeReplace 메소드를 갖는다면 이 메소드들은 private이 아닌 protected로 선언해야 한다. private으로 선언한다면 하위 클래스에서 무시되기 때문이다.

클래스 상속 문제를 해결하는 방법

이제 클래스를 상속용으로 설계하려면 엄청난 노력이 들고 그 클래스에 안기는 제약도 상당함을 알았다. 클래스에 변화가 생길 때마다 하위 클래스를 오동작하게 만들 수 있고, 이 문제를 해결하는 가장 좋은 방법은 상속용으로 설계하지 않은 클래스는 상속을 금지하는 것이다.

상속을 금지하는 방법은 두 가지이다. 첫 번째는 클래스를 final로 선언하는 방법이고, 두 번째는 모든 생성자를 private이나 package-private으로 선언하고 public 정적 팩토리를 만들어주는 방법이다.

상속을 꼭 허용해야겠다면 클래스 내부에서는 재정의 가능 메소드를 사용하지 않게 만들고 이 사실을 문서로 남기는 방법이 있다. 재정의 가능 메소드를 호출하는 자기 사용 코드를 완벽히 제거함으로써 상속해도 그리 위험하지 않은 클래스를 만들 수 있다.

클래스의 동작을 유지하면서 재정의 가능 메소드를 사용하는 코드를 제거할 수 있는 기계적인 방법도 있다. 먼저 각각의 재정의 가능 메소드는 자신의 본문 코드를 private '도우미 메소드'로 옮기고, 이 도우미 메소드를 호출하도록 수정한다. 그런 다음 재정의 가능 메소드를 호출하는 다른 코드들도 모두 이 도우미 메소드를 직접 호출하도록 수정하면 된다.


핵심 정리

상속용 클래스를 설계하기란 결코 만만치 않다. 클래스 내부에서 스스로를 어떻게 사용하는지(자기사용 패턴) 모두 문서로 남겨야 하며, 일단 문서화한 것은 그 클래스가 쓰이는 한 반드시 지켜야 한다. 그러지 않으면 그 내부 구현 방식을 믿고 활용하던 하위 클래스를 오동작하게 만들 수 있다. 다른 이가 효율 좋은 하위 클래스를 만들 수 있도록 일부 메소드를 protected로 제공해야 할 수도 있다. 그러니 클래스를 확장해야 할 명확한 이유가 떠오르지 않으면 상속을 금지하는 편이 나을 것이다. 상속을 금지하려면 클래스를 final로 선언하거나 생성자 모두를 외부에서 접근할 수 없도록 만들면 된다.