Executor 스레드풀 파라미터 CompletableFuture ForkJoinPool ThreadLocal 가상 스레드 📝 문제풀이
◀ 심화 CH 02 📋 목차 심화 CH 04 ▶
🧵 심화 CHAPTER 03 · 5년차 트랙

스레드를 직접 만들지 말고, 도구에게 맡겨라 — 그리고 가상 스레드

CH 02에서 메모리 모델을 봤다면, 이번엔 실전 도구예요. ExecutorService로 스레드를 관리하고, CompletableFuture로 비동기를 조합하고, Java 21의 가상 스레드로 블로킹 I/O를 수백만 개 다루는 법 — 장애 없는 서버를 만드는 실무 지식과 시니어 면접의 핵심이에요.

🎯 이 장을 끝내면
🎛️
Executor
Thread를 직접 만들지 마라
생성·수명·재사용을 프레임워크에게 위임하기

new Thread(...).start()는 요청마다 OS 스레드를 새로 만들어요. 스레드는 비싼 자원(스택 메모리·커널 객체)이라, 트래픽이 몰리면 수천 개가 생겨 메모리 폭발·컨텍스트 스위칭 지옥에 빠집니다. 그래서 스레드를 미리 만들어 두고 재사용하는 ExecutorService가 표준이에요.

📨
작업 제출
submit() / execute()로 Runnable·Callable
📥
작업 큐
BlockingQueue에 대기
👷
워커 스레드 풀
재사용되는 스레드가 꺼내 실행
⚠️ Executors 팩토리의 숨은 함정 Executors.newFixedThreadPool·newCachedThreadPool는 편하지만 위험해요.
newFixedThreadPool — 내부적으로 무제한 LinkedBlockingQueue를 써서, 작업이 폭주하면 큐에 무한정 쌓여 OOM이 날 수 있어요.
newCachedThreadPool — maxPoolSize가 Integer.MAX_VALUE라 스레드가 무한 생성될 수 있어요. 그래서 실무·많은 스타일 가이드는 ThreadPoolExecutor를 직접 생성해 큐 크기와 포화 정책을 명시하라고 권합니다.
💡
종료도 도구가 관리한다. 애플리케이션을 깔끔히 내리려면 shutdown()(새 작업 거부, 남은 작업 완료)이나 shutdownNow()(진행 중 인터럽트) 후 awaitTermination()으로 대기하세요. 풀을 닫지 않으면 비-데몬 워커 스레드 때문에 JVM이 종료되지 않을 수 있어요.
🎚️
스레드풀 파라미터
core · max · 큐 · 포화 정책의 상호작용
이 네 개가 어떻게 맞물리는지가 전부예요.
파라미터의미
corePoolSize평소 유지하는 기본 스레드 수. 유휴여도 (기본적으로) 살려둠
maximumPoolSize스레드가 늘어날 수 있는 상한. 단, 큐가 가득 찬 뒤에야 core를 넘어 확장
keepAliveTimecore 초과분 스레드가 유휴 상태로 버티는 시간(넘으면 회수)
workQueueBlockingQueue. 이 선택이 풀의 동작을 좌우함(아래)
RejectedExecutionHandler큐도 스레드도 꽉 찼을 때의 포화 정책
⚠️
동작 순서를 정확히. 작업이 들어오면 ① core 스레드가 남으면 스레드를 만들어 실행 → ② core가 다 차면 큐에 넣음 → ③ 큐가 가득 차야 비로소 max까지 스레드를 늘림 → ④ max도 큐도 꽉 차면 포화 정책 발동. 즉 큐에 여유가 있으면 절대 max까지 늘지 않아요.
📥 작업 큐(BlockingQueue) 종류가 동작을 지배 LinkedBlockingQueue(무제한) — 큐가 절대 안 차므로 스레드는 corePoolSize를 절대 넘지 않아요. → maximumPoolSize무의미. 대신 작업 폭주 시 큐가 무한 증가 → OOM.
ArrayBlockingQueue(유한) — 큐가 차면 max까지 확장. 큐 크기·max로 배압(backpressure)을 설계.
SynchronousQueue(용량 0) — 큐잉 안 함. 넘겨줄 워커가 없으면 즉시 새 스레드 생성(그래서 newCachedThreadPool이 무한 확장). "maxPoolSize를 키웠는데 스레드가 안 늘어난다"의 90%는 무제한 큐가 원인이에요.
포화 정책(RejectedExecutionHandler)동작
AbortPolicy (기본)RejectedExecutionException을 던짐
CallerRunsPolicy제출한 스레드가 직접 실행 → 자연스러운 배압·속도 조절
DiscardPolicy조용히 버림(예외 없음)
DiscardOldestPolicy큐의 가장 오래된 작업을 버리고 새 작업을 넣음
🖼️ 그림으로 보기 — 작업 제출 → 처리 순서
작업 제출 (submit / execute) core 스레드에 여유가 있나? core 스레드로 즉시 실행 작업 큐에 여유가 있나? 큐에 넣고 대기 스레드 수 < max 인가? 새 스레드 생성 (max까지) 포화 정책 발동 Abort · CallerRuns · Discard ... 아니오 아니오 아니오
🔗
CompletableFuture
Future의 블로킹을 넘어 조합으로
get()을 기다리지 말고 파이프라인을 엮어라.

Future는 결과를 담지만, 결과를 꺼내려면 future.get()으로 블로킹해야 해요. 여러 비동기 작업을 이어붙이거나 합치는 조합이 불가능하죠. CompletableFuture는 이 한계를 깨고 선언적 파이프라인을 제공합니다.

메서드역할
thenApply결과를 변환(T → U). 동기 매핑
thenCompose결과로 또 다른 CF를 반환해 이어붙임(flatMap. 중첩 CF 평탄화)
thenCombine독립적인 두 CF의 결과를 하나로 합침
allOf / anyOf여러 CF가 모두 / 하나라도 끝날 때까지 대기하는 CF 생성
exceptionally예외 발생 시 대체값으로 복구
handle성공·실패 모두 받아 처리(결과, 예외 둘 다 인자)
CompletableFuture<String> cf = CompletableFuture
    .supplyAsync(() -> fetchUser(id), executor)   // 비동기 시작 (풀 지정)
    .thenApply(user -> user.getName())            // 변환
    .thenCompose(name -> loadProfileAsync(name))  // 또 다른 CF 이어붙이기
    .exceptionally(ex -> "guest");                // 예외 시 대체값

// thenCombine: 독립 두 작업을 병렬로 하고 합치기
CompletableFuture<Integer> a = CompletableFuture.supplyAsync(() -> 2, executor);
CompletableFuture<Integer> b = CompletableFuture.supplyAsync(() -> 3, executor);
int sum = a.thenCombine(b, (x, y) -> x + y).join(); // 5
⚠️
기본 실행자는 ForkJoinPool.commonPool. *Async 계열(supplyAsync·thenApplyAsync 등)에 Executor를 지정하지 않으면 공용 ForkJoinPool.commonPool()에서 돕니다. 여기에 DB 조회·HTTP 호출 같은 블로킹 작업을 올리면 공용 풀이 마비돼 앱 전체가 느려져요. 실무에선 전용 Executor를 명시하는 게 안전합니다.
🧭
thenApply vs thenCompose. 변환 함수가 일반 값을 반환하면 thenApply, 또 다른 CompletableFuture를 반환하면 thenCompose예요. thenApply에 CF를 반환하면 CompletableFuture<CompletableFuture<T>>중첩돼 버립니다.
🍴
ForkJoinPool
분할정복과 work-stealing
parallelStream의 정체, 그리고 공용 풀의 위험.

ForkJoinPool은 큰 작업을 재귀적으로 쪼개(fork) 병렬 실행하고 결과를 합치는(join) 분할정복 프레임워크예요. 핵심은 work-stealing: 각 워커가 자기 덱(deque)을 갖고, 자기 일이 떨어지면 바쁜 다른 워커의 큐 꼬리에서 작업을 훔쳐 옵니다. 덕분에 부하가 고르게 퍼지고 유휴 스레드가 줄어요.

🌊 parallelStream도 공용 풀을 쓴다 list.parallelStream()은 별도 지정이 없으면 ForkJoinPool.commonPool()에서 실행돼요. 이 공용 풀은 JVM에 하나뿐이고, 크기는 기본적으로 CPU 코어 수 − 1 정도로 작습니다. 그래서 parallelStream 안에서 블로킹 I/O(HTTP·DB)를 돌리면, 몇 개의 느린 작업이 공용 풀의 스레드를 전부 붙잡아 앱의 다른 병렬 작업까지 멈춰요. 병렬 스트림은 CPU 바운드·짧은 연산에만 쓰세요.
💡
공용 풀을 피하는 법. 정말 병렬 스트림에 블로킹이 불가피하면, 작업을 직접 만든 ForkJoinPoolsubmit(() -> stream.parallel()...) 안에서 돌려 공용 풀 오염을 피하는 우회가 있어요. 하지만 블로킹은 애초에 병렬 스트림이 아니라 가상 스레드/전용 Executor로 처리하는 게 정석입니다.
🖼️ 그림으로 보기 — work-stealing (작업 훔치기)
한가한 워커가 바쁜 워커의 큐 꼬리(tail)에서 훔친다 워커 A · 바쁨 작업1 (head) 작업2 작업3 (tail) 자기 작업은 head에서 꺼냄 워커 C · 한가 내 큐가 비었다 → 훔칠 준비 워커 B · 바쁨 작업4 작업5 steal ⟵ 꼬리 유휴 스레드가 줄어 부하가 고르게 분산
🧷
ThreadLocal
풀 재사용 환경의 조용한 지뢰
remove()를 잊으면 값이 오염되고 메모리가 샌다.

ThreadLocal은 스레드마다 독립적인 값을 주는 편리한 도구예요(사용자 컨텍스트·트랜잭션·SimpleDateFormat 등). 문제는 스레드풀이에요. 풀의 스레드는 재사용되므로, 한 작업이 남긴 ThreadLocal 값이 다음 작업에 그대로 노출됩니다.

⚠️
두 가지 사고.값 오염 — 요청 A가 넣은 인증 정보를, 같은 워커 스레드에 배정된 요청 B가 읽어 보안 사고로 이어질 수 있어요. ② 메모리 누수ThreadLocalMap의 키(ThreadLocal)는 약한 참조지만 값은 강한 참조라, 스레드가 살아있는 한(풀이라 계속 살아있음) 값이 회수되지 않아요. 그래서 작업 끝에 반드시 remove() 해야 합니다(보통 try ... finally).
private static final ThreadLocal<User> CTX = new ThreadLocal<>();

void handle(Request req) {
    CTX.set(req.getUser());
    try {
        process();               // CTX.get()으로 컨텍스트 사용
    } finally {
        CTX.remove();            // ★ 풀 재사용 대비: 반드시 정리
    }
}
🪶
가상 스레드
Virtual Threads (Java 21 정식, JEP 444)
블로킹 I/O를 수백만 개로 — 단, pinning을 조심.

전통적 스레드는 OS 스레드에 1:1로 매핑돼 비싸요(수천 개가 한계). 가상 스레드는 JVM이 관리하는 경량 스레드로, 수백만 개를 만들 수 있어요. 소수의 캐리어 스레드(플랫폼 스레드, 보통 ForkJoinPool) 위에서 마운트/언마운트됩니다.

🪶
가상 스레드
JVM이 관리, 수백만 개
🚚
캐리어 스레드
플랫폼 스레드에 마운트
🧬
OS 스레드
실제 커널 스레드
🖼️ 그림으로 보기 — 플랫폼 스레드 1:1 vs 가상 스레드 M:N
플랫폼 스레드 · OS와 1:1 스레드1 스레드2 스레드3 OS 1 OS 2 OS 3 스레드 수 = OS 스레드 수 → 비싸고 수천 개가 한계 가상 스레드 · 다수 : 소수 (M:N) VT VT VT VT VT VT 캐리어1 캐리어2 OS 스레드 OS 스레드 많은 가상 스레드가 소수 캐리어 위에서 마운트/언마운트

마법의 핵심은 블로킹 시 언마운트예요. 가상 스레드가 블로킹 I/O(소켓·파일 등)를 만나면, 캐리어를 붙잡지 않고 떨어져 나가(unmount) 대기하고, 캐리어는 다른 가상 스레드를 실행해요. I/O가 끝나면 다시 마운트됩니다. 그래서 블로킹 코드를 그대로 쓰면서도 높은 처리량을 냅니다.

🎯 언제 유리하고, 언제 이득이 없나 ✅ I/O 바운드에 최적 — DB·HTTP·파일처럼 대기가 많은 작업. 스레드당 대기 비용이 거의 공짜라 스레드 폭발 없이 동시성 확보.
➖ CPU 바운드엔 이득 적음 — 계산이 캐리어를 계속 점유하므로, 결국 코어 수가 병목. 가상 스레드가 있어도 속도가 빨라지지 않아요(오히려 스케줄링 오버헤드). 그래서 "스레드 풀 크기 튜닝"이라는 오랜 고민이, I/O 서버에서는 가상 스레드 + 요청당 스레드 하나 모델로 상당 부분 사라집니다.
// 요청마다 가상 스레드 하나 — 수만 개도 부담 없음
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    for (var req : requests) {
        executor.submit(() -> handleBlockingIo(req)); // 블로킹 코드 그대로
    }
} // close()가 모든 작업 완료를 대기
⚠️
pinning(고정)을 조심하라. 가상 스레드가 synchronized 블록·메서드 안에서 블로킹하면, 언마운트하지 못하고 캐리어 스레드에 고정(pinned)돼요. 이러면 캐리어가 낭비되고, 가상 스레드의 장점이 사라집니다. 또 네이티브 메서드(JNI) 안에서도 pinning이 생겨요. 처방은 synchronized 대신 ReentrantLock을 쓰는 것 — 락을 잡은 채 블로킹해도 언마운트가 됩니다.
🧭
구조적 동시성(Structured Concurrency). 여러 하위 작업을 하나의 단위로 묶어 생명주기·에러·취소를 함께 다루는 StructuredTaskScope가 함께 논의돼요. Java 21에서는 preview 기능으로, 가상 스레드와 결합해 "여러 병렬 호출 중 하나 실패 시 나머지 자동 취소" 같은 패턴을 안전하게 만듭니다.
🧠 이 장 핵심 요약
📝
문제풀이 · 점검
시니어 관점으로 점검
면접·장애대응에서 실제로 묻는 결의 문제예요. 틀려도 바로 해설이 나옵니다.
🧪
개념을 "안다"와 "설명할 수 있다"는 달라요. 네 유형(객관식 · O/X · 빈칸 · 코드결과)으로 점검해보세요.
심화 CH 04 🍃 Spring 심화 — IoC/DI 내부, 프록시·AOP, 트랜잭션 전파, 빈 스코프 (제작 예정)