Virtual Thread 마스터 — Patterns·실전·안티패턴

2026-05-03확률과 통계 마스터 노트

Java 21 Virtual Thread 마스터 노트 시리즈 8편 (마지막). Virtual Thread 환경에서 자주 쓰이는 패턴 6종(Fan-out·Race·Pipeline·Producer-Consumer·Worker Pool·Saga), 안티패턴(Pool에 VT·synchronized·CPU 집약 처리·ThreadLocal 남용), 실전 마이그레이션 체크리스트, 시리즈 마무리.

이 글은 Java 21 Virtual Thread 마스터 노트 시리즈의 마지막 여덟 번째 편입니다. 1~7편이 토대였다면, 이번엔 자주 쓰이는 패턴 + 안티패턴 모음.

언제 어떤 패턴? 무엇을 하지 말아야? 운영 환경 가이드 + 시리즈 마무리.

처음 패턴이 어렵게 느껴지는 이유

처음 이 단원이 어렵게 느껴지는 이유는 두 가지예요. 첫째, 패턴 이름이 낯섭니다. Fan-out·Race·Pipeline. 둘째, 언제 어떤 패턴인지 막연합니다.

해결법은 한 가지예요. 각 패턴 = 한 줄 사용처. Fan-out=병렬 호출, Race=가장 빠른 것, Pipeline=순차 단계, Producer-Consumer=큐 기반. 이 매핑만 잡으면 끝.

패턴 1 — Fan-out (병렬 호출)

여러 외부 호출 동시:

public CombinedData fetchAll(String userId) {
    try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
        Subtask<User> user = scope.fork(() -> userClient.fetch(userId));
        Subtask<List<Order>> orders = scope.fork(() -> orderClient.fetch(userId));
        Subtask<Profile> profile = scope.fork(() -> profileClient.fetch(userId));
        
        scope.join();
        scope.throwIfFailed();
        
        return new CombinedData(user.get(), orders.get(), profile.get());
    }
}

순차 = 3초 (각 1초). Fan-out = ~1초 (병렬).

여기서 정말 중요한 시험 함정 — 마이크로서비스 합성 = Fan-out 표준. 기존 코드는 직렬 호출 → Virtual Thread + Structured Concurrency로 자연스럽게 병렬.

패턴 2 — Race (Hedging)

같은 요청 여러 곳에 → 가장 빠른 응답:

public String fetchFromMirror(String key) {
    try (var scope = new StructuredTaskScope.ShutdownOnSuccess<String>()) {
        scope.fork(() -> mirror1.fetch(key));
        scope.fork(() -> mirror2.fetch(key));
        scope.fork(() -> mirror3.fetch(key));
        
        scope.join();
        return scope.result();
    }
}

타임아웃 회피·고가용성. 여러 데이터 센터 미러.

여기서 시험 함정이 하나 있어요. Race = 자원 소비 ↑ (모든 요청 동시). 비용·부하 고려. 응답 지연이 결정적일 때만.

패턴 3 — Pipeline (순차 단계)

public Flux<ProcessedData> pipeline(Flux<RawData> input) {
    return input
        .flatMap(raw -> Mono.fromCallable(() -> validate(raw)).subscribeOn(Schedulers.boundedElastic()))
        .flatMap(valid -> Mono.fromCallable(() -> enrich(valid)).subscribeOn(Schedulers.boundedElastic()))
        .flatMap(enriched -> Mono.fromCallable(() -> save(enriched)).subscribeOn(Schedulers.boundedElastic()));
}

또는 Virtual Thread + 큐:

BlockingQueue<RawData> stage1 = new LinkedBlockingQueue<>();
BlockingQueue<ValidData> stage2 = new LinkedBlockingQueue<>();
BlockingQueue<EnrichedData> stage3 = new LinkedBlockingQueue<>();

// 각 단계마다 Virtual Thread Worker
Thread.startVirtualThread(() -> {
    while (running) {
        RawData raw = stage1.take();
        stage2.put(validate(raw));
    }
});
// stage2 → stage3 worker
// stage3 → save worker

각 단계 독립·병렬. 처리량 ↑.

패턴 4 — Producer-Consumer (큐 기반)

BlockingQueue<Task> queue = new LinkedBlockingQueue<>(1000);

// Producer
Thread.startVirtualThread(() -> {
    while (true) {
        Task task = generateTask();
        queue.put(task);   // 가득 차면 대기 (Virtual Thread → unmount)
    }
});

// Consumer 100개
for (int i = 0; i < 100; i++) {
    Thread.startVirtualThread(() -> {
        while (true) {
            Task task = queue.take();   // 비면 대기
            process(task);
        }
    });
}

여기서 정말 중요한 시험 함정 — BlockingQueue는 Virtual Thread 친화 (LockSupport 기반). put·take = unmount OK.

패턴 5 — Worker Pool (제한)

VT 무한 가능하지만 외부 자원(DB·API) 보호:

Semaphore semaphore = new Semaphore(50);   // 최대 50 동시 DB

public User fetchWithLimit(String id) {
    semaphore.acquire();
    try {
        return db.fetch(id);
    } finally {
        semaphore.release();
    }
}

여기서 시험 함정이 하나 있어요. Semaphore도 Virtual Thread 친화. acquire 시 unmount OK. 외부 자원 보호 표준.

패턴 6 — Saga (분산 트랜잭션)

public OrderResult placeOrder(Order order) {
    try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
        // 1. 결제
        Subtask<Payment> payment = scope.fork(() -> paymentService.charge(order));
        scope.join();
        
        try {
            scope.throwIfFailed();
        } catch (Exception e) {
            return OrderResult.failed("Payment failed");
        }
        
        // 2. 재고 차감 (실패 시 결제 취소)
        try (var scope2 = new StructuredTaskScope.ShutdownOnFailure()) {
            Subtask<Inventory> inv = scope2.fork(() -> inventoryService.deduct(order));
            scope2.join();
            scope2.throwIfFailed();
            
            // 3. 배송
            Subtask<Shipping> ship = scope2.fork(() -> shippingService.schedule(order));
            scope2.join();
            scope2.throwIfFailed();
            
            return OrderResult.success();
        } catch (Exception e) {
            // 보상 — 결제 환불
            paymentService.refund(payment.get().getId());
            return OrderResult.failed("Order failed");
        }
    }
}

여러 서비스 단계·실패 시 보상.

안티패턴 1 — Pool에 Virtual Thread

// X 안티패턴
ThreadFactory factory = Thread.ofVirtual().factory();
ExecutorService pool = Executors.newFixedThreadPool(200, factory);

여기서 정말 중요한 시험 함정 — VT를 풀에 두면 의미 X. VT의 가벼움이 풀의 재사용 의미를 무력화. 재사용 비용 < 새로 만들기. 항상 newVirtualThreadPerTaskExecutor (Per-Task).

안티패턴 2 — synchronized 남용

// X
public synchronized void process(Data d) {
    externalApi.call();   // Pinning
}

// O
private final ReentrantLock lock = new ReentrantLock();
public void process(Data d) {
    lock.lock();
    try {
        externalApi.call();
    } finally {
        lock.unlock();
    }
}

Pinning 회피. 4편 참조.

안티패턴 3 — CPU 집약 처리

// X — CPU 집약을 VT에
Thread.startVirtualThread(() -> {
    for (int i = 0; i < 1_000_000_000; i++) {
        compute();
    }
});

// O — Platform Thread (또는 ForkJoinPool)
ExecutorService cpuPool = ForkJoinPool.commonPool();
cpuPool.submit(() -> {
    for (int i = 0; i < 1_000_000_000; i++) {
        compute();
    }
});

여기서 시험 함정이 하나 있어요. CPU Bound = ForkJoinPool. VT는 I/O 위주. 혼합 시 분리.

안티패턴 4 — ThreadLocal 남용

// X — 수백만 VT가 ThreadLocal 보관
ThreadLocal<Map<String, Object>> userCache = ThreadLocal.withInitial(HashMap::new);

// O — ScopedValue (Java 21+)
final static ScopedValue<Map<String, Object>> CACHE = ScopedValue.newInstance();

ScopedValue.where(CACHE, new HashMap<>()).run(() -> {
    // ...
});

VT 환경 = ThreadLocal 메모리 폭주 위험.

안티패턴 5 — 무한 VT 생성

// X
while (true) {
    String request = receive();
    Thread.startVirtualThread(() -> handle(request));
    // 무한 → DB·외부 API 폭주
}

// O — Rate Limit + Backpressure
RateLimiter limiter = RateLimiter.create(1000);   // 1000 RPS
while (true) {
    String request = receive();
    limiter.acquire();
    Thread.startVirtualThread(() -> handle(request));
}

VT는 가벼우니 무한 가능 = 외부 자원 폭주. Rate Limit 필수.

안티패턴 6 — Reactive 강제 변환

// X — Reactive를 강제 사용
public Mono<User> fetchUser(String id) {
    return webClient.get()...   // 익숙치 않으면 어려움
}

// O — Virtual Thread + 동기 (단순)
public User fetchUser(String id) {
    return restClient.get()...   // 단순·디버깅 쉬움
}

여기서 정말 중요한 시험 함정 — Reactive 강제 X. Virtual Thread 환경에선 동기 코드가 더 단순·효율. WebFlux는 백프레셔 결정적일 때만.

안티패턴 7 — 짧은 작업에 VT

// X — 작은 함수를 VT로
String result = "data";
Thread.startVirtualThread(() -> result.toUpperCase()).join();   // 오버헤드

// O — 그냥 직접 호출
String result = "data".toUpperCase();

VT 생성도 오버헤드 있음 (~수 us). 매우 짧은 작업엔 X.

운영 권장 패턴

✓ I/O Bound → Virtual Thread Per Task
✓ CPU Bound → ForkJoinPool·Platform Thread
✓ 마이크로서비스 합성 → Fan-out (Structured Concurrency)
✓ 외부 API 보호 → Semaphore·RateLimiter
✓ DB 보호 → HikariCP 연결 풀
✓ Pipeline → BlockingQueue + VT Worker
✓ 백프레셔 결정적 → WebFlux
✓ Lock 필요 → ReentrantLock (synchronized X)
✓ 컨텍스트 전달 → ScopedValue
✓ 모니터링 → JFR + Prometheus

마이그레이션 체크리스트

✓ Java 21 업그레이드
✓ Spring Boot 3.2+ 업그레이드
✓ spring.threads.virtual.enabled: true
✓ JDBC 드라이버 최신
✓ HikariCP 5+
✓ Logback 1.4+
✓ synchronized → ReentrantLock 점검
✓ ThreadLocal 남용 점검
✓ FileInputStream → NIO
✓ JNI 호출 점검
✓ -Djdk.tracePinnedThreads (개발)
✓ JFR 운영 활성
✓ Rate Limit·Circuit Breaker
✓ 부하 테스트 (이전 vs 이후)
✓ Java 24+ 평가 (synchronized 자동)

시리즈 마무리 — 8편 종합

1편부터 8편까지의 흐름:

주제 한 줄
1 동시성 기초 Java Thread 한계, VT 등장 배경
2 Carrier·Mount·Unmount JVM 내부 동작
3 API 4 생성 방법, ExecutorService
4 Pinning 가장 큰 함정, ReentrantLock
5 Spring Boot 통합 한 줄 설정, MVC vs WebFlux
6 Structured Concurrency 자식 관리, ScopedValue
7 Performance JFR·JMH·메모리·GC
8 Patterns Fan-out·Race·Pipeline·안티패턴

Virtual Thread의 거의 모든 운영 패턴을 한 번에 통찰할 토대.

시험 직전 한 번 더 — 자주 헷갈리는 함정 모음

여기까지가 8편의 핵심입니다. 시험 직전 또는 실무에서 헷갈릴 때 다시 펼쳐 볼 수 있게 압축 노트로 마무리할게요.

  • 패턴 6 — Fan-out / Race / Pipeline / Producer-Consumer / Worker Pool / Saga
  • Fan-out = 마이크로서비스 합성 표준 (StructuredTaskScope.ShutdownOnFailure)
  • Race (Hedging) = 가장 빠른 응답 (ShutdownOnSuccess), 자원 소비 ↑
  • Pipeline = 단계별 BlockingQueue + VT Worker
  • Producer-Consumer = BlockingQueue (VT 친화)
  • Worker Pool 제한 = Semaphore (외부 자원 보호)
  • Saga = 단계 + 보상 + StructuredTaskScope
  • 안티패턴 7 — Pool에 VT·synchronized·CPU 집약·ThreadLocal·무한 생성·Reactive 강제·짧은 작업
  • VT 풀에 두지 마라 — Per-Task 표준
  • synchronized 점검 → ReentrantLock
  • CPU Bound = ForkJoinPool
  • ThreadLocal 남용 → ScopedValue
  • 무한 VT = DB·외부 폭주 → Rate Limit
  • Reactive 강제 X — VT + 동기가 단순
  • 짧은 작업 = 직접 호출 (VT 오버헤드)
  • 운영 — I/O Bound = VT, CPU = FJ, 합성 = Fan-out, 보호 = Semaphore, Pipeline = Queue+VT, Lock = ReentrantLock, Context = ScopedValue, 모니터 = JFR + Prometheus
  • 마이그레이션 — Java 21·Spring Boot 3.2·드라이버·HikariCP·Logback·synchronized·ThreadLocal·File·JNI·tracePinnedThreads·JFR·Rate Limit·부하 테스트·Java 24+ 평가

시리즈 다른 편 (시리즈 마지막)

공식 문서: JEP 444 — Virtual Threads / JEP 491 — Synchronize Virtual Threads / JEP 446 — Scoped Values 에서 더 깊이.

Java 21 Virtual Thread 마스터 시리즈는 여기서 마무리. 1편부터 8편까지의 흐름이 머리에 남으면 Java 동시성의 패러다임 전환을 한 번에 통찰할 토대가 됩니다.

※ 이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.

답글 남기기

error: Content is protected !!