5. 스레드 풀 & 동시성 제어

요청마다 스레드를 새로 생성하면 어떻게 될까요? 스레드 하나당 기본 1MB 스택 메모리와 OS 스케줄링 비용이 발생합니다. 초당 수천 요청이 들어오는 서비스에서 이 비용은 서비스 전체를 죽입니다. 이 챕터는 스레드 풀의 동작 원리부터 CompletableFuture, Virtual Thread(Java 21)까지 설명합니다.

1. ThreadPoolExecutor — 스레드 풀의 핵심 동작 흐름

Spring의 ThreadPoolTaskExecutor, Tomcat의 스레드 풀, @Async 모두 내부적으로 ThreadPoolExecutor를 사용합니다. 동작 흐름을 정확히 알아야 병목 원인을 파악하고 설정값을 제대로 튜닝할 수 있습니다.

① 새 스레드 생성 현재 스레드 수 < corePoolSize → 새 스레드 생성해서 즉시 처리
② 큐에 적재 스레드 수 ≥ corePoolSizeworkQueue에 작업 추가. 큐가 빌 때까지 기다림
③ 스레드 확장 큐가 꽉 참 + 스레드 수 < maximumPoolSize → 임시 스레드 추가 생성
④ 작업 거부 큐 가득 + 스레드 수 = maximumPoolSizeRejectedExecutionHandler 실행
파라미터의미실무 주의점
corePoolSize 항상 유지하는 기본 스레드 수 너무 낮으면 큐 적재 후 응답 지연 발생
maximumPoolSize 트래픽 급증 시 늘어날 수 있는 최대 스레드 수 큐가 먼저 차야 확장됨. LinkedBlockingQueue 기본 크기=Integer.MAX → 실질적으로 확장 안 됨!
workQueue 처리 대기 작업을 쌓는 큐 SynchronousQueue: 큐 없음(즉시 거부 or 확장) / LinkedBlockingQueue: 무한큐(maximumPoolSize 무의미)
keepAliveTime core 초과 임시 스레드의 유휴 허용 시간 트래픽 감소 후 임시 스레드 정리 시간
RejectedExecutionHandler 작업 거부 시 처리 정책 AbortPolicy(예외)/CallerRunsPolicy(호출자 실행)/DiscardPolicy(무시) 중 선택
⚠️ Spring @Async의 기본값 함정 — SimpleAsyncTaskExecutor

@EnableAsync만 추가하고 별도 설정 없이 @Async를 사용하면 SimpleAsyncTaskExecutor가 기본 Executor로 사용됩니다. 이 Executor는 요청마다 새 스레드를 생성합니다. 스레드 풀이 아닙니다. 트래픽이 몰리면 스레드가 무한정 생성되어 OOM이 발생합니다. 반드시 ThreadPoolTaskExecutor를 Bean으로 등록하고 @Async에 지정해야 합니다.

❌ 위험한 기본값
@Configuration
@EnableAsync
public class AsyncConfig {
    // 아무 Executor Bean 없음
    // → SimpleAsyncTaskExecutor 기본 사용
    // → 요청마다 새 스레드 생성!
}

@Service
public class NotificationService {
    @Async // 매 호출마다 새 스레드 생성
    public void sendPush(Long userId) {
        pushClient.send(userId);
    }
}
✅ ThreadPoolTaskExecutor 명시 설정
@Configuration
@EnableAsync
public class AsyncConfig {
    @Bean(name = "asyncExecutor")
    public Executor asyncExecutor() {
        ThreadPoolTaskExecutor executor =
            new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(10);
        executor.setMaxPoolSize(50);
        executor.setQueueCapacity(200);
        executor.setKeepAliveSeconds(60);
        executor.setThreadNamePrefix("async-");
        executor.setRejectedExecutionHandler(
            new CallerRunsPolicy()); // 거부 시 호출자 실행
        executor.initialize();
        return executor;
    }
}

@Async("asyncExecutor") // Bean 이름 명시
public void sendPush(Long userId) { ... }

2. Tomcat Thread Pool — HTTP 요청을 처리하는 스레드들

설정기본값의미튜닝 포인트
server.tomcat.threads.max 200 최대 동시 처리 HTTP 스레드 수 I/O 바운드 서비스는 높여도 되지만 DB 커넥션 풀 크기와 연동 필요
server.tomcat.threads.min-spare 10 항상 유지할 최소 스레드 수 (= corePoolSize) 콜드 스타트 지연 방지를 위해 예상 최소 부하만큼 설정
server.tomcat.accept-count 100 max 스레드가 모두 사용 중일 때 OS 수준에서 대기할 요청 수 초과 시 connection refused. 스레드 풀 고갈 시 503 반환 전 마지막 버퍼
server.tomcat.connection-timeout 20000ms 클라이언트 연결 후 첫 바이트 수신 대기 시간 악성 클라이언트가 연결만 맺고 데이터를 안 보내는 Slow Loris 공격 방어
스레드 풀 고갈 시나리오 — 쿠팡 프로모션 서비스 케이스

선착순 예산이 소진된 후에도 클라이언트가 계속 요청을 보내는 상황에서, 외부 API 호출(결제 서버, 포인트 서버) 타임아웃이 길면 스레드들이 I/O 대기 상태로 블로킹됩니다. max 200개 스레드가 모두 블로킹되면 신규 요청은 accept-count 큐에 쌓이고, 큐도 가득 차면 503 Service Unavailable이 발생합니다. 대책: 외부 API 타임아웃을 짧게(connectionTimeout 1~2s), CircuitBreaker로 빠른 실패, 또는 Virtual Thread(Java 21)로 블로킹 I/O 스레드 점유 없애기.

3. CompletableFuture — 비동기 작업 조합

동기 코드처럼 읽히면서 비동기로 실행됩니다. 여러 비동기 작업을 체이닝하거나 병렬로 실행할 때 사용합니다.

supplyAsync()
비동기 작업 시작
별도 스레드에서 실행
thenApply()
결과 변환
(동기, 같은 스레드)
thenCompose()
다른 CF 반환
(flatMap 같은 개념)
thenApplyAsync()
변환도 별도 스레드
풀 지정 가능
exceptionally()
에러 처리
fallback 값 반환
단일 체이닝
CompletableFuture
    .supplyAsync(() -> fetchUser(userId), executor)
    .thenApply(user -> enrichWithProfile(user))
    .thenApplyAsync(user -> callExternalApi(user),
                    executor) // 별도 스레드 풀 사용
    .exceptionally(ex -> {
        log.error("Failed", ex);
        return defaultUser(); // fallback
    })
    .thenAccept(user -> cache.put(userId, user));
병렬 실행 후 결합
// 두 API를 병렬로 호출하고 결과 합치기
CompletableFuture<User> userFuture =
    CompletableFuture.supplyAsync(
        () -> userClient.get(userId), executor);

CompletableFuture<Point> pointFuture =
    CompletableFuture.supplyAsync(
        () -> pointClient.get(userId), executor);

// 둘 다 완료되면 합치기 (병렬 실행)
CompletableFuture<UserWithPoint> combined =
    userFuture.thenCombine(pointFuture,
        (user, point) ->
            new UserWithPoint(user, point));
allOf — 전체 완료 대기
// N개 알림을 병렬로 발송하고 전체 완료 대기
List<CompletableFuture<Void>> futures =
    userIds.stream()
        .map(id -> CompletableFuture.runAsync(
            () -> sendPush(id), executor))
        .collect(toList());

// 모두 완료될 때까지 대기
CompletableFuture.allOf(
    futures.toArray(new CompletableFuture[0])
).join(); // 블로킹 (타임아웃 설정 권장)
⚠️ 흔한 실수 — 기본 ForkJoinPool 사용
// executor 파라미터 없으면
// ForkJoinPool.commonPool() 사용 → 전역 공유!
CompletableFuture.supplyAsync(() -> {
    return blockingDbCall(); // I/O 블로킹!
}); // ← executor 없음

// ForkJoinPool은 CPU 코어 수 기반 풀
// 블로킹 I/O 작업이 점유하면
// 전체 시스템 CF 처리가 마비됨
// → 반드시 전용 executor 지정!

4. Virtual Thread (Java 21) — 블로킹 I/O의 패러다임 전환

Java 21의 Project Loom이 정식 출시되면서 Virtual Thread(가상 스레드)가 도입되었습니다. 블로킹 I/O가 많은 서비스에서 스레드 풀 크기 튜닝 부담을 획기적으로 줄여줍니다.

기존 Platform Thread (OS Thread)
요청 A — DB 쿼리 실행 중
요청 B — DB 응답 대기 (블로킹) ← 스레드 점유!
요청 C — 외부 API 대기 (블로킹) ← 스레드 점유!
요청 D — 대기 중 (스레드 없음)

블로킹 동안 OS 스레드가 점유된 채로 idle. 스레드 풀 고갈 → 503

Virtual Thread (Java 21)
Carrier-1 — 요청 A 실행
Carrier-2 — 요청 C 실행 (B가 park한 동안)
요청 B: 블로킹 I/O → Virtual Thread park → Carrier 반환
요청 B I/O 완료 → Virtual Thread resume → 남는 Carrier에 mount

Carrier Thread(OS 스레드)는 CPU 코어 수만큼만 유지, Virtual Thread는 수백만 개도 가능

Spring Boot 3.2+ Virtual Thread 활성화
# application.properties
spring.threads.virtual.enabled=true

# Tomcat이 자동으로 Virtual Thread Executor 사용
# 별도 스레드 풀 튜닝 거의 불필요
Virtual Thread 주의사항
// ❌ synchronized 블록 안에서 I/O → "Pinning"
// Virtual Thread가 Carrier에 고정되어
// 블로킹 효과가 없어짐
synchronized (lock) {
    networkCall(); // pinning 발생!
}

// ✅ ReentrantLock 사용 (pinning 없음)
lock.lock();
try {
    networkCall(); // park → Carrier 반환 가능
} finally { lock.unlock(); }

// CPU 집약 작업은 Virtual Thread 이점 없음
// (스레드 수 늘려도 CPU 코어 수가 병목)

5. 면접 대비 핵심 Q&A

Q1. "ThreadPoolExecutor에서 corePoolSize와 maximumPoolSize의 관계를 설명해주세요."

요청이 들어올 때 현재 스레드 수가 corePoolSize보다 적으면 새 스레드를 만들어 처리합니다. corePoolSize 이상이 되면 workQueue에 쌓이고, 큐가 꽉 찼을 때 비로소 maximumPoolSize까지 스레드를 추가합니다. 주의할 점은 LinkedBlockingQueue의 기본 용량이 Integer.MAX_VALUE라 사실상 무한 큐인 경우, 큐가 절대 꽉 차지 않아 maximumPoolSize까지 스레드가 늘어나지 않습니다. 따라서 maximumPoolSize가 의미를 가지려면 queueCapacity를 유한하게 설정해야 합니다.

Q2. "CompletableFuture.supplyAsync() 사용 시 주의해야 할 점은?"

executor를 지정하지 않으면 ForkJoinPool.commonPool()을 사용합니다. 이 풀은 JVM 전역으로 공유되며 CPU 코어 수 기반으로 크기가 결정됩니다. 여기서 블로킹 I/O 작업(DB 쿼리, 외부 API)을 실행하면 공통 풀의 스레드가 모두 블로킹되어 시스템 전체의 CompletableFuture 처리가 마비될 수 있습니다. 항상 전용 I/O 스레드 풀을 별도로 생성해서 executor 파라미터로 전달해야 합니다.

Q3. "Virtual Thread가 기존 Platform Thread와 어떻게 다른가요?"

Platform Thread는 OS 스레드와 1:1로 매핑됩니다. 블로킹 I/O 시 OS 스레드가 idle 상태로 점유됩니다. 스레드 생성 비용이 높아(기본 스택 ~1MB) 수천 개 이상 생성하기 어렵습니다.
Virtual Thread는 JVM이 관리하는 경량 스레드입니다. 블로킹 I/O 발생 시 Virtual Thread가 park되어 Carrier Thread(OS 스레드)를 반환하고, I/O 완료 시 남는 Carrier에 다시 mount됩니다. OS 스레드는 CPU 코어 수만큼만 유지하고, Virtual Thread는 수백만 개도 생성 가능합니다. 단, CPU 집약 작업이나 synchronized 블록 내 I/O(Pinning)에서는 효과가 없습니다.

이 챕터의 핵심 한 줄

스레드 풀은 큐가 꽉 차야 max까지 늘어난다 — @Async 기본값(SimpleAsyncTaskExecutor)은 스레드 풀이 아니다. CompletableFuture에는 전용 executor를, Java 21이면 Virtual Thread로 블로킹 I/O 부담을 없애라.