entries 006 · #0006
#0002entry

sealed interface + record + switch, 분기되는 처리 결과에 대입해본 기록

분기되는 처리 결과 한 곳에 sealed + record + switch 묶음을 대입해, 누락된 호출자를 grep 에서 javac 으로 옮겼다.

이 글에 대해

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왜 써야 하나

이 묶음이 통하려면 세 조건이 동시에 참이어야 한다.

  1. 케이스가 finite 하고 도메인적으로 닫혀 있다. 외부 plugin 이 임의로 확장할 가능성이 낮다.
  2. 케이스마다 필요한 정보가 다르다. 단일 자료구조에 끌어올리면 null 로 채워지는 필드가 생긴다.
  3. 호출자가 모든 케이스를 의식해야 한다. 한두 곳에서만 처리하고 나머지를 무시하면 안 된다.

이 셋이 같이 참일 때 흔히 쓰던 대안 세 가지를 같이 두고 보면, sealed 가 어디서 비용을 옮겨주는지 그대로 드러난다.

대안 A. 단일 record + boolean + nullable

public record AssignmentResult(boolean success, Long requestId, Long workerId, String reason) {}

호출자가 successreason 의 조합으로 분기를 만든다.

새 케이스가 생기면 reason 의 prefix 규약("LIMIT", "SKIP_BY_…") 같은 비공식 합의로 끼워 넣는 모양이 되고, 검수에서 빠진 호출자는 마지막 else 로 흘러가 ASSIGNED 도 ERROR 도 아닌 회색 분기를 응답으로 내보낸다.

컴파일러는 아무 말도 안 한다.

대안 B. RuntimeException 사슬

"한도 초과", "처리 대상 없음" 같은 정상 분기를 QuotaExceededException, NoCandidateException 으로 throw 한다.

어떤 예외가 던져질 수 있는지 컴파일러가 호출자에게 짚어주지 않고, stack trace 생성 비용이 정상 경로에서 든다.

더 큰 문제는 의미론이다. 예외가 아니라 결과를 예외처럼 다루게 된다.

대안 C. if 사슬 + instanceof

타입은 closed 가 아니라서 누락을 컴파일러가 검사하지 못한다. 새 타입을 끼우는 사람은 인터페이스가 어디서 소비되는지 grep 으로 다 찾아야 한다.

세 가지 모두에서 비용은 한 곳으로 모인다. 누락된 호출자를 컴파일러가 아니라 사람이 찾는다.

sealed 묶음은 정확히 그 비용을 컴파일 단계로 옮긴다.


§3내 문제에 어떻게 대입했나

배정 처리 한 건의 결과가 단순 성공·실패가 아니라 여섯 가지로 갈라지는 코드가 있었다. 성공, 사전 검증 스킵, 처리 대상 없음, 사전 한도 초과, 일일 한도 소진, 내부 오류.

각 케이스마다 호출자가 알아야 할 정보도 달랐다. 앞서 정리한 세 조건이 모두 참이었다.

분기되는 처리 결과 자료구조

단일 record + boolean + nullable (대안 A)
RuntimeException 사슬 (대안 B)
if 사슬 + instanceof (대안 C)
sealed interface + record + switch

이전 모양은 위 "대안 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 개, SkipError 는 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 결과 처리

switch expression 으로 sealed exhaustiveness 강제
instanceof 사슬 유지 (세 조건 중 둘이 약함)
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 의 타입 시스템 안으로 들어왔다.