Contents
see ListJava 25의 동시성 혁명
Java 25(JDK 25)는 동시성 프로그래밍을 근본적으로 변화시키는 두 가지 핵심 기능을 완성했습니다. Virtual Threads는 수십만 개의 경량 스레드를 지원하고, Structured Concurrency는 복잡한 비동기 코드를 안전하고 읽기 쉽게 만들어줍니다.
Virtual Threads 이해하기
Virtual Thread는 JVM이 관리하는 경량 스레드로, OS 스레드와 1:1로 매핑되지 않습니다. 수십만 개를 동시에 실행해도 메모리 부담이 없습니다.
// 플랫폼 스레드 vs 버추얼 스레드 비교
// 기존 플랫폼 스레드 (OS 스레드 1:1)
Thread platformThread = new Thread(() -> {
System.out.println("플랫폼 스레드: " + Thread.currentThread());
});
platformThread.start();
// Virtual Thread 생성 방법 1: Thread.ofVirtual()
Thread vThread = Thread.ofVirtual().start(() -> {
System.out.println("버추얼 스레드: " + Thread.currentThread().isVirtual()); // true
});
// Virtual Thread 생성 방법 2: Thread.startVirtualThread()
Thread.startVirtualThread(() -> {
System.out.println("간편 생성!");
});
// Virtual Thread 생성 방법 3: ExecutorService 사용 (권장)
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 100_000; i++) {
executor.submit(() -> {
// 블로킹 I/O도 가상 스레드에서 안전
String data = fetchFromDatabase(); // 블로킹 OK!
return processData(data);
});
}
} // try-with-resources로 자동 종료
JDK 24: 동기화 핀닝 문제 해결 (JEP 491)
JDK 24 이전에는 synchronized 블록 안에서 가상 스레드가 블로킹되면 캐리어 스레드(OS 스레드)가 점유되는 핀닝(Pinning) 문제가 있었습니다. JDK 24의 JEP 491로 이 문제가 해결되었습니다.
// JDK 24 이후 - synchronized 안에서도 안전
public class SafeCounter {
private int count = 0;
public synchronized void increment() {
// 이 안에서 블로킹 I/O를 해도 이제 캐리어 스레드를 점유하지 않음
String log = writeLog(count); // 블로킹 I/O - 안전!
count++;
}
}
// 웹 서버 예시 - 10만 동시 요청 처리
public class HighConcurrencyServer {
public static void main(String[] args) throws Exception {
try (ServerSocket serverSocket = new ServerSocket(8080);
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
while (true) {
Socket socket = serverSocket.accept();
executor.submit(() -> handleRequest(socket)); // 각 요청을 가상 스레드로 처리
}
}
}
static void handleRequest(Socket socket) {
try (socket) {
// 블로킹 I/O가 포함된 복잡한 로직도 OK
String request = readRequest(socket.getInputStream());
String dbResult = queryDatabase(request); // DB 블로킹 OK
String apiResult = callExternalAPI(dbResult); // HTTP 블로킹 OK
writeResponse(socket.getOutputStream(), apiResult);
}
}
}
Structured Concurrency (JEP 505)
Java 25에서 구조적 동시성 API가 5번째 프리뷰를 거쳐 StructuredTaskScope가 완전히 재설계되었습니다. 여러 작업을 하나의 단위로 묶어 안전하게 관리합니다.
import java.util.concurrent.StructuredTaskScope;
// 기본 사용 예시
public class UserService {
record UserProfile(String name, List orders, String balance) {}
public UserProfile getUserProfile(String userId) throws Exception {
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
// 세 가지 작업을 병렬로 실행
var nameFuture = scope.fork(() -> fetchName(userId));
var ordersFuture = scope.fork(() -> fetchOrders(userId));
var balanceFuture = scope.fork(() -> fetchBalance(userId));
// 모든 작업 완료 대기 (하나라도 실패하면 나머지 취소)
scope.join().throwIfFailed();
// 모두 성공한 경우 결과 수집
return new UserProfile(
nameFuture.get(),
ordersFuture.get(),
balanceFuture.get()
);
}
}
}
// ShutdownOnSuccess - 하나만 성공하면 나머지 취소
public String fetchFromFastestSource(String query) throws Exception {
try (var scope = new StructuredTaskScope.ShutdownOnSuccess()) {
scope.fork(() -> fetchFromPrimaryDB(query));
scope.fork(() -> fetchFromReplicaDB(query));
scope.fork(() -> fetchFromCache(query));
scope.join(); // 가장 빨리 성공한 것 반환
return scope.result();
}
}
Scoped Values (JEP 506 - Java 25에서 finalized)
불변 데이터를 스레드 계층 구조 전체에 안전하게 전달하는 ScopedValue가 Java 25에서 정식 API로 확정되었습니다.
// ThreadLocal 대신 ScopedValue 사용
public class RequestContext {
// ScopedValue - 불변, 안전, 성능 좋음
static final ScopedValue CURRENT_USER = ScopedValue.newInstance();
static final ScopedValue REQUEST_ID = ScopedValue.newInstance();
public void handleRequest(HttpRequest req) {
User user = authenticate(req);
String reqId = UUID.randomUUID().toString();
ScopedValue.runWhere(CURRENT_USER, user,
() -> ScopedValue.runWhere(REQUEST_ID, reqId,
() -> processRequest(req)
)
);
}
void processRequest(HttpRequest req) {
// 어디서든 현재 사용자 정보 접근 가능
User user = CURRENT_USER.get();
String reqId = REQUEST_ID.get();
logAction(reqId, user, "processing request");
// 가상 스레드로 분기해도 ScopedValue 자동 상속
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
scope.fork(() -> {
// 자식 스레드에서도 CURRENT_USER 접근 가능!
checkPermission(CURRENT_USER.get(), req);
return null;
});
scope.join().throwIfFailed();
}
}
}
// ThreadLocal과 비교
// ThreadLocal: 변경 가능, 상속 불안정, 메모리 누수 가능
// ScopedValue: 불변, 자동 상속, 스코프 종료 시 자동 정리
성능 비교: 플랫폼 스레드 vs 가상 스레드
// 벤치마크 예시: 1만 개 I/O 작업
import java.time.Instant;
public class ThreadBenchmark {
static final int TASK_COUNT = 10_000;
// 플랫폼 스레드 풀 (최대 200개)
static void platformThreadTest() throws Exception {
long start = Instant.now().toEpochMilli();
try (ExecutorService exec = Executors.newFixedThreadPool(200)) {
for (int i = 0; i < TASK_COUNT; i++) {
exec.submit(() -> { Thread.sleep(100); return null; });
}
}
System.out.println("플랫폼 스레드: " + (Instant.now().toEpochMilli() - start) + "ms");
// 약 5000ms (10000/200 * 100)
}
// 가상 스레드 (무제한)
static void virtualThreadTest() throws Exception {
long start = Instant.now().toEpochMilli();
try (ExecutorService exec = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < TASK_COUNT; i++) {
exec.submit(() -> { Thread.sleep(100); return null; });
}
}
System.out.println("가상 스레드: " + (Instant.now().toEpochMilli() - start) + "ms");
// 약 110ms (모두 병렬 실행!)
}
}
언제 가상 스레드를 사용해야 하나?
- I/O 바운드 작업: 데이터베이스 쿼리, HTTP 요청, 파일 읽기
- 고동시성 서버: 수천~수만 개의 동시 연결 처리
- 마이크로서비스: 여러 서비스를 병렬로 호출하는 패턴
- CPU 바운드 작업(계산 집약적)에는 기존 스레드 풀이 더 적합
마치며
Java 25의 Virtual Threads와 Structured Concurrency는 Java 동시성 프로그래밍의 패러다임을 바꿉니다. 복잡한 비동기 코드 없이 간단한 블로킹 스타일로 높은 처리량을 달성할 수 있습니다.