스레드 풀 올바르게 선언하기
스레드 풀은 반드시 `ThreadPoolExecutor` 생성자를 통해 수동으로 선언해야 하며, `Executors` 클래스를 사용하여 생성하면 OOM(메모리 부족) 위험이 있습니다.
`Executors`가 반환하는 스레드 풀에는 다음과 같은 문제점이 있습니다:
- `FixedThreadPool`과 `SingleThreadExecutor`: 무제한 `LinkedBlockingQueue`를 사용하며, 큐 크기가 `Integer.MAX_VALUE`에 달할 수 있어 OOM을 유발할 수 있습니다.
- `CachedThreadPool`: `SynchronousQueue`를 사용하며, 생성 가능한 스레드 수가 `Integer.MAX_VALUE`에 달할 수 있어 OOM을 유발할 수 있습니다.
- `ScheduledThreadPool`과 `SingleThreadScheduledExecutor`: 무제한 지연 블로킹 큐 `DelayedWorkQueue`를 사용하며, 큐 크기가 `Integer.MAX_VALUE`에 달할 수 있어 OOM을 유발할 수 있습니다.
결론적으로: 제한된 큐를 사용하고 스레드 생성 수를 제어해야 합니다.
스레드 풀 상태 모니터링
SpringBoot의 Actuator 컴포넌트를 통해 스레드 풀의 실행 상태를 감시할 수 있습니다.
또한 `ThreadPoolExecutor`의 관련 API를 사용하여 간단한 모니터링을 구현할 수 있습니다. 아래는 간단한 데모 코드입니다. `displayPoolStatus()`는 매초마다 현재 스레드 수, 활성 스레드 수, 완료된 작업 수, 그리고 큐에 대기 중인 작업 수를 출력합니다.
/**
* 스레드 풀 상태 출력
*
* @param pool 스레드 풀 객체
*/
public static void displayPoolStatus(ThreadPoolExecutor pool) {
ScheduledExecutorService scheduler = new ScheduledThreadPoolExecutor(1, buildThreadFactory("monitor/pool-status", false));
scheduler.scheduleAtFixedRate(() -> {
log.info("=========================");
log.info("현재 스레드 풀 크기: [{}]", pool.getPoolSize());
log.info("활성 스레드 수: {}", pool.getActiveCount());
log.info("완료된 작업 수: {}", pool.getCompletedTaskCount());
log.info("큐 대기 중인 작업 수: {}", pool.getQueue().size());
log.info("=========================");
}, 0, 1, TimeUnit.SECONDS);
}
다른 종류의 작업에 다른 스레드 풀 사용
실제 프로젝트에서 여러 비즈니스에 스레드 풀이 필요한 경우, 각 비즈니스에 맞는 별도의 스레드 풀을 사용하는 것이 좋습니다. 각 비즈니스의 동시성 및 리소스 사용량이 다르므로, 성능 병목 현상이 발생하는 비즈니스에 집중하여 최적화해야 합니다.
한 가지 잠재적인 문제는 데드락입니다. 스레드 풀의 핵심 스레드 수가 n이고, 부모 작업(예: 결제 작업) 수가 n이며, 각 부모 작업에는 두 개의 자식 작업이 있다고 가정해 보겠습니다. 부모 작업이 스레드 풀의 핵심 스레드 자원을 모두 사용하면, 자식 작업은 스레드 자원을 얻지 못해 큐에서 차단됩니다. 부모 작업은 자식 작업이 완료될 때까지 기다리고, 자식 작업은 부모 작업이 스레드 풀 자원을 해제하기를 기다리는 상황이 발생합니다.
이 문제를 해결하려면 자식 작업을 전담으로 처리하기 위한 별도의 스레드 풀을 추가해야 합니다.
스레드 풀에 이름 지정하기
스레드 풀을 초기화할 때 명시적으로 이름을 지정(스레드 풀 이름 접두사 설정)하면 문제 해결에 도움이 됩니다.
기본적으로 생성되는 스레드 이름은 `pool-1-thread-n`과 같이 비즈니스 의미가 없어 문제 해결이 어렵습니다.
스레드 풀의 스레드에 이름을 지정하는 방법은 다음과 같습니다:
1. Guava의 `ThreadFactoryBuilder` 사용
ThreadFactory factory = new ThreadFactoryBuilder()
.setNameFormat(threadNamePrefix + "-%d")
.setDaemon(true).build();
ExecutorService pool = new ThreadPoolExecutor(corePoolSize, maxPoolSize, keepAliveTime, TimeUnit.MINUTES, workQueue, factory)
2. 직접 `ThreadFactory` 구현
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.atomic.AtomicInteger;
/**
* 이름을 지정하는 스레드 팩토리
*/
public final class NamedThreadFactory implements ThreadFactory {
private final AtomicInteger threadNum = new AtomicInteger();
private final ThreadFactory baseFactory;
private final String poolName;
public NamedThreadFactory(ThreadFactory baseFactory, String poolName) {
this.baseFactory = baseFactory;
this.poolName = poolName;
}
@Override
public Thread newThread(Runnable r) {
Thread t = baseFactory.newThread(r);
t.setName(poolName + " [#" + threadNum.incrementAndGet() + "]");
return t;
}
}
스레드 풀 매개변수 올바르게 설정하기
스레드 풀 매개변수를 설정하는 방법은 다음과 같습니다:
일반적인 설정 방법
스레드 풀을 너무 크게 설정하는 것은 좋지 않습니다. 스레드 수가 너무 많으면 컨텍스트 전환 비용이 증가합니다.
- 스레드 풀 크기가 너무 작으면 많은 작업이 큐에서 대기하거나 OOM이 발생할 수 있습니다.
- 스레드 수가 너무 많으면 CPU 자원을 놓고 경쟁하는 스레드가 많아 컨텍스트 전환 비용이 증가합니다.
다음은 간단하면서도 광범위하게 적용되는 공식입니다:
- CPU 집약형 작업(N+1): CPU 자원을 주로 사용하는 작업으로, 스레드 수를 CPU 코어 수(N)+1로 설정합니다. 추가된 스레드는 스레드의 예외적인 페이지 인터럽트나 기타 이유로 작업이 중단될 때 대비합니다.
- I/O 집약형 작업(2N): 시스템의 대부분의 시간을 I/O 상호작용에 사용하는 작업으로, 스레드 수를 2N으로 설정합니다.
동적 매개변수 설정
실제 운영 환경에서는 프로젝트의 실제 실행 상황에 따라 매개변수를 동적으로 조정해야 합니다. 기존에는 이러한 기능을 지원하지 않았지만, 최근에는 동적 스레드 풀 매개변수 설정을 지원하는 오픈소스 프로젝트가 있습니다:
- Hippo-4: 강력한 동적 스레드 풀 프레임워크로, 전통적인 스레드 풀 사용의 문제점을 해결합니다.
- Dynamic TP: 가벼운 동적 스레드 풀로, 내장 모니터링 및 알림 기능을 제공합니다.
스레드 풀 사용 시 발생하는 문제점
반복적인 스레드 풀 생성
스레드 풀은 재사용할 수 있으므로, 사용자 요청이 올 때마다 별도의 스레드 풀을 생성해서는 안 됩니다.
Spring 내부 스레드 풀의 함정
Spring 내부 스레드 풀을 사용할 때는 반드시 수동으로 스레드 풀을 사용자 정의하고 합리적인 매개변수를 설정해야 합니다.
스레드 풀과 ThreadLocal의 함정
스레드 풀과 `ThreadLocal`을 함께 사용하면, 스레드 풀이 스레드 객체를 재사용함에 따라 `ThreadLocal`이 이전 값/오염된 데이터를 가져올 수 있습니다. 이 문제를 해결하기 위해 Alibaba의 `TransmittableThreadLocal`(TTL)을 사용하는 것이 좋습니다.