Backend/Java

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

김세진 2024. 2. 12. 19:21
반응형

 

 

 

 

개요

 

상속용 클래스 혹은 상속이 가능한 클래스를 설계할 때 고려해야 할 규약이 몇 가지 있다(여기서의 상속은 extends로 확장하는 구현 상속을 의미). 문서화하는 것, 충분한 검증이 이루어져야 하는 것, 그리고 몇 가지 구현상의 제약이다.

 

상속용 클래스를 설계하는 것은 많은 노력이 들고 그 클래스에 안기는 제약 또한 많으므로 설계 전 충분한 고민이 필요하다. 상속 대신 컴포지션을 사용하는 방법을 고려하는 것(아이템 18)도 그 고민 중 하나일 것이다.

 

상속을 고려하지 않은 일반적인 클래스를 작성할 때에도 여전히 누군가 임의로 확장해서 사용할 가능성이 있다. 따라서 이를 방지하기 위해 일반 클래스를 누군가 상속하지 못하도록 조치를 취해야 한다.

 

 

 


상속용 클래스 작성 규약

 

1. 상속용 클래스는 재정의할 수 있는 메서드들을 내부적으로 어떻게 이용하는지(자기사용) 문서로 남겨야 한다.

여기서 재정의 가능 메서드란,  final이 아니면서 public 혹은 protected로 선언된 메서드를 뜻한다. 

 

클래스 내부 메서드에서 자신의 또 다른 메서드를 호출하는 상황이 있을 수 있고 마침 그 메서드가 재정의 가능한 메서드라면 이를 메서드의 API 설명에 적시해야 한다. 어떤 순서로 호출하는지, 이어지는 처리에 어떤 영향을 주는지도 담아 설명해야 한다. 심지어는 백그라운드 스레드나 정적 초기화 과정에서도 호출이 일어날 경우에도 대비하여 해당 메서드가 호출될 수 있는 모든 상황을 문서로 남겨야 한다.

 

 

위는 java.util.AbstractCollection 의 remove 메서드에 작성된 API 문서이다. 위의 Implementation Requirements가 바로 메서드의 내부 동작 방식에 대해 설명하는 부분이다. 이 절은 자바 8부터 주석 내부에 @implSpec 태그를 붙여주면 자바독 도구가 생성해준다.

 

하지만 이같이 메서드의 내부 동작을 세부적으로 작성하는 것은 "좋은 API 문서란 '어떻게'가 아닌 '무엇'을 하는지를 설명해야 한다."라는 격언과 대치된다. 하지만 상속이 캡슐화를 해치기 때문에 안전한 상속을 위해서는 어쩔 수 없다.

 

 

2. 클래스의 내부 동작 과정에 끼어들 수 있는 훅(hook)을 잘 선별하여 protected 메서드 형태로 공개해야 할 것을 고려해야 한다.

 

훅(hook): 클래스의 내부 동작 과정 중간에 끼어들 수 있는 코드

 

위는 java.util.AbstractList의 removeRange 메서드의 API 문서이다.

 

List 구현체의 최종 사용자는 위 메서드에 관심이 없다. 그럼에도 removeRange가 protected로 제공된 이유는 부분리스트의 clear 메서드를 고성능으로 만들기 쉽게 하기 위해서이다. 만약 해당 메서드가 없었다면 하위 클래스에서 clear를 호출할 경우, O(N^2)의 성능을 가지게 되거나 아예 새로 구현해야 했을 것이다.

 

불행히도 어떠한 메서드를 protected로 제공해야 한다는 명확한 기준은 존재하지 않는다. 개발자가 심사숙고하여 어떤 메서드를 노출할지 결정하고 이를 직접 하위 클래스로 만들어 검증해보아야 하는 것이 최선이다.

 

 

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

상위 클래스의 생성자는 하위 클래스의 생성자보다 먼저 실행된다. 이때, 상위 클래스의 생성자에서 하위 클래스에서 재정의한 메서드를 호출한다면 하위 클래스가 초기화되기도 전에 호출하게 되는 셈이다. 만약 그 메서드가 하위 클래스의 생성자에서 초기화하는 값에 의존한다면 개발자의 의도대로 동작하지 않게 된다.

 

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

 

위 코드는 Super의 생성자에서 재정의 가능 메서드를 호출하고 있고 이를 하위 클래스에서 재정의하고 있는 것을 보여준다. 그리고 그 재정의한 메서드는 하위 클래스에서 초기화하는 값에 의존한다. 이 코드의 실행 결과는 아래와 같다.

 

 

Sub의 인스턴스를 얻을 때 Super 생성자가 호출되는데, Sub에서 재정의한 overrideMe에서 instant의 값은 아직 초기화되지 않았기 때문에 null이 출력되는 것을 볼 수 있다. 이는 println이 null 입력을 허용하기 때문에 예외를 던지진 않았지만, 실전에서는 NullPointerException을 던질 수도 있는 코드이다.

 

 

그리고 만약 Clonable 혹은 Serializable 둘 중 하나라도 구현한 클래스를 상속 가능하도록 설계하려 한다면 확장하려는 개발자에게 많은 부담을 지우게 되므로 좋지 않은 생각이다. 그래도 둘을 구현하려 한다면, clone과 readObject 모두 직접적으로든 간접적으로든 재정의 가능 메서드를 호출해서는 안 된다. 위에서 언급한 것과 비슷한 문제를 겪게 되기 때문이다.

 

또한 Serializable의 경우 상속용 클래스가 readResolve나 wirteReplace 메서드를 갖는다면 이 메서드들은 protected로 선언해야 하위 클래스에서 무시되지 않는다.

 


상속을 고려하지 않았다면

 

상속 금지

일반적인 구체 클래스는 상속을 고려하여 설계하거나 그것을 문서화하지 않는 형태가 많고, 실제로 수많은 개발자가 그렇게 코드를 작성한다. 하지만 그대로 둔다면 언젠가 문제가 될 여지를 두는 것이므로 상속을 고려하지 않은 클래스의 경우 상속을 금지하는 편이 낫다. 따라서 아래와 같은 두 가지 방법 중 하나를 수행하도록 하자.

 

1. 클래스를 final로 선언한다. (더 쉽고 간결한 방법)

2. 모든 생성자를 private이나 package-private으로 선언하고 public 정적 팩토리를 제공한다.

 

표준 인터페이스를 구현하지 않았다면

구현된 클래스가 표준 인터페이스를 만족하지 않는 상태에서 상속을 금지하면 불편해지는 경우가 있다. 이 같은 이유로 상속을 허용하려 한다면 아래와 같이 하자. 그러면 상속하여 메서드를 재정의해도 다른 메서드의 동작에 아무 영향을 끼치지 않기 때문에 보다 안전한 클래스를 만들 수 있다.

 

1. 클래스 내부에서 재정의 가능 메서드를 호출하는 자기사용 코드를 완벽히 제거한다.

2. 위 사실을 문서화한다.

 

 


정리

 

1. 클래스 내부의 자기사용 패턴을 모두 문서로 남겨야 하며, 문서화된 것은 클래스가 쓰이는 한 반드시 지켜져야 한다.

2. 하위 클래스 구현 시 유연함을 위해 일부 메서드를 protected로 제공할 것을 고려해야 한다.

3. 상속을 고려하지 않았다면 상속을 금지하는 편이 낫다.

 

 

 

 

 

반응형