Java 14부터 17까지 추가된 문법들을 합치면, 자바 코드의 스타일이 완전히 달라진다. 그런데 왜 이런 문법이 추가되었을까? 각 기능이 해결하는 문제를 이해하면 "언제 쓸지"도 자연스럽게 보인다.

도입 버전 한눈에 보기

각 기능이 어떤 버전에서 정식 도입됐는지 먼저 정리해볼게요.

기능JEP정식 도입 버전
Switch ExpressionsJEP 361Java 14
Text BlocksJEP 378Java 15
RecordsJEP 395Java 16
Pattern Matching for instanceofJEP 394Java 16
Sealed ClassesJEP 409Java 17

preview 버전과 정식 버전이 헷갈리기 쉬운데, 정식 도입 버전을 기준으로 기억하면 됩니다.

Records (Java 16)

불변 데이터를 담기 위한 특수한 클래스 입니다. DTO나 값 객체를 만들 때 반복되는 보일러플레이트를 제거하기 위해 도입되었어요.

기본 사용법

JAVA
// Record 선언 — 이게 끝이다
public record Point(int x, int y) {}

이 한 줄로 다음이 전부 자동 생성됩니다.

  • private final 필드 (x, y)
  • 모든 필드를 받는 생성자 (Canonical Constructor)
  • x(), y() — 접근자 메서드 (getter가 아니라 필드명 그대로)
  • equals(), hashCode() — 모든 컴포넌트 기반
  • toString()Point[x=1, y=2] 형태
JAVA
Point p1 = new Point(1, 2);
Point p2 = new Point(1, 2);

System.out.println(p1.x());        // 1 — getter가 아닌 컴포넌트명
System.out.println(p1.equals(p2)); // true — 값 기반 비교
System.out.println(p1);            // Point[x=1, y=2]

커스텀 생성자

검증 로직이 필요하면 컴팩트 생성자를 쓰면 됩니다.

JAVA
public record Age(int value) {
    // 컴팩트 생성자 — 파라미터 선언 없이 바로 검증
    public Age {
        if (value < 0 || value > 200) {
            throw new IllegalArgumentException("나이는 0~200 사이여야 합니다: " + value);
        }
    }
}

컴팩트 생성자에서는 this.value = value; 같은 할당을 직접 하지 않아요. 컴파일러가 자동으로 처리해줍니다.

Record의 제약

  • 다른 클래스를 상속할 수 없습니다 — 암묵적으로 java.lang.Record를 상속
  • ** 필드 추가 불가** — 컴포넌트 외에 인스턴스 필드를 선언할 수 없어요
  • ** 필드가 모두 final** — 수정 불가

대신 인터페이스 구현은 가능하고, static 필드와 메서드는 추가할 수 있습니다.

JAVA
public record ApiResponse<T>(int code, String message, T data)
    implements Serializable {

    // static 팩토리 메서드는 가능
    public static <T> ApiResponse<T> success(T data) {
        return new ApiResponse<>(200, "OK", data);
    }
}

Record가 상속을 허용하지 않는 이유는 ** 값 기반 동등성 보장** 때문입니다. 상속이 끼면 equals()의 대칭성이 깨질 수 있어요. 부모 타입과 자식 타입의 equals()가 서로 다르게 동작하면, a.equals(b)b.equals(a)의 결과가 달라지는 문제가 생깁니다.

Text Blocks (Java 15)

** 여러 줄 문자열을 깔끔하게 쓸 수 있는 문법 **입니다. JSON, SQL, HTML 같은 문자열을 코드에 넣을 때 이스케이프 지옥에서 벗어나게 해줘요.

JAVA
// 기존 방식 — 이스케이프와 + 연결의 지옥
String json = "{\n" +
    "  \"name\": \"홍길동\",\n" +
    "  \"age\": 25\n" +
    "}";

// Text Block — 그냥 쓰면 된다
String json = """
        {
          "name": "홍길동",
          "age": 25
        }
        """;

들여쓰기 규칙

Text Block의 들여쓰기는 닫는 """의 위치가 기준이 됩니다. 공통 들여쓰기는 자동으로 제거돼요.

JAVA
String sql = """
        SELECT *
        FROM users
        WHERE age > 20
        """;
// 결과: 각 줄 앞의 공통 공백이 제거됨

한 가지 주의할 점이 있어요. 닫는 """를 마지막 줄에 붙이면 마지막 줄바꿈이 없어지고, 새 줄에 쓰면 줄바꿈이 포함됩니다.

JAVA
// 마지막에 줄바꿈 없음
String noNewline = """
        hello""";

// 마지막에 줄바꿈 있음
String withNewline = """
        hello
        """;

Pattern Matching for instanceof (Java 16)

타입 검사와 캐스팅을 한 번에 처리하는 문법 입니다. 기존에는 instanceof로 확인한 뒤 별도로 캐스팅해야 했는데, 이 과정에서 캐스팅 실수가 발생할 수 있었어요.

JAVA
// 기존 방식 — 검사 후 캐스팅을 별도로 해야 한다
if (obj instanceof String) {
    String s = (String) obj;
    System.out.println(s.length());
}

// 패턴 매칭 — 검사와 캐스팅이 한 줄
if (obj instanceof String s) {
    System.out.println(s.length());
}

패턴 변수 s의 스코프는 컴파일러가 타입이 확실한 범위에서만 사용 가능하게 관리합니다.

JAVA
// 부정 조건에서도 사용 가능
if (!(obj instanceof String s)) {
    return; // 여기서는 s 사용 불가
}
// 여기서는 s 사용 가능 — obj가 String인 게 확실하니까
System.out.println(s.toUpperCase());

코드가 짧아지는 것뿐 아니라, 캐스팅 실수를 원천 차단한다 는 점이 핵심이에요. 패턴 변수의 타입은 컴파일러가 보장하므로, ClassCastException이 발생할 여지가 없습니다.

Switch Expressions (Java 14)

기존 switch 문의 fall-through 문제를 해결하고, 값을 반환할 수 있게 만든 새로운 형태 입니다.

기존 switch의 문제

JAVA
// 기존 방식 — fall-through 때문에 break를 빼먹으면 다음 케이스까지 실행됨
String result;
switch (day) {
    case MONDAY:
    case TUESDAY:
        result = "업무";
        break;
    case SATURDAY:
    case SUNDAY:
        result = "휴일";
        break;
    default:
        result = "기타";
        break;
}

Arrow Syntax

JAVA
// Arrow syntax — fall-through 없음, 값 반환 가능
String result = switch (day) {
    case MONDAY, TUESDAY -> "업무";
    case SATURDAY, SUNDAY -> "휴일";
    default -> "기타";
};

한 줄이면 -> 오른쪽에 바로 값을 쓰면 돼요. 여러 줄이 필요하면 블록을 쓰고 yield로 값을 반환합니다.

yield 키워드

JAVA
String result = switch (status) {
    case "OK" -> "성공";
    case "ERROR" -> {
        // 여러 줄 로직이 필요한 경우
        log.warn("에러 발생");
        yield "실패"; // yield로 값 반환
    }
    default -> "알 수 없음";
};

yield는 switch expression에서만 쓰는 키워드예요. return과 헷갈리기 쉬운데, return은 메서드를 빠져나가고 yield는 switch 블록의 결과값을 지정합니다.

핵심 동작 정리

  • Arrow syntax(->)는 fall-through가 없습니다break가 필요 없으므로 빼먹을 일도 없어요
  • Switch Expression은 값을 반환합니다 — 변수에 바로 할당 가능
  • exhaustiveness 체크 — enum을 switch에 쓰면 모든 케이스를 다뤘는지 컴파일러가 검증해요. 케이스를 하나 빠뜨리면 컴파일 에러가 발생합니다

Sealed Classes (Java 17)

** 상속할 수 있는 클래스를 명시적으로 제한하는 기능 **입니다. permits 키워드로 허용된 하위 클래스를 지정하면, 그 외의 클래스는 상속이 불가능해요.

JAVA
// Shape를 상속할 수 있는 클래스를 permits로 지정
public sealed class Shape
    permits Circle, Rectangle, Triangle {
}

// 하위 클래스는 final, sealed, non-sealed 중 하나를 선언해야 한다
public final class Circle extends Shape {
    private final double radius;

    public Circle(double radius) {
        this.radius = radius;
    }
}

public final class Rectangle extends Shape {
    private final double width, height;

    public Rectangle(double width, double height) {
        this.width = width;
        this.height = height;
    }
}

// non-sealed: 다른 클래스가 자유롭게 상속 가능
public non-sealed class Triangle extends Shape {
    // ...
}

final, sealed, non-sealed의 차이

  • final: 더 이상 상속 불가. 계층 구조가 여기서 끝남
  • sealed: 이 클래스도 다시 permits로 하위 클래스를 제한
  • non-sealed: 봉인을 풀고 누구나 상속 가능

non-sealed는 sealed 클래스의 하위에서 다시 상속을 개방하는 것이에요. 프레임워크 설계에서 ** 확장 포인트를 의도적으로 열어둘 때** 사용합니다.

Sealed + Pattern Matching 조합

이 둘이 합쳐지면 진짜 강력해집니다. sealed 클래스의 모든 하위 타입이 정해져 있으니, switch에서 exhaustiveness 체크가 가능해요.

JAVA
public sealed interface Shape
    permits Circle, Rectangle {}

public record Circle(double radius) implements Shape {}
public record Rectangle(double width, double height) implements Shape {}

// 모든 케이스를 다루면 default 불필요 — 컴파일러가 검증
public double area(Shape shape) {
    return switch (shape) {
        case Circle c -> Math.PI * c.radius() * c.radius();
        case Rectangle r -> r.width() * r.height();
        // default 없어도 컴파일 OK — 모든 하위 타입을 다뤘으니까
    };
}

이 조합의 장점을 정리하면요:

  • **컴파일 타임 안전성 **: 새로운 하위 타입을 추가하면, 해당 타입을 처리하지 않는 switch에서 컴파일 에러가 발생합니다
  • **default 불필요 **: 모든 케이스를 다루면 default가 필요 없어요
  • **if-else 체인 제거 **: 타입별 분기를 깔끔한 switch로 대체

Sealed Classes의 진짜 가치는 단순한 상속 제한이 아니라 Pattern Matching과의 조합 에 있어요. 이 조합이 왜 강력한지 코드로 확인해볼게요.

JAVA
// sealed + record + pattern matching의 실전 활용
public sealed interface PaymentResult
    permits Success, Failure, Pending {}

public record Success(String transactionId) implements PaymentResult {}
public record Failure(String errorCode, String message) implements PaymentResult {}
public record Pending(String redirectUrl) implements PaymentResult {}

// 처리하는 쪽 — 새 결과 타입이 추가되면 컴파일 에러로 잡아준다
public String handleResult(PaymentResult result) {
    return switch (result) {
        case Success s -> "결제 완료: " + s.transactionId();
        case Failure f -> "결제 실패: " + f.message();
        case Pending p -> "결제 진행중 — " + p.redirectUrl() + "로 이동";
    };
}

Record vs Lombok @Value

둘 다 불변 객체를 만드는 용도인데, 차이점이 있습니다.

RecordLombok @Value
도입Java 16 표준외부 라이브러리
** 상속**불가 (java.lang.Record 상속)클래스 자체는 final이지만 다른 클래스 extends 가능
getter 이름name()getName()
** 커스터마이징**컴팩트 생성자, 메서드 추가@Builder, @With 등 풍부한 옵션
** 추가 필드**컴포넌트 외 인스턴스 필드 불가자유롭게 추가 가능
** 직렬화**안전한 역직렬화 지원별도 설정 필요
** 의존성**없음 (JDK 내장)lombok 라이브러리 필요

어떤 걸 선택해야 할까

  • **Record를 쓸 때 **: 단순 데이터 캐리어, DTO, 값 객체. 추가 필드나 빌더가 필요 없을 때
  • **Lombok @Value를 쓸 때 **: 빌더 패턴이 필요하거나, 기존 클래스를 상속해야 하거나, 프로젝트에 이미 Lombok이 있을 때

새 프로젝트라면 Record를 우선 고려하고, Record로 부족할 때 Lombok을 쓰는 방향이 합리적입니다. 외부 의존성이 없는 쪽이 장기적으로 유지보수에 유리하기 때문이에요.

실무에서의 조합 패턴

이 모던 문법들은 따로 쓰는 것보다 조합했을 때 진가가 발휘됩니다.

API 응답 처리

JAVA
// sealed interface + record로 API 응답 모델링
public sealed interface ApiResult<T>
    permits ApiResult.Ok, ApiResult.Error {

    record Ok<T>(T data) implements ApiResult<T> {}
    record Error<T>(int code, String message) implements ApiResult<T> {}
}

// 사용하는 쪽
public String toMessage(ApiResult<?> result) {
    return switch (result) {
        case ApiResult.Ok<?> ok -> "성공: " + ok.data();
        case ApiResult.Error<?> err -> "에러 %d: %s".formatted(err.code(), err.message());
    };
}

커맨드 패턴

JAVA
// sealed + record로 커맨드 정의
public sealed interface Command
    permits CreateUser, DeleteUser, UpdateEmail {}

public record CreateUser(String name, String email) implements Command {}
public record DeleteUser(long userId) implements Command {}
public record UpdateEmail(long userId, String newEmail) implements Command {}

// 핸들러 — 새 커맨드 추가 시 컴파일 에러로 누락 방지
public void handle(Command cmd) {
    switch (cmd) {
        case CreateUser c -> userService.create(c.name(), c.email());
        case DeleteUser d -> userService.delete(d.userId());
        case UpdateEmail u -> userService.updateEmail(u.userId(), u.newEmail());
    }
}

Java 21 이후 — 더 나아간 모던 문법

Java 21 이후에도 개발자 편의를 위한 문법 개선이 계속되고 있어요. 여기서는 간략히 소개하고, 상세 내용은 별도 글을 참고하세요.

Sequenced Collections (Java 21)

순서가 있는 컬렉션(List, LinkedHashSet, LinkedHashMap 등)에 ** 통일된 접근 메서드 **가 추가되었습니다. 20년 넘게 제각각이던 "첫 번째/마지막 요소 접근"이 getFirst(), getLast(), reversed()로 통일됐어요.

JAVA
var list = List.of("A", "B", "C");
list.getFirst();   // A
list.getLast();    // C
list.reversed();   // [C, B, A] — 역순 뷰 (복사 아님)

상세: Sequenced Collections — Java 21의 새 컬렉션 인터페이스

Unnamed Variables (Java 22)

사용하지 않는 변수를 _로 명시할 수 있게 되었습니다.

JAVA
try { parseJson(input); }
catch (JsonException _) { return fallback; }  // 예외 객체 안 씀

map.forEach((_, value) -> process(value));     // 키 안 씀

상세: Java 22-25 신규 기능

주의할 점

Record를 엔티티로 쓰면 안 됩니다

Record는 불변이고 기본 생성자가 없습니다. JPA 엔티티는 프록시 생성을 위해 기본 생성자와 mutable 필드가 필요하므로, Record를 @Entity로 쓰면 Hibernate가 제대로 동작하지 않아요. Record는 DTO나 값 객체 용도로만 사용해야 합니다.

Sealed Classes의 permits와 패키지 제약

permits에 지정하는 하위 클래스는 ** 같은 패키지(또는 같은 모듈)**에 있어야 합니다. 다른 패키지의 클래스를 permits에 넣으면 컴파일 에러가 발생해요. 모듈 시스템을 쓰지 않는다면 같은 패키지에 모아두는 것이 안전합니다.

Text Block의 들여쓰기가 예상과 다를 수 있습니다

닫는 """의 위치가 기준이 되므로, IDE의 자동 포맷팅과 충돌하는 경우가 있어요. 특히 formatted() 메서드와 조합할 때 들여쓰기가 의도와 다르게 나올 수 있으니, stripIndent() 동작을 이해하고 사용해야 합니다.

정리

기능해결하는 문제핵심 특성
RecordsDTO/값 객체 보일러플레이트불변, 자동 equals/hashCode/toString, 상속 불가
Text Blocks여러 줄 문자열의 이스케이프 지옥닫는 """가 들여쓰기 기준, 줄바꿈 제어 가능
Pattern Matchinginstanceof 후 캐스팅 반복타입 검사+캐스팅 한 번에, 스코프 자동 관리
Switch Expressionsfall-through 버그, 값 반환 불가화살표 문법, yield, exhaustiveness 체크
Sealed Classes상속 계층 통제 불가permits로 하위 타입 제한, Pattern Matching과 조합 시 컴파일 타임 안전성
댓글 로딩 중...