모던 자바 문법 총정리 — Records부터 Pattern Matching까지
Java 14부터 17까지, 자바에는 꽤 많은 문법 변화가 있었다. Records, Sealed Classes, Pattern Matching, Switch Expressions, Text Blocks — 하나하나는 작은 변화처럼 보이지만, 합치면 코드 스타일이 완전히 달라진다. 면접에서도 "모던 자바 문법 알고 있냐"는 질문이 점점 늘고 있어서, 이 참에 한 번 정리해봤다.
도입 버전 한눈에 보기
먼저 각 기능이 어떤 버전에서 정식 도입됐는지 정리한다.
| 기능 | JEP | 정식 도입 버전 |
|---|---|---|
| Switch Expressions | JEP 361 | Java 14 |
| Text Blocks | JEP 378 | Java 15 |
| Records | JEP 395 | Java 16 |
| Pattern Matching for instanceof | JEP 394 | Java 16 |
| Sealed Classes | JEP 409 | Java 17 |
공부하다 보면 preview 버전과 정식 버전이 헷갈리는데, 면접에서는 정식 도입 버전을 기준으로 답하면 된다.
Records (Java 16)
불변 데이터를 담기 위한 특수한 클래스다. DTO나 값 객체를 만들 때 보일러플레이트를 획기적으로 줄여준다.
기본 사용법
// 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]형태
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]
커스텀 생성자
검증 로직이 필요하면 컴팩트 생성자를 쓴다.
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 필드와 메서드는 추가할 수 있다.
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 같은 걸 코드에 넣을 때 특히 유용하다.
// 기존 방식 — 이스케이프와 + 연결의 지옥
String json = "{\n" +
" \"name\": \"홍길동\",\n" +
" \"age\": 25\n" +
"}";
// Text Block — 그냥 쓰면 된다
String json = """
{
"name": "홍길동",
"age": 25
}
""";
들여쓰기 규칙
Text Block의 들여쓰기는 닫는 """의 위치가 기준이 된다. 공통 들여쓰기는 자동으로 제거된다.
String sql = """
SELECT *
FROM users
WHERE age > 20
""";
// 결과: 각 줄 앞의 공통 공백이 제거됨
공부하면서 헷갈렸던 포인트가 하나 있었는데, 닫는 """를 마지막 줄에 붙이면 마지막 줄바꿈이 없어지고, 새 줄에 쓰면 줄바꿈이 포함된다.
// 마지막에 줄바꿈 없음
String noNewline = """
hello""";
// 마지막에 줄바꿈 있음
String withNewline = """
hello
""";
Pattern Matching for instanceof (Java 16)
타입 검사와 캐스팅을 한 번에 처리하는 문법이다.
// 기존 방식 — 검사 후 캐스팅을 별도로 해야 한다
if (obj instanceof String) {
String s = (String) obj;
System.out.println(s.length());
}
// 패턴 매칭 — 검사와 캐스팅이 한 줄
if (obj instanceof String s) {
System.out.println(s.length());
}
패턴 변수 s의 스코프는 컴파일러가 타입이 확실한 범위에서만 사용 가능하게 관리한다.
// 부정 조건에서도 사용 가능
if (!(obj instanceof String s)) {
return; // 여기서는 s 사용 불가
}
// 여기서는 s 사용 가능 — obj가 String인 게 확실하니까
System.out.println(s.toUpperCase());
이전에는 instanceof 뒤에 반드시 캐스팅 코드가 따라왔는데, 이제 그 보일러플레이트가 사라진 거다. 코드가 짧아지는 것뿐 아니라, 캐스팅 실수를 원천 차단한다는 점이 중요하다.
Switch Expressions (Java 14)
기존 switch 문의 문제점을 해결한 새로운 형태다.
기존 switch의 문제
// 기존 방식 — fall-through 때문에 break를 빼먹으면 다음 케이스까지 실행됨
String result;
switch (day) {
case MONDAY:
case TUESDAY:
result = "업무";
break;
case SATURDAY:
case SUNDAY:
result = "휴일";
break;
default:
result = "기타";
break;
}
Arrow Syntax
// Arrow syntax — fall-through 없음, 값 반환 가능
String result = switch (day) {
case MONDAY, TUESDAY -> "업무";
case SATURDAY, SUNDAY -> "휴일";
default -> "기타";
};
한 줄이면 -> 오른쪽에 바로 값을 쓰면 된다. 여러 줄이 필요하면 블록을 쓰고 yield로 값을 반환한다.
yield 키워드
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)
상속할 수 있는 클래스를 명시적으로 제한하는 기능이다.
// 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 체크가 가능하다.
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과의 조합까지 같이 설명하면 좋다. 이게 핵심이다.
// 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
둘 다 불변 객체를 만드는 용도인데, 차이점이 있다.
| Record | Lombok @Value | |
|---|---|---|
| 도입 | Java 16 표준 | 외부 라이브러리 |
| 상속 | 불가 (java.lang.Record 상속) | 클래스 자체는 final이지만 다른 클래스 extends 가능 |
| getter 이름 | name() | getName() |
| 커스터마이징 | 컴팩트 생성자, 메서드 추가 | @Builder, @With 등 풍부한 옵션 |
| 추가 필드 | 컴포넌트 외 인스턴스 필드 불가 | 자유롭게 추가 가능 |
| 직렬화 | 안전한 역직렬화 지원 | 별도 설정 필요 |
| 의존성 | 없음 (JDK 내장) | lombok 라이브러리 필요 |
어떤 걸 선택해야 할까
- Record를 쓸 때: 단순 데이터 캐리어, DTO, 값 객체. 추가 필드나 빌더가 필요 없을 때
- Lombok @Value를 쓸 때: 빌더 패턴이 필요하거나, 기존 클래스를 상속해야 하거나, 프로젝트에 이미 Lombok이 있을 때
개인적으로는 새 프로젝트라면 Record를 우선 고려하고, Record로 부족할 때 Lombok을 쓰는 방향이 좋다고 생각한다. 외부 의존성이 없는 게 장기적으로 유지보수에 유리하다.
실무에서의 조합 패턴
이 모던 문법들은 따로 쓰는 것보다 조합했을 때 진가가 발휘된다.
API 응답 처리
// 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());
};
}
커맨드 패턴
// 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은 타입 안전성 — 이런 식으로 목적과 연결해서 답하면 면접관 입장에서도 "아, 이해하고 쓰는 사람이구나" 싶을 거다.