본문으로 건너뛰기
안동민 개발노트 아이콘

안동민 개발노트

본문 시작
9장 : 고급 타입 모델링

sealed 타입으로 계층 닫기

인터페이스는 기본적으로 누구나 새 구현을 추가할 수 있지만 업무 상태처럼 가능한 하위 타입을 제한하고 싶을 수 있습니다.

sealed 클래스와 인터페이스는 permits 목록이나 같은 컴파일 단위의 선언으로 직접 하위 타입을 통제합니다.

허용된 하위 타입은 final, sealed, non-sealed 가운데 하나를 선택해 다음 확장 정책을 밝혀야 합니다.

닫힌 계층은 패턴 switch가 모든 경우를 검사하도록 돕습니다.

이 절에서는 확장 가능성을 타입 선언에 기록합니다.

닫힌 계층의 장점은 타입 수가 적다는 데 있지 않습니다. 컴파일러가 “현재 가능한 변형의 전체 집합”을 알 수 있다는 데 있습니다. 이 정보가 있어야 switch의 누락을 컴파일 오류로 바꾸고, 새 상태를 추가한 사람이 영향을 받는 모든 분기를 다시 살펴보게 할 수 있습니다.


sealed 선언이 직접 하위 타입을 제한한다

허용 목록 밖의 클래스는 상속하거나 구현할 수 없습니다.

라이브러리 외부 확장을 의도한 공개 SPI에는 맞지 않을 수 있습니다.


하위 타입이 다음 확장 정책을 선택한다

final은 끝내고 sealed는 다시 목록을 제한하며 non-sealed는 그 아래를 엽니다.

계층의 어느 지점에서 외부 확장을 허용하는지 보입니다.

SealedResults.java는 성공과 실패를 별도 record에 담고, 닫힌 Result 계층을 빠짐없이 switch합니다.

SealedResults.java
public class SealedResults {
    sealed interface Result permits Success, Failure { }
    record Success(String value) implements Result { }
    record Failure(String reason) implements Result { }

    static String message(Result result) {
        return switch (result) {
            case Success success -> "ok:" + success.value();
            case Failure failure -> "error:" + failure.reason();
        };
    }

    public static void main(String[] args) {
        System.out.println(message(new Success("saved")));
        System.out.println(message(new Failure("disk full")));
    }
}

첫 줄은 Success의 값만, 둘째 줄은 Failure의 이유만 꺼냅니다. 하나의 결과 객체에 valuereason을 모두 nullable 필드로 두었다면 생겼을 “성공인데 이유도 있음” 같은 모순 상태가 타입 단계에서 사라집니다.

ok:saved
error:disk full

Result 계층이 닫혀 있어 default 없이도 두 상태를 모두 처리하는 switch가 됩니다.


닫힌 계층은 상태별 데이터를 분리한다

성공과 실패처럼 서로 다른 필드를 가진 상태를 하나의 nullable 클래스에 넣는 대신 하위 record로 분리할 수 있습니다.

final, sealed, non-sealed가 각각 다음 하위 단계의 확장을 어떻게 닫거나 여는지 비교합니다.

sealed class Command permits Start, Stop { }
final class Start extends Command { }
non-sealed class Stop extends Command { }

class DelayedStop extends Stop { } // non-sealed 아래는 다시 열림

// class Pause extends Command { }
// 컴파일 오류: permits 목록에 없는 직접 하위 타입

완전한 switch가 새 타입 추가를 감지한다

모든 허용 타입을 다루면 default 없이 결과를 만들 수 있습니다.

permits 변경 시 빠진 분기를 컴파일러가 알려 줍니다.

연습으로 record Pending(int percent) implements Result를 permits 목록에 추가해 보세요. messageswitch가 먼저 컴파일되지 않는 것이 정상입니다. default로 덮기보다 Pending 분기를 명시하면 새 상태를 도입할 때 어떤 소비 코드가 함께 바뀌어야 하는지 드러납니다.

반대로 외부 플러그인이 임의의 구현을 계속 추가해야 하는 SPI라면 sealed는 맞지 않습니다. 가능한 상태를 소유한 도메인 모델에는 닫힌 계층을, 구현 확장이 계약인 라이브러리 경계에는 열린 인터페이스를 선택합니다.


sealed 타입은 상속을 막기 위한 장치가 아니라 가능한 변형을 알고 있는 닫힌 모델을 만드는 장치입니다.