본문으로 건너뛰기 Java Virtual Thread로 동시성 코드 바꾸기 | JDK 21 마이그레이션 가이드

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

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

이 글의 핵심

Project Loom의 가상 스레드는 플랫폼 스레드 부담 없이 블로킹 코드를 표현합니다. 기존 풀 대비 장점과 마이그레이션 체크포인트를 정리했습니다.

들어가며

전통적으로 Java 서버는 요청당 스레드 모델을 쓰면 OS 스레드 한계에 부딪혔으며, 스레드 풀 + 작은 스레드 + 논블로킹으로 우회해 왔습니다. JDK 21부터 가상 스레드(Virtual Thread)는 “블로킹 호출을 쓰되, 플랫폼 스레드를 거의 먹지 않는” 방향을 열어 줍니다. Java 가상 스레드(virtual thread)를 도입할지 판단할 때는 워크로드가 I/O 대기인지, 네이티브 블로킹이 있는지부터 봅니다. 이 글은 가상 스레드가 무엇인지, 기존 ExecutorService 풀과 무엇이 다른지, 코드를 옮길 때 어디를 점검해야 하는지를 실무 관점에서 정리합니다. 스레드 기본기는 Java 멀티스레드, I/O는 I/O 가이드와 연결됩니다.

개념 설명

플랫폼 스레드 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의 가장 큰 변화 중 하나입니다. 기존 블로킹 코드를 최소한의 변경으로 고성능 동시성 애플리케이션으로 전환할 수 있습니다.

자주 묻는 질문 (FAQ)

Q. 이 내용을 실무에서 언제 쓰나요?

A. Project Loom의 가상 스레드는 플랫폼 스레드 부담 없이 블로킹 코드를 표현합니다. 기존 풀 대비 장점과 마이그레이션 체크포인트를 정리했습니다. 실전 예제와 코드로 개념부터 활용까지 정리합니다. Java·Vi… 실무에서는 위 본문의 예제와 선택 가이드를 참고해 적용하면 됩니다.

Q. 선행으로 읽으면 좋은 글은?

A. 각 글 하단의 이전 글 또는 관련 글 링크를 따라가면 순서대로 배울 수 있습니다. C++ 시리즈 목차에서 전체 흐름을 확인할 수 있습니다.

Q. 더 깊이 공부하려면?

A. cppreference와 해당 라이브러리 공식 문서를 참고하세요. 글 말미의 참고 자료 링크도 활용하면 좋습니다.


같이 보면 좋은 글 (내부 링크)

이 주제와 연결되는 다른 글입니다.


이 글에서 다루는 키워드 (관련 검색어)

Java, Virtual Thread, Project Loom, 동시성, JDK 21, 마이그레이션 등으로 검색하시면 이 글이 도움이 됩니다.