제리 devlog

[이펙티브자바] 상속을 고려해 설계하고 문서화하라. 그러지 않았다면 상속을 금지하라 본문

Java

[이펙티브자바] 상속을 고려해 설계하고 문서화하라. 그러지 않았다면 상속을 금지하라

제리 . 2020. 12. 28. 15:48

상속의 위험성

외부 클래스를 상속할 때의 위험성은 앞선 '상속보다 컴포지션을 사용하라'라는 게시글에서 다뤘다. 여기서 외부란 프로그래머 통제권 밖에 있어서 언제 어떻게 변경될지 모른다는 뜻이다. 예를 들어 Set, List같이 프로그래머가 직접 다루지 않는 클래스들을 말한다.

 

상속은 문서화를 요구한다.

메소드를 재정의하면 어떤 일이 일어나는지를 정확히 정리하여 문서로 남겨야한다. 즉, 상속용 클래스는 재정의할 수 있는 메서드들을 내부적으로 어떻게 이용하는지 문서로 남겨야한다. 

 

상속용 클래스에서 재정의 가능한 메서드는 어떻게 동작하는지 문서화해야할 필요가 있다. 만약, 재정의 가능한 메서드가 내부의 다른 재정의 가능한 메서드를 호출해서 사용하는 상황이라면 기능이 오동작 할 수 있다.

 

ex )  HashSet클래스의 addAll메서드가 내부의 add메서드를 사용하는 경우

재정의 가능한 메서드는 final이 아닌 public, protect 메서드를 의미하고 어떤 순서로 호출되는지, 각각의 호출 결과가 이어지는 처리에 어떤 영향을 주는 지도 담아야한다. 넓게 말하면 재정의 가능 메서드를 호출할 수 있는 모든 상황을 문서화 해야한다.

 

API문서의 메서드 설명을 보면 종종 Implementation Requirements로 시작하는 절이 존재함 

메서드 주석에 @implSpec태그를 붙여주면 자바독 도구가 생성해준다(자바 8부터 도입).

java.util.AbstractCollection

Implementation Requirements의 내용은 다음과 같다.

이 메서드는 컬렉션을 순회하며 주어진 원소를 찾도록 구현되었다. 주어진 원소를 찾으면 반복자의 remove메서드를 사용해 컬렉션에서 제거한다. 이 컬렉션이 주어진 객체를 갖고 있으나, 이 컬렉션의 iterator 메서드가 반환한 반복자가 remove메서드를 구현하지 않았다면 UnsupportedOperationException을 던진다.

 이 문서와 대조적이게 HashSet은 상속하여 add를 재정의하면 addAll까지 영향을 준다는 사실을 알 수 없었다.

 

Protected 메서드 형태로 공개하라

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

 

java.util.AbstractList

이 리스트 혹은 이 리스트의 부분리스트에 정의된 clear 연산이 이 메서드를 호출한다. 리스트 구현의 내부 구조를 활용하도록 이 메서드를 재정의하면 이 리스트와 부분리스트의 clear 연산 성능을 크게 개선할 수 있다.

clear메서드가 removeRange를 호출하고 있다.

list.subList(start, end).clear();

이 메서드가 존재하는 이유는 단지 하위 클래스에서 부분리스트의 clear메서드를 고성능으로 만들기 쉽게 하기 위해서이다. removeRange메서드가 없는 경우, 하위 클래스에서 clear메서드를 호출하면 제거할 원소 수의 제곱에 비례해 성능이 느려지거나 부분리스트의 매커니즘을 밑바닥 부터 새로 구현해야 했을 것이다.

 

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

 

상속용 클래스를 시험하는 방법은 직접 하위 클래스를 만들어보는 것이 '유일'하다.  

꼭 필요한 protected 맴버를 놓쳤다면 하위 클래스를 작성할 때 그 빈자리가 확연하게 드러난다. 반대로 하위 클래스를 여러 개 만들 때까지 전혀 쓰이지않는 protected 맴버는 사실 private이었어야 할 가능성이 크다. 저자의 경험상 검증에는 하위 클래스 3개 정도가 적당하다고하며 이 중하나 이상은 제3자가 작성해봐야한다고 말한다.

 

상속용 클래스 생성자는 직접/간접적 재정의 가능 메서드 호출 금지

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

public class Super {
    //잘못된 예 - 생성자가 재정의가능 메서드를 호출한다.
    public Super() {
    	overridMe();
    }
    
    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();
    }
}
  • 상위 클래스의 생성자는 하위 클래스의 생성자가 인스턴스 필드를 초기화하기도 전에  overrideMe를 호출하기 때문에 null을 출력한다.
  • overrideMe에서 instant객체의 메서드를 호출하려 한다면 상위 클래스의 생성자가 overrideMe를 호출할 때 NullPointerException을 던지게 된다.
  • private, final, static 메서드는 재정의가 불가능하니 생성자에서 안심하고 호출해도 된다.

 

Cloneable과 Serializable 인터페이스를 상속용 클래스에 적용하지 마라

clone과 readObject는 생성자와 비슷한 효과를 낸다(객체를 만든다)  상속용 클래스에서 Cloneable이나 Serializable을 구현할지 정해야 한다면, clone과 readObject 모두 직접적,/간접적으로든 재정의 가능 메서드를 호출해서는 안된다.

 

  • readObject의 경우 하위 클래스의 상태가 미쳐 역직렬화 되기 전에 재정의한 메서드부터 호출된다.
  • clone은 하위 클래스의 clone메서드가 복제본 상태를 올바른 상태로 수정하기 전에 재정의한 메서드를 호출한다. clone이 잘못되면 더 큰문제는 깊은 복사가 이뤄지지 않아 원본객체에 타격을 줄 수 있다. 

 

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

 

 

클래스를 상속용으로 설계하려면 엄청난 노력이 들고 그 클래스에 안기는 제약도 크다. 여기에 대한 가장 좋은 방법은 상속용으로 설계하지 않은 클래스는 상속을 금지하는 것이다. 가장 쉬운 방법은 클래스를 final로 선언하는것, 두 번 쨰는 모든 생성자를 private나 package private로 선언하고 public한 정적 팩터리를 만들어주는 방법. 정적 팩터리 방법은 내부에서 다양한 하위 클래스를 만들어 쓸 수 있는 유연성을 준다.

 

상속의 대안으로는 인터페이스, 래퍼 클래스 패턴등이 있다. 상속을 금지하더라도 개발에 큰 어려움이 없을 것이다. 다만, 구체 클래스가 표준 인터페이스를 구현하지 않았는데 상속을 금지하면 사용하기 불편해진다. 이런 경우에도 상속을 꼭 허용하겠다면 클래스 내부에서는 재정의 가능 메서드를 사용하지않게 만들고 이 사실을 문서로 남기는 것이다. 

 

결론 

상속용 클래스를 설계하기 쉽지 않다. 클래스 내부에서 자신의 메서드가 어떻게 사용되는지 모두 문서로 기록해야하고 그 내용은 클래스가 사용되는 곳에서 반드시 지켜야한다. 만약 지켜지지 않는다면 내부 구현방식을 믿고 활용하던 하위 클래스가 오동작할 수 있다. 클래스를 확장해야할 명확한 이유가 떠오르지 않는다면 상속을 금지하도록 하자.

Comments