Java Pattern Matching 완벽 가이드: instanceof부터 record 분해까지

타입을 확인하고, 캐스팅하고, 변수에 담고, 그제야 값을 쓴다. instanceof로 분기하는 코드를 짜다 보면 이 세 줄이 타입마다 반복된다. 여기에 타입이 늘어나면 if-else instanceof 체인이 길어지고, 어떤 타입을 빠뜨렸는지도 눈으로 좇아야 한다. Java Pattern Matching은 이 반복을 언어 차원에서 걷어낸다. Java 16의 instanceof 패턴으로 캐스팅이 사라지고, Java 21에서 정식화된 switch 패턴과 record 패턴으로 타입 분기와 데이터 분해가 한 줄로 접힌다. sealed 타입과 만나면 컴파일러가 “안 다룬 타입 있다”고 잡아주기까지 한다. 이번 포스팅에서는 Java Pattern Matching을 instanceof부터 record 분해, 그리고 실무에서 반드시 만나는 case 순서 함정까지 정리하고자 한다.

Java Pattern Matching의 시작 — instanceof 캐스팅 없애기

Java Pattern Matching의 출발점은 instanceof 패턴이다. Java 16부터 instanceof로 타입을 확인하면서 동시에 그 타입의 변수를 바로 선언할 수 있어, 별도의 캐스팅 한 줄이 사라진다. 타입 검사에 성공하면 패턴 변수에 캐스팅된 값이 자동으로 담긴다.

// Java 16 이전 — 확인하고 따로 캐스팅
if (obj instanceof String) {
    String s = (String) obj;
    System.out.println(s.length());
}

// Java 16부터 — 확인과 동시에 변수 바인딩
if (obj instanceof String s) {
    System.out.println(s.length());   // s는 이미 String
}
Java

위 두 코드는 같은 일을 하지만, 아래는 (String) obj 캐스팅과 변수 선언이 instanceof String s 한 곳으로 합쳐졌다. s는 조건이 참인 블록 안에서만 유효하고, 이미 String으로 캐스팅된 상태라 바로 메서드를 호출할 수 있다. 이 패턴 변수는 &&로 이어지는 조건에서도 쓸 수 있어서, if (obj instanceof String s && s.length() > 3)처럼 검사와 활용을 한 줄에 담는 것도 가능하다. 이것이 JEP 394로 정식화된 Pattern Matching for instanceof다.

if-else instanceof 체인을 switch로 접기

Java Pattern Matching에서 여러 타입을 분기할 때는 switch 패턴이 if-else instanceof 체인을 통째로 대체한다. Java 21부터 switch의 case label에 타입 패턴을 쓸 수 있어, 타입마다 자동으로 캐스팅된 변수를 받으며 분기한다. 길게 늘어지던 else-if가 평평한 case 목록으로 접힌다.

// Java 21 이전 — instanceof 체인
static String format(Object obj) {
    String result = "unknown";
    if (obj instanceof Integer i) {
        result = String.format("int %d", i);
    } else if (obj instanceof Long l) {
        result = String.format("long %d", l);
    } else if (obj instanceof Double d) {
        result = String.format("double %f", d);
    } else if (obj instanceof String s) {
        result = String.format("String %s", s);
    }
    return result;
}

// Java 21부터 — switch 패턴
static String format(Object obj) {
    return switch (obj) {
        case Integer i -> String.format("int %d", i);
        case Long l    -> String.format("long %d", l);
        case Double d  -> String.format("double %f", d);
        case String s  -> String.format("String %s", s);
        default        -> "unknown";
    };
}
Java

두 코드는 동작이 같지만, switch 버전은 각 타입이 case Integer i처럼 한 줄로 정렬되고 캐스팅도 자동이다. 무엇보다 switch expression은 값을 직접 반환하므로 result 임시 변수도 사라진다. 이 기능은 JEP 441로 Java 21에서 정식(final)이 됐다. 참고로 이런 스타일은 Java 25 주요 변경에서 다룬 최신 문법들과도 자연스럽게 어울린다.

record를 통째로 분해하기

Java Pattern Matching에서 record를 다룰 때는 record 패턴으로 필드를 한 번에 꺼낸다. Java 21부터 Point(int x, int y)처럼 record의 구조를 패턴에 그대로 적어, 타입 확인과 동시에 내부 필드를 지역 변수로 분해(deconstruction)할 수 있다. 접근자(p.x())를 하나씩 호출하던 코드가 사라진다.

record Point(int x, int y) {}

// Java 21 이전 — 타입 확인 후 접근자 호출
static void printSum(Object obj) {
    if (obj instanceof Point p) {
        int x = p.x();
        int y = p.y();
        System.out.println(x + y);
    }
}

// Java 21부터 — record 패턴으로 바로 분해
static void printSum(Object obj) {
    if (obj instanceof Point(int x, int y)) {
        System.out.println(x + y);   // x, y가 바로 나온다
    }
}
Java

instanceof Point(int x, int y)는 “obj가 Point이고, 그 x·y를 각각 x, y로 받겠다”는 뜻이다. 타입 검사와 필드 추출이 한 번에 이뤄진다. 필드 타입을 명시하기 싫으면 Point(var x, var y)로 추론하게 둘 수도 있다. 이 record 패턴은 JEP 440으로 Java 21에서 정식화됐고, switch와 함께 쓸 때 진가를 발휘한다.

중첩된 record도 한 번에 꺼낸다

record 패턴의 강점은 중첩 구조에서 드러난다. record 안에 record가 들어 있어도, 패턴을 중첩해서 안쪽 필드까지 한 번에 분해할 수 있다. 여러 단계의 접근자 호출(r.upperLeft().color())이 패턴 하나로 접힌다.

record Point(int x, int y) {}
enum Color { RED, GREEN, BLUE }
record ColoredPoint(Point point, Color color) {}
record Rectangle(ColoredPoint upperLeft, ColoredPoint lowerRight) {}

// 중첩 record 패턴 — 안쪽 x, y, color까지 한 번에
static void printUpperLeftColor(Object obj) {
    if (obj instanceof Rectangle(ColoredPoint(Point(var x, var y), var color), var lowerRight)) {
        System.out.println("좌상단: (" + x + ", " + y + "), 색: " + color);
    }
}
Java

Rectangle(ColoredPoint(Point(var x, var y), var color), var lowerRight)는 Rectangle → ColoredPoint → Point까지 파고들어 x, y, color를 한 번에 뽑아낸다. 접근자를 세 번 체이닝하던 코드가 패턴 한 줄로 정리된다. 다만 중첩이 깊어지면 패턴 자체가 읽기 어려워질 수 있으니, 실제로 안쪽 값이 다 필요한 경우에만 깊게 분해하고 그렇지 않으면 필요한 깊이까지만 꺼내는 편이 낫다.

조건을 덧붙이려면 — when 가드

Java Pattern Matching에서 타입만으로 부족하고 값 조건까지 걸어야 할 때는 guarded pattern을 쓴다. case 뒤에 when 절을 붙이면 “이 타입이면서 이 조건도 만족할 때”만 매칭된다. 타입 분기 안에서 다시 if로 값을 검사하던 코드가 case 한 줄로 합쳐진다.

static String describe(Object obj) {
    return switch (obj) {
        case Integer i when i < 0  -> "음수 " + i;
        case Integer i when i == 0 -> "영";
        case Integer i             -> "양수 " + i;   // 위 두 가드에 안 걸린 나머지
        case String s when s.isBlank() -> "빈 문자열";
        case String s              -> "문자열 " + s;
        default                    -> "기타";
    };
}
Java

case Integer i when i < 0은 “Integer이면서 음수일 때”만 매칭된다. 같은 타입에 여러 조건을 걸 때는 when이 붙은 구체적인 case를 먼저 두고, 아무 가드도 없는 case를 마지막에 둬야 한다. 순서를 뒤집어 가드 없는 case Integer i를 먼저 두면 컴파일 에러가 나는데(가드 없는 case가 같은 타입의 가드 있는 case를 지배한다), 이 규칙이 다음에 설명할 “case 순서 함정”의 핵심이다. guarded pattern도 JEP 441에 함께 포함돼 Java 21에서 정식화됐다.

Java Pattern Matching과 sealed — default가 사라진다

sealed 타입과 switch 패턴을 함께 쓰면 default 없이도 컴파일이 된다. sealed는 하위 타입을 제한하므로, switch가 그 하위 타입을 전부 다루면 컴파일러가 “모든 경우를 처리했다(exhaustive)”고 인정해 default를 요구하지 않는다. 대신 나중에 하위 타입을 추가하면 그 switch에서 컴파일 에러가 나서, 빠뜨린 곳을 컴파일 단계에서 잡아준다.

sealed interface Shape permits Circle, Rectangle, Triangle {}
record Circle(double radius) implements Shape {}
record Rectangle(double width, double height) implements Shape {}
record Triangle(double base, double height) implements Shape {}

// default 없이도 컴파일 OK — Shape의 모든 하위 타입을 다뤘으므로
static double area(Shape shape) {
    return switch (shape) {
        case Circle(double r)            -> Math.PI * r * r;
        case Rectangle(double w, double h) -> w * h;
        case Triangle(double b, double h)  -> 0.5 * b * h;
    };
}
Java

Shape가 sealed라 하위 타입이 Circle, Rectangle, Triangle 셋뿐임이 보장된다. switch가 이 셋을 모두 다루므로 default가 필요 없다. 이게 왜 좋냐면, 만약 나중에 ShapePentagon을 추가하면 위 area switch가 컴파일 에러를 낸다. “Pentagon을 안 다뤘다”고 컴파일러가 알려주는 것이다. default로 대충 넘겼다면 런타임에야 발견됐을 누락을, sealed + exhaustive switch는 컴파일 시점에 잡는다. 이 조합이 Java Pattern Matching의 실무적 가치가 가장 큰 지점이다.

null은 case null로 명시한다

switch 패턴에서 null은 별도로 처리해야 한다. 전통적인 switch는 null을 만나면 NullPointerException을 던졌지만, 패턴 switch에서는 case null을 명시적으로 둬서 null을 하나의 분기로 다룰 수 있다. 이걸 빼먹으면 여전히 NPE가 발생한다.

static String describe(Object obj) {
    return switch (obj) {
        case null      -> "널";                 // null 전용 분기
        case Integer i -> "정수 " + i;
        case String s  -> "문자열 " + s;
        default        -> "기타";
    };
}
Java

case null을 두면 obj가 null일 때 NPE 없이 “널” 분기로 간다. case null, default ->처럼 null과 default를 한 케이스로 묶는 것도 가능하다. 반대로 case null이 없는 패턴 switch에 null이 들어오면 예전처럼 NPE가 난다. 외부에서 들어오는 값을 switch로 분기한다면 null 가능성을 먼저 따져 case null을 둘지 결정하는 게 안전하다.

흔한 함정 — case 순서(dominance)

Java Pattern Matching을 쓰면서 가장 많이 걸리는 실수는 case 순서다. 더 일반적인(넓은) 패턴을 구체적인 패턴보다 먼저 두면, 뒤의 구체적인 case에 값이 도달하지 못한다. 컴파일러는 이런 “지배(dominance)” 관계를 감지해 대부분 컴파일 에러로 막지만, 가드가 낀 경우엔 논리 실수로 이어지기 쉽다.

// ✗ 컴파일 에러 — Number가 Integer를 가려버림(dominance)
switch (obj) {
    case Number n  -> ...;   // Integer도 Number라 여기서 다 걸림
    case Integer i -> ...;   // 도달 불가 → 컴파일 에러
}

// ✓ 구체적인 것을 먼저
switch (obj) {
    case Integer i -> ...;   // 먼저 걸러내고
    case Number n  -> ...;   // 나머지 Number
}
Java

규칙은 단순하다. 구체적인 타입·가드가 있는 case를 먼저, 일반적인 case를 나중에 둔다. 상위 타입(Number)을 하위 타입(Integer)보다 먼저 두면 하위 case가 지배당해 컴파일 에러가 나고, 가드 있는 case(case Integer i when ...)를 가드 없는 같은 타입 case보다 뒤에 둬도 마찬가지로 컴파일 에러가 난다(가드 없는 case가 가드 있는 case를 지배하기 때문이다). 타입 switch를 짤 때 “위에서 아래로 갈수록 넓어진다”는 순서만 지키면 이 함정은 대부분 피할 수 있다.

FAQ

Java Pattern Matching은 어느 버전부터 쓸 수 있나

Java Pattern Matching은 기능마다 정식화 시점이 다르다. instanceof 패턴은 Java 16, switch 패턴과 record 패턴은 Java 21에서 정식(final)이 됐다. 따라서 switch·record 패턴까지 온전히 쓰려면 Java 21 이상이 필요하다. Java 21은 LTS이므로 실무 도입 기준으로 삼기 좋다.

switch 패턴에서 default를 꼭 써야 하나

selector 타입이 sealed이고 모든 하위 타입을 case로 다뤘다면 default 없이도 컴파일된다(exhaustive). 반대로 Object처럼 하위 타입이 열려 있는 경우엔 컴파일러가 모든 경우를 보장할 수 없어 default가 필요하다. sealed + 완전한 case 조합이 default 생략과 컴파일 시점 누락 검출이라는 두 이점을 동시에 준다.

record 패턴에서 필드 타입을 꼭 적어야 하나

아니다. Point(var x, var y)처럼 var로 두면 컴파일러가 타입을 추론한다. 필드 타입이 명확하거나 문서화가 중요하면 Point(int x, int y)로 명시하고, 간결함이 우선이면 var를 쓰면 된다. 둘은 동작이 같다.

case 순서를 잘못 두면 어떻게 되나

더 일반적인 패턴을 구체적인 패턴보다 먼저 두면 뒤의 case가 도달 불가가 되고, 대부분 “dominance” 컴파일 에러로 막힌다. 상위 타입을 하위 타입보다 먼저 두거나, 가드 없는 case를 같은 타입의 가드 있는 case보다 먼저 두는 경우가 여기에 해당한다. 다만 가드끼리 포함 관계일 때(예: case Integer i when i > 0case Integer i when i > 10보다 먼저)는 컴파일러가 가드 식을 평가하지 않아 컴파일은 되면서 뒤 case가 안 타는 논리 버그가 될 수 있다. 어느 쪽이든 구체적·가드 있는 case를 위에, 일반 case를 아래에 두는 순서를 지키는 게 안전하다.

언제 if-else 대신 패턴 매칭을 써야 하나

타입에 따라 분기하거나 데이터 구조를 분해하는 코드라면 Java Pattern Matching이 거의 항상 더 짧고 안전하다. 특히 sealed 타입을 다룰 때는 exhaustive switch로 누락을 컴파일 시점에 잡을 수 있어 이점이 크다. 반대로 단순 boolean 조건 하나를 검사하는 경우엔 기존 if가 더 자연스럽다.

직접 실행해 확인하기

지금까지의 기능을 한 파일로 모아 실제로 돌려보면 이해가 확실해진다. 아래는 sealed·record·switch 패턴·guarded pattern·case null을 모두 담은 완결 프로그램이다. Java 21 이상이면 컴파일 없이 java PatternMatchingDemo.java로 바로 실행된다.

import java.util.List;

public class PatternMatchingDemo {

    // sealed 계층 + record: switch 패턴의 대상
    sealed interface Shape permits Circle, Rectangle, Triangle {}
    record Circle(double radius) implements Shape {}
    record Rectangle(double width, double height) implements Shape {}
    record Triangle(double base, double height) implements Shape {}

    // record 패턴으로 필드를 바로 분해하고, sealed라 default 없이 exhaustive
    static double area(Shape shape) {
        return switch (shape) {
            case Circle(double r)              -> Math.PI * r * r;
            case Rectangle(double w, double h) -> w * h;
            case Triangle(double b, double h)  -> 0.5 * b * h;
        };
    }

    // switch 타입 패턴 + guarded pattern(when) + case null
    static String describe(Object obj) {
        return switch (obj) {
            case null                  -> "널";
            case Integer i when i < 0  -> "음수 " + i;
            case Integer i             -> "양수/영 " + i;
            case String s              -> "문자열 \"" + s + "\"";
            default                    -> "기타: " + obj;
        };
    }

    public static void main(String[] args) {
        List<Shape> shapes = List.of(
                new Circle(2.0), new Rectangle(3.0, 4.0), new Triangle(6.0, 5.0));
        for (Shape s : shapes) {
            System.out.printf("%-24s area = %.2f%n", s, area(s));
        }
        for (Object o : new Object[]{-3, 7, "hello", 3.14, null}) {
            System.out.println(describe(o));
        }
    }
}
Java

이 프로그램을 Java 25로 실행한 결과가 아래다. area는 record 패턴으로 각 도형의 필드를 분해해 넓이를 계산하고(sealed라 default 없이 컴파일됐다), describe는 타입 패턴·when 가드·case null로 값을 분기한다. 특히 마지막 null이 NPE 없이 “널”로 처리되는 것이 case null의 효과다.

마치며

패턴 매칭을 처음 도입했을 때 가장 체감된 건 코드가 짧아진 것보다, sealed 타입에 새 구현을 추가하자마자 관련 switch들이 줄줄이 컴파일 에러를 내준 순간이었다. 예전 같으면 default에 숨어 런타임까지 갔을 누락을, 컴파일러가 “여기 Pentagon 안 다뤘다”고 즉시 짚어줬다. 캐스팅이 사라지고 record가 한 줄로 분해되는 것도 편하지만, 진짜 값어치는 “빠뜨린 경우를 컴파일 단계에서 못 넘어가게 만드는” 안전성에 있다고 느낀다. 물론 case 순서 함정처럼 처음엔 헷갈리는 지점도 있지만, “구체적인 것부터 위에” 원칙 하나만 몸에 익히면 대부분 자연스럽게 풀린다.