이펙티브 자바 완벽 공략 2부

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

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


아이템 19. 핵심 정리

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

아이템 18에서는 상속보다는 합성(Composition)을 사용하라는 원칙을 살펴봤다.

그렇다고 해서 상속 자체가 나쁜 기술이라는 뜻은 아니다. 상속은 객체지향 프로그래밍의 핵심적인 특징 중 하나이고, 기존 코드를 재사용하고 타입 계층을 구성하는 강력한 방법이다.

문제는 상속을 고려하지 않고 만든 클래스를 다른 클래스가 상속하기 시작할 때 발생한다.

따라서 이번 아이템의 핵심은 다음과 같다.

상속을 허용하려면 처음부터 상속을 고려해서 설계하고, 하위 클래스가 알아야 하는 동작을 명확하게 문서화해야 한다. 그렇지 않다면 상속 자체를 막는 것이 낫다.


상속을 허용하는 클래스는 일반 클래스와 다르다

일반적인 API 문서는 보통 다음 질문에 답하면 충분하다.

이 메서드는 무엇을 하는가?

예를 들어 어떤 메서드가 있다고 하자.

public void moveToSeoul() {
}

좋은 API 문서라면 보통 다음 정도를 설명한다.

서울로 이동한다.

내부적으로

몇 번 버스를 타고
어느 역에서 내려서
어떤 지하철을 타고
몇 번 출구로 나오는지

까지 설명할 필요는 없다.

왜냐하면 내부 구현은 캡슐화되어야 하기 때문이다.


하지만 상속용 클래스에서는 이야기가 달라진다

상속을 허용하고 메서드 재정의를 허용한다면 하위 클래스는 상위 클래스의 내부 동작을 어느 정도 알아야 한다.

이것은 일반적인 API 설계 원칙과 다소 충돌한다.

일반적인 API 설계는 다음을 지향한다.

What을 설명한다.
How는 숨긴다.

하지만 상속용 클래스에서는 경우에 따라 다음까지 설명해야 한다.

이 메서드는 내부적으로 어떤 메서드를 호출한다.
어떤 순서로 호출된다.
어떤 메서드를 재정의하면 다른 메서드에도 영향을 줄 수 있다.

왜냐하면 하위 클래스가 상위 클래스의 내부 호출 구조를 모르면 예상하지 못한 동작이 발생할 수 있기 때문이다.


InstrumentedHashSet 문제가 대표적인 사례다

아이템 18에서 다음과 같은 클래스를 살펴봤다.

public class InstrumentedHashSet<E>
        extends HashSet<E> {

    private int addCount = 0;

    @Override
    public boolean add(E e) {
        addCount++;
        return super.add(e);
    }

    @Override
    public boolean addAll(
            Collection<? extends E> c
    ) {
        addCount += c.size();
        return super.addAll(c);
    }
}

다음과 같이 세 개의 값을 추가한다고 생각해보자.

set.addAll(List.of("A", "B", "C"));

개발자는 자연스럽게 다음 결과를 기대한다.

addCount = 3

하지만 실제로는 6이 될 수 있다.

이유는 상위 클래스의 addAll() 내부 구현이 add()를 반복 호출하기 때문이다.

InstrumentedHashSet.addAll()

        ↓

addCount += 3

        ↓

super.addAll()

        ↓

내부적으로 add() 반복 호출

        ↓

재정의한 add() 호출

        ↓

addCount +1
addCount +1
addCount +1

        ↓

최종 결과 = 6

하위 클래스 입장에서는 상위 클래스의 내부 구현을 알아야만 올바르게 재정의할 수 있는 것이다.


상속용 클래스는 내부 구현 정보를 문서화해야 한다

상속을 고려해서 설계한 클래스라면 재정의 가능한 메서드가 다른 메서드에 어떤 영향을 주는지 문서로 알려줘야 한다.

예를 들어 addAll()이 내부적으로 add()를 사용한다면 다음과 같은 정보가 필요할 수 있다.

이 메서드는 컬렉션의 각 요소에 대해 add()를 호출한다.

그러면 하위 클래스를 만드는 개발자는 다음 사실을 알 수 있다.

add()를 재정의하면
addAll()의 동작에도 영향을 줄 수 있다.

상속에서는 이런 내부 구현 관계 자체가 하위 클래스에게 중요한 계약의 일부가 된다.


@implSpec이란?

Java API 문서에서는 구현 요구사항을 표현하기 위해 @implSpec 태그를 사용할 수 있다.

예를 들어 다음과 같이 작성할 수 있다.

/**
 * 요소를 추가한다.
 *
 * @implSpec
 * 이 구현은 내부적으로 특정 연산을 수행한다.
 */
public boolean add(E e) {
    return true;
}

@implSpec은 일반적인 Java 애노테이션이 아니다.

@Override
@Deprecated

처럼 컴파일러가 처리하는 애노테이션이 아니라 Javadoc에서 사용하는 문서 태그다.


@implSpec의 목적

일반적인 문서는 API 사용자에게 다음을 알려준다.

이 메서드가 무엇을 하는가?

반면 @implSpec은 주로 구현자나 상속을 통해 확장하려는 개발자에게 다음을 알려준다.

이 구현은 어떤 방식으로 동작하는가?

즉 목적이 다르다.

일반 API 설명
        ↓
클라이언트 관점

@implSpec
        ↓
구현자 / 하위 클래스 관점

Javadoc에서 구현 요구사항을 표현하기

상속을 위한 클래스를 만들었다고 가정해보자.

public class ExtendableClass {

    /**
     * 작업을 수행한다.
     *
     * @implSpec
     * 이 구현은 doSomethingInternal()을 호출한다.
     */
    public void doSomething() {
        doSomethingInternal();
    }

    protected void doSomethingInternal() {
    }
}

이렇게 작성하면 하위 클래스를 만드는 개발자는 doSomething()이 내부적으로 어떤 확장 지점을 사용하는지 알 수 있다.


Javadoc 명령으로 문서 만들기

Javadoc은 명령어를 이용해서 직접 생성할 수도 있다.

기본적인 형태는 다음과 같다.

javadoc -d target/api <source>

-d는 생성된 API 문서를 저장할 디렉터리를 지정한다.

예를 들어

javadoc -d target/api src/main/java/...

처럼 사용할 수 있다.

실행하면 대략 다음 구조로 문서가 만들어진다.

target/
 └─ api/
     ├─ index.html
     ├─ package-summary.html
     └─ ...

브라우저로 index.html을 열면 생성된 API 문서를 확인할 수 있다.


커스텀 태그를 정의하는 방법

Javadoc에서 기본적으로 인식하지 못하는 태그를 직접 사용한다면 -tag 옵션을 사용할 수 있다.

개념적인 형태는 다음과 같다.

-tag name:location:header

즉 세 가지 정보를 지정한다.

name
location
header

예를 들어 특정 태그를 문서 전체에서 찾아 Implementation Requirements라는 제목으로 표현하도록 설정할 수 있다.

Javadoc 명령어의 다양한 옵션은 다음 명령으로 확인할 수 있다.

javadoc -h

또는 JDK 버전에 따라 도움말 옵션을 이용해 사용 가능한 옵션을 확인할 수 있다.


문서화만으로 충분하지 않다

상속을 허용하는 클래스를 잘 설계하려면 단순히 내부 구현을 문서화하는 것만으로는 부족하다.

두 번째로 중요한 것은 하위 클래스가 사용할 수 있는 확장 지점을 적절히 제공하는 것이다.

즉 다음 질문을 해야 한다.

어떤 메서드는 재정의를 허용해야 하는가?

어떤 메서드는 protected로 공개해야 하는가?

어떤 내부 기능은 하위 클래스가 직접 사용할 필요가 있는가?

protected 메서드는 신중하게 선택해야 한다

다음과 같은 클래스가 있다고 생각해보자.

public class OrderProcessor {

    public void process() {
        validate();
        execute();
    }

    protected void validate() {
    }

    protected void execute() {
    }
}

validate()execute()protected로 공개했다는 것은 사실상 하위 클래스에게 다음 확장 지점을 제공한 것이다.

검증 과정 변경 가능
실행 과정 변경 가능

그런데 모든 내부 메서드를 무작정 protected로 만들면 어떻게 될까?

하위 클래스가 상위 클래스 내부 구조에 지나치게 강하게 의존하게 된다.

따라서 정말 필요한 확장 지점만 선별해서 공개해야 한다.


protected도 사실상 API다

private 메서드는 자유롭게 변경할 수 있다.

하지만 protected 메서드는 다르다.

protected void validate() {
}

누군가 이 클래스를 상속해서 validate()를 사용하거나 재정의하기 시작하면 해당 메서드는 사실상 외부 계약이 된다.

즉 다음 변경이 어려워진다.

메서드 삭제
이름 변경
파라미터 변경
호출 순서 변경
의미 변경

따라서 protected 메서드를 추가하는 것은 단순히 접근 범위를 한 단계 넓히는 것이 아니다.

상속 API를 하나 추가하는 것이라고 생각해야 한다.


어떤 protected 메서드가 필요한지는 직접 상속해봐야 안다

상속용 클래스에서 어떤 메서드를 protected로 제공해야 하는지 정답을 공식처럼 정하기는 어렵다.

클래스의 역할과 확장 방식마다 다르기 때문이다.

따라서 가장 현실적인 방법은 직접 하위 클래스를 만들어보는 것이다.

상위 클래스 설계

        ↓

하위 클래스 구현

        ↓

확장하기 불편한 부분 발견

        ↓

protected API 조정

최소 세 개 정도의 하위 클래스를 만들어본다

상속을 고려해 설계한 클래스라면 실제로 여러 하위 클래스를 구현해보면서 설계가 적절한지 검증할 수 있다.

예를 들어 다음과 같이 만들어볼 수 있다.

SubClassA
SubClassB
SubClassC

다양한 방식으로 확장해보면 다음과 같은 문제가 보인다.

이 메서드는 protected가 필요하다.

이 메서드는 오히려 노출하면 위험하다.

이 호출 순서는 확장하기 어렵다.

이 부분은 template method로 분리해야 한다.

실제 사용 사례를 만들어봐야 확장 API의 품질을 확인할 수 있다.


가능하면 다른 개발자가 직접 상속해보게 한다

클래스를 만든 개발자가 직접 하위 클래스를 만들면 자신의 설계 의도를 이미 알고 있기 때문에 문제를 발견하기 어렵다.

설계자
 ↓
설계 의도를 모두 알고 있음
 ↓
자연스럽게 올바른 방식으로 사용

반면 다른 개발자는 문서와 API만 보고 사용한다.

다른 개발자
 ↓
공개된 API와 문서만 확인
 ↓
실제 사용성 검증

따라서 적어도 하나 정도는 다른 개발자가 상속해서 사용해보는 것이 도움이 된다.

이 과정을 통해 문서가 충분한지, protected 확장 지점이 적절한지 검증할 수 있다.


생성자에서는 재정의 가능한 메서드를 호출하면 안 된다

상속을 고려할 때 가장 중요한 규칙 중 하나다.

상속용 클래스의 생성자에서는 직접적이든 간접적이든 재정의 가능한 메서드를 호출해서는 안 된다.

이 규칙을 위반하면 아직 초기화되지 않은 하위 클래스 상태가 사용될 수 있다.


문제가 되는 상위 클래스

다음 클래스를 보자.

public class Super {

    public Super() {
        overrideMe();
    }

    public void overrideMe() {
    }
}

생성자에서 다음 메서드를 호출하고 있다.

overrideMe();

문제는 overrideMe()가 하위 클래스에서 재정의 가능하다는 것이다.


하위 클래스를 만들어보자

public class Sub extends Super {

    private final String value;

    public Sub() {
        this.value = "Hello";
    }

    @Override
    public void overrideMe() {
        System.out.println(value);
    }
}

코드 자체만 보면 이상해 보이지 않는다.

value를 생성자에서 초기화하고 overrideMe()에서 출력한다.

그런데 다음 코드를 실행하면 문제가 생긴다.

Sub sub = new Sub();

하위 클래스 생성자는 상위 클래스 생성자를 먼저 호출한다

실제 초기화 흐름을 보면 문제를 이해할 수 있다.

new Sub()

    ↓

Sub 생성자 시작

    ↓

Super 생성자 호출

    ↓

overrideMe() 호출

    ↓

Sub.overrideMe() 실행

    ↓

value 아직 초기화되지 않음

    ↓

null 출력

    ↓

Super 생성자 종료

    ↓

Sub 생성자 계속 실행

    ↓

value = "Hello"

즉 하위 클래스 필드가 초기화되기 전에 재정의 메서드가 호출되는 것이다.


실제 문제 코드

public class Super {

    public Super() {
        overrideMe();
    }

    public void overrideMe() {
    }
}
public class Sub extends Super {

    private final String value;

    public Sub() {
        value = "Hello";
    }

    @Override
    public void overrideMe() {
        System.out.println(value);
    }
}

다음 코드를 실행한다.

Sub sub = new Sub();

sub.overrideMe();

개념적인 결과는 다음과 같다.

null
Hello

첫 번째 overrideMe()Super 생성자에서 호출된다.

이 시점에는 Sub.value가 아직 초기화되지 않았다.

두 번째 호출은 객체 생성이 완료된 이후이기 때문에 정상적으로 값이 출력된다.


왜 이런 문제가 발생하는가?

Java에서는 하위 클래스 인스턴스를 생성할 때 먼저 상위 클래스의 생성 과정이 진행된다.

Object
   ↓
Super
   ↓
Sub

하지만 가상 메서드 호출은 실제 런타임 타입의 재정의된 메서드를 호출한다.

즉 아직 Sub 생성이 끝나지 않았는데도 다음 메서드는

overrideMe();

Super.overrideMe()가 아니라

Sub.overrideMe();

를 호출할 수 있다.

결국

객체 초기화 순서

vs

동적 디스패치

가 충돌하면서 문제가 발생한다.


생성자에서는 private 또는 final 메서드를 사용하는 것이 안전하다

생성자에서 어떤 메서드를 반드시 호출해야 한다면 하위 클래스가 재정의할 수 없도록 만들어야 한다.

예를 들어 private 메서드라면 재정의할 수 없다.

public class Super {

    public Super() {
        initialize();
    }

    private void initialize() {
    }
}

또는 final 메서드로 재정의를 막을 수 있다.

public final void initialize() {
}

다만 생성자 내부 로직 자체를 가능한 단순하게 유지하는 것이 좋다.


간접적인 호출도 주의해야 한다

문제는 생성자가 직접 재정의 메서드를 호출하는 경우만이 아니다.

다음처럼 중간 메서드를 거칠 수도 있다.

public Super() {
    initialize();
}

private void initialize() {
    overrideMe();
}

protected void overrideMe() {
}

겉으로 보면 생성자는 private initialize()만 호출한다.

하지만 내부적으로 다시 재정의 가능한 overrideMe()를 호출한다.

생성자
 ↓
initialize()
 ↓
overrideMe()
 ↓
하위 클래스 메서드 실행 가능

결과적으로 동일한 문제가 발생할 수 있다.

따라서 직접적이든 간접적이든 생성 과정에서 재정의 가능한 메서드가 호출되지 않는지 확인해야 한다.


clone과 직렬화에서도 비슷한 문제를 고려해야 한다

객체를 생성하는 경로는 일반 생성자만 있는 것이 아니다.

앞에서 살펴봤던 Cloneable은 기존 객체를 복제하여 새로운 객체를 만들어낸다.

clone()

직렬화에서는 역직렬화를 통해 새로운 객체 상태를 복원한다.

readObject()

이러한 과정 역시 결과적으로 객체의 생성이나 초기화와 유사한 역할을 한다.

따라서 객체 상태가 완전히 준비되기 전에 재정의 가능한 메서드가 실행되지 않도록 주의해야 한다.


상속을 허용한다는 것은 많은 책임을 의미한다

지금까지의 내용을 보면 상속을 안전하게 허용하기 위해 해야 할 일이 꽤 많다.

재정의 가능한 메서드 내부 동작 문서화

protected 확장 지점 설계

실제 하위 클래스 구현을 통한 검증

다른 개발자의 사용성 검증

생성자에서 재정의 가능 메서드 호출 금지

clone / 직렬화 같은 객체 생성 경로 주의

단순히

public class Parent {
}

라고 만든다고 해서 좋은 상속용 클래스가 되는 것이 아니다.

상속을 허용하는 순간 클래스 내부 구현 일부가 하위 클래스와의 계약이 될 수 있다.


상속을 고려하지 않았다면 막아라

반대로 상속을 고려해서 설계하고 문서화할 생각이 없다면 가장 좋은 방법은 상속 자체를 막는 것이다.

대표적인 방법은 두 가지다.


방법 1. 클래스를 final로 선언한다

가장 간단하다.

public final class PhoneNumber {

}

이 클래스는 상속할 수 없다.

class MyPhoneNumber
        extends PhoneNumber {
}

컴파일 단계에서 막힌다.


final 클래스의 장점

의도가 매우 명확하다.

이 클래스는 상속을 위한 클래스가 아니다.

개발자는 상속 관계에서 발생할 수 있는 여러 문제를 고려하지 않아도 된다.

내부 구현도 자유롭게 변경할 수 있다.


방법 2. 생성자의 접근 범위를 제한한다

클래스 자체를 final로 만들지 않고도 상속을 제한할 수 있다.

public class Complex {

    private Complex() {
    }
}

private 생성자는 클래스 외부에서 호출할 수 없다.

따라서 외부 클래스가 상속할 수도 없다.


package-private 생성자를 사용할 수도 있다

public class Complex {

    Complex() {
    }
}

생성자가 package-private이라면 같은 패키지 안에서는 상속할 수 있다.

같은 패키지
    ↓
상속 가능

다른 패키지
    ↓
상속 불가능

이 방법은 내부 구현에서는 상속을 사용하고 싶지만 외부 사용자에게는 상속을 허용하고 싶지 않을 때 사용할 수 있다.


private 생성자와 package-private 생성자의 차이

private 생성자

클래스 외부 상속 불가능

클래스 내부의 중첩 클래스 등 제한적인 위치에서만 생성자에 접근할 수 있다.

package-private 생성자

같은 패키지에서는 상속 가능
다른 패키지에서는 상속 불가능

패키지를 하나의 구현 경계로 사용할 수 있다.


final 클래스와 생성자 제한 중 무엇이 좋은가?

둘 다 사용할 수 있다.

상속을 완전히 금지하고 싶다면 가장 단순한 방법은 final이다.

public final class Money {
}

반면 내부 구현에서 다양한 하위 구현체를 만들 가능성이 있다면 생성자의 접근 범위를 제한하는 방식이 조금 더 유연할 수 있다.

public class Money {

    Money() {
    }
}

외부 사용자는 상속할 수 없지만 같은 패키지에서는 내부 구현 목적으로 상속할 수 있다.


상속을 허용할지 결정하는 기준

클래스를 만들 때 다음과 같은 질문을 해볼 수 있다.

이 클래스가 정말 상속될 필요가 있는가?

하위 클래스가 사용할 확장 지점을 정의할 수 있는가?

재정의 가능한 메서드의 동작을 문서화할 수 있는가?

상위 클래스 내부 구현 변경이 하위 클래스에 미치는 영향을 관리할 수 있는가?

직접 여러 하위 클래스를 만들어 검증할 수 있는가?

이 계약을 장기적으로 유지할 의지가 있는가?

답하기 어렵다면 상속을 열지 않는 편이 안전하다.


상속 가능성도 API 설계의 일부다

클래스가 public이라는 것은 외부에서 사용할 수 있다는 뜻이고, 클래스가 상속 가능하다는 것은 그보다 더 큰 API를 제공한다는 의미가 될 수 있다.

클라이언트가 단순히 메서드를 호출하는 것뿐 아니라 내부 동작에 끼어들 수 있기 때문이다.

일반 public API

클라이언트
   ↓
메서드 호출

상속 API는 다음과 같다.

하위 클래스
   ↓
메서드 재정의
   ↓
상위 클래스 내부 실행 과정에 개입

따라서 상속 가능 여부 역시 의도적으로 결정해야 한다.


아이템 18과 아이템 19의 관계

두 아이템은 서로 연결해서 이해하면 좋다.

아이템 18

가능하면 상속보다 합성을 사용하라.

왜냐하면 구현 상속은 캡슐화를 깨뜨릴 수 있기 때문이다.

아이템 19

그래도 상속을 허용하려면
상속을 고려해서 철저히 설계하고 문서화하라.

그럴 계획이 없다면

상속을 금지하라.

라고 정리할 수 있다.


핵심 정리

  • 상속은 강력한 코드 재사용 방법이지만 상속을 고려하지 않은 클래스를 확장하면 문제가 생길 수 있다.
  • 상속용 클래스는 일반적인 API보다 더 많은 내부 구현 정보를 공개해야 할 수 있다.
  • 재정의 가능한 메서드가 내부적으로 어떤 메서드를 호출하는지 하위 클래스가 알아야 하는 경우가 있다.
  • 이러한 구현 요구사항은 Javadoc의 @implSpec 같은 형태로 문서화할 수 있다.
  • @implSpec은 Java 애노테이션이 아니라 Javadoc 태그다.
  • 상속용 클래스에서는 하위 클래스가 사용할 확장 지점을 신중하게 선정해야 한다.
  • protected 멤버 역시 사실상 상속 API의 일부다.
  • 어떤 protected 메서드가 필요한지는 실제 하위 클래스를 여러 개 구현하면서 검증하는 것이 좋다.
  • 가능하면 설계자가 아닌 다른 개발자에게도 하위 클래스를 만들어보게 하는 것이 좋다.
  • 생성자에서는 직접적이든 간접적이든 재정의 가능한 메서드를 호출하면 안 된다.
  • 상위 클래스 생성자가 실행되는 동안 하위 클래스 상태는 아직 초기화되지 않았을 수 있다.
  • 이 상태에서 하위 클래스의 재정의 메서드가 호출되면 null이나 기본값 같은 예상치 못한 상태를 사용할 수 있다.
  • 객체를 생성하거나 복원하는 clone, 직렬화 관련 코드에서도 비슷한 주의를 기울여야 한다.
  • 상속을 고려해 설계하고 문서화하지 않을 것이라면 상속을 막는 것이 좋다.
  • 상속을 막는 가장 간단한 방법은 클래스를 final로 선언하는 것이다.
  • 생성자를 private 또는 package-private으로 제한해서 외부 상속을 막는 방법도 있다.
  • package-private 생성자는 같은 패키지 내부에서는 상속을 허용하면서 외부 패키지에는 상속을 금지할 수 있다.

한 줄 요약

상속은 단순히 extends를 허용하는 것으로 끝나는 기능이 아니라 하위 클래스와 내부 동작까지 공유하는 장기적인 계약이므로, 상속을 고려한 설계·문서화·확장 지점 검증을 할 수 없다면 final이나 제한된 생성자를 이용해 처음부터 상속을 금지하는 것이 더 안전하다.


© 2020. All rights reserved.

SIKSIK