핵심 정리
sealed interface PaymentResult
permits Approved, Declined, PendingReview {
}
record Approved(String transactionId) implements PaymentResult {}
record Declined(String reason) implements PaymentResult {}
final class PendingReview implements PaymentResult {
private final String ticketId;
PendingReview(String ticketId) {
this.ticketId = ticketId;
}
String ticketId() {
return ticketId;
}
}
String message = switch (result) {
case Approved approved -> "승인: " + approved.transactionId();
case Declined declined -> "거절: " + declined.reason();
case PendingReview review -> "검토 필요: " + review.ticketId();
};문법
sealed 계층을 구성하는 키워드
| 위치 | 역할 |
|---|---|
sealed | 하위 타입을 명시적으로 제한 |
permits | 허용된 직접 하위 타입 목록 |
final | 더 이상 확장할 수 없는 하위 타입 |
sealed 하위 타입 | 다시 제한된 하위 계층을 열 때 사용 |
non-sealed | 이후 확장을 다시 열 때 사용 |
permits: 닫힌 타입 집합을 선언하기
sealed는 "이 타입을 아무나 상속하거나 구현할 수 없다"는 선언입니다. permits에는 직접 하위 타입만 적습니다. 하위 타입이 같은 소스 파일에 함께 선언된 경우에는 컴파일러가 목록을 알 수 있으므로 permits를 생략할 수 있지만, 여러 파일로 나뉘면 명시하는 편이 읽기 쉽습니다.
public sealed interface Shape
permits Circle, Rectangle, UnknownShape {
}
public record Circle(double radius) implements Shape {}
public record Rectangle(double width, double height) implements Shape {}
public final class UnknownShape implements Shape {}허용된 하위 타입은 반드시 final, sealed, non-sealed 중 하나로 자신의 확장 정책을 다시 선언해야 합니다. 이 요구사항 때문에 상속 계층의 열린 지점과 닫힌 지점이 코드에서 직접 보입니다.
sealed class Document permits PdfDocument, OfficeDocument, ExternalDocument {
}
final class PdfDocument extends Document {
}
sealed class OfficeDocument extends Document
permits WordDocument, ExcelDocument {
}
non-sealed class ExternalDocument extends Document {
}switch와 함께 쓰는 이유
sealed 계층은 가능한 하위 타입이 컴파일 시점에 정해져 있으므로 switch 패턴 매칭과 잘 맞습니다. 모든 타입을 처리하면 default 없이도 분기가 완결됩니다. 새 하위 타입을 추가하면 처리 누락이 컴파일 오류로 드러나므로 상태 모델 변경에 강한 안전망이 됩니다.
sealed interface Command permits CreateUser, DeleteUser, RefreshCache {
}
record CreateUser(String email) implements Command {}
record DeleteUser(long id) implements Command {}
record RefreshCache() implements Command {}
String auditMessage(Command command) {
return switch (command) {
case CreateUser c -> "create user " + c.email();
case DeleteUser d -> "delete user " + d.id();
case RefreshCache r -> "refresh cache";
};
}선택 기준
| 상황 | 선택 |
|---|---|
| 가능한 하위 타입이 도메인상 고정 | sealed |
| 외부 확장까지 열어야 함 | 일반 interface 또는 abstract class |
| 일부 하위 타입만 다시 열어야 함 | 해당 하위 타입을 non-sealed |
| 값 객체 하위 타입이 많음 | record + sealed interface |
| 분기 누락을 컴파일 시점에 잡고 싶음 | sealed 계층 + switch pattern |
주의사항
sealed는 플러그인 확장 지점에는 맞지 않습니다. 외부 모듈이나 사용자 코드가 타입을 추가해야 하는 구조라면 일반 interface가 더 자연스럽습니다.
// 결제 결과처럼 도메인 경우의 수가 닫혀 있으면 sealed가 적합
sealed interface PaymentResult permits Approved, Declined {
}
// 외부 구현체를 계속 받는 플러그인 지점이면 일반 interface가 적합
interface ImageCodec {
byte[] encode(Image image);
}허용된 하위 타입은 같은 모듈 안에 있어야 합니다. 이름만 permits에 적는다고 외부 jar의 임의 타입을 허용할 수 있는 것은 아닙니다.
참고 링크
2 sources