티스토리 뷰

아이템 18에서는 상속을 염두에 두지 않고 설계했고 상속할 때의 주의점도 문서화해놓지 않은 '외부' 클래스를 상속할 때의 위험을 경고했습니다.

 

아이템[18]. 상속보다는 컴포지션을 사용하라

상속은 코드를 재사용하는 강력한 수단이지만, 항상 최선은 아닙니다. 잘못 사용하면 오류를 내기 쉬운 소프트웨어를 만들게 됩니다. 상위 클래스와 하위 클래스를 모두 같은 프로그래머가 통

kkoon9.tistory.com

💡
여기서 '외부'란 프로그래머의 통제권 밖에 있어서 언제 어떻게 변경될지 모른다는 뜻입니다.

 

그렇다면 상속을 고려한 설계와 문서화란 정확히 무얼 뜻할까요?

우선, 메서드를 재정의하면 어떤 일이 일어나는지를 정확히 정리하여 문서로 남겨야 합니다.

달리 말하면, 상속용 클래스는 재정의할 수 있는 메서드들을 내부적으로 어떻게 이용하는지 문서로 남겨야 합니다.

클래스의 API로 공개된 메서드에서 클래스 자신의 또 다른 메서드를 호출할 수도 있습니다.

그런데 마침 호출되는 메서드가 재정의 가능 메서드라면 그 사실을 호출하는 메서드의 API 설명에 적시해야 합니다.

덧붙여서 어떤 순서로 호출하는지, 각각의 호출 결과가 이어지는 처리에 어떤 영향을 주는지도 담아야 합니다.

('재정의 가능'이란 public과 protected 메서드 중 final이 아닌 모든 메서드를 뜻합니다.)

더 넓게 말하면, 재정의 가능 메서드를 호출할 수 있는 모든 상황을 문서로 남겨야 합니다.

예를 들어 백그라운드 스레드나 정적 초기화 과정에서도 호출이 일어날 수 있습니다.

API 문서의 메서드 설명 끝에서 종종 "Implementation Requirements"로 시작하는 절을 볼 수 있습니다.

그 절은 그 메서드의 내부 동작 방식을 설명하는 곳입니다.

이 절은 메서드 주석에 @implSpec 태그를 붙여주면 자바독 도구가 생성해줍니다.

다음은 java.util.AbstractCollection에서 발췌한 예입니다.

public boolean remove(Object o)

주어진 원소가 이 컬렉션 안에 있다면 그 인스턴스를 하나 제거한다.(선택적 동작)
더 정확하게 말하면 이 컬렉션 안에 'Object.equals(o, e)가 참인 원소' e가 하나 이상 있다면 그 중 하나를 제거한다.
주어진 원소가 컬렉션 안에 있었다면(즉, 호출 결과 이 컬렉션이 변경 됐다면) true를 반환한다.

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

이 설명에 따르면 iterator 메서드를 재정의하면 remove 메서드의 동작에 영향을 줌을 확실히 알 수 있습니다.

iterator 메서드로 얻은 반복자의 동작이 remove 메서드의 동작에 주는 영향도 정확히 설명했습니다.

아이템 18에서는 HashSet을 상속하여 add를 재정의한 것이 addAll에까지 영향을 준다는 사실을 알 수 없었는데, 아주 대조적입니다.

하지만 이런 식은 "좋은 API 문서란 '어떻게'가 아닌 '무엇'을 하는지를 설명해야 한다'라는 격언과는 대치됩니다.

상속이 캡슐화를 해치기 때문에 일어나는 안타까운 현실입니다.

클래스를 안전하게 상속할 수 있도록 하려면 (상속만 아니었다면 기술하지 않았어야 할) 내부 구현 방식을 설명해야만 합니다.

@implSpec 태그는 자바 8에서 처음 도입되어 자바 9부터 본격적으로 사용되기 시작했습니다.

이 태그가 기본값으로 활성화되어야 바람직하다고 생각하지만 자바 11의 자바독에서도 선택사항으로 남겨져 있습니다.

이 태그를 활성화하려면 명령줄 매개변수로 -tag "implSpec:a Implementaion Requirements:"를 지정해주면 됩니다.

이처럼 내부 메커니즘을 문서로 남기는 것만이 상속을 위한 설계의 전부는 아닙니다.

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

드물게는 protected 필드로 공개해야 할 수도 있습니다.

java.util.AbstractList의 removeRange 메서드를 예로 살펴봅시다.

protected void removeRange(int fromIndex, int toIndex)

fromIndex(포함)부터 toIndex(미포함)까지의 모든 원소를 이 리스트에서 제거한다.
toIndex 이후의 원소들은 앞으로 (index만큼씩) 당겨진다.
이 호출로 리스트는 'toIndex - fromIndex'만큼 짧아진다.
이 리스트 혹은 이 리스트의 부분리스트에 정의된 clear 연산이 이 메서드를 호출한다.
리스트 구현의 내부 구조를 활용하도록 이 메서드를 재정의하면 이 리스트와 부분리스트의 clear 연산 성능을 크게 개선할 수 있다.

Implementation Requirements: 이 메서드는 fromIndex에서 시작하는 리스트 반복자를 얻어 모든 원소를
제거할 때까지 ListIterator.next와 ListIterator.remove를 반복 호출하도록 구현되었다.

주의: ListIterator.remove가 선형 시간이 걸리면 이 구현의 성능은 제곱에 비례한다.

List 구현체의 최종 사용자는 removeRange 메서드에 관심이 없습니다.

그럼에도 이 메서드를 제공한 이유는 단지 하위 클래스에서 부분리스트의 clear 메서드를 고성능으로 만들기 쉽게 하기 위해서입니다.

removeRange 메서드가 없다면 하위 클래스에서 clear 메서드를 호출하면 (제거할 원소 수의) 제곱에 비례해 성능이 느려지거나 부분리스트의 메커니즘을 밑바닥부터 새로 구현해야 했을 것입니다.

🤔
그렇다면 상속용 클래스를 설계할 때 어떤 메서드를 protected로 노출해야 할지는 어떻게 결정할까요?

 

안타깝게도 완벽한 방법은 없습니다.

심사숙고해서 잘 예측해본 다음, 실제 하위 클래스를 만들어 시험해보는 것이 최선입니다.

protected 메서드 하나하나가 내부 구현에 해당하므로 그 수는 가능한 한 적어야 합니다.

한편으로는 너무 적게 노출해서 상속으로 얻는 이점마저 없애지 않도록 주의해야 합니다.

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

꼭 필요한 protected 멤버를 놓쳤다면 하위 클래스를 작성할 때 그 빈자리가 확연히 드러납니다.

거꾸로, 하위 클래스를 여러 개 만들 때까지 전혀 쓰이지 않는 protected 멤버는 사실 private이었어야 할 가능성이 큽니다.

책에서는 이러한 검증에 하위 클래스 3개 정도가 적당하다고 합니다.

그리고 이 중 하나 이상은 제 3자가 작성해봐야 한다고 합니다.

널리 쓰일 클래스를 상속용으로 설계한다면 여러분이 문서화한 내부 사용 패턴과, protected 메서드와 필드를 구현하면서 선택한 결정에 영원히 책임져야 함을 잘 인식해야 합니다.

이 결정들이 그 클래스의 성능과 기능에 영원한 족쇄가 될 수 있습니다.

그러니 상속용으로 설계한 클래스는 배포 전에 반드시 하위 클래스를 만들어 검증해야 합니다.

또한, 상속하려는 사람을 위해 덧붙인 설명은 단순히 그 클래스의 인스턴스만 만들어 사용할 프로그래머에게는 필요 없는 군더더기일 뿐입니다.

상속을 허용하는 클래스가 지켜야 할 제약이 아직 몇 개 남았습니다.

상속용 클래스의 생성자는 직접적으로든 간접적으로든 재정의 가능 메서드를 호출해서는 안 됩니다.

이 규칙을 어기면 프로그램이 오동작할 것입니다.

상위 클래스의 생성자가 하위 클래스의 생성자보다 먼저 실행되므로 하위 클래스에서 재정의한 메서드가 하위 클래스의 생성자보다 먼저 호출됩니다.

이 때 그 재정의한 메서드가 하위 클래스의 생성자에서 초기화하는 값에 의존한다면 의도대로 동작하지 않을 것입니다.

이 규칙을 어기는 코드를 준비했으니 구체적인 모습을 확인해봅시다.

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

    public void overrideMe() {}
}

다음은 하위 클래스의 코드로, 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 필드의 상태가 이 프로그램에서는 두 가지임에 주목합시다. (정상이라면 단 하나뿐이어야 합니다.)

overrideMe에서 instant 객체의 메서드를 호출하려 한다면 상위 클래스의 생성자가 overrideMe를 호출할 때 NullPointerException을 던지게 됩니다.

이 프로그램이 NullPointerException을 던지지 않은 유일한 이유는 println이 null 입력도 받아들이기 때문입니다.

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

</aside>

Cloneable과 Serializable 인터페이스는 상속용 설계의 어려움을 한층 더해줍니다.

둘 중 하나라도 구현한 클래스를 상속할 수 있게 설계하는 것은 일반적으로 좋지 않은 생각입니다.

그 클래스를 확장하려는 프로그래머에게 엄청난 부담을 지우기 때문입니다.

물론 이 인터페이스들을 하위 클래스에서 구현하도록 하는 특별한 방법도 있습니다.

이 방법들은 아이템 13아이템 86에서 설명합니다.

clone과 readObject 메서드는 생성자와 비슷한 효과를 냅니다. (새로운 객체를 만듭니다.)

따라서 상속용 클래스에서 Cloneable이나 Serializable을 구현할지 정해야 한다면, 이들을 구현할 때 따르는 제약도 생성자와 빗스하다는 점에 주의합시다.

즉, clone과 readObject 모두 직접적으로든 간접적으로든 재정의 가능 메서드를 호출해서는 안 됩니다.

readObject의 경우 하위 클래스의 상태가 미처 다 역직렬화되기 전에 재정의한 메서드부터 호출하게 됩니다.

clone의 경우 하위 클래스의 clone 메서드가 복제본의 상태를 수정하기 전에 재정의한 메서드를 호출합니다.

어느 쪽이든 프로그램 오작동으로 이어질 것입니다.

특히 clone이 잘못되면 복제본뿐 아니라 원본 객체에도 피해를 줄 수 있습니다.

예를 들어 재정의한 메서드에서 원본 객체의 깊숙한 내부 자료구조까지 복제본으로 완벽히 복사됐다고 가정하고 복제본을 수정했다고 합시다.

그런데 사실은 clone이 완벽하지 못했어서 복제본의 내부 어딘가에서 여전히 원본 객체의 데이터를 참조하고 있다면 원본 객체도 피해를 입는 것입니다.

마지막으로, Serializable을 구현한 상속용 클래스가 readResolve나 writeReplace 메서드를 갖는다면 이 메서드들은 private이 아닌 protected로 선언해야 합니다.

private으로 선언한다면 하위 클래스에서 무시되기 때문입니다.

이 역시 상속을 허용하기 위해 내부 구현을 클래스 API로 공개하는 예 중 하나입니다.

이제 클래스를 상속용으로 설계하려면 엄청난 노력이 들고 그 클래스에 안기는 제약도 상당함을 알았습니다.

절대 가볍게 생각하고 정할 문제가 아닙니다.

추상 클래스나 인터페이스의 골격 구현(아이템 20)처럼 상속을 허용하는 게 명백히 정당한 상황이 있고, 불변 클래스(아이템 17)처럼 명백히 잘못된 상황이 있습니다.

🤔
그렇다면 그 외의 일반적인 구체 클래스는 어떨까요?

 

전통적으로 이런 클래스는 final도 아니고 상속용으로 설계되거나 문서화되지도 않았습니다.

하지만 그대로 두면 위험합니다.

클래스에 변화가 생길 때마다 하위 클래스를 오동작하게 만들 수 있기 때문입니다.

실제로도 보통의 구체 클래스를 그 내부만 수정했음에도 이를 확장한 클래스에서 문제가 생겼다는 버그 리포트를 받는 일이 드물지 않습니다.

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

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

둘 중 더 쉬운 쪽은 클래스를 final로 선언하는 방법입니다.

두 번째 선택지는 모든 생성자를 private이나 package-private으로 선언하고 public 정적 팩터리를 만들어주는 방법입니다.

정적 팩터리 방법은 내부에서 다양한 하위 클래스를 만들어 쓸 수 있는 유연성을 주며, 이와 관련해서는 아이템 17에서 다뤘습니다.

둘 중 어느 방식이든 좋습니다.

이 조언은 다소 논란의 여지가 있습니다.

그 동안 수많은 프로그래머가 일반적인 구체 클래스를 상속해 계측, 통지, 동기화, 기능 제약 등을 추가해왔을 테니 말입니다.

핵심 기능을 정의한 인터페이스가 있고, 클래스가 그 인터페이슬르 구현했다면 상속을 금지해도 개발하는 데 아무런 어려움이 없을 것입니다.

Set, List, Map이 좋은 예입니다.

아이템 18에서 설명한 래퍼 클래스 패턴 역시 기능을 증강할 때 상속 대신 쓸 수 있는 더 나은 대안이라 하겠습니다.

구체 클래스가 표준 인터페이스를 구현하지 않았는데 상속을 금지하면 사용하기에 상당히 불편해집니다.

이런 클래스라도 상속을 꼭 허용해야겠다면 합당한 방법이 하나 있습니다.

클래스 내부에서는 재정의 가능 메서드를 사용하지 않게 만들고 이 사실을 문서로 남기는 것입니다.

재정의 가능 메서드를 호출하는 자시 사용 코드를 완벽히 제거하라는 말입니다.

이렇게 하면 상속해도 그리 위험하지 않은 클래스를 만들 수 있습니다.

메서드를 재정의해도 다른 메서드의 동작에 아무런 영향을 주지 않기 때문입니다.

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

먼저 각각의 재정의 가능 메서드는 자신의 본문 코드를 private '도우미 메서드'로 옮기고, 이 도우미 메서드를 호출하도록 수정합니다.

그런 다음 재정의 가능 메서드를 호출하는 다른 코드들도 모두 이 도우미 메서드를 직접 호출하도록 수정하면 됩니다.

정리

  • 상속용 클래스를 설계하기란 결코 만만치 않다.
  • 클래스 내부에서 스스로를 어떻게 사용하는지 모두 문서로 남겨야 하며, 일단 문서화한 것은 그 클래스가 쓰이는 한 반드시 지켜야 한다.
  • 그렇지 않으면 그 내부 구현 방식을 믿고 활용하던 하위 클래스를 오동작하게 만들 수 있다.
  • 다른 이가 효율 좋은 하위 클래스를 만들 수 있도록 일부 메서드를 protected로 제공해야 할 수도 있다.
  • 그러니 클래스를 확장해야 할 명확한 이유가 떠오르지 않으면 상속을 금지하는 편이 나을 것이다.
  • 상속을 금지하려면 클래스를 final로 선언하거나 생성자 모두를 외부에서 접근할 수 없도록 만들면 된다.
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2024/11   »
1 2
3 4 5 6 7 8 9
10 11 12 13 14 15 16
17 18 19 20 21 22 23
24 25 26 27 28 29 30
글 보관함