동기, 비동기, 그리고 Batch의 thread-pool : thread-pool은 다다익선이 아닐까? (2)
동기, 비동기, 그리고 Batch에서 사용되는 thread의 지식을 정리한 글 입니다.
내용이 긴 관계로 part를 나누어 업로드합니다.
부족한 부분, 또는 잘못된 부분이 있으면 피드백을 남겨주시면 감사드리겠습니다. 🙆🏻♀️🙇🏻♀
목차
- ThreadPoolExecutor의 가용 스레드를 최대로?
- 내장형 Tomcat(WAS)의 가용 스레드를 최대로?
- Batch의 thread-pool은 다를까?
- 결론
- 참고 자료
1. ThreadPoolExecutor의 가용 스레드를 최대로?
ThreadPoolExecutor(int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit, BlockingQueue<Runnable> workQueue)
/*
corePoolSize = 최초 스레드의 생성 사이즈
maximumPoolSize = workQueue가 가득 찼을때 추가적으로 허용하는 thread 수
keepAliveTime = maximumPoolSize에서 coolPoolSize가 될때까지 유지되는 시간
unit = SECONDS = keepAliveTime의 시간 단위
workQueue = corePoolSize보다 스레드가 많아질 경우, 남는 스레드가 없을 때 해당 큐에 저장
*/
Java 코드를 통해 ThreadPoolExecutor
의 가용량을 최대로 설정하며 아래와 같은 한계점들을 마주할 수 있다.
아래 내용을 이해하기 위해 Java
의 thread-pool
은 JVM
이 구동되고 있는 CPU
에서 자원을 할당받는 것을 잊지말자.
(1) 리소스 고갈
- 스레드 수를 무작정 크게 설정하면, CPU, 메모리, 그리고 I/O 리소스가 고갈될 수 있음
- CPU, 메모리를 차지하기 위해 스레드끼리 경쟁이 발생
- 스레드는 생성될 때 메모리와 시스템 리소스를 차지하게 되므로, 너무 많은 스레드를 생성하면 메모리 부족 또는 CPU 과부하가 발생할 수 있음
- 만약 스레드 수가 CPU 코어 수보다 훨씬 많아지면, 많은 스레드가 동시에 실행되려 하면서 스레드 간 문맥 교환이 잦아져 오히려 성능이 떨어지게 됨 (= A.K.A
CPU Context-Swtiching 오버헤드
)- 이는 스레드 전환 작업에 더 많은 시간이 소모됨을 뜻하고, 결국 실제 작업 처리 시간이 줄어들고 대기 시간이 증가하는 원인이 됨
- 결국 스레드가 많다고 모든 요청이 즉시 처리되지는 않음
- 내가 많이 보낸다고 다운스트림(수신처)에서 많아 받아줄 수 있는것도 아니다
- 많은 스레드로 인한 네트워크 I/O가 동시에 발생시, 그만큼 요청 지연 및
TimeoutException
이 발생할 수 있음
(2) 메모리 사용량 증가
- 각 스레드는 고유의 스택 메모리와 힙 메모리를 사용
- 일반적으로 JVM의 각 스레드 스택은 1MB로 크기가 설정되며, 많은 스레드를 생성하면 그만큼 메모리가 사용됨
- 예시로 스레드가 1,000개라면 최소 1GB의 메모리가 스택 메모리로만 사용되며, 이 수치가 커질수록 힙 메모리 부족 내지는
OOM(Out Of Memory Error)
가 발생할 위험이 있음
이러한 연유로, spring boot
가 올라간 인스턴스, 즉 서버의 CPU 코어와 JVM의 힙 메모리 크기를 잘 파악해야한다.
적정 corePoolSize는?
일반적으로 아래와 같은 공식으로 이상적인 corePoolSize
를 측정할 수 있는 것으로 알려져있다.
Number of threads = Number of Available Cores * (1 + Wait time / Service time)
예시로, worker가 MSA API를 비동기로 호출하고 이 결과를 JSON
으로 직렬화 할 때,
MSA API의 응답 시간은 50ms, JSON 직렬화 처리 시간은 5ms, 그리고 사용중인 CPU의 가용 코어 수는 2라고 가정하자.
그렇다면 아래와 같은 공식을 대략적으로나마 유추할 수 있다.
2 * (1 + 50 / 5) = 22 // optimal thread pool size
2. 내장형 Tomcat(WAS)의 가용 스레드를 최대로?
# application.yml (적어놓은 값은 default)
server:
tomcat:
threads:
max: 200 # 생성할 수 있는 thread의 총 개수
min-spare: 10 # 항상 활성화 되어있는(idle) thread의 개수
max-connections: 8192 # 수립가능한 connection의 총 개수
accept-count: 100 # 작업큐의 사이즈
connection-timeout: 20000 # timeout 판단 기준 시간, 20초
port: 8080 # 서버를 띄울 포트번호
적정 max threads는?
우선 아파치 공식 문서에 언급된 Tomcat9
기준 thread 관련 Default 값들은 이러하다.
보다시피 기본 maxThreads
는 200으로 선언되어있다.
이미 앞선 내용에 동기의 thread
와 비동기의 thread
는 서로 다른 thread-pool에서 할당받는다고 언급했다.
그렇다면 각각의 thread-pool
은 전혀 다른 별개의 영역인걸까?
Spring Boot
에서 프로젝트를 run하고 Tomcat WAS
를 실행하면 일어나는 상황을 이해하자.
1. JVM이 먼저 실행되고, 어플리케이션 코드가 JVM 내에서 돌아가며 메모리와 리소스를 관리
2. WAS(Tomcat)는 JVM 위에서 실행되어, HTTP 요청이 올 때마다 thread-pool에서 스레드를 할당하고 이 요청을 JVM으로 전달
3. JVM에서는 각 요청을 처리하는 코드를 실행하며, 비동기 작업은 별도의 thread-pool에서 처리
위 내용에서 이해할 수 있는 WAS와 JVM의 관계는 다음과 같다.
WAS
는JVM
위에서 동작WAS
는JVM
을 사용해 자바 어플리케이션을 실행하고, 어플리케이션의 요청 - 응답 흐름을 관리
WAS
는JVM
을 사용하여 스레드를 생성하고 어플리케이션을 구동하며, 이JVM
내에서 비동기 작업이나 어플리케이션 내부의 다양한 로직이 실행됨
이들의 관계를 기반으로 JVM의 thread
와 WAS의 thread
가 어떻게 CPU
를 할당받는지 정리하면 다음과 같다.
JVM
의 스레드- 비동기 작업이나 어플리케이션 로직에서 생성된 스레드
- JVM 내부에서 생성된 스레드이지만, 실제로는 OS 레벨에서 관리되며 CPU에서 실행됨
WAS
의 스레드- HTTP 요청을 처리하기 위해 WAS가 생성하는 스레드
- 이 역시 JVM 내부에서 생성되지만, OS가 관리하며 CPU를 할당받음
고로 WAS
, JVM
의 스레드 모두 동일한 CPU 자원 풀에서 스케쥴링 된다.
이들이 실제로는 서로 다른 스레드 풀에 속해있어도 운영체제에서는 동일한 CPU 자원 풀에서 경쟁하게 된다.
CPU는 이러한 스레드들을 번갈아가며 실행하므로(= A.K.A Context-Swtiching), thread-pool의 크기가 영향을 끼칠 수 밖에 없다.
그리고 Context Swtiching
은 JVM / OS 커널에 무거운 작업이며, 이 행동이 빈번하게 일어날수록 많은 메모리를 사용하게 된다.
그렇다면 메모리를 늘리면 되지 않을까?
고로RAM
을 늘리면 더 큰 thread-pool을 할당할 수 있지 않을까?
이는 앞서 언급된 1번과도 연관이 있다.RAM
을 늘리더라도 다른 스레드 수를 처리하는데 제한이 있으며, CPU Core
와도 연관이 있기 때문이다. 예시로 CPU Core
가 8개라면 최대 8개의 스레드만 동시에 처리할 수 있다.
때문에 내장형 Tomcat(WAS)
의 thread-pool을 적정선을 고려하지 않고 마음대로 높이면 앞서 1번에서 언급한 이슈들을 그대로 마주할 수 밖에 없다.
CPU Core가 8개라면 최대 8개의 스레드만 동시에 처리할 수 있는데,
왜 SpringBoot Tomcat의 max-threads의 값이 200이 Default인거지?
이는 웹 서버에서 요청을 처리할 때, DB나 외부 API와 통신하는 등의 I/O 작업이 많기 때문에 CPU 자원이 아닌 I/O 대기 시간이 많이 발생한다.
또한 CPU 코어 수보다 더 많은 thread를 사용해도 컨텍스트 스위칭 비용이 발생하지 않거나, 아주 미미한 경우가 많기 때문에 트래픽을 수용하는 측면에서 타협한 값이다.
I/O 대기 상태에서 CPU가 다른 스레드를 처리할 수 있으므로, CPU 코어 수보다 더 많은 스레드를 운영해도 효율적으로 작동할 수 있다.
3. Batch의 thread-pool은 다를까?
Spring Batch의 thread-pool은 앞서 언급된 동기, 비동기의 thread-pool (= ThreadPoolExecutor
)과 다르다.
Spring Batch의 thread-pool은 Spring 환경에서 돌아가야 하며, 이는 2번에서 언급된 ThreadPoolExecutor
가 아닌 ThreadPoolTaskExecutor
을 사용한다.
ThreadPoolTaskExecutor
도 내부적으로는ThreadPoolExecutor
를 래핑하여 사용Spring Batch
,@Async
와 같은 Spring 환경에서 사용되는 비동기는 모두ThreadPoolTaskExecutor
사용
ThreadPoolExecutor
와 ThreadPoolTaskExecutor
의 특징을 이해하려면 해당 글을 참고하자.
ThreadPoolTaskExecutor
도 결국은 ThreadPoolExecutor
를 래핑해서 사용하므로, 적정 corePoolSize
는 2번을 참고하면 될 것이다.
4. DB Connection Pool과의 관계는 어떻게 될까?
추가로 API를 호출하여 DB Connection Pool을 할당받아 DB에 적재된 데이터를 가져오는의 경우, 아래와 같은 이슈도 함께 고려해야 한다.
thread pool > DB Connection
- 동시에 많은 스레드가 DB 커넥션을 요구하게 되지만 사용 가능한 커넥션이 제한되어 있기 때문에 병목 현상이 발생할 수 있음
thread pool < DB Connection
- 많은 DB 커넥션이 유휴 상태로 남게 되어 자원이 낭비될 수 있음
DB connection pool을 사용중인 DB의 성능과도 관계가 있으므로, 마음대로 thread-pool을 늘릴 수는 없는 노릇이다.
(DB Connection Pool과 분석은 내용은 추후 디테일하게 다룰 예정이다.)
5. 결론
Thread
는 많으면 많을수록 좋을까?
처리할 수 있는 요청이 더 많아보이니 그럴싸하게 보이지만, 상기 나열된 연유들로 아래와 같은 문제점들이 존재한다.
1. 스레드를 생성하는데 비용이 든다.
2. 스레드 간의 콘텍스트 스위칭(Context Switching)이 발생하여 오버헤드가 발생한다.
CPU에서 실행되던 스레드(t1)는 짧은 시간 동작하고 다른 스레드(t2)로 바뀌게 된다.
현재까지 진행한 기존 스레드(t1)의 정보를 저장하고 이전 스레드(t2)의 정보를 다시가져와 작업을 진행해야 한다.
이런 작업, 즉, Context Switching
과정 중에 오버헤드가 발생하게 된다.
이에 따라 적절한 수를 측정하는 것이 중요하다.
또한 DB Connection Pool Thread도 함께 고려를 해야한다. (해당 내용은 다음 기회에 다룰 예정이다.)
심지어 본인이 OkHttp
와 같은 별도의 라이브러리로 통신이 이루어진다면, 해당 라이브러리의 Connection Pool도 고려해야할 수 있다.
그리고 1번에 언급했던 내용처럼, 내가 많이 요청한다고 다운스트림에서 많이 응답해줄수 있는것도 아니다.
교류하는 대상과의 적절한 TPS
도 고려해야한다.