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

콜트·2021년 7월 27일
1
post-thumbnail
post-custom-banner

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

  • 메서드를 재정의하려면 어떤 일이 일어나는지를 정확히 정리하여 문서로 남겨야 한다. 즉, 상속용 클래스는 재정의할 수 있는 메서드들을 내부적으로 어떻게 이용하는지(자기사용) 문서로 남겨야 한다.
  • API 문서의 메서드 설명 끝에서 종종 "Implementation Requirements"로 시작하는 절을 볼 수 있는데, 그 메서드의 내부 동작 방식을 설명하는 곳이다. 이 절은 메서드 주석에
    @implSpec 태그를 붙여주면 자바독 도구가 생성해준다.

"좋은 API 문서란 '어떻게'가 아닌 '무엇'을 하는지를 설명해야 한다"

  • 하지만, 특정 클래스의 메서드를 재정의하면 내부의 다른 메서드의 동작에 주는 영향도 정확하게 설명하고 있는 API들이 있다.
  • 이는 상속이 캡슐화를 해치기 때문에 클래스를 안전하게 상속할 수 있도록 하려면 내부 구현 방식을 설명해야만 하기 때문이다.

@implSpec

  • @implSpec 태그는 자바 8에서 처음 도입되어 자바 9부터 본격적으로 사용되었다. 자바 11에서도 선택사항으로 남겨져 있다.
  • 이 태그를 활성화하려면 명령줄 매개변수로 -tag "implSpec:a:Implementation Requirements:"를 지정해주면 된다.
    • 자바독의 커스텀 태그 기능을 이용해 자바 개발팀에서 내부적으로 사용하는 규약이라고 한다.

효율적인 하위 클래스를 큰 어려움 없이 만들 수 있게 하려면 클래스의 내부 동작 과정 중간에 끼어들 수 있는 훅(hook)을 잘 선별하여 protected 메서드 형태로 공개해야 할 수도 있다. 또는, 드물게는 protected 필드로 공개해야 할 수도 있다.

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

  • protected 메서드 하나하나가 내부 구현에 해당하므로 그 수는 가능한 한 적어야 한다. 다만, 너무 적게 노출해서 상속으로 얻는 이점을 없애지 않도록 주의해야 한다.
  • 상속용 클래스를 시험하는 방법은 직접 하위 클래스를 만들어보는 것이 '유일'하다.
  • 꼭 필요한 protected 멤버를 놓쳤다면 하위 클래스를 작성할 때 그 빈자리가 확연히 드러난다.
  • 거꾸로, 하위 클래스를 여러 개 만들 때까지 전혀 쓰이지 않는 protected 멤버는 사실 private이었어야 할 가능성이 크다.

상속용 클래스는 영원히 책임져야 한다

  • 널리 쓰일 클래스를 상속용으로 설계한다면 문서화한 내부 사용 패턴과 protected 메서드와 필드를 구현하면서 선택한 결정을 영원히 책임져야 한다.
  • 이 결정들이 그 클래스의 성능과 기능에 영원한 족쇄가 될 수 있다.
  • 상속용으로 설계한 클래스는 배포 전에 반드시 하위 클래스를 만들어 검증해야 한다.

상속용 클래스의 생성자는 재정의 메서드를 호출해서는 안 된다

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

    public void overrideMe() {

    }
}
public class Sub extends Super {

    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 필드인 instant의 상태가 2가지가 된다(정상이라면 단 하나뿐이어야 한다).
  • overrideMe에서 instant 객체의 메서드를 호출하려 한다면 상위 클래스의 생성자가 overrideMe를 호출할 때 NullPointerException을 던지게 된다.

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

clone과 readObject 메서드

  • clone과 readObject 메서드는 생성자와 비슷한 효과를 낸다(새로운 객체를 만든다).
  • 따라서 상속용 클래스에서 Cloneable이나 Serializable을 구현할지 정해야 한다면, 이들을 구현할 때 따르는 제약도 생성자와 비슷하다.
  • 즉, clone과 readObject 모두 직접적으로든 간접적으로든 재정의 가능 메서드를 호출해서는 안 된다.
  • readObject의 경우 하위 클래스의 상태가 미처 다 역직렬화되기 전에 재정의한 메서드부터 호출하게 된다.
  • clone의 경우 하위 클래스의 clone 메서드가 복제본의 상태를 (올바른 상태로) 수정하기 전에 재정의한 메서드를 호출한다.
    • 특히 clone이 잘못되면 복제본뿐만 아니라 원본 객체에도 피해를 줄 수 있다.
  • Serializable을 구현한 상속용 클래스가 readResolve나 writeReplace 메서드를 갖는다면 이 메서드들은 private이 아닌 protected로 선언해야 한다.
    • private로 선언하면 하위 클래스에서 무시되기 때문이다.

클래스를 상속용으로 설계할 때는 주의해야 한다

  • 클래스를 상속용으로 설계하려면 엄청난 노력이 들고 그 클래스에 안기는 제약도 상당하다
  • 추상 클래스나 인터페이스의 골격 구현처럼 상속을 허용하는 게 명백히 정당한 상황이 있고, 불변 클래스처럼 명백히 잘못된 상황이 있다.
  • 일반적인 구체 클래스는 final도 아니고 상속용으로 설계되지도 않았고 문서화되지도 않았다. 따라서 그대로 두면 위험하다. 클래스에 변화가 생길 때마다 하위 클래스를 오동작하게 만들 수 있다.
    • 이 문제를 해결하는 가장 좋은 방법은 상속용으로 설계하지 않은 클래스는 상속을 금지하는 것이다.

상속을 금지하는 두 가지 방법

  • 첫 번째는 클래스를 final로 선언하는 것이다.
  • 두 번재는 모든 생성자를 private이나 package-private으로 선언하고 public 정적 팩터리를 만들어주는 것이다.

핵심 기능을 정의한 인터페이스가 있고, 클래스가 그 인터페이스를 구현했다면 상속을 금지해도 개발하는 데 아무런 어려움이 없을 것이다. Set, List, Map이 그 예다. 또는 래퍼 클래스를 이용하는 것도 적절한
대안이 될 수 있다.

상속을 반드시 허용해야 한다면?

  • 클래스 내부에서는 재정의 가능 메서드를 사용하지 않게 만들고 문서로 남겨야 한다.
    • 즉, 재정의 가능 메서드를 호출하는 자기사용 코드를 완벽히 제거해야 한다.
    • 이렇게 하면 상속해도 그리 위험하지 않다. 메서드를 재정의해도 다른 메서드의 동작에 아무런 영향을 주지 않기 때문이다.

핵심 정리

  • 상속용 클래스를 설계한다면, 클래스 내부에서 스스로를 어떻게 사용하는지(자기사용 패턴) 모두 문서로 남겨야 한다.
  • 일단 문서화한 것은 그 클래스가 쓰이는 한 반드시 지켜야 한다. 그러지 않으면 그 내부 구현 방식을 믿고 활용하던 하위 클래스를 오동작하게 만들 수 있다.
  • 다른 이가 효율 좋은 하위 클래스를 만들 수 있도록 일부 메서드를 protected로 제공해야 할 수도 있다.
  • 클래스를 확장해야 할 명확한 이유가 떠오르지 않으면 상속을 금지하는 편이 낫다.
  • 상속을 금지하려면 클래스를 final로 선언하거나 생성자 모두를 외부에서 접근할 수 없도록 만들면 된다.
profile
개발 블로그이지만 꼭 개발 이야기만 쓰라는 법은 없으니, 그냥 쓰고 싶은 내용이면 뭐든 쓰려고 합니다. 코드는 깃허브에다 작성할 수도 있으니까요.
post-custom-banner

0개의 댓글