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

심화 부록: 구현·운영 관점

이 부록은 앞선 본문에서 다룬 주제(「Java Virtual Thread로 동시성 코드 바꾸기 | JDK 21 마이그레이션 가이드」)를 구현·런타임·운영 관점에서 다시 압축합니다. 도메인별 세부 구현은 글마다 다르지만, 입력 검증 → 핵심 연산 → 부작용(I/O·네트워크·동시성) → 관측의 흐름으로 장애를 나누면 원인 추적이 빨라집니다.

내부 동작과 핵심 메커니즘

flowchart TD
  A[입력·요청·이벤트] --> B[파싱·검증·디코딩]
  B --> C[핵심 연산·상태 전이]
  C --> D[부작용: I/O·네트워크·동시성]
  D --> E[결과·관측·저장]
sequenceDiagram
  participant C as 클라이언트/호출자
  participant B as 경계(런타임·게이트웨이·프로세스)
  participant D as 의존성(API·DB·큐·파일)
  C->>B: 요청/이벤트
  B->>D: 조회·쓰기·RPC
  D-->>B: 지연·부분 실패·재시도 가능
  B-->>C: 응답 또는 오류(코드·상관 ID)
  • 불변 조건(Invariant): 버퍼 경계, 프로토콜 상태, 트랜잭션 격리, FD 상한 등 단계별로 문장으로 적어 두면 디버깅 비용이 줄어듭니다.
  • 결정성: 순수 층과 시간·네트워크·스케줄에 의존하는 층을 분리해야 테스트와 장애 분석이 쉬워집니다.
  • 경계 비용: 직렬화, 인코딩, syscall 횟수, 락 경합, 할당·GC, 캐시 미스를 의심 목록에 둡니다.
  • 백프레셔: 생산자가 소비자보다 빠를 때 버퍼·큐·스트림에서 속도를 줄이는 신호를 어디에 둘지 정의합니다.

프로덕션 운영 패턴

영역운영 관점 질문
관측성요청 단위 상관 ID, 에러율·지연 p95/p99, 의존성 타임아웃·재시도가 대시보드에 보이는가
안전성입력 검증·권한·비밀·감사 로그가 코드 경로마다 일관적인가
신뢰성재시도는 멱등 연산에만 적용되는가, 서킷 브레이커·백오프·DLQ가 있는가
성능캐시·배치 크기·커넥션 풀·인덱스·백프레셔가 데이터 규모에 맞는가
배포롤백 룬북, 카나리/블루그린, 마이그레이션·피처 플래그가 문서화되어 있는가
용량피크 트래픽·디스크·FD·스레드 풀 상한을 주기적으로 검증하는가

스테이징은 데이터 양·네트워크 RTT·동시성을 프로덕션에 가깝게 맞출수록 재현율이 올라갑니다.

확장 예시: 엔드투엔드 미니 시나리오

앞선 본문 주제(「Java Virtual Thread로 동시성 코드 바꾸기 | JDK 21 마이그레이션 가이드」)를 배포·운영 흐름에 맞춰 옮긴 체크리스트입니다. 도메인에 맞게 단계 이름만 바꿔 적용할 수 있습니다.

  1. 입력 계약 고정: 스키마·버전·최대 페이로드·타임아웃·에러 코드를 경계에 둔다.
  2. 핵심 경로 계측: 요청 ID, 단계별 지연, 외부 호출 결과 코드를 로그·메트릭·트레이스에서 한 흐름으로 본다.
  3. 실패 주입: 의존성 타임아웃·5xx·부분 데이터·락 대기를 스테이징에서 재현한다.
  4. 호환·롤백: 설정/마이그레이션/클라이언트 버전을 되돌릴 수 있는지 확인한다.
  5. 부하 후 검증: 피크 대비 p95/p99, 에러율, 리소스 상한, 알림 임계값을 점검한다.
handle(request):
  ctx = newCorrelationId()
  validated = validateSchema(request)
  authorize(validated, ctx)
  result = domainCore(validated)
  persistOrEmit(result, idempotentKey)
  recordMetrics(ctx, latency, outcome)
  return result

문제 해결(Troubleshooting)

증상가능 원인조치
간헐적 실패레이스, 타임아웃, 외부 의존성, DNS최소 재현 스크립트, 분산 트레이스·로그 상관관계, 재시도·서킷 설정 점검
성능 저하N+1, 동기 I/O, 락 경합, 과도한 직렬화, 캐시 미스프로파일러·APM으로 핫스팟 확인 후 한 가지씩 제거
메모리 증가캐시 무제한, 구독/리스너 누수, 대용량 버퍼, 커넥션 미반납상한·TTL·힙/FD 스냅샷 비교
빌드·배포만 실패환경 변수, 권한, 플랫폼 차이, lockfileCI 로그와 로컬 diff, 런타임·이미지 버전 핀
설정 불일치프로필·시크릿·기본값, 리전스키마 검증된 설정 단일 소스와 배포 매트릭스 표준화
데이터 불일치비멱등 재시도, 부분 쓰기, 캐시 무효화 누락멱등 키·아웃박스·트랜잭션 경계 재검토

권장 순서: (1) 최소 재현 (2) 최근 변경 범위 축소 (3) 환경·의존성 차이 (4) 관측으로 가설 검증 (5) 수정 후 회귀·부하 테스트.

배포 전에는 git addgit commitgit pushnpm run deploy 순서를 권장합니다.


자주 묻는 질문 (FAQ)

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

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

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

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

Q. 더 깊이 공부하려면?

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


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

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


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

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