Study/이펙티브 자바 / / 2023. 9. 16. 21:32

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

💥 개요

메서드를 재정의하면 어떤 일이 일어나는지를 정확히 정리하여 문서로 남겨야 합니다. 상속용 클래스는 재정의할 수 있는 메서드들을 내부적으로 어떻게 이용하는지(자기사용) 문서로 남겨야 합니다. 클래스의 API로 공개된 메서드에서 클래스 자신의 또 다른 메서드를 호출할 수도 있씁니다. 그런데 마침 호출되는 메서드가 재정의 가능 메서드라면 그 사실을 호출하는 메서드의 API 설명에 적시해야 합니다. 어떤 순서로 호출하는지, 각각의 호출 결과가 이어지는 처리에 어떤 영향을 주는지도 담아야 합니다. 즉 모든 상황을 문서로 남겨야 합니다.

 

상속이 캡슐화를 해친다.

API문서 메서드 설명 끝에 종종 "Implementation Requirements"로 시작하는 절을 볼 수 있는데 내부 동작방식을 설명하는 곳 입니다. 이 절은 메서드 주석에 @impleSpec 태그를 붙여주면 자바독 도구가 생성해줍니다.

\

java.util.AbstractCollection

하지만 이런 식은 "좋은 API 문서란 '어떻게'가 아닌 '무엇'을 하는지를 설명해야 한다." 라는 격언과 대치됩니다. 상속이 캡슐화를 해치기 때문에 일어나는 안타까운 현실입니다. 클래스를 안전하게 상속할 수 있도록 하려면 내부 구현 방식을 설명해야만 합니다.(상속만 아니라면 기술하지 않았음) 

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

 

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

안타깝게 마법은 없습니다. 심사숙고해 잘 예측해, 실제 하위 클래스를 만들어 시험해보는게 최선입니다. protected 메서드 하나하나가 내부 구현에 해당하므로 그 수는 가능한 적어야 합니다. 한편으로 너무 적게 노출해 상속의 이점을 없애지 않도록 주의해야 합니다.

상속용 클래스를 시험하는 방법은 직접 하위 클래스를 만들어보는 것이 유일합니다. protected 멤버를 3개 정도의 하위 클래스를 만들 때 까지 사용하지 않았다면 private이었어야 할 가능성이 큽니다. 널리 쓰일 클래스를 상속용으로 설계한다면 문서화한 내부 패턴과, 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에서 instant 객체의 메서드를 호출하려 하면 상위 클래스의 생성자가 overrideMe를 호출할 때 NPE를 던지게 됩니다. 하지만 println이 null 입력도 받아들이기 때문이지만, 다른 로직이 있다면 NPE가 터지는 문제가 발생합니다. (private, final, static 메서드는 재정의가 불가능하니 안심하고 생성자에서 호출해도 됩니다.)

 

Cloneable과 Serializable 인터페이스를 구현한 클래스를 상속할때 주의하자

Cloneable과 Serializable 인터페이스는 상속용 설계의 어려움을 한층 더해줍니다. 둘 중 하나라도 상속할 수 있게 설계하는건 좋지않고 확장하려는 프로그래머에게 엄청난 부담을 지웁니다. clone과 readObject 메서드는 생성자와 비슷한 효과를 냅니다.(새로운 객체를 생성함)

따라서 상속용 클래스에서 Cloneable이나 Serializable을 구현할지 정해야 한다면, 이들을 구현할 때 따르는 제약도 생성자와 비슷하다는 점을 주의해야 합니다. 즉 clone과 readObject 모두 재정의 가능 메서드를 호출해서는 안됩니다.

그리고 Serializable을 구현한 상속용 클래스가 readResolve나 writeReplace 메서드를 갖는다면 이 메서드들은 private이 아닌 protected로 선언해야 합니다. private으로 선언하면 하위 클래스에서 무시되기 때문입니다. 이 역시 상속을 허용하기 위해 내부 구현을 클래스 API로 공개하는 예 중 하나입니다. 이처럼 클래스를 상속용으로 설계하려면 엄청난 노력이 들고, 그 클래스에 안기는 제약도 상당합니다.

 

일반적인 구체 클래스의 경우

전통적으로 이런 클래스는 final도 아니고 상속용으로 설계되거나 문서화되지도 않았습니다. 하지만 그대로 두게 된다면 클래스의 변화가 생길 때마다 하위 클래스를 오동작하게 만들 수 있습니다. 실제로 보통 구체 클래스의 내부만 수정했음에도 이를 확장한 클래스에서 문제가 생기는 일은 드물지 않습니다.

 

👍 문제를 해결하는 방법

문제를 해결하는 가장 좋은 방법은 상속용으로 설계하지 않은 클래스는 상속을 금지하는 것 입니다.

상속을 금지하는 방법은 2가지 입니다.

1.클래스를 final로 선언하는 방법

2.모든 생성자를 private, package-private으로 선언하고 public 정적 팩터리를 만드는 방법

정적 팩터리 방법은 내부에 다양한 하위 클래스를 만들어 쓸 수 있는 유연성을 줍니다.

이건 다소 논란의 여지가 있습니다. 그동안 일반적인 구체 클래스를 상속해왔기 때문입니다.

핵심 기능을 정의한 인터페이스가 있고, 클래스가 그 인터페이스를 구현했다면 상속을 금지해도 개발하는데 아무런 어려움이 없을 것 입니다.(Set, List, Map이 좋은 예) 또한 래퍼 클래스 패턴 역시 기능을 증강할 때 상속 대신 쓸 수 있는 더 나은 대안입니다. 

구체 클래스가 표준 인터페이스를 구현하지 않았는데 상속을 금지하면 사용하기에 상당히 불편해집니다. 상속을 꼭 허용해야 한다면 합당한 방법으로 클래스 내부 재정의 가능 메서드를 사용하지 않게 만들고 이를 문서로 남기는 것 입니다.

이렇게 하면 상속해도 그리 위험하지 않은 클래스를 만들 수 있습니다. 메서드를 재정의해도 다른 메서드의 동작에 아무런 영향을 주지않습니다. 

클래스의 동작을 유지하며 재정의 가능 메서드를 사용하는 코드를 제거할 수 있는 기계적인 방법

  1. 각각의 재정의 가능 메서드는 자신 본문 코드를 private '도우미 메서드'로 옮긴다.
  2. 이 도우미 메서드를 호출하도록 수정한다.
  3. 재정의 가능 메서드를 호출하는 다른 코드들도 이 도우미 메서드를 직접 호출하도록 수정한다.

 

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



❤️ 스터디 질답 정리

Q. hook이란게 무엇인가?
A. hook이란 낚아챈다고 이해하고 있는데, 중간 로직이 있으면 필요한걸 가져오는것.

Q. 클래스의 내부 동작 과정 중간에 끼어들 수 있는 훅(hook)을 잘 선별하여 protected 메서드 형태로 공개한다는게 무슨말인지?
A. 만약 한 부분을 공개해야 하는데, 그 부분을 잘 선별해서 protected로 공개해라. AbstractAuthenticationProcessingFilter클래스에서 attemptAuthentication과 secuessfullAthentication 두 메서드가 그 예시다.