sealed 계층과 상태 분기
Java 25 안정 기능인 sealed class·인터페이스로 허용 하위 타입을 닫고 빠짐없는 switch가 도메인 변화 누락을 컴파일 시점에 드러내게 합니다.
sealed class와 sealed 인터페이스는 Java 17에서 정식 도입돼 Java 25에서 프리뷰 플래그 없이 컴파일됩니다.
permits는 직접 하위 타입을 제한합니다. 중간 분기도 닫혀 있을 때 컴파일러와 독자가 가능한 리프 타입을 따라갈 수 있습니다.
record와 결합하면 각 하위 타입은 필요한 데이터를 값으로 담고, 패턴 switch는 닫힌 합집합을 빠짐없이 처리합니다.
permits에 없는 하위 타입
아래 DeleteNote는 BoardCommand가 직접 허용한 하위 타입이 아니므로 컴파일되지 않습니다.
이 제한은 외부 모듈이 임의의 명령을 추가해 빠짐없는 처리기를 우회하는 일을 막습니다.
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);
}
}원칙은 실제로 닫힌 도메인에 sealed를 적용하는 것입니다.
플러그인처럼 제3자가 하위 타입을 추가해야 하는 확장점에는 일반 인터페이스와 서비스 제공자 구조가 더 적합합니다.
sealed 하위 타입 규칙
허용된 일반 class는 final, sealed, non-sealed 중 상속 정책을 명시해야 합니다.
record는 암묵적으로 final이므로 리프에 적합합니다.
중간 분기를 다시 sealed로 좁히거나 non-sealed로 열 수 있습니다. 열린 분기 아래의 후손 타입 집합까지 permits가 제한하지는 않습니다.
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")));
}
}SealedBoardEventHierarchy의 permits와 extends·implements 선언을 계층으로 읽습니다.
permits는 직접 하위 타입을 제한
BoardEvent가 직접 허용한 두 갈래와 세 record 리프
BoardEvent는 PostEvent와 BoardRenamed를 허용합니다. PostEvent도 sealed이므로 전체 계층의 record 리프는 세 개로 닫힙니다.
연결선은 permits에 선언된 직접 하위 타입 관계를 나타냅니다.
describe는 세 리프 타입을 다뤄 default 없이 컴파일됩니다. 다만 case null은 없으므로 null을 넘기면 NullPointerException을 던집니다.
트리의 선은 실행 순서가 아니라 직접 허용 관계입니다. PostCreated와 PostPublished는 PostEvent를 통해 BoardEvent의 값이 되며, BoardRenamed는 바로 BoardEvent를 구현합니다. 그래서 루트의 직접 하위 타입은 두 개지만 describe가 구별하는 리프는 세 개입니다. null은 네 번째 리프 타입으로 추가된 상태가 아닙니다.
하위 타입과 switch 책임
객체 다형성은 각 하위 타입이 동작을 구현하므로 하위 타입을 추가하기 쉽고 새 연산을 추가하려면 모두 수정합니다.
sealed 데이터와 외부 switch는 연산을 추가하기 쉽지만 하위 타입을 추가할 때 모든 연산을 점검합니다.
설계를 설계를 변화 축에 맞춥니다.
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 계층은 타입의 범위를 제한하지만 보안 정책이나 구성 요소 값은 검증하지 않습니다. 이 예제의 String·Throwable 구성 요소는 null을 받을 수 있으며 Failed(null)의 메시지 계산은 예외를 던집니다.
sealed 도메인의 상태 제한
boolean loading, null 허용 데이터, null 허용 오류 세 필드로 상태를 만들면 로딩인데 데이터가 있거나 오류와 데이터가 동시에 있는 조합이 가능합니다.
하위 타입별 구성 요소를 나누면 각 상태가 필요한 데이터만 가집니다.
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() + " " + ready.viewCount() + "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 post")));
}
}원문의 Ready 분기는 조회 수 뒤에 문자열 m을 붙입니다. 이는 시간 단위를 변환한 결과가 아니며, main은 ErrorState 분기를 호출하지 않습니다.
바이너리 진화와 모듈
새 하위 타입을 추가한 계층으로 소비자도 다시 컴파일하면, 그 타입을 포괄하지 못하는 switch가 컴파일 오류를 냅니다. 소비자 코드를 재컴파일하지 않은 채 계층만 교체하면 새 값에서 일치하는 분기를 찾지 못해 MatchException이 발생할 수 있습니다.
공개 라이브러리 API라면 메이저 버전 마이그레이션으로 다룹니다.
이름 있는 모듈의 허용된 직접 하위 타입은 같은 모듈 안의 다른 패키지에 있을 수 있습니다. 이름 없는 모듈에서는 같은 패키지여야 하며, 두 경우 모두 타입 접근 가능성과 직접 상속 관계 조건을 충족해야 합니다.
- 닫힌 명령·이벤트·결과·상태 모델에 적합합니다.
- 영속성 프록시가 하위 클래스를 만들어야 하는 프레임워크와의 호환성을 확인합니다.
- 열린 플러그인 확장점에는
sealed를 쓰지 않습니다. public계층에default를 넣어 컴파일 오류를 피하면 새 하위 타입의 처리 누락을 숨길 수 있습니다.- 외부 입력을 하위 타입으로 변환하는 파서는 알 수 없는 타입 식별자를 처리하는 정책을 별도로 가져야 합니다.
빠짐없는 switch에는 언제나 default를 빼야 하나요?
내부의 닫힌 계층에서는 default를 빼 새 하위 타입 추가를 컴파일러가 알려 주게 하는 편이 유리합니다.
외부 프로토콜 문자열이나 버전이 다른 직렬화 데이터에는 알 수 없는 값이 들어올 수 있으므로 파싱 경계에 대체 경로를 둡니다.
타입 switch와 신뢰할 수 없는 입력의 디코딩을 구분합니다.
연습 문제
sealed 인터페이스와 이를 구현하는 세 record로 성공·입력 검사 실패·충돌을 모델링하고, switch 식으로 사용자 메시지를 반환하세요.
입력은 성공한 게시글 식별자 10, 잘못된 필드 이름 "title", 현재 충돌 버전 3입니다. 각 값의 타입에 필요한 구성 요소를 정하고 세 메시지를 먼저 적습니다. 다음으로 Unavailable을 추가할 때 허용 목록과 메시지 함수 중 어느 곳을 고쳐야 하는지 두 단계로 설명합니다.
해설 보기
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의 처리 누락이 컴파일 오류로 드러나는지 확인합니다.
원래 세 결과는 created:10, invalid:title, retry-version:3입니다. 새 직접 하위 타입을 허용 목록에 넣지 않으면 타입 선언에서 거절되고, 허용 목록에만 넣으면 빠짐없는 메시지 분기에 새 경우가 필요합니다. 두 오류는 같은 단계에서 발생하지 않습니다.