본문 바로가기
카테고리 없음

스레드 풀(Thread Pool) 관리

by SuldenLion 2026. 3. 4.
반응형

백엔드 성능의 핵심: 스레드 풀 관리 완벽 가이드

스레드 풀은 현대 백엔드 시스템에서 가장 중요한 리소스 관리 메커니즘 중 하나입니다. 잘못 설정된 스레드 풀은 시스템 전체의 성능을 저하시키거나, 최악의 경우 서비스 장애로 이어질 수 있습니다. 반면, 적절히 관리된 스레드 풀은 제한된 리소스로 최대의 처리량을 달성하게 해줍니다.

 

스레드 풀이란 무엇인가?

스레드 풀(Thread Pool)은 미리 생성된 스레드들의 집합으로, 작업 요청이 들어올 때마다 스레드를 새로 생성하는 대신 풀에서 유휴 스레드를 할당하여 재사용하는 패턴입니다.

 

왜 필요한가?

매 요청마다 스레드를 생성/소멸하는 방식의 문제점:

java
// 비효율적인 방식
for (Task task : tasks) {
    new Thread(() -> {
        task.execute();
    }).start();  // 매번 새 스레드 생성 - 비용이 큼!
}

문제점:

  • 스레드 생성 비용: 약 1MB의 메모리와 수 밀리초의 시간 소요
  • 무제한 스레드 생성: 시스템 리소스 고갈 위험
  • Context switching 오버헤드: 너무 많은 스레드는 성능 저하

스레드 풀의 이점:

  • 스레드 재사용으로 생성/소멸 비용 제거
  • 동시 실행 스레드 수 제어로 리소스 보호
  • 작업 대기열을 통한 부하 관리

 

스레드 풀의 핵심 구성 요소

1. Core Pool Size (코어 풀 크기)

평상시 유지되는 최소 스레드 수입니다.

java
ThreadPoolExecutor executor = new ThreadPoolExecutor(
    10,  // corePoolSize: 항상 유지되는 스레드 수
    // ...
);

특징:

  • 풀이 생성된 후 작업이 제출되면 코어 크기까지 스레드를 생성
  • 작업이 없어도 이 스레드들은 유지됨 (allowCoreThreadTimeOut이 false인 경우)
  • 너무 작으면: 대기 시간 증가
  • 너무 크면: 불필요한 리소스 낭비

 

2. Maximum Pool Size (최대 풀 크기)

풀이 생성할 수 있는 최대 스레드 수입니다.

java
ThreadPoolExecutor executor = new ThreadPoolExecutor(
    10,   // corePoolSize
    100,  // maximumPoolSize: 최대 스레드 수
    // ...
);

동작 방식:

  • 큐가 가득 찬 후에만 코어 크기를 초과하여 스레드 생성
  • 유휴 시간이 지나면 코어 크기까지 축소

 

3. Work Queue (작업 큐)

실행 대기 중인 작업들을 저장하는 큐입니다.

java
// 1. Bounded Queue (제한된 큐)
BlockingQueue<Runnable> boundedQueue = new ArrayBlockingQueue<>(1000);

// 2. Unbounded Queue (무제한 큐)
BlockingQueue<Runnable> unboundedQueue = new LinkedBlockingQueue<>();

// 3. Synchronous Queue (직접 전달)
BlockingQueue<Runnable> syncQueue = new SynchronousQueue<>();

큐 선택의 영향:

큐 타입특징사용 시나리오
ArrayBlockingQueue 고정 크기, FIFO 메모리 보호가 중요한 경우
LinkedBlockingQueue 선택적 제한, FIFO 일반적인 용도
SynchronousQueue 크기 0, 직접 전달 즉시 처리가 필요한 경우
PriorityBlockingQueue 우선순위 기반 작업 우선순위가 있는 경우

 

4. Keep Alive Time (유지 시간)

코어 크기를 초과하는 유휴 스레드가 종료되기까지의 시간입니다.

java
ThreadPoolExecutor executor = new ThreadPoolExecutor(
    10, 100,
    60, TimeUnit.SECONDS,  // 60초간 유휴 상태면 종료
    new LinkedBlockingQueue<>()
);

 

5. Rejection Policy (거부 정책)

큐가 가득 차고 최대 스레드 수에 도달했을 때의 처리 방식입니다.

java
// 1. AbortPolicy (기본값) - 예외 발생
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.AbortPolicy());

// 2. CallerRunsPolicy - 호출자 스레드에서 실행
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());

// 3. DiscardPolicy - 조용히 무시
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.DiscardPolicy());

// 4. DiscardOldestPolicy - 가장 오래된 작업 제거
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.DiscardOldestPolicy());

// 5. Custom Policy
executor.setRejectedExecutionHandler((r, executor) -> {
    log.error("Task rejected: {}", r);
    metrics.incrementRejectedTasks();
    // 대체 처리 로직
});
```

## 스레드 풀 크기 결정하기

### CPU 바운드 작업

CPU 집약적 작업(계산, 데이터 처리 등)의 경우:
```
최적 스레드 수 = CPU 코어 수 + 1

이유: CPU가 주된 병목이므로, 코어 수만큼의 스레드가 가장 효율적입니다. +1은 페이지 폴트나 캐시 미스 시를 대비한 것입니다.

java
int processors = Runtime.getRuntime().availableProcessors();
ExecutorService cpuBoundExecutor = Executors.newFixedThreadPool(processors + 1);
```

### I/O 바운드 작업

네트워크, 데이터베이스, 파일 I/O 등의 경우:
```
최적 스레드 수 = CPU 코어 수 × (1 + 대기시간/연산시간)
```

**예시 계산**:
- CPU 코어: 8개
- 평균 응답 시간: 100ms
- 실제 CPU 연산 시간: 10ms
- 대기 시간: 90ms
```
스레드 수 = 8 × (1 + 90/10) = 8 × 10 = 80

실전 코드 예시

java
public class ThreadPoolSizer {
    
    public static int calculateOptimalPoolSize(
            int availableProcessors,
            double targetUtilization,  // 0.0 ~ 1.0
            double waitTime,
            double computeTime) {
        
        return (int) (availableProcessors * targetUtilization * 
                     (1 + waitTime / computeTime));
    }
    
    public static void main(String[] args) {
        int processors = Runtime.getRuntime().availableProcessors();
        
        // 목표 CPU 활용률 80%, I/O 대기 90%, 연산 10%
        int poolSize = calculateOptimalPoolSize(processors, 0.8, 90, 10);
        
        System.out.println("Recommended pool size: " + poolSize);
    }
}

 

실무 패턴과 안티패턴

패턴 1: 분리된 스레드 풀

서로 다른 특성의 작업에는 별도의 풀을 사용합니다.

java
public class ThreadPoolManager {
    
    // 빠른 API 요청용 (I/O 바운드)
    private final ExecutorService apiExecutor = 
        new ThreadPoolExecutor(
            20, 100,
            60, TimeUnit.SECONDS,
            new LinkedBlockingQueue<>(500),
            new ThreadPoolExecutor.CallerRunsPolicy()
        );
    
    // 무거운 배치 작업용 (CPU 바운드)
    private final ExecutorService batchExecutor = 
        new ThreadPoolExecutor(
            4, 8,
            60, TimeUnit.SECONDS,
            new LinkedBlockingQueue<>(100),
            new ThreadPoolExecutor.AbortPolicy()
        );
    
    // 외부 API 호출용 (높은 지연시간)
    private final ExecutorService externalApiExecutor = 
        new ThreadPoolExecutor(
            10, 50,
            120, TimeUnit.SECONDS,
            new ArrayBlockingQueue<>(200),
            new ThreadPoolExecutor.CallerRunsPolicy()
        );
}

이유: 각 작업 유형에 맞는 최적화가 가능하고, 한 유형의 작업이 다른 작업에 영향을 주지 않습니다.

 

패턴 2: 모니터링과 동적 조정

java
public class MonitoredThreadPool {
    
    private final ThreadPoolExecutor executor;
    private final ScheduledExecutorService monitor;
    
    public MonitoredThreadPool(ThreadPoolExecutor executor) {
        this.executor = executor;
        this.monitor = Executors.newScheduledThreadPool(1);
        
        // 1분마다 모니터링
        monitor.scheduleAtFixedRate(this::logPoolStats, 0, 1, TimeUnit.MINUTES);
    }
    
    private void logPoolStats() {
        int activeCount = executor.getActiveCount();
        int poolSize = executor.getPoolSize();
        int corePoolSize = executor.getCorePoolSize();
        long completedTasks = executor.getCompletedTaskCount();
        int queueSize = executor.getQueue().size();
        
        logger.info("ThreadPool Stats - Active: {}/{}, Queue: {}, Completed: {}",
                   activeCount, poolSize, queueSize, completedTasks);
        
        // 동적 조정 로직
        if (queueSize > 100 && poolSize < executor.getMaximumPoolSize()) {
            logger.warn("Queue building up, consider scaling");
            // 알림 전송 또는 자동 스케일링
        }
        
        // 메트릭 전송 (Prometheus, CloudWatch 등)
        metrics.gauge("threadpool.active", activeCount);
        metrics.gauge("threadpool.queue.size", queueSize);
        metrics.gauge("threadpool.utilization", 
                     (double) activeCount / poolSize);
    }
}

 

안티패턴 1: Unbounded Queue + Fixed Pool

java
// 위험한 설정!
ExecutorService badExecutor = Executors.newFixedThreadPool(10);
// 내부적으로 LinkedBlockingQueue() 사용 - 무제한 큐!

문제점:

  • 작업이 처리 속도보다 빠르게 들어오면 큐가 무한정 증가
  • 결국 OutOfMemoryError 발생
  • 큐 크기로 인한 지연 시간 예측 불가

해결책:

java
// 제한된 큐 사용
ExecutorService goodExecutor = new ThreadPoolExecutor(
    10, 10,
    0, TimeUnit.MILLISECONDS,
    new ArrayBlockingQueue<>(1000),  // 제한된 큐
    new ThreadPoolExecutor.CallerRunsPolicy()  // 백프레셔
);

 

안티패턴 2: 공유 스레드 풀에서 블로킹 대기

java
// 데드락 위험!
ExecutorService sharedPool = Executors.newFixedThreadPool(10);

sharedPool.submit(() -> {
    // 이 작업이 같은 풀의 다른 작업을 기다림
    Future<String> future = sharedPool.submit(() -> "result");
    String result = future.get();  // 데드락 가능!
});

문제: 모든 스레드가 서로를 기다리며 블로킹되면 데드락 발생

해결책:

java
// 별도 풀 사용
ExecutorService parentPool = Executors.newFixedThreadPool(10);
ExecutorService childPool = Executors.newFixedThreadPool(10);

parentPool.submit(() -> {
    Future<String> future = childPool.submit(() -> "result");
    String result = future.get();  // 안전함
});

 

안티패턴 3: 스레드 풀 종료 안 함

java
public void processData() {
    ExecutorService executor = Executors.newFixedThreadPool(10);
    executor.submit(() -> heavyTask());
    // shutdown() 호출 없음 - 스레드 누수!
}

해결책:

java
public void processData() {
    ExecutorService executor = Executors.newFixedThreadPool(10);
    try {
        executor.submit(() -> heavyTask());
    } finally {
        executor.shutdown();
        try {
            if (!executor.awaitTermination(60, TimeUnit.SECONDS)) {
                executor.shutdownNow();
            }
        } catch (InterruptedException e) {
            executor.shutdownNow();
            Thread.currentThread().interrupt();
        }
    }
}

 

고급 주제: Virtual Threads (Java 21+)

Java 21에서 도입된 Virtual Thread는 스레드 풀 관리의 패러다임을 바꿀 수 있습니다.

java
// 기존 플랫폼 스레드
ExecutorService platformThreads = Executors.newFixedThreadPool(100);

// Virtual Threads - 수백만 개도 가능!
ExecutorService virtualThreads = Executors.newVirtualThreadPerTaskExecutor();

// 사용법은 동일
virtualThreads.submit(() -> {
    // I/O 작업이 블로킹되어도 OS 스레드는 블로킹되지 않음
    String data = fetchFromDatabase();
    return process(data);
});

Virtual Threads의 장점:

  • 풀 크기 걱정 불필요: 작업당 스레드 생성 가능
  • 블로킹 I/O가 OS 스레드를 블로킹하지 않음
  • 동기식 코드 스타일 유지

주의사항:

  • CPU 바운드 작업에는 여전히 제한 필요
  • Synchronized 블록 사용 시 주의
  • 기존 라이브러리와의 호환성 확인 필요

 

실전 체크리스트

설계 단계

  • 작업 유형 분석 (CPU/IO 바운드, 지연시간)
  • 각 유형별 별도 풀 설계
  • 적절한 큐 타입 선택
  • 거부 정책 결정

구현 단계

  • 초기 풀 크기 계산 및 설정
  • 최대 풀 크기 설정
  • 큐 크기 제한
  • 타임아웃 설정
  • Custom ThreadFactory (명명, 우선순위 등)

운영 단계

  • 모니터링 메트릭 설정
  • 알림 임계값 설정
  • 로깅 및 추적
  • 성능 테스트 및 부하 테스트
  • Graceful shutdown 로직

모니터링 메트릭

스레드 풀의 건강 상태를 파악하기 위한 핵심 메트릭:

java
public class ThreadPoolMetrics {
    
    public static Map<String, Object> getMetrics(ThreadPoolExecutor executor) {
        Map<String, Object> metrics = new HashMap<>();
        
        // 1. 활용률
        int poolSize = executor.getPoolSize();
        int activeCount = executor.getActiveCount();
        metrics.put("utilization", (double) activeCount / poolSize);
        
        // 2. 큐 크기
        int queueSize = executor.getQueue().size();
        metrics.put("queue_size", queueSize);
        
        // 3. 처리량
        long completedTasks = executor.getCompletedTaskCount();
        metrics.put("completed_tasks", completedTasks);
        
        // 4. 거부된 작업 (Custom Handler로 추적)
        metrics.put("rejected_tasks", getRejectedCount());
        
        // 5. 평균 대기 시간 (Custom Queue로 추적)
        metrics.put("avg_wait_time_ms", getAverageWaitTime());
        
        return metrics;
    }
}

주요 지표 해석:

  • Utilization > 90%: 풀 크기 증가 고려
  • Queue Size 지속 증가: 처리 속도 < 유입 속도, 스케일 아웃 필요
  • Rejected Tasks > 0: 과부하 상태, 거부 정책 재검토
  • Active Threads ≈ Core Size: 적절한 설정

 

프레임워크별 스레드 풀 관리

Spring Boot

java
@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {
    
    @Override
    public Executor getAsyncExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(10);
        executor.setMaxPoolSize(50);
        executor.setQueueCapacity(100);
        executor.setThreadNamePrefix("async-");
        executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
        executor.setWaitForTasksToCompleteOnShutdown(true);
        executor.setAwaitTerminationSeconds(60);
        executor.initialize();
        return executor;
    }
}

 

Tomcat (Spring Boot 내장)

yaml
# application.yml
server:
  tomcat:
    threads:
      max: 200          # 최대 스레드 수
      min-spare: 10     # 최소 유지 스레드 수
    max-connections: 10000  # 최대 연결 수
    accept-count: 100   # 대기 큐 크기

 

Netty (비동기 프레임워크)

java
EventLoopGroup bossGroup = new NioEventLoopGroup(1);
EventLoopGroup workerGroup = new NioEventLoopGroup(
    Runtime.getRuntime().availableProcessors() * 2
);

ServerBootstrap bootstrap = new ServerBootstrap()
    .group(bossGroup, workerGroup)
    .channel(NioServerSocketChannel.class)
    // ...

 

마치며

스레드 풀 관리는 단순해 보이지만, 실제로는 시스템의 성능, 안정성, 확장성을 결정하는 핵심 요소입니다. 올바른 크기 설정, 적절한 큐 전략, 철저한 모니터링이 조화를 이루어야 합니다.

핵심 원칙:

  1. 작업 특성에 맞는 풀을 분리하라
  2. 무제한 리소스는 존재하지 않는다 (큐 크기 제한)
  3. 항상 측정하고 모니터링하라
  4. 거부 정책을 신중히 선택하라
  5. Graceful shutdown을 구현하라

기억하세요: "완벽한 설정"은 없습니다. 각 시스템의 워크로드, SLA, 리소스에 맞는 최적의 설정을 찾는 것이 중요합니다.

반응형

댓글