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+ 평가
시리즈 다른 편 (시리즈 마지막)
- 1편 — 동시성 기초·Java Thread
- 2편 — Carrier·Mount·Unmount
- 3편 — API·Builder·ExecutorService
- 4편 — Pinning·synchronized·ReentrantLock
- 5편 — Spring Boot 통합
- 6편 — Structured Concurrency
- 7편 — Performance·JFR·메모리
- 8편 — Patterns·실전·안티패턴 (현재 글, 시리즈 마지막)
공식 문서: JEP 444 — Virtual Threads / JEP 491 — Synchronize Virtual Threads / JEP 446 — Scoped Values 에서 더 깊이.
Java 21 Virtual Thread 마스터 시리즈는 여기서 마무리. 1편부터 8편까지의 흐름이 머리에 남으면 Java 동시성의 패러다임 전환을 한 번에 통찰할 토대가 됩니다.