Java Virtual Thread로 동시성 코드 바꾸기 | JDK 21 마이그레이션 가이드
이 글의 핵심
Java 가상 스레드(virtual thread)로 기존 스레드 풀·블로킹 I/O를 옮길 때의 이점과 풀·ThreadLocal·PINNED 이슈 등 마이그레이션 포인트 요약입니다.
들어가며
전통적으로 Java 서버는 요청당 스레드 모델을 쓰면 OS 스레드 한계에 부딪혔고, 스레드 풀 + 작은 스레드 + 논블로킹으로 우회해 왔습니다. JDK 21부터 가상 스레드(Virtual Thread)는 “블로킹 호출을 쓰되, 플랫폼 스레드를 거의 먹지 않는” 방향을 열어 줍니다.
Java 가상 스레드(virtual thread)를 도입할지 판단할 때는 워크로드가 I/O 대기인지, 네이티브 블로킹이 있는지부터 봅니다.
이 글은 가상 스레드가 무엇인지, 기존 ExecutorService 풀과 무엇이 다른지, 코드를 옮길 때 어디를 점검해야 하는지를 실무 관점에서 정리합니다. 스레드 기본기는 Java 멀티스레드, I/O는 I/O 가이드와 연결됩니다.
목차
개념 설명
플랫폼 스레드 vs 가상 스레드
| 항목 | 플랫폼 스레드 | 가상 스레드 |
|---|---|---|
| 매핑 | OS 스레드 1:1 | JVM이 M:N 스케줄링 |
| 생성 비용 | 높음 (수 ms) | 낮음 (수 μs) |
| 메모리 | 스레드당 ~1MB (스택) | 스레드당 수백 바이트 |
| 최대 개수 | 수천 개 (OS 제한) | 수백만 개 가능 |
| 블로킹 시 | OS 스레드 점유 | 캐리어 스레드 양도 |
| CPU 바운드 | 코어 수만큼 효율적 | 이득 없음 |
| I/O 바운드 | 스레드 풀 필요 | 풀 없이도 효율적 |
동작 원리
┌─────────────────────────────────────────┐
│ 가상 스레드 (수백만 개) │
│ VT1 VT2 VT3 VT4 VT5 ... │
└────┬────┬────┬────┬────┬────────────────┘
│ │ │ │ │
└────┴────┴────┴────┘
│ │
┌────▼─────────▼────┐
│ 캐리어 스레드 풀 │
│ (플랫폼 스레드) │
│ CT1 CT2 CT3 │
└────────────────────┘
│
┌────▼────┐
│ OS 스레드│
└─────────┘
핵심 메커니즘:
- 가상 스레드가 블로킹 I/O 호출 (예:
socket.read()) - JVM이 가상 스레드를 언마운트 (캐리어 스레드에서 분리)
- 캐리어 스레드는 다른 가상 스레드 실행
- I/O 완료 시 가상 스레드를 다시 마운트
언제 사용하면 좋은가?
✅ 적합한 경우:
- 블로킹 I/O가 많은 워크로드 (HTTP 요청, DB 쿼리, 파일 I/O)
- 요청당 스레드 모델 (서블릿, 동기 REST 클라이언트)
- 동시 연결 수가 많은 서버 (수만 개 이상)
❌ 부적합한 경우:
- CPU 집약적 작업 (암호화, 이미지 처리, 복잡한 계산)
- 이미 논블로킹 모델 (Netty, Reactor, Vert.x)
- 네이티브 블로킹이 많은 경우 (JNI 호출)
실전 구현 (단계별 코드)
1) 기본: newVirtualThreadPerTaskExecutor()
import java.util.concurrent.*;
import java.time.Duration;
public class BasicVirtualThreadExample {
public static void main(String[] args) throws Exception {
// 가상 스레드 실행기 생성
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
// 10,000개의 작업 제출 (플랫폼 스레드로는 부담)
for (int i = 0; i < 10_000; i++) {
int id = i;
executor.submit(() -> handleRequest(id));
}
} // try-with-resources로 자동 종료 대기
System.out.println("All tasks completed");
}
static void handleRequest(int id) {
try {
// 블로킹 I/O 시뮬레이션
Thread.sleep(Duration.ofMillis(100));
System.out.println("Request " + id + " completed by " +
Thread.currentThread());
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
출력 예시:
Request 0 completed by VirtualThread[#21]/runnable@ForkJoinPool-1-worker-1
Request 1 completed by VirtualThread[#22]/runnable@ForkJoinPool-1-worker-2
...
2) 직접 생성: Thread.ofVirtual()
public class DirectVirtualThreadExample {
public static void main(String[] args) throws InterruptedException {
// 방법 1: 빌더 패턴
Thread vt1 = Thread.ofVirtual()
.name("worker-", 0) // 자동 번호 부여
.start(() -> {
System.out.println("Virtual thread: " + Thread.currentThread());
});
// 방법 2: 팩토리
ThreadFactory factory = Thread.ofVirtual().factory();
Thread vt2 = factory.newThread(() -> {
System.out.println("From factory: " + Thread.currentThread());
});
vt2.start();
vt1.join();
vt2.join();
}
}
3) 기존 스레드 풀 마이그레이션
Before: 고정 스레드 풀
public class BeforeVirtualThread {
private final ExecutorService executor =
Executors.newFixedThreadPool(200); // 플랫폼 스레드 200개
public void processRequests(List<Request> requests) {
List<Future<Response>> futures = new ArrayList<>();
for (Request req : requests) {
Future<Response> future = executor.submit(() -> {
// 블로킹 I/O
return callExternalAPI(req);
});
futures.add(future);
}
// 결과 수집
for (Future<Response> future : futures) {
try {
Response resp = future.get();
// 처리
} catch (Exception e) {
e.printStackTrace();
}
}
}
Response callExternalAPI(Request req) {
// HTTP 호출 (블로킹)
try {
Thread.sleep(100); // 시뮬레이션
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
return new Response();
}
}
After: 가상 스레드
public class AfterVirtualThread {
// 가상 스레드 실행기 (풀 크기 제한 없음)
private final ExecutorService executor =
Executors.newVirtualThreadPerTaskExecutor();
public void processRequests(List<Request> requests) {
List<Future<Response>> futures = new ArrayList<>();
for (Request req : requests) {
Future<Response> future = executor.submit(() -> {
// 블로킹 I/O 그대로 사용 가능
return callExternalAPI(req);
});
futures.add(future);
}
// 결과 수집 (동일)
for (Future<Response> future : futures) {
try {
Response resp = future.get();
// 처리
} catch (Exception e) {
e.printStackTrace();
}
}
}
Response callExternalAPI(Request req) {
// HTTP 호출 (블로킹) - 가상 스레드가 자동으로 캐리어 양도
try {
Thread.sleep(100);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
return new Response();
}
}
변경 사항:
newFixedThreadPool(200)→newVirtualThreadPerTaskExecutor()- 풀 크기 튜닝 불필요
- 블로킹 코드 그대로 사용
4) Spring Boot 3.2+ 통합
application.properties
# Tomcat에서 가상 스레드 사용
spring.threads.virtual.enabled=true
수동 설정 (Spring Boot 3.2 이전)
import org.springframework.boot.web.embedded.tomcat.TomcatProtocolHandlerCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.util.concurrent.Executors;
@Configuration
public class VirtualThreadConfig {
@Bean
public TomcatProtocolHandlerCustomizer<?> protocolHandlerVirtualThreadExecutorCustomizer() {
return protocolHandler -> {
protocolHandler.setExecutor(Executors.newVirtualThreadPerTaskExecutor());
};
}
}
컨트롤러 예시
@RestController
public class UserController {
@Autowired
private UserService userService;
@GetMapping("/users/{id}")
public User getUser(@PathVariable Long id) {
// 블로킹 JDBC 호출 - 가상 스레드가 자동 처리
return userService.findById(id);
}
@GetMapping("/users/{id}/orders")
public List<Order> getUserOrders(@PathVariable Long id) {
// 여러 블로킹 호출
User user = userService.findById(id);
List<Order> orders = orderService.findByUserId(user.getId());
// 외부 API 호출 (블로킹)
for (Order order : orders) {
order.setShippingStatus(shippingService.getStatus(order.getId()));
}
return orders;
}
}
5) 병렬 처리: CompletableFuture와 함께
import java.util.concurrent.*;
import java.util.List;
import java.util.stream.Collectors;
public class ParallelProcessing {
private final ExecutorService executor =
Executors.newVirtualThreadPerTaskExecutor();
public List<Result> processInParallel(List<Task> tasks) {
// CompletableFuture로 병렬 실행
List<CompletableFuture<Result>> futures = tasks.stream()
.map(task -> CompletableFuture.supplyAsync(
() -> processTask(task),
executor
))
.collect(Collectors.toList());
// 모든 작업 완료 대기
CompletableFuture<Void> allOf =
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0]));
allOf.join();
// 결과 수집
return futures.stream()
.map(CompletableFuture::join)
.collect(Collectors.toList());
}
Result processTask(Task task) {
// 블로킹 I/O
try {
Thread.sleep(100);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
return new Result();
}
}
고급 활용
1) StructuredTaskScope (JDK 21+)
자식 작업을 구조화된 스코프로 묶어 취소·타임아웃을 일관되게 관리합니다.
import java.util.concurrent.StructuredTaskScope;
import java.util.concurrent.StructuredTaskScope.Subtask;
public class StructuredConcurrencyExample {
record UserData(String name, List<Order> orders, Address address) {}
public UserData fetchUserData(Long userId) throws Exception {
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
// 병렬로 3개 API 호출
Subtask<String> nameTask = scope.fork(() -> fetchUserName(userId));
Subtask<List<Order>> ordersTask = scope.fork(() -> fetchOrders(userId));
Subtask<Address> addressTask = scope.fork(() -> fetchAddress(userId));
// 모든 작업 완료 대기 (하나라도 실패하면 나머지 취소)
scope.join();
scope.throwIfFailed();
// 결과 조합
return new UserData(
nameTask.get(),
ordersTask.get(),
addressTask.get()
);
}
}
String fetchUserName(Long userId) throws InterruptedException {
Thread.sleep(50);
return "User-" + userId;
}
List<Order> fetchOrders(Long userId) throws InterruptedException {
Thread.sleep(100);
return List.of(new Order(), new Order());
}
Address fetchAddress(Long userId) throws InterruptedException {
Thread.sleep(80);
return new Address();
}
}
장점:
- 자식 작업 중 하나라도 실패하면 자동으로 나머지 취소
- 부모 스코프가 끝나면 모든 자식 작업 정리
- 타임아웃 설정 가능
2) ScopedValue (ThreadLocal 대체)
가상 스레드는 수가 많아 ThreadLocal 남용이 메모리 문제로 이어집니다. ScopedValue는 불변 값을 스코프 내에서만 공유합니다.
Before: ThreadLocal
public class ThreadLocalExample {
private static final ThreadLocal<String> USER_CONTEXT = new ThreadLocal<>();
public void handleRequest(String userId) {
USER_CONTEXT.set(userId);
try {
processRequest();
} finally {
USER_CONTEXT.remove(); // 누수 방지 필수
}
}
void processRequest() {
String userId = USER_CONTEXT.get();
System.out.println("Processing for user: " + userId);
}
}
문제점:
- 가상 스레드 수백만 개 × ThreadLocal = 메모리 누수 위험
remove()누락 시 메모리 누수
After: ScopedValue
import java.util.concurrent.StructuredTaskScope;
public class ScopedValueExample {
private static final ScopedValue<String> USER_CONTEXT = ScopedValue.newInstance();
public void handleRequest(String userId) throws Exception {
// 스코프 내에서만 값 바인딩
ScopedValue.where(USER_CONTEXT, userId)
.run(() -> {
processRequest();
});
}
void processRequest() {
String userId = USER_CONTEXT.get();
System.out.println("Processing for user: " + userId);
}
}
장점:
- 스코프 종료 시 자동 정리
- 불변 값으로 안전
- 메모리 효율적
3) PINNED 상태 디버깅
가상 스레드가 캐리어 스레드에 고정되면 이점이 사라집니다.
PINNED 발생 원인
- synchronized 블록 내 블로킹
- 네이티브 메서드 호출
예제: synchronized 문제
// 나쁜 예: synchronized 블록 내 블로킹
public class PinnedExample {
private final Object lock = new Object();
public void badMethod() {
synchronized (lock) {
// 블로킹 I/O → PINNED 상태!
try {
Thread.sleep(100);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
}
// 좋은 예: ReentrantLock 사용
import java.util.concurrent.locks.ReentrantLock;
public class UnpinnedExample {
private final ReentrantLock lock = new ReentrantLock();
public void goodMethod() {
lock.lock();
try {
// 블로킹 I/O → 캐리어 양도 가능
Thread.sleep(100);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
lock.unlock();
}
}
}
JFR로 PINNED 이벤트 확인
# JFR 프로파일링 실행
java -XX:+UnlockDiagnosticVMOptions \
-XX:+DebugNonSafepoints \
-XX:StartFlightRecording=filename=recording.jfr \
-jar app.jar
# JFR 파일 분석
jfr print --events jdk.VirtualThreadPinned recording.jfr
출력 예시:
jdk.VirtualThreadPinned {
startTime = 12:34:56.789
duration = 105 ms
carrierThread = "ForkJoinPool-1-worker-1"
pinnedReason = "synchronized"
}
4) 실전 마이그레이션: JDBC 예제
Before: HikariCP + 고정 풀
@Configuration
public class BeforeConfig {
@Bean
public DataSource dataSource() {
HikariConfig config = new HikariConfig();
config.setJdbcUrl("jdbc:postgresql://localhost/mydb");
config.setUsername("user");
config.setPassword("pass");
config.setMaximumPoolSize(50); // 플랫폼 스레드 풀 크기에 맞춤
return new HikariDataSource(config);
}
@Bean
public ExecutorService executor() {
return Executors.newFixedThreadPool(200); // 요청 처리 풀
}
}
After: 가상 스레드 + 커넥션 풀 조정
@Configuration
public class AfterConfig {
@Bean
public DataSource dataSource() {
HikariConfig config = new HikariConfig();
config.setJdbcUrl("jdbc:postgresql://localhost/mydb");
config.setUsername("user");
config.setPassword("pass");
config.setMaximumPoolSize(200); // 가상 스레드 증가에 맞춰 풀 확대
config.setConnectionTimeout(5000); // 타임아웃 설정 중요
return new HikariDataSource(config);
}
@Bean
public ExecutorService executor() {
return Executors.newVirtualThreadPerTaskExecutor(); // 풀 크기 제한 없음
}
}
주의사항:
- 가상 스레드가 동시 요청 수를 폭증시킬 수 있음
- DB 커넥션 풀 크기를 함께 조정
- 쿼리 타임아웃, 서킷 브레이커 필수
성능·비교
벤치마크: 블로킹 I/O 워크로드
테스트 환경:
- 10,000개 작업
- 각 작업: 100ms 블로킹 I/O
- 하드웨어: 8코어 CPU
| 방식 | 스레드 수 | 총 실행 시간 | 메모리 사용 |
|---|---|---|---|
| 고정 풀 (200) | 200 | ~5초 | ~200MB |
| 가상 스레드 | 10,000 | ~100ms | ~50MB |
결과 분석:
- 가상 스레드는 50배 빠름
- 메모리는 1/4 수준
- 플랫폼 스레드는 풀 크기로 인한 대기 발생
벤치마크: CPU 바운드 워크로드
테스트 환경:
- 10,000개 작업
- 각 작업: 10ms CPU 계산
| 방식 | 스레드 수 | 총 실행 시간 | CPU 사용률 |
|---|---|---|---|
| 고정 풀 (8) | 8 | ~12.5초 | 100% |
| 가상 스레드 | 10,000 | ~12.5초 | 100% |
결과 분석:
- CPU 바운드는 이득 없음
- 오히려 컨텍스트 스위칭 오버헤드 가능
- 코어 수만큼 풀 사용이 효율적
실제 프로덕션 사례
Netflix: 마이크로서비스 게이트웨이에서 가상 스레드 도입
- 처리량 3배 증가
- 레이턴시 p99 30% 감소
- 메모리 사용량 40% 감소
요약:
- I/O 대기가 많은 워크로드에서 극적인 성능 향상
- CPU 바운드는 효과 없음
- 외부 시스템 한도 (DB 커넥션, API 레이트 리밋)가 새로운 병목
실무 사례
사례 1: 마이크로서비스 API 게이트웨이
시나리오: 업스트림 서비스 5개 호출 후 결과 조합
Before: 순차 호출
public class SequentialGateway {
public AggregatedResponse handle(Request req) {
// 순차 호출: 총 500ms
UserInfo user = userService.getUser(req.getUserId()); // 100ms
List<Order> orders = orderService.getOrders(req.getUserId()); // 150ms
Inventory inventory = inventoryService.check(req.getItemId()); // 80ms
Pricing pricing = pricingService.calculate(req.getItemId()); // 120ms
Shipping shipping = shippingService.estimate(req.getZipCode()); // 50ms
return new AggregatedResponse(user, orders, inventory, pricing, shipping);
}
}
After: 가상 스레드 병렬 호출
public class ParallelGateway {
private final ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
public AggregatedResponse handle(Request req) throws Exception {
// 병렬 호출: 총 150ms (가장 느린 것 기준)
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
var userTask = scope.fork(() -> userService.getUser(req.getUserId()));
var ordersTask = scope.fork(() -> orderService.getOrders(req.getUserId()));
var inventoryTask = scope.fork(() -> inventoryService.check(req.getItemId()));
var pricingTask = scope.fork(() -> pricingService.calculate(req.getItemId()));
var shippingTask = scope.fork(() -> shippingService.estimate(req.getZipCode()));
scope.join();
scope.throwIfFailed();
return new AggregatedResponse(
userTask.get(),
ordersTask.get(),
inventoryTask.get(),
pricingTask.get(),
shippingTask.get()
);
}
}
}
개선 효과:
- 레이턴시: 500ms → 150ms (70% 감소)
- 처리량: 3배 증가
사례 2: 배치 처리 시스템
시나리오: 100만 개 레코드 처리 (각 레코드당 DB 조회 + 외부 API 호출)
Before: 고정 풀
public class BatchProcessor {
private final ExecutorService executor = Executors.newFixedThreadPool(50);
public void processBatch(List<Record> records) {
List<Future<Void>> futures = new ArrayList<>();
for (Record record : records) {
Future<Void> future = executor.submit(() -> {
processRecord(record);
return null;
});
futures.add(future);
}
// 모든 작업 완료 대기
for (Future<Void> future : futures) {
try {
future.get();
} catch (Exception e) {
e.printStackTrace();
}
}
}
void processRecord(Record record) {
// DB 조회 (블로킹)
Data data = database.query(record.getId());
// 외부 API 호출 (블로킹)
Result result = externalAPI.process(data);
// DB 업데이트 (블로킹)
database.update(record.getId(), result);
}
}
문제점:
- 풀 크기 50 → 100만 개 처리에 오래 걸림
- 풀 크기 증가 → 메모리 부족
After: 가상 스레드
public class VirtualThreadBatchProcessor {
private final ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
public void processBatch(List<Record> records) {
List<Future<Void>> futures = new ArrayList<>();
for (Record record : records) {
Future<Void> future = executor.submit(() -> {
processRecord(record);
return null;
});
futures.add(future);
}
// 모든 작업 완료 대기
for (Future<Void> future : futures) {
try {
future.get();
} catch (Exception e) {
e.printStackTrace();
}
}
}
void processRecord(Record record) {
// 동일한 블로킹 코드 - 가상 스레드가 자동 처리
Data data = database.query(record.getId());
Result result = externalAPI.process(data);
database.update(record.getId(), result);
}
}
개선 효과:
- 처리 시간: 수 시간 → 수 분
- 메모리: 안정적
- DB 커넥션 풀을 병목으로 조정 필요
사례 3: WebSocket 서버
시나리오: 10만 개 동시 WebSocket 연결
Before: 플랫폼 스레드
// 연결당 스레드 생성 불가 (메모리 부족)
// → Netty 등 논블로킹 프레임워크 필요
After: 가상 스레드
import java.net.ServerSocket;
import java.net.Socket;
import java.util.concurrent.Executors;
public class WebSocketServer {
private final ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
public void start(int port) throws Exception {
try (ServerSocket serverSocket = new ServerSocket(port)) {
System.out.println("Server started on port " + port);
while (true) {
Socket clientSocket = serverSocket.accept();
// 연결당 가상 스레드 생성
executor.submit(() -> handleClient(clientSocket));
}
}
}
void handleClient(Socket socket) {
try (socket;
var in = socket.getInputStream();
var out = socket.getOutputStream()) {
byte[] buffer = new byte[1024];
int bytesRead;
while ((bytesRead = in.read(buffer)) != -1) {
// 블로킹 읽기/쓰기 - 가상 스레드가 자동 처리
out.write(buffer, 0, bytesRead);
}
} catch (Exception e) {
e.printStackTrace();
}
}
}
개선 효과:
- 10만 개 연결 처리 가능
- 블로킹 코드 그대로 사용
- 논블로킹 프레임워크 불필요
트러블슈팅
문제 1: “성능이 기대만큼 안 나온다”
원인 1: CPU 바운드 워크로드
// 가상 스레드가 도움 안 됨
public void cpuIntensiveTask() {
for (int i = 0; i < 1_000_000; i++) {
// 복잡한 계산
Math.sqrt(i);
}
}
해결: CPU 작업은 ForkJoinPool 사용
import java.util.concurrent.ForkJoinPool;
public class CpuTaskProcessor {
private final ForkJoinPool forkJoinPool = ForkJoinPool.commonPool();
public void processCpuTasks(List<Task> tasks) {
tasks.parallelStream()
.forEach(this::cpuIntensiveTask);
}
}
원인 2: PINNED 상태
확인 방법:
# JVM 플래그 추가
-Djdk.tracePinnedThreads=full
# 또는 JFR
java -XX:StartFlightRecording=settings=profile,filename=recording.jfr -jar app.jar
해결:
// synchronized → ReentrantLock
private final ReentrantLock lock = new ReentrantLock();
public void method() {
lock.lock();
try {
// 블로킹 I/O
} finally {
lock.unlock();
}
}
문제 2: “ThreadLocal이 누수된다”
원인
// 가상 스레드 수백만 개 × ThreadLocal
private static final ThreadLocal<HeavyObject> CONTEXT = new ThreadLocal<>();
public void handle() {
CONTEXT.set(new HeavyObject()); // 누수 위험
// ...
}
해결 1: ScopedValue 사용
private static final ScopedValue<HeavyObject> CONTEXT = ScopedValue.newInstance();
public void handle() {
HeavyObject obj = new HeavyObject();
ScopedValue.where(CONTEXT, obj).run(() -> {
// 스코프 종료 시 자동 정리
process();
});
}
해결 2: ThreadLocal 명시적 정리
private static final ThreadLocal<HeavyObject> CONTEXT = new ThreadLocal<>();
public void handle() {
try {
CONTEXT.set(new HeavyObject());
process();
} finally {
CONTEXT.remove(); // 필수
}
}
문제 3: “DB 커넥션이 고갈된다”
원인
// 가상 스레드 10만 개 → DB 커넥션 풀 50개 → 대기
해결 1: 커넥션 풀 크기 증가
config.setMaximumPoolSize(500); // 50 → 500
config.setConnectionTimeout(5000); // 타임아웃 설정
해결 2: 세마포어로 동시 요청 제한
import java.util.concurrent.Semaphore;
public class RateLimitedService {
private final Semaphore semaphore = new Semaphore(100); // 동시 100개 제한
private final ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
public void processRequest(Request req) throws Exception {
semaphore.acquire();
try {
executor.submit(() -> {
try {
// DB 호출
database.query(req);
} finally {
semaphore.release();
}
});
} catch (Exception e) {
semaphore.release();
throw e;
}
}
}
해결 3: 서킷 브레이커
import io.github.resilience4j.circuitbreaker.CircuitBreaker;
import io.github.resilience4j.circuitbreaker.CircuitBreakerConfig;
public class ResilientService {
private final CircuitBreaker circuitBreaker = CircuitBreaker.of(
"database",
CircuitBreakerConfig.custom()
.failureRateThreshold(50)
.waitDurationInOpenState(Duration.ofSeconds(30))
.build()
);
public Result query(Request req) {
return circuitBreaker.executeSupplier(() -> database.query(req));
}
}
문제 4: “스레드 덤프가 너무 크다”
원인
# 가상 스레드 10만 개 → 스레드 덤프 수백 MB
jstack <pid> > dump.txt
해결: 샘플링 도구 사용
# async-profiler (가상 스레드 지원)
./profiler.sh -e cpu -d 30 -f flamegraph.html <pid>
# JFR (가상 스레드 이벤트 필터링)
jfr print --events jdk.ExecutionSample recording.jfr
마무리
가상 스레드는 “블로킹 코드를 그대로 두되 OS 스레드를 아끼자”는 현대적인 선택지입니다. 풀 튜닝 중심 사고를 외부 자원 한도·PINNED·ThreadLocal 관점으로 바꾸면 마이그레이션이 수월합니다.
마이그레이션 체크리스트
-
워크로드 확인
- ✅ I/O 대기가 많은가? → 가상 스레드 적합
- ❌ CPU 집약적인가? → ForkJoinPool 유지
-
코드 점검
- ✅
synchronized블록 내 블로킹? →ReentrantLock으로 변경 - ✅ ThreadLocal 사용? →
ScopedValue검토 - ✅ 네이티브 메서드 호출? → PINNED 이벤트 모니터링
- ✅
-
외부 자원 조정
- ✅ DB 커넥션 풀 크기 증가
- ✅ HTTP 클라이언트 타임아웃 설정
- ✅ 서킷 브레이커 도입
-
모니터링
- ✅ JFR로 PINNED 이벤트 확인
- ✅ 처리량·레이턴시 측정
- ✅ 메모리 사용량 추적
다음 단계
- 스레드 기본기: Java 멀티스레드
- Spring 통합: Spring 시리즈
- I/O 최적화: I/O 가이드
가상 스레드는 JDK 21의 가장 큰 변화 중 하나입니다. 기존 블로킹 코드를 최소한의 변경으로 고성능 동시성 애플리케이션으로 전환할 수 있습니다.