Effective Java - 아이템 19: 상속을 고려해 설계하고 문서화하라. 그러지 않았다면 상속을 금지하라

2 분 소요

상속을 고려한 설계와 문서화란?

상속용 클래스는 재정의할 수 있는 메서드들을 내부적으로 어떻게 이용하는지 문서로 남겨야 한다.
‘재정의 가능’이란 public과 protected 메서드 중 final이 아닌 모든 메서드를 뜻한다.
API 문서의 메서드 설명 끝에서 종종 “Implementation Requirements”로 시작하는 그 메서드 내부 동작 방식을 설명하는 곳이 있는데, 메서드 주석에 @implSpec 태그를 붙여주면 자바독 도구가 생성해준다.
효율적인 하위 클래스를 큰 어려움 없이 만들 수 있게 하려면 클래스 내부 동작 과정 중간에 끼어들 수 있는 훅(hook)을 잘 선별하여 protected 메서드 형태로 공개해야 할 수도 있다.

상속용 클래스를 설계할 때 어떤 메서드를 protected로 노출해야 할지?

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

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

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

public class Super {
    // 잘못된 예 - 생성자가 재정의 가능 메서드를 호출한다.
    public Super() {
        overrideMe();
    }

    public void overrideMe() {
    }
}

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();
    }
}

이 프로그램이 instant를 두 번 출력할 것이라고 예상했지만, 첫 번째는 null을 출력한다. 상위 클래스의 생성자는 하위 클래스의 생성자가 인스턴스 필드를 초기화하기도 전에 overrideMe를 호출하기 때문이다. final 필드의 상태가 이 프로그램에서는 두 가지임에 주목하자(정상적이라면 단 하나뿐이어야 한다).

private, final, static 메서드는 재정의가 불가능하니 생성자에서 안심하고 호출해도 된다.

Cloneable과 Serializable 인터페이스

Cloneable과 Serializable 인터페이스는 상속용 설계의 어려움을 한층 더해준다. 둘 중 하나라도 구현한 클래스를 상속할 수 있게 설계하는 것은 일반적으로 좋지 않은 생각이다. clone과 readObject 메서드는 생성자와 비슷한 효과를 낸다(새로운 객체를 만든다). 따라서 상속용 클래스에서 Cloneable이나 Serializable을 구현할지 정해야 한다면, 이들을 구현할 때 따르는 제약도 생성자와 비슷하다는 점에 주의하자. 즉 clone과 readObject 모두 직접적으로든 간접적으로든 재정의 가능 메서드를 호출해서는 안 된다. readObejct의 경우 하위 클래스의 상태가 미처 다 역직렬화되기 전에 재정의한 메서드부터 호출하게 된다.

clone의 경우 하위 클래스의 clone 메서드가 복제본의 상태를(올바른 상태로) 수정하기 전에 재정의한 메서드를 호출한다. 특히 clone이 잘못되면 복제본뿐 아니라 원본 객체에도 피해를 줄 수 있다. clone이 완벽하지 못했어서 복제본의 내부 어딘가에서 여전히 원본 객체의 데이터를 참조하고 있다면 원본 객체도 피해를 입는 것이다.

Serializable을 구현한 상속용 클래스가 readResolve나 writeReplace 메서드를 갖는다면 이 메서드들은 private이 아닌 protected로 선언해야 한다. private으로 선언해야 한다면 하위 클래스에서 무시되기 때문이다. 이 역시 상속을 허용하기 위해 내부 구현을 클래스 API로 공개하는 예 중 하나다.

상속용으로 설계하지 않은 클래스는 상속을 금지하는 것이 좋다

상속을 금지하는 방법은 두 가지다. 첫 번째는 둘 중 더 쉬운 쪽의 클래스를 final로 선언하는 방법이다. 두 번째는 모든 생성자를 private나 package-private로 선언하고 public 정적 팩터리를 만들어주는 방법이다. 핵심 기능을 정의한 인터페이스가 있고, 클래스가 그 인터페이스를 구현했다면 상속을 금지해도 개발하는 데 아무런 어려움이 없을 것이다. Set, List, Map이 좋은 예다. 래퍼 클래스 패턴 역시 기능을 증강할 때 상속 대신 쓸 수 있는 더 나은 대안이다.

핵심 정리

클래스를 확장해야 할 명확한 이유가 떠오르지 않으면 상속을 금지하는 편이 낫다.
상속을 금지하려면 클래스를 final로 선언하거나 생성자 모두를 외부에서 접근할 수 없도록 만들면 된다.

댓글남기기