Java Virtual Thread로 동시성 코드 바꾸기 | JDK 21 마이그레이션 가이드

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 가이드와 연결됩니다.


목차

  1. 개념 설명
  2. 실전 구현 (단계별 코드)
  3. 고급 활용
  4. 성능·비교
  5. 실무 사례
  6. 트러블슈팅
  7. 마무리

개념 설명

플랫폼 스레드 vs 가상 스레드

항목플랫폼 스레드가상 스레드
매핑OS 스레드 1:1JVM이 M:N 스케줄링
생성 비용높음 (수 ms)낮음 (수 μs)
메모리스레드당 ~1MB (스택)스레드당 수백 바이트
최대 개수수천 개 (OS 제한)수백만 개 가능
블로킹 시OS 스레드 점유캐리어 스레드 양도
CPU 바운드코어 수만큼 효율적이득 없음
I/O 바운드스레드 풀 필요풀 없이도 효율적

동작 원리

┌─────────────────────────────────────────┐
│          가상 스레드 (수백만 개)          │
│  VT1   VT2   VT3   VT4   VT5   ...      │
└────┬────┬────┬────┬────┬────────────────┘
     │    │    │    │    │
     └────┴────┴────┴────┘
          │         │
     ┌────▼─────────▼────┐
     │  캐리어 스레드 풀   │
     │  (플랫폼 스레드)    │
     │  CT1  CT2  CT3     │
     └────────────────────┘

     ┌────▼────┐
     │ OS 스레드│
     └─────────┘

핵심 메커니즘:

  1. 가상 스레드가 블로킹 I/O 호출 (예: socket.read())
  2. JVM이 가상 스레드를 언마운트 (캐리어 스레드에서 분리)
  3. 캐리어 스레드는 다른 가상 스레드 실행
  4. 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 발생 원인

  1. synchronized 블록 내 블로킹
  2. 네이티브 메서드 호출

예제: 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 관점으로 바꾸면 마이그레이션이 수월합니다.

마이그레이션 체크리스트

  1. 워크로드 확인

    • ✅ I/O 대기가 많은가? → 가상 스레드 적합
    • ❌ CPU 집약적인가? → ForkJoinPool 유지
  2. 코드 점검

    • synchronized 블록 내 블로킹? → ReentrantLock으로 변경
    • ✅ ThreadLocal 사용? → ScopedValue 검토
    • ✅ 네이티브 메서드 호출? → PINNED 이벤트 모니터링
  3. 외부 자원 조정

    • ✅ DB 커넥션 풀 크기 증가
    • ✅ HTTP 클라이언트 타임아웃 설정
    • ✅ 서킷 브레이커 도입
  4. 모니터링

    • ✅ JFR로 PINNED 이벤트 확인
    • ✅ 처리량·레이턴시 측정
    • ✅ 메모리 사용량 추적

다음 단계

  • 스레드 기본기: Java 멀티스레드
  • Spring 통합: Spring 시리즈
  • I/O 최적화: I/O 가이드

가상 스레드는 JDK 21의 가장 큰 변화 중 하나입니다. 기존 블로킹 코드를 최소한의 변경으로 고성능 동시성 애플리케이션으로 전환할 수 있습니다.