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

안동민 개발노트

본문 시작
36장 : Java 25 안정 기능

sealed 계층과 상태 분기

Java 25 안정 기능인 `sealed` `class`·인터페이스로 허용 하위 타입을 닫고 빠짐없는 `switch`가 도메인 변화 누락을 컴파일 시점에 드러내게 합니다.

sealed classsealed 인터페이스는 Java 17에서 정식 도입돼 Java 25에서 프리뷰 플래그 없이 컴파일됩니다. 상속 가능한 하위 타입 집합을 permits로 제한해 컴파일러와 독자가 계층 전체를 알 수 있습니다. record와 결합하면 각 하위 타입은 필요한 데이터를 값으로 담고, 패턴 switch는 닫힌 합집합을 빠짐없이 처리합니다.

permits에 없는 하위 타입

아래 DeleteNoteStudyCommand가 직접 허용한 하위 타입이 아니므로 컴파일되지 않습니다. 이 제한은 외부 모듈이 임의의 명령을 추가해 빠짐없는 처리기를 우회하는 일을 막습니다.

lab/UnpermittedStudyCommandFailure.java
public final class UnpermittedStudyCommandFailure {
    private sealed interface StudyCommand permits AddNote, CompleteNote {}

    private record AddNote(String title) implements StudyCommand {}

    private record CompleteNote(long id) implements StudyCommand {}

    private record DeleteNote(long id) implements StudyCommand {}

    public static void main(String[] args) {
        StudyCommand command = new DeleteNote(1);
        System.out.println(command);
    }
}

원칙은 실제로 닫힌 도메인에 sealed를 적용하는 것입니다. 플러그인처럼 제3자가 하위 타입을 추가해야 하는 확장점에는 일반 인터페이스와 서비스 제공자 구조가 더 적합합니다.

sealed 하위 타입 규칙

허용된 class는 상속 정책을 다시 명시해야 합니다. record는 암묵적으로 final이므로 리프에 적합합니다. 중간 분기를 다시 sealed로 좁히거나 특정 분기만 non-sealed로 열 수 있습니다.

src/SealedStudyEventHierarchy.java
public final class SealedStudyEventHierarchy {
    private sealed interface StudyEvent permits SessionEvent, PlanChanged {}

    private sealed interface SessionEvent extends StudyEvent
            permits SessionStarted, SessionCompleted {}

    private record SessionStarted(long sessionId, String topic) implements SessionEvent {}

    private record SessionCompleted(long sessionId, int minutes) implements SessionEvent {}

    private record PlanChanged(String title) implements StudyEvent {}

    static String describe(StudyEvent event) {
        return switch (event) {
            case SessionStarted started -> "start:" + started.topic();
            case SessionCompleted completed -> "complete:" + completed.minutes();
            case PlanChanged changed -> "plan:" + changed.title();
        };
    }

    public static void main(String[] args) {
        System.out.println(describe(new SessionStarted(1, "sealed")));
        System.out.println(describe(new SessionCompleted(1, 45)));
        System.out.println(describe(new PlanChanged("Java 25")));
    }
}

switchdefault가 없어도 컴파일러는 모든 허용된 리프가 처리됐음을 압니다. 새 하위 타입을 계층에 추가하면 관련 switch가 컴파일 오류로 알려 마이그레이션 목록이 됩니다.

하위 타입과 switch 책임

객체 다형성은 각 하위 타입이 동작을 구현하므로 하위 타입을 추가하기 쉽고 새 연산을 추가하려면 모두 수정합니다. sealed 데이터와 외부 switch는 연산을 추가하기 쉽지만 하위 타입을 추가할 때 모든 연산을 점검합니다. 변화 축에 맞춥니다.

src/SealedResultOperations.java
public final class SealedResultOperations {
    private sealed interface SaveResult permits Saved, Rejected, Failed {}

    private record Saved(long id) implements SaveResult {}

    private record Rejected(String reason) implements SaveResult {}

    private record Failed(Throwable cause) implements SaveResult {}

    static int httpStatus(SaveResult result) {
        return switch (result) {
            case Saved ignored -> 201;
            case Rejected ignored -> 400;
            case Failed ignored -> 500;
        };
    }

    static String message(SaveResult result) {
        return switch (result) {
            case Saved saved -> "saved id=" + saved.id();
            case Rejected rejected -> "rejected: " + rejected.reason();
            case Failed failed -> "failed: " + failed.cause().getClass().getSimpleName();
        };
    }

    public static void main(String[] args) {
        for (SaveResult result :
                new SaveResult[] {
                    new Saved(7),
                    new Rejected("blank title"),
                    new Failed(new IllegalStateException("disk"))
                }) {
            System.out.println(httpStatus(result) + " " + message(result));
        }
    }
}

실패 객체에 민감한 내부 메시지가 들어 있다면 외부 응답으로 직접 노출하지 않습니다. sealed 계층은 타입의 완전성을 제공하지만 보안 정책까지 자동으로 제공하지는 않습니다.

sealed 도메인의 상태 제한

boolean loading, null 허용 데이터, null 허용 오류 세 필드로 상태를 만들면 로딩인데 데이터가 있거나 오류와 데이터가 동시에 있는 조합이 가능합니다. 하위 타입별 구성 요소를 나누면 각 상태가 필요한 데이터만 가집니다.

app/StudyViewStateModel.java
public final class StudyViewStateModel {
    private sealed interface ViewState permits Loading, Ready, Empty, ErrorState {}

    private record Loading() implements ViewState {}

    private record Ready(String title, int minutes) implements ViewState {}

    private record Empty(String message) implements ViewState {}

    private record ErrorState(String code) implements ViewState {}

    static String render(ViewState state) {
        return switch (state) {
            case Loading ignored -> "loading";
            case Ready ready -> ready.title() + " " + ready.minutes() + "m";
            case Empty empty -> empty.message();
            case ErrorState error -> "error=" + error.code();
        };
    }

    public static void main(String[] args) {
        System.out.println(render(new Loading()));
        System.out.println(render(new Ready("sealed", 50)));
        System.out.println(render(new Empty("no session")));
    }
}

바이너리 진화와 모듈

sealed 계층에 하위 타입을 추가하는 일은 기존 소비자의 빠짐없는 switch를 깨는 의도적인 변화입니다. 공개 라이브러리 API라면 메이저 버전 마이그레이션으로 다룹니다. 같은 모듈 안에서는 허용된 하위 타입의 위치가 자유롭지만, 이름 없는 모듈에서는 같은 패키지에 있어야 한다는 제약도 확인합니다.

  • 닫힘 명령·이벤트·결과·상태 모델에 적합합니다.
  • 영속성 프록시가 하위 클래스를 만들어야 하는 프레임워크와의 호환성을 확인합니다.
  • 열린 플러그인 확장점에는 sealed를 쓰지 않습니다.
  • public 계층에 default를 넣어 컴파일 오류를 피하면 새 하위 타입의 처리 누락을 숨길 수 있습니다.
  • 외부 입력을 하위 타입으로 변환하는 파서는 알 수 없는 타입 식별자를 처리하는 정책을 별도로 가져야 합니다.
빠짐없는 switch에는 언제나 default를 빼야 하나요?

내부의 닫힌 계층에서는 default를 빼 새 하위 타입 추가를 컴파일러가 알려 주게 하는 편이 유리합니다. 외부 프로토콜 문자열이나 버전이 다른 직렬화 데이터에는 알 수 없는 값이 들어올 수 있으므로 파싱 경계에 대체 경로를 둡니다. 타입 switch와 신뢰할 수 없는 입력의 디코딩을 구분합니다.

연습 문제

성공, 입력 검사 실패, 충돌 세 결과를 sealed record로 만들고 switch 식으로 사용자 메시지를 반환하세요.

해설 보기
exercise/ExhaustiveCommandResultSolution.java
public final class ExhaustiveCommandResultSolution {
    private sealed interface CommandResult permits Success, Invalid, Conflict {}

    private record Success(long noteId) implements CommandResult {}

    private record Invalid(String field) implements CommandResult {}

    private record Conflict(long currentVersion) implements CommandResult {}

    static String message(CommandResult result) {
        return switch (result) {
            case Success success -> "created:" + success.noteId();
            case Invalid invalid -> "invalid:" + invalid.field();
            case Conflict conflict -> "retry-version:" + conflict.currentVersion();
        };
    }

    public static void main(String[] args) {
        System.out.println(message(new Success(10)));
        System.out.println(message(new Invalid("title")));
        System.out.println(message(new Conflict(3)));
    }
}

종료 기준은 default 없이 컴파일되는 것입니다. 새 Unavailable 하위 타입을 permits에 추가하면 메시지 switch가 즉시 컴파일 오류가 되는지 확인합니다.