Java 21 Sealed Class 패턴 매칭

Java 21 패턴 매칭이란?

Java 21에서 정식 도입된 Pattern Matching for switchSealed Class는 타입 안전한 분기 처리를 근본적으로 바꿉니다. 기존의 instanceof 체인이나 Visitor 패턴 없이도, 컴파일러가 모든 경우를 검증하는 강력한 타입 시스템을 구축할 수 있습니다. Spring Boot 3.x와 함께 사용하면 도메인 모델링과 비즈니스 로직 표현이 훨씬 간결해집니다.

Sealed Class 기본

sealed 키워드는 클래스의 하위 타입을 명시적으로 제한합니다. 컴파일러가 모든 하위 타입을 알고 있으므로, switch 문에서 default 없이 완전한 분기가 가능합니다.

// 결제 이벤트 도메인 모델
public sealed interface PaymentEvent
    permits PaymentRequested, PaymentApproved, PaymentFailed, PaymentRefunded {

    String orderId();
    Instant occurredAt();
}

public record PaymentRequested(
    String orderId, BigDecimal amount, String currency, Instant occurredAt
) implements PaymentEvent {}

public record PaymentApproved(
    String orderId, String transactionId, Instant occurredAt
) implements PaymentEvent {}

public record PaymentFailed(
    String orderId, String errorCode, String reason, Instant occurredAt
) implements PaymentEvent {}

public record PaymentRefunded(
    String orderId, BigDecimal refundAmount, String reason, Instant occurredAt
) implements PaymentEvent {}

record는 암묵적으로 final이므로 permits 절에 바로 사용할 수 있습니다. sealed interface + record 조합은 Kotlin의 sealed class와 동등한 표현력을 제공합니다.

Pattern Matching for switch

Java 21의 switch 패턴 매칭은 타입 검사와 변수 바인딩을 한 번에 수행합니다. sealed 타입과 결합하면 컴파일 타임에 누락된 케이스를 잡아줍니다.

@Service
@RequiredArgsConstructor
public class PaymentEventHandler {

    private final NotificationService notificationService;
    private final AccountingService accountingService;

    public void handle(PaymentEvent event) {
        // exhaustive switch — 새 하위 타입 추가 시 컴파일 에러 발생
        switch (event) {
            case PaymentRequested req -> {
                log.info("결제 요청: {} - {}{}",
                    req.orderId(), req.amount(), req.currency());
                accountingService.hold(req.orderId(), req.amount());
            }
            case PaymentApproved app -> {
                log.info("결제 승인: {} - txn:{}",
                    app.orderId(), app.transactionId());
                accountingService.confirm(app.orderId());
                notificationService.sendReceipt(app.orderId());
            }
            case PaymentFailed fail -> {
                log.warn("결제 실패: {} - [{}] {}",
                    fail.orderId(), fail.errorCode(), fail.reason());
                accountingService.release(fail.orderId());
                notificationService.sendFailure(fail.orderId(), fail.reason());
            }
            case PaymentRefunded refund -> {
                log.info("환불 처리: {} - {}",
                    refund.orderId(), refund.refundAmount());
                accountingService.refund(refund.orderId(), refund.refundAmount());
                notificationService.sendRefundConfirm(refund.orderId());
            }
        }
        // default 불필요 — sealed이므로 컴파일러가 완전성 보장
    }
}

Guarded Pattern: when 절

패턴 매칭에 when 가드를 추가하면 타입뿐 아니라 조건까지 분기할 수 있습니다.

public String classifyPayment(PaymentEvent event) {
    return switch (event) {
        case PaymentRequested req when req.amount().compareTo(BigDecimal.valueOf(1_000_000)) > 0
            -> "HIGH_VALUE_REQUEST";
        case PaymentRequested req
            -> "STANDARD_REQUEST";
        case PaymentFailed fail when "FRAUD".equals(fail.errorCode())
            -> "FRAUD_ALERT";
        case PaymentFailed fail when "INSUFFICIENT_FUNDS".equals(fail.errorCode())
            -> "BALANCE_ISSUE";
        case PaymentFailed fail
            -> "GENERAL_FAILURE";
        case PaymentApproved app
            -> "APPROVED";
        case PaymentRefunded refund when refund.refundAmount().compareTo(BigDecimal.valueOf(500_000)) > 0
            -> "LARGE_REFUND";
        case PaymentRefunded refund
            -> "STANDARD_REFUND";
    };
}

when 절은 위에서 아래로 평가되므로, 더 구체적인 조건을 먼저 배치해야 합니다.

Record Pattern: 구조 분해

Java 21의 Record Pattern은 record의 컴포넌트를 직접 분해하여 바인딩합니다. 중첩된 구조도 한 번에 풀어낼 수 있습니다.

// 중첩 도메인 모델
public record Address(String city, String zipCode) {}
public record Customer(String name, Address address) {}

public sealed interface ShippingRequest
    permits DomesticShipping, InternationalShipping {}

public record DomesticShipping(
    Customer customer, BigDecimal weight
) implements ShippingRequest {}

public record InternationalShipping(
    Customer customer, BigDecimal weight, String destinationCountry
) implements ShippingRequest {}

// Record Pattern으로 중첩 구조 분해
public BigDecimal calculateFee(ShippingRequest request) {
    return switch (request) {
        // 서울 도메인 배송 — 주소까지 분해
        case DomesticShipping(Customer(var name, Address(var city, _)), var weight)
            when "서울".equals(city)
            -> weight.multiply(BigDecimal.valueOf(2500));

        // 일반 국내 배송
        case DomesticShipping(_, var weight)
            -> weight.multiply(BigDecimal.valueOf(3000));

        // 국제 배송
        case InternationalShipping(_, var weight, var country)
            -> weight.multiply(getCountryRate(country));
    };
}

_(unnamed pattern)는 Java 22 프리뷰지만, 개별 변수명을 사용해도 동일한 효과를 얻을 수 있습니다.

Sealed 계층 설계 패턴

실무에서 자주 사용되는 sealed 계층 설계 패턴을 소개합니다.

Result 타입

public sealed interface Result<T> {

    record Success<T>(T value) implements Result<T> {}
    record Failure<T>(String error, int code) implements Result<T> {}

    default <R> Result<R> map(Function<T, R> mapper) {
        return switch (this) {
            case Success<T>(var value) -> new Success<>(mapper.apply(value));
            case Failure<T>(var error, var code) -> new Failure<>(error, code);
        };
    }

    default T orElse(T defaultValue) {
        return switch (this) {
            case Success<T>(var value) -> value;
            case Failure<T> f -> defaultValue;
        };
    }
}

// 사용
Result<Order> result = orderService.findOrder(orderId);
String status = switch (result) {
    case Result.Success<Order>(var order) -> order.getStatus().name();
    case Result.Failure<Order>(var error, var code) -> "ERROR: " + error;
};

Command 패턴

public sealed interface OrderCommand {
    record CreateOrder(String customerId, List<OrderItem> items) implements OrderCommand {}
    record CancelOrder(String orderId, String reason) implements OrderCommand {}
    record UpdateQuantity(String orderId, String itemId, int newQty) implements OrderCommand {}
}

@Service
public class OrderCommandHandler {

    public OrderResult execute(OrderCommand command) {
        return switch (command) {
            case OrderCommand.CreateOrder cmd -> createOrder(cmd);
            case OrderCommand.CancelOrder cmd -> cancelOrder(cmd);
            case OrderCommand.UpdateQuantity cmd -> updateQuantity(cmd);
        };
    }
}

Spring에서의 활용

Sealed class와 pattern matching은 Spring ProblemDetail 에러 처리나 컨트롤러 응답 변환에서 특히 유용합니다.

// API 응답 sealed 타입
public sealed interface ApiResponse<T> {
    record Ok<T>(T data) implements ApiResponse<T> {}
    record Created<T>(T data, URI location) implements ApiResponse<T> {}
    record Error<T>(String message, int status) implements ApiResponse<T> {}
}

@RestController
public class OrderController {

    @PostMapping("/orders")
    public ResponseEntity<?> createOrder(@RequestBody CreateOrderRequest request) {
        ApiResponse<Order> result = orderService.create(request);

        return switch (result) {
            case ApiResponse.Ok(var data)
                -> ResponseEntity.ok(data);
            case ApiResponse.Created(var data, var location)
                -> ResponseEntity.created(location).body(data);
            case ApiResponse.Error(var message, var status)
                -> ResponseEntity.status(status)
                    .body(Map.of("error", message));
        };
    }
}

Jackson 직렬화 설정

Sealed class를 JSON으로 직렬화/역직렬화하려면 @JsonTypeInfo를 사용합니다.

@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type")
@JsonSubTypes({
    @JsonSubTypes.Type(value = PaymentRequested.class, name = "REQUESTED"),
    @JsonSubTypes.Type(value = PaymentApproved.class, name = "APPROVED"),
    @JsonSubTypes.Type(value = PaymentFailed.class, name = "FAILED"),
    @JsonSubTypes.Type(value = PaymentRefunded.class, name = "REFUNDED")
})
public sealed interface PaymentEvent
    permits PaymentRequested, PaymentApproved, PaymentFailed, PaymentRefunded {
    // ...
}

// JSON 결과:
// {"type":"REQUESTED","orderId":"ORD-001","amount":50000,"currency":"KRW",...}

기존 코드 리팩토링 예시

Before — instanceof 체인:

// ❌ 타입 안전하지 않음, 새 타입 추가 시 런타임 에러
public String process(Notification notification) {
    if (notification instanceof EmailNotification email) {
        return sendEmail(email);
    } else if (notification instanceof SmsNotification sms) {
        return sendSms(sms);
    } else if (notification instanceof PushNotification push) {
        return sendPush(push);
    }
    throw new IllegalArgumentException("Unknown type");
}

After — sealed + switch:

// ✅ 컴파일 타임 완전성 보장
public sealed interface Notification
    permits EmailNotification, SmsNotification, PushNotification {}

public String process(Notification notification) {
    return switch (notification) {
        case EmailNotification email -> sendEmail(email);
        case SmsNotification sms -> sendSms(sms);
        case PushNotification push -> sendPush(push);
        // 새 타입 추가 시 컴파일 에러 → 누락 방지
    };
}

실전 팁

  • sealed + record 조합: 가장 관용적인 패턴입니다. record는 불변이고 equals/hashCode를 자동 생성하므로 도메인 이벤트에 최적입니다
  • permits 생략: 하위 타입이 같은 파일에 정의되어 있으면 permits 절을 생략할 수 있습니다
  • non-sealed 확장점: sealed 계층의 일부를 non-sealed로 선언하면 외부 확장이 가능한 열린 지점을 만들 수 있습니다
  • switch 표현식: switch를 표현식으로 사용하면 반환값을 강제하므로 실수로 케이스를 빠뜨릴 수 없습니다
  • 성능: pattern matching switch는 JIT 최적화를 통해 가상 메서드 디스패치보다 빠를 수 있습니다

마무리

Java 21의 sealed class + pattern matching은 함수형 언어의 대수적 데이터 타입(ADT)을 Java에 도입한 것입니다. 컴파일러가 모든 경우를 검증해주므로 런타임 에러를 원천 차단하고, record와 결합하면 불변 도메인 모델을 간결하게 표현할 수 있습니다. 기존 instanceof 체인이나 Visitor 패턴을 사용하고 있다면, sealed + switch로의 전환을 적극 권장합니다.

위로 스크롤
WordPress Appliance - Powered by TurnKey Linux