CH 02에서 메모리 모델을 봤다면, 이번엔 실전 도구예요. ExecutorService로 스레드를 관리하고, CompletableFuture로 비동기를 조합하고, Java 21의 가상 스레드로 블로킹 I/O를 수백만 개 다루는 법 — 장애 없는 서버를 만드는 실무 지식과 시니어 면접의 핵심이에요.
ThreadPoolExecutor의 core·max·keepAlive·작업 큐·포화 정책이 어떻게 맞물리는지 설명해요.Future의 블로킹 한계와 CompletableFuture의 조합·비동기·예외 처리를 구분해요.new Thread(...).start()는 요청마다 OS 스레드를 새로 만들어요. 스레드는 비싼 자원(스택 메모리·커널 객체)이라, 트래픽이 몰리면 수천 개가 생겨 메모리 폭발·컨텍스트 스위칭 지옥에 빠집니다. 그래서 스레드를 미리 만들어 두고 재사용하는 ExecutorService가 표준이에요.
Executors.newFixedThreadPool·newCachedThreadPool는 편하지만 위험해요.LinkedBlockingQueue를 써서, 작업이 폭주하면 큐에 무한정 쌓여 OOM이 날 수 있어요.Integer.MAX_VALUE라 스레드가 무한 생성될 수 있어요.
그래서 실무·많은 스타일 가이드는 ThreadPoolExecutor를 직접 생성해 큐 크기와 포화 정책을 명시하라고 권합니다.
shutdown()(새 작업 거부, 남은 작업 완료)이나 shutdownNow()(진행 중 인터럽트) 후 awaitTermination()으로 대기하세요. 풀을 닫지 않으면 비-데몬 워커 스레드 때문에 JVM이 종료되지 않을 수 있어요.| 파라미터 | 의미 |
|---|---|
corePoolSize | 평소 유지하는 기본 스레드 수. 유휴여도 (기본적으로) 살려둠 |
maximumPoolSize | 스레드가 늘어날 수 있는 상한. 단, 큐가 가득 찬 뒤에야 core를 넘어 확장 |
keepAliveTime | core 초과분 스레드가 유휴 상태로 버티는 시간(넘으면 회수) |
workQueue | BlockingQueue. 이 선택이 풀의 동작을 좌우함(아래) |
RejectedExecutionHandler | 큐도 스레드도 꽉 찼을 때의 포화 정책 |
maximumPoolSize가 무의미. 대신 작업 폭주 시 큐가 무한 증가 → OOM.newCachedThreadPool이 무한 확장).
"maxPoolSize를 키웠는데 스레드가 안 늘어난다"의 90%는 무제한 큐가 원인이에요.
| 포화 정책(RejectedExecutionHandler) | 동작 |
|---|---|
AbortPolicy (기본) | RejectedExecutionException을 던짐 |
CallerRunsPolicy | 제출한 스레드가 직접 실행 → 자연스러운 배압·속도 조절 |
DiscardPolicy | 조용히 버림(예외 없음) |
DiscardOldestPolicy | 큐의 가장 오래된 작업을 버리고 새 작업을 넣음 |
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
*Async 계열(supplyAsync·thenApplyAsync 등)에 Executor를 지정하지 않으면 공용 ForkJoinPool.commonPool()에서 돕니다. 여기에 DB 조회·HTTP 호출 같은 블로킹 작업을 올리면 공용 풀이 마비돼 앱 전체가 느려져요. 실무에선 전용 Executor를 명시하는 게 안전합니다.thenApply, 또 다른 CompletableFuture를 반환하면 thenCompose예요. thenApply에 CF를 반환하면 CompletableFuture<CompletableFuture<T>>로 중첩돼 버립니다.ForkJoinPool은 큰 작업을 재귀적으로 쪼개(fork) 병렬 실행하고 결과를 합치는(join) 분할정복 프레임워크예요. 핵심은 work-stealing: 각 워커가 자기 덱(deque)을 갖고, 자기 일이 떨어지면 바쁜 다른 워커의 큐 꼬리에서 작업을 훔쳐 옵니다. 덕분에 부하가 고르게 퍼지고 유휴 스레드가 줄어요.
list.parallelStream()은 별도 지정이 없으면 ForkJoinPool.commonPool()에서 실행돼요. 이 공용 풀은 JVM에 하나뿐이고, 크기는 기본적으로 CPU 코어 수 − 1 정도로 작습니다.
그래서 parallelStream 안에서 블로킹 I/O(HTTP·DB)를 돌리면, 몇 개의 느린 작업이 공용 풀의 스레드를 전부 붙잡아 앱의 다른 병렬 작업까지 멈춰요. 병렬 스트림은 CPU 바운드·짧은 연산에만 쓰세요.
ForkJoinPool의 submit(() -> stream.parallel()...) 안에서 돌려 공용 풀 오염을 피하는 우회가 있어요. 하지만 블로킹은 애초에 병렬 스트림이 아니라 가상 스레드/전용 Executor로 처리하는 게 정석입니다.ThreadLocal은 스레드마다 독립적인 값을 주는 편리한 도구예요(사용자 컨텍스트·트랜잭션·SimpleDateFormat 등). 문제는 스레드풀이에요. 풀의 스레드는 재사용되므로, 한 작업이 남긴 ThreadLocal 값이 다음 작업에 그대로 노출됩니다.
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(); // ★ 풀 재사용 대비: 반드시 정리
}
}
전통적 스레드는 OS 스레드에 1:1로 매핑돼 비싸요(수천 개가 한계). 가상 스레드는 JVM이 관리하는 경량 스레드로, 수백만 개를 만들 수 있어요. 소수의 캐리어 스레드(플랫폼 스레드, 보통 ForkJoinPool) 위에서 마운트/언마운트됩니다.
마법의 핵심은 블로킹 시 언마운트예요. 가상 스레드가 블로킹 I/O(소켓·파일 등)를 만나면, 캐리어를 붙잡지 않고 떨어져 나가(unmount) 대기하고, 캐리어는 다른 가상 스레드를 실행해요. I/O가 끝나면 다시 마운트됩니다. 그래서 블로킹 코드를 그대로 쓰면서도 높은 처리량을 냅니다.
// 요청마다 가상 스레드 하나 — 수만 개도 부담 없음
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (var req : requests) {
executor.submit(() -> handleBlockingIo(req)); // 블로킹 코드 그대로
}
} // close()가 모든 작업 완료를 대기
synchronized 블록·메서드 안에서 블로킹하면, 언마운트하지 못하고 캐리어 스레드에 고정(pinned)돼요. 이러면 캐리어가 낭비되고, 가상 스레드의 장점이 사라집니다. 또 네이티브 메서드(JNI) 안에서도 pinning이 생겨요. 처방은 synchronized 대신 ReentrantLock을 쓰는 것 — 락을 잡은 채 블로킹해도 언마운트가 됩니다.StructuredTaskScope가 함께 논의돼요. Java 21에서는 preview 기능으로, 가상 스레드와 결합해 "여러 병렬 호출 중 하나 실패 시 나머지 자동 취소" 같은 패턴을 안전하게 만듭니다.ExecutorService. Executors 팩토리는 무제한 큐·무한 스레드 함정이 있어 ThreadPoolExecutor 직접 생성이 안전.Future.get은 블로킹. CompletableFuture는 thenApply/thenCompose/thenCombine/allOf로 조합. 기본 실행자는 commonPool → *Async에 전용 Executor 지정.parallelStream도 여기서 돎 → 블로킹 작업 금지(CPU 바운드만).remove() 필수 — 값 오염·메모리 누수 방지.synchronized 안 블로킹 → pinning, ReentrantLock 권장.