이 글에 대해
sealed interface 와 record, switch exhaustive 매칭이 같이 묶여 한 가지 모양으로 동작하는 코드가 있다. 분기되는 처리 결과를 어떤 자료구조로 받을지 정하는 영역이다.
이 글은 그 묶음이 무엇이고, 어떤 조건에서 통하며, 실제로 적용했을 때 무엇이 측정 가능하게 바뀌었는지를 적는다.
§1sealed + record + switch 가 뭔가
세 도구가 한 묶음으로 움직인다.
- sealed interface (또는 sealed class): 어떤 타입이 이 인터페이스를 구현할 수 있는지
permits로 닫는다. 외부에서 임의로 상속·구현할 수 없다. - record: permits 에 적힌 각 케이스를 record 로 정의한다. 케이스마다 자기에게 필요한 필드만 들고 있다.
- switch (Java 21+): sealed 위에서 switch expression 이 모든 케이스를 다뤘는지 컴파일러가 검사한다.
가장 작은 형태로 적으면 이런 모양이다.
public sealed interface AssignmentResult
permits AssignmentResult.Assigned, AssignmentResult.Empty, AssignmentResult.Error {
record Assigned(Long requestId, Long workerId) implements AssignmentResult {}
record Empty() implements AssignmentResult {}
record Error(String message) implements AssignmentResult {}
}
AssignmentResponse response = switch (result) {
case AssignmentResult.Assigned a -> AssignmentResponse.ofAssigned(a.requestId(), a.workerId());
case AssignmentResult.Empty e -> AssignmentResponse.ofEmpty();
case AssignmentResult.Error err -> AssignmentResponse.ofError(err.message());
};세 도구가 같이 있어야 컴파일러가 switch 의 exhaustiveness 를 검사한다. 케이스 하나만 빠뜨려도 컴파일 에러로 떨어진다.
인터페이스 쪽에서 permits 에 새 케이스를 추가하면, 그 결과를 받는 모든 switch 가 동시에 컴파일이 막힌다.
§2왜 써야 하나
이 묶음이 통하려면 세 조건이 동시에 참이어야 한다.
- 케이스가 finite 하고 도메인적으로 닫혀 있다. 외부 plugin 이 임의로 확장할 가능성이 낮다.
- 케이스마다 필요한 정보가 다르다. 단일 자료구조에 끌어올리면 null 로 채워지는 필드가 생긴다.
- 호출자가 모든 케이스를 의식해야 한다. 한두 곳에서만 처리하고 나머지를 무시하면 안 된다.
이 셋이 같이 참일 때 흔히 쓰던 대안 세 가지를 같이 두고 보면, sealed 가 어디서 비용을 옮겨주는지 그대로 드러난다.
대안 A. 단일 record + boolean + nullable
public record AssignmentResult(boolean success, Long requestId, Long workerId, String reason) {}호출자가 success 와 reason 의 조합으로 분기를 만든다.
새 케이스가 생기면 reason 의 prefix 규약("LIMIT", "SKIP_BY_…") 같은 비공식 합의로 끼워 넣는 모양이 되고, 검수에서 빠진 호출자는 마지막 else 로 흘러가 ASSIGNED 도 ERROR 도 아닌 회색 분기를 응답으로 내보낸다.
컴파일러는 아무 말도 안 한다.
대안 B. RuntimeException 사슬
"한도 초과", "처리 대상 없음" 같은 정상 분기를 QuotaExceededException, NoCandidateException 으로 throw 한다.
어떤 예외가 던져질 수 있는지 컴파일러가 호출자에게 짚어주지 않고, stack trace 생성 비용이 정상 경로에서 든다.
더 큰 문제는 의미론이다. 예외가 아니라 결과를 예외처럼 다루게 된다.
대안 C. if 사슬 + instanceof
타입은 closed 가 아니라서 누락을 컴파일러가 검사하지 못한다. 새 타입을 끼우는 사람은 인터페이스가 어디서 소비되는지 grep 으로 다 찾아야 한다.
세 가지 모두에서 비용은 한 곳으로 모인다. 누락된 호출자를 컴파일러가 아니라 사람이 찾는다.
sealed 묶음은 정확히 그 비용을 컴파일 단계로 옮긴다.
§3내 문제에 어떻게 대입했나
배정 처리 한 건의 결과가 단순 성공·실패가 아니라 여섯 가지로 갈라지는 코드가 있었다. 성공, 사전 검증 스킵, 처리 대상 없음, 사전 한도 초과, 일일 한도 소진, 내부 오류.
각 케이스마다 호출자가 알아야 할 정보도 달랐다. 앞서 정리한 세 조건이 모두 참이었다.
분기되는 처리 결과 자료구조
이전 모양은 위 "대안 A" 그대로였다.
public record AssignmentResult(
boolean success,
Long requestId,
Long workerId,
String reason
) {}호출자 측 응답 변환은 if 사슬이었다.
public AssignmentResponse from(AssignmentResult result) {
if (result.success()) {
return AssignmentResponse.builder()
.resultType(ResponseType.ASSIGNED)
.requestId(result.requestId())
.workerId(result.workerId())
.build();
}
if (result.reason() != null && result.reason().startsWith("LIMIT")) {
return AssignmentResponse.builder()
.resultType(ResponseType.CAPACITY_FULL)
.message(result.reason())
.build();
}
// 분기 4개 더
}결과 자체를 sealed interface 로 닫고, 각 케이스를 record 로 두었다.
public sealed interface AssignmentResult
permits AssignmentResult.Assigned,
AssignmentResult.Skip,
AssignmentResult.Empty,
AssignmentResult.QuotaExceeded,
AssignmentResult.CapacityFull,
AssignmentResult.Error {
record Assigned(
Long requestId,
Long workerId,
AssignmentCase assignmentCase
) implements AssignmentResult {}
record Skip(Long requestId, String reason) implements AssignmentResult {}
record Empty() implements AssignmentResult {}
record QuotaExceeded() implements AssignmentResult {}
record CapacityFull() implements AssignmentResult {}
record Error(Long requestId, String message) implements AssignmentResult {}
}AssignmentResult sealed interface root에서 6개 record 케이스로 분기되는 계층. 각 케이스가 자신에게 필요한 필드만 보유하고, Empty·QuotaExceeded·CapacityFull은 0 필드 record로 표현된다
각 record 는 자기 케이스에 필요한 필드만 들고 있다. Assigned 는 3 개, Skip 과 Error 는 2 개, Empty·QuotaExceeded·CapacityFull 은 0 개.
이전처럼 6 케이스의 필드를 한 record 안에 다 모아두고 분기마다 일부를 null 로 채우는 자료구조는 사라졌다.
호출자 4 곳(응답 DTO 변환, 메트릭 태거, 구조화 로거, 알림 디스패처)이 switch expression 한 번으로 정렬됐다.
public static AssignmentResponse from(AssignmentResult result) {
return switch (result) {
case AssignmentResult.Assigned a -> AssignmentResponse.ofAssigned(a.requestId(), a.workerId());
case AssignmentResult.Skip s -> AssignmentResponse.ofSkip(s.requestId(), s.reason());
case AssignmentResult.Empty e -> AssignmentResponse.ofEmpty();
case AssignmentResult.QuotaExceeded q -> AssignmentResponse.ofQuotaExceeded();
case AssignmentResult.CapacityFull c -> AssignmentResponse.ofCapacityFull();
case AssignmentResult.Error err -> AssignmentResponse.ofError(err.requestId(), err.message());
};
}핵심: 새 케이스 한 줄이 호출자 4 곳을 동시에 컴파일러로 끌어온다
permits 에 케이스 한 줄을 추가하는 변경을 가정해 보자. 운영 중에 timeout 분기가 새로 필요해진다면 PR 의 diff 는 이렇게 시작할 것이다.
public sealed interface AssignmentResult
permits AssignmentResult.Assigned,
AssignmentResult.Skip,
AssignmentResult.Empty,
AssignmentResult.QuotaExceeded,
AssignmentResult.CapacityFull,
- AssignmentResult.Error {
+ AssignmentResult.Error,
+ AssignmentResult.TimeoutExpired {
+
+ record TimeoutExpired(Long requestId, Duration elapsed) implements AssignmentResult {}이 줄 직후, 같은 결과를 받아 변환하는 호출자 4 곳의 모든 switch 가 동시에 컴파일 에러로 떨어진다.
permits 에 새 케이스 TimeoutExpired 를 추가하는 가상 변경 위에서, 응답 DTO 변환·메트릭 태깅·구조화 로깅·알림 발송 네 곳의 switch 호출자가 동시에 NOT EXHAUSTIVE 컴파일 에러로 떨어지는 fan-out
컴파일이 통과했다는 사실 자체가, 호출자 4 곳이 모두 새 케이스를 보고 지나갔다는 증거가 된다.
이 diff 자체는 가정이지만, sealed 의 컴파일 강제는 실제로 이렇게 동작한다. 새 케이스를 추가하는 사람이 호출자 위치를 스스로 찾아낼 필요가 없다.
수치로 잰 변화
sealed + record + switch 로 전환한 뒤 null check 라인, 정상 흐름을 표현하던 예외, reason prefix 매칭 분기 세 항목이 같이 0 으로 떨어진 측정 결과
LoC 와 PR diff 는 경우에 따라 줄어들 수도 늘어날 수도 있는 약한 지표다.
다만 그 옆의 null check 라인, prefix 매칭 분기, 정상 흐름 예외 세 항목이 같이 0 으로 떨어진다는 사실이 본질에 더 가깝다. 분기 표현이 컴파일러 강제로 옮겨가면서, 그 분기를 보조하던 코드들도 같이 사라진다.
가장 분명한 한 줄. 누락된 호출자를 코드 리뷰에서 grep 으로 찾던 부담이 javac 으로 옮겨갔다.
리뷰 코멘트가 줄었다는 것이 아니라, 같은 종류의 리뷰 코멘트를 더 이상 쓸 일이 없어졌다는 쪽이 더 큰 변화다.
§4같은 묶음을 적용한 다른 영역
같은 묶음을 세 영역에 더 적용했다.
Strategy 의 구현체 enumeration
처리 핸들러 인터페이스에도 같은 도구를 썼다.
public sealed interface RequestHandler permits
StandardRequestHandler,
RenewalRequestHandler,
ContractRequestHandler,
ConversionRequestHandler,
TenantRequestHandler,
FallbackRequestHandler {
RequestResult tryProcess(RequestCommand command);
void afterProcess(RequestContext context);
}Spring 의 List<RequestHandler> 주입처럼 런타임에 구성되더라도, 어떤 구현체가 존재하는지는 인터페이스 파일 한 곳에 적혀 있다. 외부에서 새 구현체를 임의로 끼워 넣는 길이 막혀 있다.
여기서 sealed 의 가치는 앞서 살펴본 결과 모델 사례와 결이 다르다는 점은 짚어둘 만하다. 여기서는 switch exhaustiveness 가 핵심이 아니다. List<RequestHandler> 를 iterate 하는 호출자는 케이스를 의식하지 않고, switch 도 쓰지 않는다.
그 대신 닫고 싶은 건 "어떤 구현체가 이 인터페이스를 구현할 수 있는가" 라는 확장 집합 그 자체다. 같은 도구지만 용도가 ADT 가 아니라 closed extension point 다.
핸들러의 결과도 더 작은 sealed 로
public sealed interface RequestResult permits RequestResult.Success, RequestResult.Skip {
record Success(RequestContext context) implements RequestResult {}
record Skip(String reason) implements RequestResult {}
}체인 액션의 흐름 제어 상태 세 개
public sealed interface AssignmentMatch<T>
permits AssignmentMatch.Pass,
AssignmentMatch.Matched,
AssignmentMatch.Terminal {
record Pass<T>() implements AssignmentMatch<T> {}
record Matched<T>(T candidate) implements AssignmentMatch<T> {}
record Terminal<T>(AssignmentResult result) implements AssignmentMatch<T> {}
static <T> Pass<T> pass() { return new Pass<>(); }
static <T> Matched<T> matched(T candidate) { return new Matched<>(candidate); }
static <T> Terminal<T> terminal(AssignmentResult r) { return new Terminal<>(r); }
}세 상태가 곧 흐름의 의도다.
Pass: 내 액션의 조건이 아니다, 다음 액션으로Matched(candidate): 후보를 찾았다, 다음 단계에서 활용Terminal(result): 즉시 종료, 이 결과를 반환
boolean continueChain + nullable candidate + Optional<AssignmentResult> 같은 3-튜플로 풀면 호출자가 매번 셋의 정합성을 검사해야 한다. continueChain == true && candidate != null 이 가능한 상태인지 불가능한지, 같은 질문이 계속 따라온다.
sealed 로 닫으면 시그니처 자체가 세 상태의 명세가 되고, 후보 타입은 generic <T> 로 자유롭게 둘 수 있다.
같은 sealed + record 가 두 갈래 가치로 쓰이는 모습. 그룹 1 (ADT · exhaustive switch): 결과 모델 AssignmentResult 와 흐름 제어 AssignmentMatch 두 sealed interface 가 묶인다. 호출자가 switch 로 모든 케이스를 의식한다. 그룹 2 (closed extension point): 전략 enumeration RequestHandler 가 묶인다. 외부 plugin 의 임의 구현을 컴파일 단계에서 차단하는 용도다
결과 모델과 흐름 제어 두 영역은 앞서 정리한 세 조건 (케이스 finite, 케이스별 필드 다름, 호출자가 모든 케이스 의식) 을 모두 만족한다.
전략 enumeration 한 영역은 세 조건과는 다른 가치 명제 (closed extension point, 외부 임의 구현 차단) 가 작동한다.
어느 쪽도 강하지 않은 곳에서는 sealed 를 꺼내지 않았다.
§5묶음을 끝까지 끌고 가지 못한 두 지점
같은 묶음을 한 코드베이스 안 모든 곳에 같은 무게로 적용한 건 아니다. 두 지점이 남아 있다.
한 곳은 세 조건이 충분히 강하지 않아 묶음을 들이지 않은 영역이고, 한 곳은 묶음을 들였지만 sealed 의 컴파일 보증이 끊기는 경계다.
dispatcher 한 곳은 instanceof 사슬로 의도적으로 남겨뒀다. for 루프 안에서 if + instanceof 로 결과를 받고 있다.
dispatcher 결과 처리
for (var handler : requestHandlers) {
var res = handler.tryProcess(command);
if (res instanceof RequestResult.Success success) {
...
}
if (res instanceof RequestResult.Skip skip) {
log.info(skip.reason());
}
}switch expression 으로 갈아탈 수 있었지만 갈아타지 않았다. 결과 케이스가 Success/Skip 둘뿐이고 분기 동작도 거의 같다.
앞서 정리한 세 조건 중 "케이스 finite" 는 참이지만 나머지 둘이 약하다. "케이스별 필드 다름" 은 Success 의 context 와 Skip 의 reason 차이가 작아 약하고, "호출자가 모든 케이스 의식" 은 dispatcher 가 for 루프로 결과를 받아 흘려보내는 구조라 약하다.
세 조건 중 하나만 강하면 sealed 가 가져오는 컴파일 강제의 이득이 switch 변환 비용을 넘지 못한다. 케이스가 더 늘거나 분기 동작이 갈라지는 시점이 오면 함께 손볼 후보로 적어두었다.
직렬화 경계에서 sealed 의 컴파일 보증이 끊긴다. AssignmentResult 를 큐에 실어 보내거나 외부 API 응답으로 내보낼 때 polymorphic deserialization 을 위해 @JsonTypeInfo 와 @JsonSubTypes 를 sealed interface 에 붙여야 한다.
새 케이스를 permits 에 더해도 @JsonSubTypes 갱신을 빠뜨리면 javac 은 침묵한다. 호출자 누락을 컴파일 단계로 옮겨준다는 sealed 묶음의 핵심 보증이, JSON 으로 나갔다 들어오는 경계에서는 작동하지 않는다.
이 구간은 직렬화 라운드트립 테스트로 메우고 있다.
§6한 줄 결론
sealed interface + record + switch 는 누락된 호출자를 사람이 grep 으로 찾던 비용을 javac 으로 옮긴다. 분기되는 처리 결과의 자료구조를 정의할 때 이 묶음이 가장 깨끗하게 들어맞았다.
호출자 4 곳에 흩어져 있던 null check, 정상 흐름 예외, prefix 매칭 분기가 한 번에 걷혔고, 케이스 분기 표현 자체가 사람이 짜던 if · exception 사슬에서 sealed 의 타입 시스템 안으로 들어왔다.