본문 바로가기

sealed 계층과 상태 분기

sealed 인터페이스로 구현할 수 있는 타입을 미리 정해 두고, record와 switch를 함께 써서 처리하지 않은 경우가 있으면 컴파일러가 알려 주게 만드는 방법을 배웁니다.

게시글 저장은 세 가지로 끝날 수 있다고 해 봅시다. 저장에 성공해 새 글 번호를 받거나, 제목이 비어 있어 거절되거나, 디스크 오류로 실패합니다. 세 결과는 담는 데이터도 다릅니다. 성공은 번호를, 거절은 이유를, 실패는 예외 객체를 가져야 합니다. enum으로 정해진 값만 갖는 타입 만들기에서 배운 enum은 종류를 셋으로 닫을 수는 있지만, 각 상수는 프로그램 전체에 하나씩만 존재하므로 저장할 때마다 다른 글 번호나 예외 객체를 담을 수 없습니다. 그렇다고 SaveResult 인터페이스를 만들고 결과마다 구현 클래스를 두면, 누구든 네 번째 구현을 추가할 수 있습니다. 그래서 결과를 처리하는 switch는 모르는 구현에 대비해 default를 둘 수밖에 없고, 처리할 경우를 하나 빠뜨려도 컴파일러는 알려 주지 못합니다.

sealed 인터페이스는 이 문제를 풉니다. 인터페이스를 선언할 때 구현할 수 있는 타입을 직접 나열하면, 컴파일러는 그 인터페이스의 값이 목록에 있는 타입 가운데 하나라는 것을 압니다. 그러면 switch가 목록의 모든 타입을 다뤘는지 검사할 수 있고, 하나라도 빠지면 컴파일 오류를 냅니다. enum이 값의 종류를 닫는다면 sealed는 타입의 종류를 닫는 셈이고, 각 타입은 record로 자기에게 필요한 데이터를 따로 가집니다. sealed는 Java 17(JEP 409), switch의 패턴 매칭은 Java 21(JEP 441)에 정식 기능이 되었으므로 JDK 25에서는 아무 옵션 없이 씁니다.

permits에 적은 타입만 구현할 수 있습니다

봉인된(sealed) 인터페이스는 sealed 수식어를 붙이고, permits 뒤에 구현을 허용할 타입을 적어 선언합니다. 다음 프로그램의 BoardCommand는 게시판 명령을 나타내며 AddNote와 CompleteNote 두 타입만 허용합니다. 그런데 아래쪽의 DeleteNote도 BoardCommand를 구현하려고 합니다.

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

    private record AddNote(String title) implements BoardCommand {}

    private record CompleteNote(long id) implements BoardCommand {}

    private record DeleteNote(long id) implements BoardCommand {}

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

이 파일은 컴파일되지 않습니다. 오류가 가리키는 곳은 DeleteNote를 사용하는 main이 아니라 DeleteNote를 선언한 줄입니다.

UnpermittedBoardCommandFailure.java:8: error: class is not allowed to extend sealed class: BoardCommand (as it is not listed in its 'permits' clause)
    private record DeleteNote(long id) implements BoardCommand {}
            ^
1 error

오류 메시지는 DeleteNote가 permits 목록에 없어서 BoardCommand를 구현할 수 없다고 알려 줍니다. 이 검사는 선언 단계에서 일어나기 때문에, 컴파일된 프로그램 안의 BoardCommand 값은 언제나 AddNote 아니면 CompleteNote입니다. 다른 패키지나 다른 개발자가 새 명령을 몰래 추가해 BoardCommand를 처리하는 코드를 우회할 길이 없다는 뜻이기도 합니다. DeleteNote를 정말 명령으로 추가하려면 permits 목록에 넣어야 하고, 그 순간 BoardCommand를 다루는 switch가 새 타입을 처리하는지도 함께 검사받습니다. 이 과정은 셋째 절에서 확인합니다.

따라서 sealed는 종류가 실제로 닫혀 있는 곳에 씁니다. 저장 결과, 화면 상태, 프로토콜 명령처럼 우리 코드가 모든 경우를 알고 있어야 하는 곳입니다. 반대로 플러그인처럼 다른 사람이 구현을 추가하는 것이 목적인 곳에는 일반 인터페이스가 맞습니다. 그런 구조는 모듈 시스템과 ServiceLoader에서 다룹니다.

허용된 하위 타입이 지켜야 할 규칙

permits에 적힌 타입에도 규칙이 있습니다. 첫째, 허용된 타입은 봉인된 타입을 직접 구현하거나 상속해야 합니다. 둘째, 허용된 타입이 클래스라면 자기 아래를 어떻게 할지 final, sealed, non-sealed 가운데 하나로 밝혀야 합니다. final은 더 이상 상속을 허용하지 않고, sealed는 자기 permits 목록으로 한 번 더 제한하며, non-sealed는 그 갈래부터 다시 누구나 상속할 수 있게 엽니다. 셋 중 아무것도 적지 않은 static class Circle implements Shape {}는 sealed, non-sealed or final modifiers expected 오류가 납니다. 허용된 타입이 인터페이스라면 final을 붙일 수 없으므로 sealed나 non-sealed를 적습니다. record는 암묵적으로 final이므로 따로 적을 필요가 없고, 그래서 봉인된 계층의 맨 끝을 채우기에 알맞습니다.

허용된 타입이 있을 수 있는 위치도 정해져 있습니다. 이름 있는 모듈 안이라면 같은 모듈의 다른 패키지에 있어도 되지만, 모듈을 쓰지 않는 프로그램(이름 없는 모듈)에서는 봉인된 타입과 같은 패키지에 있어야 합니다. 또 이 글의 예제처럼 허용된 타입이 모두 같은 소스 파일 안에 선언되어 있다면 permits 절을 생략할 수 있고, 컴파일러가 그 파일 안의 하위 타입으로 목록을 채웁니다. 이 글에서는 어떤 타입이 허용되는지 한눈에 보이도록 permits를 적어 둡니다.

중간 갈래를 다시 sealed로 만들면 계층을 여러 단계로 나눌 수 있습니다. 다음 BoardEvent는 게시글 이벤트 PostEvent와 게시판 이름 변경 BoardRenamed를 허용하고, PostEvent는 다시 PostCreated와 PostPublished만 허용합니다.

src/SealedBoardEventHierarchy.java
public final class SealedBoardEventHierarchy {
    private sealed interface BoardEvent permits PostEvent, BoardRenamed {}

    private sealed interface PostEvent extends BoardEvent permits PostCreated, PostPublished {}

    private record PostCreated(long postId, String title) implements PostEvent {}

    private record PostPublished(long postId, int viewCount) implements PostEvent {}

    private record BoardRenamed(String title) implements BoardEvent {}

    static String describe(BoardEvent event) {
        return switch (event) {
            case PostCreated created -> "create:" + created.title();
            case PostPublished published -> "publish:" + published.viewCount();
            case BoardRenamed renamed -> "board:" + renamed.title();
        };
    }

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

describe의 switch는 중간 갈래 PostEvent 대신 그 아래의 두 record와 BoardRenamed를 직접 다룹니다. 세 case로 가능한 경우가 모두 덮이므로 default 없이 컴파일됩니다.

create:sealed
publish:45
board:Java 25
BoardEvent가 직접 허용한 두 갈래와 세 record 리프

SealedBoardEventHierarchy의 permits와 extends·implements 선언을 계층으로 읽습니다.

permits는 직접 하위 타입을 제한

BoardEvent가 직접 허용한 두 갈래와 세 record 리프

BoardEvent는 PostEvent와 BoardRenamed를 허용합니다. PostEvent도 sealed이므로 전체 계층의 record 리프는 세 개로 닫힙니다.

BoardEvent가 직접 허용한 두 갈래와 세 record 리프BoardEvent의 직접 하위는 PostEvent와 BoardRenamed이다. PostEvent의 직접 하위는 PostCreated와 PostPublished이다. 세 record는 final이며 둘의 인터페이스는 sealed이다.BoardEventsealed interfacePostEventsealed interfaceBoardRenamedrecord · finalPostCreatedrecord · finalPostPublishedrecord · final
BoardEvent가 직접 허용한 두 갈래와 세 record 리프BoardEvent 아래에 PostEvent와 BoardRenamed가 있고 PostEvent 아래에 PostCreated와 PostPublished가 있다.BoardEventsealed interfacePostEventsealed interfacePostCreatedrecord · finalPostPublishedrecord · finalBoardRenamedrecord · final

연결선은 permits에 선언된 직접 하위 타입 관계를 나타냅니다.

그림의 선은 실행 순서가 아니라 permits에 선언된 직접 허용 관계입니다. BoardEvent가 직접 허용한 타입은 PostEvent와 BoardRenamed 둘이지만, PostEvent도 봉인되어 있으므로 실제로 만들 수 있는 값은 세 record 가운데 하나입니다. 그래서 describe의 switch는 세 record를 모두 다루는 것만으로 default 없이 컴파일됩니다. 컴파일러는 PostCreated와 PostPublished를 다루면 PostEvent 전체를 다룬 것이라고 판단합니다. 두 경우를 한꺼번에 처리하고 싶다면 case PostEvent post ->처럼 중간 갈래의 타입을 적어도 됩니다.

출력의 세 줄은 각 값이 자기 타입의 case로 들어가 그 record의 구성 요소를 읽은 결과입니다. 다만 이 switch에는 case null이 없으므로 describe(null)은 어느 case로도 가지 않고 NullPointerException을 던집니다. null은 봉인된 계층의 네 번째 종류로 취급되지 않습니다. null을 다루는 방법은 패턴 매칭과 데이터 분기에서 자세히 봅니다.

switch가 빠진 경우를 찾아 줍니다

처음의 저장 결과 예로 돌아가겠습니다. 다음 프로그램은 SaveResult를 세 record로 봉인하고, 같은 결과를 두 가지 연산으로 처리합니다. httpStatus는 응답 상태 코드를, message는 사용자에게 보여 줄 문장을 만듭니다.

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));
        }
    }
}

main은 세 결과마다 상태 코드와 메시지를 한 줄로 찍습니다. Failed의 메시지에는 원인 예외의 메시지가 아니라 클래스 이름이 들어갑니다.

201 saved id=7
400 rejected: blank title
500 failed: IllegalStateException

httpStatus에서는 구성 요소를 쓰지 않으므로 패턴 변수 이름을 ignored로 지었습니다. Java 22부터는 이런 변수를 _로 쓸 수 있는데(JEP 456), 패턴 매칭 글에서 다룹니다. 두 switch 모두 default가 없는데도 컴파일되는 이유는 컴파일러가 SaveResult의 종류가 정확히 셋이라는 것을 알기 때문입니다. 이렇게 가능한 모든 값을 다룬 switch를 빠짐없는(exhaustive) switch라고 합니다.

이 검사가 실제로 도움이 되는 때는 종류가 늘어날 때입니다. 저장 시간이 초과된 경우를 나타내는 record Timeout(long millis) implements SaveResult {}를 추가하고 permits 목록에도 Timeout을 넣은 뒤 다시 컴파일하면, 두 메서드의 switch 모두에서 같은 오류가 납니다.

error: the switch expression does not cover all possible input values

오류가 나는 곳은 Timeout을 처리해야 하는 switch이고, 고쳐야 할 곳이 전부 나옵니다. 컴파일러가 수정할 목록을 만들어 주는 셈입니다. 만약 두 switch에 default -> 500과 default -> "unknown" 같은 줄이 있었다면 이 코드는 아무 오류 없이 컴파일되고, 시간 초과는 조용히 서버 오류나 '알 수 없음'으로 처리되었을 것입니다. 그래서 우리 코드 안에서 닫아 둔 계층을 다루는 switch에는 default를 두지 않는 편이 낫습니다.

sealed가 제한하는 것은 타입의 종류이지 구성 요소의 값이 아니라는 점도 기억해 두세요. new Rejected(null)이나 new Failed(null)도 만들 수 있고, Failed(null)을 message에 넘기면 cause().getClass()에서 NullPointerException이 납니다. 구성 요소의 값은 record 심화와 불변 스냅샷에서 본 컴팩트 생성자로 따로 검사해야 합니다. 또 message가 예외 메시지 대신 예외 클래스 이름만 꺼낸 데에도 이유가 있습니다. 예외 메시지에는 파일 경로나 내부 설정 같은 정보가 들어 있을 수 있어서, 사용자에게 보내는 응답에 그대로 싣지 않는 것이 안전합니다.

동시에 성립할 수 없는 상태를 타입으로 나누기

sealed는 화면 상태처럼 '이 가운데 정확히 하나'인 데이터를 표현할 때도 쓸모가 있습니다. 게시판 화면은 불러오는 중이거나, 게시글을 보여 주거나, 글이 없다고 알리거나, 오류를 보여 줍니다. 이 상태를 필드 몇 개로 표현하면 흔히 다음과 같은 클래스가 나옵니다.

final class BoardViewFlags {
    boolean loading;
    String title;       // 준비됐을 때만 의미가 있다
    Integer viewCount;  // 준비됐을 때만 의미가 있다
    String errorCode;   // 실패했을 때만 의미가 있다
}

이 클래스는 loading이 true인데 title도 들어 있거나, title과 errorCode가 동시에 들어 있는 객체를 막지 못합니다. 이런 객체를 받은 화면 코드는 무엇을 보여 줘야 할지 알 수 없고, 어느 필드가 null일 수 있는지도 주석에 기대야 합니다. 상태마다 record를 하나씩 두고 sealed로 묶으면 이 문제가 사라집니다.

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

    private record Loading() implements ViewState {}

    private record Ready(String title, int viewCount) 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() + " (views=" + ready.viewCount() + ")";
            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 post")));
        System.out.println(render(new ErrorState("E503")));
    }
}

render의 switch도 default 없이 네 상태를 모두 다룹니다. 구성 요소가 둘인 Ready만 두 값을 이어 붙여 한 줄을 만듭니다.

loading
sealed (views=50)
no post
error=E503

이제 각 상태는 자기에게 필요한 데이터만 가집니다. Loading은 구성 요소가 없고, 제목과 조회 수는 Ready에만, 오류 코드는 ErrorState에만 있습니다. '불러오는 중인데 오류 코드가 있는 상태'는 그런 타입이 없으니 아예 만들 수 없습니다. render의 각 case도 그 상태에 있는 데이터만 읽을 수 있으므로, 준비되지 않은 화면에서 제목을 읽는 실수는 컴파일 단계에서 걸러집니다. 출력의 네 줄은 네 상태가 각자의 case로 들어간 결과입니다.

새 연산을 더하기 쉬운가, 새 타입을 더하기 쉬운가

같은 문제를 instanceof와 오버라이딩에서 배운 오버라이딩으로 풀 수도 있습니다. SaveResult에 int httpStatus() 같은 추상 메서드를 두고 각 record가 직접 구현하게 하는 것입니다. 두 방식은 무엇을 쉽게 바꿀 수 있는지가 정반대입니다.

바뀌는 것각 타입이 메서드를 구현(다형성)sealed 타입 + 바깥의 switch
새 타입 추가클래스 하나만 추가하면 됨모든 switch를 고쳐야 함(컴파일러가 위치를 알려 줌)
새 연산 추가모든 타입에 메서드를 추가해야 함메서드 하나만 추가하면 됨

종류는 거의 바뀌지 않고 그 데이터로 하는 일이 계속 늘어난다면 sealed와 switch가 맞습니다. 저장 결과에 상태 코드, 사용자 메시지, 로그 문장, 통계 집계가 차례로 붙는 경우가 그렇습니다. 반대로 연산은 고정되어 있고 종류가 자주 늘어난다면 각 타입이 동작을 구현하는 다형성이 맞습니다. 어느 쪽이 더 자주 바뀌는지를 보고 고르면 됩니다.

더 깊이: 다시 컴파일하지 않은 코드와 MatchException

switch가 빠짐없는지는 컴파일할 때 검사하므로, 다시 컴파일한 코드만 보호받습니다. 라이브러리가 SaveResult에 Timeout을 추가한 새 버전을 냈는데 그 라이브러리를 쓰는 쪽은 예전에 컴파일한 클래스 파일을 그대로 쓴다고 해 봅시다. 실행 중에 Timeout 값이 그 switch에 들어오면 맞는 case가 하나도 없습니다. Java 21부터는 이때 java.lang.MatchException이 발생합니다. 실제로 Saved와 Rejected만 다루도록 컴파일한 switch에 계층만 다시 컴파일해 Timeout을 넣으면, 그 switch 줄에서 Exception in thread "main" java.lang.MatchException이 출력됩니다.

그래서 다른 팀이나 외부 사용자가 쓰는 공개 API의 봉인된 계층에 하위 타입을 추가하는 것은 호환성을 깨는 변경입니다. 쓰는 쪽이 모두 다시 컴파일하고 새 경우를 처리해야 하므로, 라이브러리의 주 버전을 올리는 수준의 변경으로 다루는 것이 안전합니다.

sealed가 맞지 않는 곳

봉인은 종류를 닫는 대신 확장을 포기하는 선택이므로, 맞지 않는 곳도 분명합니다.

  • 플러그인처럼 제3자가 구현을 추가해야 하는 확장점에는 쓰지 않습니다.
  • 실행 중에 하위 클래스를 만들어 쓰는 프레임워크가 있습니다. 지연 로딩을 위해 엔티티의 프록시 클래스를 만드는 영속성 프레임워크가 대표적입니다. 이런 프레임워크가 다룰 클래스를 봉인하거나 final로 만들면 동작하지 않을 수 있으므로, 쓰는 프레임워크가 지원하는지 먼저 확인합니다.
  • 외부에서 들어온 문자열이나 JSON을 하위 타입으로 바꾸는 파서는 모르는 타입 이름을 만날 수 있습니다. 버전이 다른 클라이언트가 새 명령을 보낼 수도 있기 때문입니다.

마지막 경우를 조금 더 보겠습니다. 외부 입력을 해석하는 곳에서는 '모르는 값'이 정상적으로 일어날 수 있는 일이므로, 거기서는 알 수 없는 타입 이름을 오류로 바꾸거나 무시하는 대체 경로를 두어야 합니다. 하지만 일단 봉인된 타입의 값으로 바꾼 뒤에는 종류가 정확히 정해져 있으므로, 그 값을 다루는 switch에는 default를 두지 않습니다. 신뢰할 수 없는 입력을 해석하는 일과 이미 만들어진 값을 종류별로 처리하는 일을 나누어 생각하면 default를 어디에 둘지가 분명해집니다.

정리

  • sealed 인터페이스나 클래스는 permits에 적은 타입만 직접 구현하거나 상속할 수 있습니다. 목록에 없는 타입은 컴파일 오류가 납니다.
  • 허용된 클래스는 final, sealed, non-sealed 가운데 하나를 밝혀야 하고, record는 암묵적으로 final입니다. 같은 파일 안의 하위 타입이라면 permits를 생략할 수 있습니다.
  • 봉인된 타입을 다루는 switch는 모든 종류를 다루면 default 없이 컴파일되고, 종류가 늘면 고쳐야 할 switch마다 컴파일 오류가 납니다. 그래서 닫힌 계층에는 default를 두지 않는 편이 낫습니다.
  • 동시에 성립할 수 없는 상태를 상태마다 다른 record로 나누면, 잘못된 조합은 만들 수조차 없습니다.
  • 종류가 고정되고 연산이 늘어나면 sealed와 switch, 연산이 고정되고 종류가 늘어나면 다형성이 맞습니다.

연습 문제

  1. sealed 인터페이스 CommandResult와 이를 구현하는 세 record로 명령 처리 결과인 성공, 입력 오류, 버전 충돌을 표현하세요. 그리고 switch 식으로 사용자 메시지를 돌려주는 message 메서드를 만드세요. 입력은 성공한 게시글 번호 10, 잘못된 필드 이름 "title", 현재 버전 3입니다. 세 메시지를 먼저 예상한 다음, 서비스 점검을 나타내는 Unavailable을 추가할 때 어떤 순서로 어떤 컴파일 오류를 만나는지 설명하세요.
  2. 다음 계층에서 Push는 non-sealed입니다. Notice를 다루는 switch에 case Email e와 case AppPush p만 적으면 컴파일될까요? case Email e와 case Push p를 적으면 어떨까요?
sealed interface Notice permits Email, Push {}
record Email(String address) implements Notice {}
non-sealed interface Push extends Notice {}
record AppPush(String token) implements Push {}

힌트

  1. 새 타입을 선언하는 것과 permits 목록에 넣는 것은 별개의 단계입니다.
  2. non-sealed 갈래 아래에는 이 파일에 없는 클래스도 얼마든지 생길 수 있습니다.

해설

1. 명령 결과와 새 타입 추가

세 결과마다 메시지에 필요한 값 하나씩을 구성 요소로 둡니다. message는 각 record의 구성 요소를 꺼내 결과마다 다른 접두어 뒤에 붙입니다.

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)));
    }
}

세 호출은 문제에서 준 입력인 게시글 번호 10, 필드 이름 "title", 현재 버전 3을 차례로 넘깁니다. 실행 결과는 다음과 같습니다.

created:10
invalid:title
retry-version:3

message의 switch는 세 종류를 모두 다루므로 default 없이 컴파일됩니다. 이제 Unavailable을 추가하는 과정을 두 단계로 나눠 보겠습니다. 먼저 private record Unavailable(String reason) implements CommandResult {}만 선언하고 permits는 그대로 두면, 선언한 줄에서 class is not allowed to extend sealed class: CommandResult 오류가 납니다. 허용 목록에 없는 타입이기 때문입니다. 이때 switch는 여전히 허용된 세 종류를 모두 다루므로 switch 쪽 오류는 나지 않습니다.

다음으로 permits 목록에 Unavailable을 넣으면 선언 오류는 사라지고, 이번에는 message의 switch에서 the switch expression does not cover all possible input values 오류가 납니다. 허용된 종류가 넷이 되었는데 switch는 셋만 다루기 때문입니다. case Unavailable unavailable -> "unavailable:" + unavailable.reason(); 같은 줄을 추가하면 다시 컴파일됩니다. 두 오류는 서로 다른 단계에서 하나씩 나타나며, 둘 다 실행하기 전에 잡힌다는 것이 핵심입니다.

2. non-sealed 갈래와 빠짐없는 switch

case Email e와 case AppPush p만 적으면 the switch expression does not cover all possible input values 오류가 납니다. Push는 non-sealed라서 누구든 Push를 구현하는 새 클래스, 예를 들어 WebPush를 만들 수 있고, 그 값은 두 case 어디에도 맞지 않기 때문입니다. 컴파일러는 AppPush가 지금 보이는 유일한 구현이라는 사실을 근거로 삼지 않습니다.

반면 case Email e와 case Push p를 적으면 컴파일됩니다. Push 타입 패턴은 앞으로 생길 구현까지 포함해 Push의 모든 값에 맞고, Notice의 허용 목록은 Email과 Push 둘뿐이기 때문입니다. 이처럼 non-sealed로 연 갈래는 그 갈래의 타입 자체로만 빠짐없이 다룰 수 있습니다.

다음 글

패턴 매칭과 데이터 분기에서는 이 글에서 case Saved saved ->처럼 써 온 패턴을 자세히 살펴봅니다. null이 들어올 때의 규칙, when 가드와 case의 순서, record의 구성 요소를 한 번에 꺼내는 record 패턴을 다룹니다.