Theme:

Java 14부터 17까지, 자바에는 꽤 많은 문법 변화가 있었다. Records, Sealed Classes, Pattern Matching, Switch Expressions, Text Blocks — 하나하나는 작은 변화처럼 보이지만, 합치면 코드 스타일이 완전히 달라진다. 면접에서도 "모던 자바 문법 알고 있냐"는 질문이 점점 늘고 있어서, 이 참에 한 번 정리해봤다.

도입 버전 한눈에 보기

먼저 각 기능이 어떤 버전에서 정식 도입됐는지 정리한다.

기능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()의 대칭성을 깨뜨릴 수 있기 때문이다.

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)

타입 검사와 캐스팅을 한 번에 처리하는 문법이다.

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());

이전에는 instanceof 뒤에 반드시 캐스팅 코드가 따라왔는데, 이제 그 보일러플레이트가 사라진 거다. 코드가 짧아지는 것뿐 아니라, 캐스팅 실수를 원천 차단한다는 점이 중요하다.

Switch Expressions (Java 14)

기존 switch 문의 문제점을 해결한 새로운 형태다.

기존 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)

상속할 수 있는 클래스를 명시적으로 제한하는 기능이다.

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());
    }
}

정리

모던 자바 문법의 핵심을 요약하면:

  • Records: 불변 데이터 객체를 한 줄로. DTO에 먼저 적용해보자
  • Text Blocks: JSON, SQL, HTML을 코드에 넣을 때 가독성이 확 올라간다
  • Pattern Matching for instanceof: 타입 검사 + 캐스팅을 한 방에. 보일러플레이트 제거
  • Switch Expressions: fall-through 없는 안전한 switch. 값 반환 가능
  • Sealed Classes: 상속 계층을 닫아서 Pattern Matching과 조합하면 컴파일 타임 안전성 확보

면접에서 이 기능들을 물어볼 때, 단순히 "이런 문법이 추가됐다"가 아니라 **"왜 추가됐고, 어떤 문제를 해결하는지"**를 설명할 수 있으면 좋다. Records는 보일러플레이트 제거, Sealed + Pattern Matching은 타입 안전성 — 이런 식으로 목적과 연결해서 답하면 면접관 입장에서도 "아, 이해하고 쓰는 사람이구나" 싶을 거다.

댓글 로딩 중...