| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | 5 | 6 | |
| 7 | 8 | 9 | 10 | 11 | 12 | 13 |
| 14 | 15 | 16 | 17 | 18 | 19 | 20 |
| 21 | 22 | 23 | 24 | 25 | 26 | 27 |
| 28 | 29 | 30 |
- SpringCloud
- MSA
- springboot
- github actions
- Java 8
- 백엔드면접준비
- 마이크로서비스
- 컨테이너
- Flyway
- 트러블슈팅
- 자바
- ci/cd
- 멀티모듈
- GitHub Packages
- dockercompose
- 백엔드
- java
- Database
- 인프라
- 분산시스템
- docker
- PostgreSQL
- 도커
- 아키텍처
- 공통모듈
- 마이그레이션
- 마이크로서비스아키텍처
- gradle
- CS
- GCP
- Today
- Total
NYO_O
Java 21 본문
지난 시간, 우리는 Java 17에서 Records, Sealed Classes, Pattern Matching for instanceof, Text Blocks가 어떤 배경에서 등장했는지 살펴보았습니다. 언어의 표현력을 한 단계 끌어올리는 변화들이었고, 동시에 Java 21을 위한 언어적 기반을 다지는 과정이기도 했습니다.
2026.05.29 - [BackEnd/Java] - Java 17
Java 17
지난 시간, 우리는 Java 11이 일상적인 코드의 군더더기를 줄이고 표준 라이브러리의 공백을 채운 버전이었음을 살펴보았습니다. var, HTTP Client, String API 개선 등 실용적인 변화들이 중심이었습니
ddangnyo.tistory.com
Java 21은 2023년에 출시된 LTS 버전입니다. Java 17에서 예고된 기능들이 완성되는 동시에, 자바의 동시성 모델 자체를 바꾸는 변화가 함께 담겨 있습니다. 언어 표현력의 완성과 동시성 혁신이 한 버전에 담긴 셈입니다.
오늘은 Java 21의 주요 변경 사항을 하나씩 살펴보겠습니다.
Java 21이 특별한 이유
Java 8은 함수형 패러다임을 언어에 녹였고, Java 17은 언어 표현력을 높였습니다. Java 21은 여기에 더해 동시성 모델 자체를 재설계했습니다. Virtual Thread의 정식화가 그 핵심입니다. 기존 방식으로는 높은 동시성을 확보하기 위해 Reactive Programming이라는 복잡한 패러다임을 배워야 했지만, Virtual Thread는 기존 동기 코드 스타일을 그대로 유지하면서 같은 수준의 효율을 얻을 수 있게 해줍니다.
Record Patterns
사전 지식 — Pattern Matching for instanceof (Java 17) instanceof로 타입을 확인하는 동시에 변수에 바인딩하는 기능입니다. if (obj instanceof String s)처럼 쓰면 별도 캐스팅 없이 바로 s를 사용할 수 있습니다. Java 17에서 정식화되었으며, Record Patterns는 이 위에 쌓아 올린 기능입니다.
Java 16에서 Record가 정식화되었고, Java 21에서는 Record Patterns가 정식화되었습니다. Record의 컴포넌트를 패턴 매칭과 결합하여 중첩된 데이터 구조를 한 번에 분해할 수 있습니다.
기존 방식과 비교하면 차이가 명확합니다.
// Java 17 방식 — 타입 확인 후 접근자를 일일이 호출
if (obj instanceof Point p) {
int x = p.x();
int y = p.y();
System.out.println(x + ", " + y);
}
// Java 21 Record Patterns — 분해와 바인딩을 한 번에
if (obj instanceof Point(int x, int y)) {
System.out.println(x + ", " + y);
}
중첩 구조에서 더욱 진가를 발휘합니다.
record Point(int x, int y) {}
record ColoredPoint(Point point, String color) {}
Object obj = new ColoredPoint(new Point(3, 5), "RED");
// 중첩 Record Pattern — 한 번에 안쪽까지 분해
if (obj instanceof ColoredPoint(Point(int x, int y), String color)) {
System.out.println("x=" + x + ", y=" + y + ", color=" + color);
}
switch 표현식과 결합하면 복잡한 분기 로직도 읽기 쉽게 표현할 수 있습니다.
String describe(Object shape) {
return switch (shape) {
case Circle(double r) -> "반지름 " + r + "인 원";
case Rectangle(double w, double h) -> w + " x " + h + " 직사각형";
default -> "알 수 없는 도형";
};
}
DTO 변환이나 응답 객체 처리 코드에서 반복적인 접근자 호출을 줄일 수 있어 서비스 레이어 코드가 훨씬 간결해집니다.
Pattern Matching for switch 정식화
Java 17에서 Preview로 제공되던 Pattern Matching for switch가 Java 21에서 정식화되었습니다. Java 17의 Pattern Matching for instanceof가 if-else 체인을 개선했다면, 이 기능은 타입 기반 분기 전체를 switch로 흡수합니다.
// Java 17 방식 — if-else 체인
static String format(Object obj) {
if (obj instanceof Integer i) {
return "정수: " + i;
} else if (obj instanceof Double d) {
return "실수: " + d;
} else if (obj instanceof String s) {
return "문자열: " + s;
}
return "기타";
}
// Java 21 — Pattern Matching for switch
static String format(Object obj) {
return switch (obj) {
case Integer i -> "정수: " + i;
case Double d -> "실수: " + d;
case String s -> "문자열: " + s;
default -> "기타";
};
}
Guarded Patterns를 사용하면 타입 조건 외에 추가 조건도 when 키워드로 함께 표현할 수 있습니다.
static String classify(Object obj) {
return switch (obj) {
case Integer i when i < 0 -> "음수";
case Integer i when i == 0 -> "영";
case Integer i -> "양수";
default -> "정수 아님";
};
}
Sealed Class와 함께 사용하면 컴파일러가 모든 케이스를 처리했는지 검증해 줍니다. permits로 선언된 하위 타입을 switch에서 빠뜨리면 컴파일 오류가 발생합니다.
sealed interface Shape permits Circle, Rectangle, Triangle {}
double area(Shape shape) {
return switch (shape) {
case Circle c -> Math.PI * c.radius() * c.radius();
case Rectangle r -> r.width() * r.height();
case Triangle t -> t.base() * t.height() / 2;
// default 없이도 컴파일 가능 — 컴파일러가 모든 케이스를 알고 있음
};
}
Java 17의 Sealed Class, Record, Pattern Matching for instanceof가 이 기능을 위한 준비 단계였다는 것이 이 시점에서 명확해집니다.
Sequenced Collections
사전 지식 — 컬렉션 프레임워크 Java에서 데이터를 담는 자료구조들의 모음입니다. List(순서 있는 목록), Set(중복 없는 집합), Map(키-값 쌍), Deque(양쪽 삽입/삭제 가능) 등이 대표적입니다.
Java의 컬렉션 프레임워크에서 오랫동안 아쉬운 부분이 있었습니다. 순서 개념이 있는 컬렉션들인데도, "첫 번째 요소"나 "마지막 요소"를 가져오는 일관된 방법이 없었습니다.
// 기존의 일관성 없는 방식들
list.get(0); // List의 첫 번째
deque.peekFirst(); // Deque의 첫 번째
sortedSet.first(); // SortedSet의 첫 번째
linkedHashMap.entrySet()
.iterator().next(); // LinkedHashMap의 첫 번째
자료구조마다 방식이 달라서 매번 찾아봐야 했습니다. Java 21은 이 문제를 해결하기 위해 세 가지 새로운 인터페이스를 컬렉션 계층에 추가했습니다.
| 인터페이스 | 설명 |
| SequencedCollection | 순서가 있고, 첫/마지막 요소 접근이 가능한 컬렉션 |
| SequencedSet | 중복 없이 순서가 있는 Set |
| SequencedMap | 순서가 있는 Map |
SequencedCollection이 제공하는 주요 메서드는 다음과 같습니다.
List<String> list = new ArrayList<>(List.of("A", "B", "C"));
list.getFirst(); // "A"
list.getLast(); // "C"
list.addFirst("Z"); // 맨 앞에 추가
list.addLast("D"); // 맨 뒤에 추가
List<String> reversed = list.reversed(); // 역순 뷰 (새 컬렉션 생성 아님)
이제 어떤 순서 기반 컬렉션이든 getFirst(), getLast()로 통일해서 접근할 수 있습니다. 작은 변화처럼 보이지만, "이 컬렉션은 어떻게 첫 요소를 가져오지?"를 찾아보는 번거로움이 사라진다는 점에서 꽤 만족스러운 개선입니다.
Virtual Thread
사전 지식 — Thread(스레드)란? 프로그램 안에서 독립적으로 실행되는 작업 단위입니다. Java 애플리케이션은 기본적으로 OS가 직접 관리하는 스레드(Platform Thread)를 사용하며, 스레드가 많아질수록 메모리 사용량과 전환 비용이 함께 늘어납니다.
사전 지식 — I/O 블로킹이란? DB 조회, 외부 API 호출처럼 응답을 기다려야 하는 작업에서, 스레드가 응답이 올 때까지 아무것도 하지 못하고 멈춰 있는 상태를 말합니다.
Virtual Thread는 Java 19, 20에서 Preview로 제공되다가 Java 21에서 정식(GA)으로 포함된 기능입니다. Project Loom이라는 이름으로 오랫동안 연구되어 온 결과물입니다.
기존 스레드 모델의 한계
전통적인 Java 서버 애플리케이션은 "요청 하나당 스레드 하나(Thread-per-Request)" 방식으로 동작합니다. 스레드가 DB 조회나 외부 API 호출을 만나면 응답이 올 때까지 그 스레드는 아무것도 하지 않으면서 자원을 점유합니다.
동시 요청이 늘어나면 스레드 풀은 빠르게 소진되고, 이후 요청들은 큐에 쌓이기 시작합니다. OS 스레드는 생성 비용이 크고, 스레드 수가 많아질수록 컨텍스트 스위칭 오버헤드도 함께 커집니다.
사전 지식 — 컨텍스트 스위칭이란? CPU가 하나의 스레드에서 다른 스레드로 전환할 때 현재 작업 상태를 저장하고 다음 상태를 불러오는 과정입니다. 스레드 수가 많아질수록 이 전환 비용이 누적되어 전체 성능에 영향을 줍니다.
이 문제를 해결하기 위해 Reactive Programming(WebFlux, RxJava 등)이 등장했습니다.
사전 지식 — Reactive Programming이란? 데이터가 준비될 때까지 스레드를 블로킹하지 않고, 데이터가 도착하면 콜백이나 스트림으로 반응하는 방식의 프로그래밍 패러다임입니다. 스레드를 효율적으로 사용할 수 있지만, 코드 구조가 기존 동기 방식과 크게 달라져 학습 비용이 높습니다.
성능 측면에서는 효과적이지만, 코드가 복잡해지고 디버깅이 어려워지며 기존 동기 방식 라이브러리와의 호환성 문제도 생깁니다. 처음에 이 복잡성에 부딪히면 꽤 당황스러울 수 있습니다.
Virtual Thread는 이 딜레마에 다른 방향으로 답합니다.
Virtual Thread의 구조
Virtual Thread는 JVM이 관리하는 경량 스레드입니다. OS 스레드(Platform Thread)와 1:1로 매핑되지 않고, JVM 내부에서 소수의 OS 스레드 위에 수백만 개의 Virtual Thread를 올려서 실행할 수 있습니다.
| 항목 | Platform Thread | Virtual Thread |
| 생성 주체 | OS | JVM |
| 메모리 (스택) | 약 1MB (기본) | 수 KB (동적 할당) |
| 생성 비용 | 높음 | 매우 낮음 |
| 최대 동시 실행 수 | 수천 개 (현실적 한계) | 수백만 개 가능 |
| 블로킹 시 동작 | OS 스레드 점유 | OS 스레드 반납 후 대기 |
동작 원리
사전 지식 — ForkJoinPool이란? Java 7부터 제공되는 스레드 풀로, 작업을 잘게 쪼개어 여러 스레드가 나눠 처리하도록 설계된 구조입니다. Virtual Thread는 이 풀 위에서 동작하며, 소수의 OS 스레드(Carrier Thread)가 수많은 Virtual Thread를 번갈아 실행합니다.
Virtual Thread는 JVM 내부의 ForkJoinPool 위에서 실행됩니다. 이 풀에 속한 OS 스레드들을 Carrier Thread(캐리어 스레드)라고 부릅니다.
Virtual Thread가 실행되면 캐리어 스레드에 마운트(mount) 되어 실제 CPU를 점유합니다. I/O 대기나 블로킹 구간을 만나면 Virtual Thread는 캐리어 스레드에서 언마운트(unmount) 됩니다. 캐리어 스레드는 즉시 다른 Virtual Thread를 마운트하여 실행을 계속합니다. I/O가 완료되면 대기 중이던 Virtual Thread는 다시 캐리어 스레드에 마운트되어 실행을 재개합니다.
OS 스레드는 실제로 일하는 시간 동안만 점유되고, 대기 시간은 다른 Virtual Thread를 위해 활용됩니다. 기존 블로킹 코드 스타일을 그대로 유지하면서 Reactive와 유사한 효율을 얻을 수 있다는 것이 핵심입니다.
실제 코드로 살펴보기
Virtual Thread 직접 생성
Thread vt = Thread.ofVirtual().start(() -> {
System.out.println("Virtual Thread 실행: " + Thread.currentThread());
});
vt.join();
ExecutorService와 함께 사용
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 100_000; i++) {
executor.submit(() -> {
Thread.sleep(Duration.ofMillis(100)); // 블로킹 — 캐리어 스레드 반납
return "done";
});
}
}
newVirtualThreadPerTaskExecutor()는 작업마다 새로운 Virtual Thread를 생성합니다. Platform Thread라면 10만 개 생성이 현실적으로 불가능하지만, Virtual Thread에서는 메모리 부담 없이 처리할 수 있습니다.
Spring Boot에서의 적용
Spring Boot 3.2 이상에서는 설정 한 줄로 Tomcat의 요청 처리 스레드를 Virtual Thread로 전환할 수 있습니다.
spring:
threads:
virtual:
enabled: true
기존 코드를 전혀 수정하지 않고도 Virtual Thread 기반으로 동작하게 됩니다. 이 단순함이 Reactive 방식과 가장 큰 차이점입니다.
스레드 타입 확인
Thread.currentThread().isVirtual(); // true or false
Scoped Values
사전 지식 — ThreadLocal이란? 스레드마다 독립적인 값을 저장할 수 있는 Java의 내장 기능입니다. 로그인한 사용자 정보처럼 요청 처리 흐름 전반에 걸쳐 값을 전달할 때 자주 사용합니다. 다만 값을 명시적으로 제거하지 않으면 메모리 누수가 발생할 수 있습니다.
수백만 개의 Virtual Thread가 생성될 수 있는 환경에서 ThreadLocal에 대용량 객체를 저장하면 메모리 사용량이 예상보다 크게 늘어날 수 있습니다. 또한 자식 스레드로 값을 상속하는 과정에서도 비용이 발생합니다.
ScopedValue는 특정 실행 범위(scope) 안에서만 값을 바인딩하고, 범위를 벗어나면 자동으로 해제됩니다.
static final ScopedValue<String> USER_ID = ScopedValue.newInstance();
void handleRequest(String userId) {
ScopedValue.where(USER_ID, userId).run(() -> {
processOrder(); // 이 범위 안에서 USER_ID 접근 가능
});
// 여기서는 USER_ID 접근 불가
}
void processOrder() {
String userId = USER_ID.get();
// ...
}
ThreadLocal과 달리 값을 명시적으로 제거하지 않아도 범위가 끝나면 자동으로 정리됩니다. Virtual Thread 환경에서 요청별 컨텍스트(사용자 정보, 요청 ID 등)를 전파할 때 ThreadLocal보다 적합한 선택이 될 수 있습니다.
Structured Concurrency
여러 작업을 동시에 실행하고 결과를 합칠 때, 기존 ExecutorService만으로는 에러 처리와 취소 전파가 복잡해지는 경우가 많았습니다.
// 기존 방식 — 하나가 실패해도 나머지가 계속 실행될 수 있음
ExecutorService executor = Executors.newFixedThreadPool(2);
Future<String> user = executor.submit(() -> fetchUser(userId));
Future<String> order = executor.submit(() -> fetchOrder(orderId));
String u = user.get(); // 예외 발생 시 처리가 복잡
String o = order.get();
Structured Concurrency는 여러 Virtual Thread를 구조화된 방식으로 관리할 수 있게 해주는 API입니다.
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
Supplier<String> user = scope.fork(() -> fetchUser(userId));
Supplier<String> order = scope.fork(() -> fetchOrder(orderId));
scope.join(); // 두 작업이 모두 완료될 때까지 대기
scope.throwIfFailed(); // 하나라도 실패하면 예외 전파
return new Response(user.get(), order.get());
}
ShutdownOnFailure는 하나의 작업이 실패하면 나머지도 함께 취소합니다. ShutdownOnSuccess는 가장 먼저 성공한 결과를 반환하고 나머지를 취소합니다. 여러 외부 서비스 호출을 병렬로 처리하고 결과를 합치는 패턴에 매우 잘 맞는 API입니다.
String Templates (Preview)
Java 21에서 Preview로 도입된 기능으로, 문자열 안에 표현식을 직접 삽입할 수 있는 템플릿 기능입니다.
String name = "World";
int count = 3;
// 기존 방식
String s1 = "Hello, " + name + "! Count: " + count;
String s2 = String.format("Hello, %s! Count: %d", name, count);
// String Templates (Preview)
String s3 = STR."Hello, \{name}! Count: \{count}";
STR 외에도 FMT(포맷팅), RAW(원본 처리) 등의 템플릿 프로세서를 제공하며, SQL 인젝션이나 XSS를 방지하는 이스케이프 로직을 프로세서에 내장할 수 있다는 점이 흥미롭습니다. 다만 Preview 기능이므로 Java 22, 23에서 스펙이 일부 조정되었습니다. 프로덕션 코드에 바로 적용하기보다는 동향을 지켜보는 것을 권장합니다.
Virtual Thread가 모든 상황에서 이점을 가져다주는 것은 아닙니다. 실제로 적용하기 전에 알아두어야 할 주의점이 있습니다.
CPU 집약적 작업에는 효과가 제한적입니다. Virtual Thread의 강점은 I/O 대기 구간에 있습니다. 이미지 처리나 암호화 연산처럼 CPU를 지속적으로 사용하는 작업에서는 Platform Thread 대비 의미 있는 성능 개선을 기대하기 어렵습니다.
Pinning 현상을 주의해야 합니다. synchronized 블록 안에서 블로킹이 발생하면 Virtual Thread가 캐리어 스레드에 고정(Pinned)되어 언마운트되지 않습니다. 이 경우 기존 Platform Thread와 동일한 문제가 발생합니다. synchronized 대신 ReentrantLock을 사용하는 것을 권장합니다.
// Pinning 발생 가능
synchronized (lock) {
someBlockingCall();
}
// 권장 방식
ReentrantLock lock = new ReentrantLock();
lock.lock();
try {
someBlockingCall();
} finally {
lock.unlock();
}
Virtual Thread를 풀링하면 안 됩니다. Executors.newVirtualThreadPerTaskExecutor()처럼 작업마다 새로운 Virtual Thread를 생성하는 것이 올바른 사용법입니다. 기존 스레드 풀처럼 Virtual Thread를 풀링하면 이점이 사라집니다. Virtual Thread는 생성 비용이 낮기 때문에 풀링 없이 사용하도록 설계되어 있습니다.
ThreadLocal 대신 ScopedValue를 고려해야 합니다. 수백만 개의 Virtual Thread 환경에서 ThreadLocal에 대용량 객체를 저장하면 메모리 사용량이 크게 늘어날 수 있습니다. 앞서 살펴본 ScopedValue가 더 적합한 대안이 됩니다.
마무리
오늘은 Java 21의 주요 변화를 살펴보았습니다. Record Patterns와 Pattern Matching for switch는 Java 17에서 시작된 언어 표현력 향상의 완성이었고, Sequenced Collections는 오랫동안 불일관했던 컬렉션 API를 정리해 주었습니다. 그리고 Virtual Thread는 기존 동기 코드 스타일을 유지하면서 높은 동시성을 확보할 수 있다는 점에서 이번 버전의 핵심이라고 할 수 있습니다.