Java 21에서 정식 도입된 패턴 매칭(Pattern Matching)과 Record Patterns는 Java의 타입 검사와 데이터 추출 방식을 근본적으로 바꾸었다. instanceof 체크 후 캐스팅하던 장황한 코드가 간결하고 안전한 패턴 표현으로 대체된다. 이 글에서는 Java 21~23의 패턴 매칭 기능을 체계적으로 정리한다.

1. switch 패턴 매칭 (Java 21 정식)

switch문에서 타입 패턴을 사용하여 instanceof 검사와 변수 바인딩을 동시에 수행할 수 있다.

// 기존 방식 - 장황한 instanceof + 캐스팅
public String formatValue(Object obj) {
    if (obj instanceof Integer i) {
        return String.format("정수: %,d", i);
    } else if (obj instanceof Double d) {
        return String.format("실수: %.2f", d);
    } else if (obj instanceof String s) {
        return String.format("문자열: \"%s\" (길이: %d)", s, s.length());
    } else if (obj instanceof List<?> list) {
        return String.format("리스트: %d개 항목", list.size());
    } else if (obj == null) {
        return "null";
    } else {
        return obj.toString();
    }
}

// Java 21 - switch 패턴 매칭
public String formatValue(Object obj) {
    return switch (obj) {
        case Integer i  -> String.format("정수: %,d", i);
        case Double d   -> String.format("실수: %.2f", d);
        case String s   -> String.format("문자열: \"%s\" (길이: %d)", s, s.length());
        case List<?> list -> String.format("리스트: %d개 항목", list.size());
        case null       -> "null";     // null도 패턴으로 처리
        default         -> obj.toString();
    };
}

2. Guarded Patterns (when 절)

패턴에 조건을 추가하여 더 세밀한 분기가 가능하다.

public String classifyAge(Object obj) {
    return switch (obj) {
        case Integer age when age < 0   -> "잘못된 나이";
        case Integer age when age < 13  -> "어린이";
        case Integer age when age < 20  -> "청소년";
        case Integer age when age < 65  -> "성인";
        case Integer age                -> "시니어";
        case String s when s.matches("\\d+") -> classifyAge(Integer.parseInt(s));
        case null, default              -> "알 수 없음";
    };
}

// HTTP 응답 처리
public String handleResponse(HttpResponse<?> response) {
    return switch (response.statusCode()) {
        case int code when code >= 200 && code < 300 -> "성공: " + response.body();
        case int code when code == 301 || code == 302 -> "리다이렉트: " + response.headers().firstValue("Location").orElse("");
        case int code when code == 404 -> "페이지 없음";
        case int code when code >= 500 -> "서버 오류 (" + code + ")";
        default -> "기타 응답: " + response.statusCode();
    };
}

3. Record Patterns (레코드 패턴)

Record의 컴포넌트를 패턴 매칭으로 직접 분해(destructuring)할 수 있다.

// Record 정의
record Point(int x, int y) {}
record Circle(Point center, double radius) {}
record Rectangle(Point topLeft, Point bottomRight) {}

sealed interface Shape permits Circle, Rectangle {}

// Record Pattern으로 중첩 분해
public double calculateArea(Shape shape) {
    return switch (shape) {
        case Circle(Point(var cx, var cy), var r) -> {
            System.out.printf("원: 중심(%d, %d), 반지름=%.1f%n", cx, cy, r);
            yield Math.PI * r * r;
        }
        case Rectangle(
            Point(var x1, var y1),
            Point(var x2, var y2)
        ) -> {
            double width = Math.abs(x2 - x1);
            double height = Math.abs(y2 - y1);
            System.out.printf("사각형: (%d,%d)-(%d,%d), %n", x1, y1, x2, y2);
            yield width * height;
        }
    }; // sealed interface이므로 default 불필요
}

// 사용
Shape circle = new Circle(new Point(0, 0), 5.0);
System.out.println("면적: " + calculateArea(circle));
// 원: 중심(0, 0), 반지름=5.0
// 면적: 78.53981633974483

4. Sealed Classes + 패턴 매칭 조합

sealed class/interface와 패턴 매칭을 결합하면 컴파일러가 모든 경우를 검증해준다.

// 결제 도메인 모델
sealed interface PaymentMethod permits CreditCard, BankTransfer, DigitalWallet {}

record CreditCard(String number, String expiry, String cvv) implements PaymentMethod {}
record BankTransfer(String bankCode, String accountNumber) implements PaymentMethod {}
record DigitalWallet(String provider, String email) implements PaymentMethod {}

sealed interface PaymentResult permits Success, Failure, Pending {}
record Success(String transactionId, Instant timestamp) implements PaymentResult {}
record Failure(String errorCode, String message) implements PaymentResult {}
record Pending(String referenceId, Duration estimatedTime) implements PaymentResult {}

// 패턴 매칭으로 모든 경우 처리
public String processPayment(PaymentMethod method, BigDecimal amount) {
    // 결제 수단별 처리
    String gateway = switch (method) {
        case CreditCard(var num, var exp, _) -> {
            // 카드번호 마스킹
            String masked = "****-****-****-" + num.substring(num.length() - 4);
            System.out.println("카드 결제: " + masked + " (만료: " + exp + ")");
            yield "card-gateway";
        }
        case BankTransfer(var bank, var account) -> {
            System.out.println("계좌이체: " + bank + " " + account);
            yield "bank-gateway";
        }
        case DigitalWallet(var provider, var email) -> {
            System.out.println(provider + " 결제: " + email);
            yield provider.toLowerCase() + "-gateway";
        }
    }; // sealed이므로 모든 케이스가 처리됨을 컴파일러가 보장
    
    return gateway;
}

// 결과 처리
public void handleResult(PaymentResult result) {
    switch (result) {
        case Success(var txId, var ts) ->
            System.out.printf("결제 성공: %s (%s)%n", txId, ts);
        case Failure(var code, var msg) when code.startsWith("TEMP_") ->
            System.out.printf("일시 오류(%s): %s - 재시도 예정%n", code, msg);
        case Failure(var code, var msg) ->
            System.out.printf("결제 실패(%s): %s%n", code, msg);
        case Pending(var refId, var eta) ->
            System.out.printf("처리 중: %s (예상 %d초)%n", refId, eta.toSeconds());
    }
}

5. Unnamed Patterns과 Variables (Java 22)

사용하지 않는 변수를 밑줄(_)로 표시하여 의도를 명확히 할 수 있다.

// _ (언더스코어)로 사용하지 않는 변수 표시
record Order(long id, String product, int quantity, BigDecimal price) {}

// 제품명만 필요한 경우
public List<String> getProductNames(List<Order> orders) {
    return orders.stream()
        .map(order -> switch (order) {
            case Order(_, var product, _, _) -> product;
        })
        .distinct()
        .toList();
}

// try-catch에서 사용하지 않는 예외 변수
try {
    processData();
} catch (IllegalArgumentException _) {
    // 예외 객체를 사용하지 않음을 명시
    System.out.println("잘못된 인자");
} catch (Exception _) {
    System.out.println("기타 오류");
}

// 람다에서 사용하지 않는 파라미터
map.forEach((_, value) -> System.out.println(value));

6. 실전 활용: JSON 파서

sealed interface JsonValue permits JsonString, JsonNumber, JsonBool, JsonNull, JsonArray, JsonObject {}
record JsonString(String value) implements JsonValue {}
record JsonNumber(double value) implements JsonValue {}
record JsonBool(boolean value) implements JsonValue {}
record JsonNull() implements JsonValue {}
record JsonArray(List<JsonValue> elements) implements JsonValue {}
record JsonObject(Map<String, JsonValue> fields) implements JsonValue {}

public String toJsonString(JsonValue value) {
    return switch (value) {
        case JsonString(var s)    -> "\"" + escapeJson(s) + "\"";
        case JsonNumber(var n)    -> Double.toString(n);
        case JsonBool(var b)      -> Boolean.toString(b);
        case JsonNull()           -> "null";
        case JsonArray(var elems) -> "[" + elems.stream()
            .map(this::toJsonString)
            .collect(Collectors.joining(", ")) + "]";
        case JsonObject(var fields) -> "{" + fields.entrySet().stream()
            .map(e -> "\"" + e.getKey() + "\": " + toJsonString(e.getValue()))
            .collect(Collectors.joining(", ")) + "}";
    };
}

패턴 매칭과 Record Patterns는 Java를 더 표현력 있고 안전한 언어로 만들었다. 특히 sealed interface와 결합하면 컴파일 타임에 모든 경우를 빠짐없이 처리하는지 검증할 수 있어, 런타임 오류를 크게 줄일 수 있다. 새 프로젝트에서는 적극적으로 활용할 것을 권장한다.