스프링의 쓰레드 풀에 대해 학습하며 작성된 글입니다. 학습 과정에서 작성된 글이기 때문에 잘못된 내용이 있을 수 있으며, 이에 대한 지적이나 피드백은 언제든 환영입니다.
1. 스프링과 쓰레드 풀
학습 과정에서 스프링 쓰레드 풀이 application.yml에 설정한 값 대로 동작할까?, 비동기 처리를 위한 쓰레드 풀은 어떻게 관리 될까?와 같은 의문이 들었습니다. 이를 검증해보고 싶어 글을 작성하게 되었는데, 이를 살펴보겠습니다.
우선 application.yml로 설정하는 쓰레드 풀 개수만큼 실제 쓰레드가 생성되는지에 대해 살펴보겠습니다. 이를 위해서는 ps M 명령어를 알아야 하는데, 해당 명령어는 현재 프로세스에서 실행 중인 모든 쓰레드의 목록을 보여줍니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
$ ps M 36629
USER PID TT %CPU STAT PRI STIME UTIME COMMAND
jjw 36629 ?? 0.0 S 31T 0:00.01 0:00.01 /Users/jjw/Library/Java/JavaVirtualMachines/graalvm-ce-17/Contents/Home/bin/java -XX:TieredStopAtLevel=1 -Dspring.output.ansi.enabled=always -Dcom.sun.management.jmxremote -Dspring.jmx.enabled=true -Dspring.liveBean
36629 0.0 S 31T 0:00.00 0:00.00
36629 0.0 S 31T 0:00.17 0:01.38
36629 0.0 S 35T 0:00.00 0:00.01
36629 0.0 S 35T 0:00.00 0:00.00
36629 0.0 S 35T 0:00.00 0:00.01
......
36629 0.0 S 31T 0:00.00 0:00.00
36629 0.0 S 31T 0:00.00 0:00.00
36629 0.0 S 31T 0:00.00 0:00.00
36629 0.0 S 31T 0:00.00 0:00.00
36629 0.0 S 31T 0:00.00 0:00.00
36629 0.0 S 31T 0:00.01 0:00.04
즉, 스프링이라는 하나의 프로세스에 속해 있는 모든 쓰레드 목록을 보여주는데, 이는 커널 쓰레드와 1:1로 매핑되어 있으며, 애플리케이션이 종료되기 전까지 유지됩니다. 만약 쓰레드 풀 개수를 변경하면, ps M으로 보는 쓰레드의 개수가 함께 변경됩니다.
이를 눈으로 확인해 보겠습니다. 처음에 max, min-spare 값을 모두 2로 한 후 값을 확인해 보면 해당 프로세스의 쓰레드가 53개인 것을 알 수 있습니다.
테스트 코드로 검증할 수도 있지만, 이를 위해서는 application.yml의 설정을 매번 바꿔야 하므로 직접 눈으로 확인해 보겠습니다.
이를 200으로 변경하면 다음과 같이 쓰레드가 248개로 변한 것을 볼 수 있습니다. 즉, application.yml에 설정된 값만큼 스프링 내부에 쓰레드를 생성하며, 해당 개수만큼 커널에 쓰레드를 생성해 매핑하는 것입니다.
쓰레드 개수를 200개로 설정했지만 248개인 이유는, 쓰레드 풀 외에도 스프링을 구동하기 위한 다른 여러 쓰레드가 존재하기 때문입니다.
이를 정리해보면, 스프링은 쓰레드 풀 이외에도 많은 쓰레드와 함께 동작한다. 쓰레드 풀에 있는 쓰레드는 커널 쓰레드와 매핑되어 애플리케이션이 종료되기 전까지 관리/유지된다라는 것을 알 수 있습니다.
- 스프링은 쓰레드 풀 외에도 많은 쓰레드와 함께 동작한다.
- 쓰레드 풀에 있는 쓰레드는 커널 쓰레드와 매핑되어 애플리케이션이 종료되기 전까지 관리/유지된다.
참고로 쓰레드 풀 이외의 쓰레드는 비동기/백그라운드 쓰레드, JVM 설정에 관한 쓰레드 등이 있습니다. 비동기/백그라운드 쓰레드는 다음과 같이 확인할 수 있습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
@Slf4j
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/threads")
public class ApiController {
private final ApiService apiService;
private final OutApiClient outApiClient;
private final ServletWebServerApplicationContext servletContext;
private final ThreadPoolTaskExecutor threadPoolTaskExecutor;
......
@GetMapping("/async-background")
public String getAsyncBackgroundThreads() {
log.info("<Async & Background Threads>");
log.info(
"ActiveCount: {}, PoolSize: {}, corePoolSize: {}, maxPoolSize: {}",
threadPoolTaskExecutor.getActiveCount(),
threadPoolTaskExecutor.getPoolSize(),
threadPoolTaskExecutor.getCorePoolSize(),
threadPoolTaskExecutor.getMaxPoolSize()
);
return "OK";
}
......
}
JVM 내부의 쓰레드는 다음과 같이 볼 수 있습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
@Slf4j
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/threads")
public class ApiController {
private final ApiService apiService;
private final OutApiClient outApiClient;
private final ServletWebServerApplicationContext servletContext;
private final ThreadPoolTaskExecutor threadPoolTaskExecutor;
......
@GetMapping("/jvm")
public String getJVMThreadsInfo() {
ThreadMXBean threadMXBean = ManagementFactory.getThreadMXBean();
log.info("<JVM Threads>");
for (ThreadInfo threadInfo : threadMXBean.dumpAllThreads(false, false)) {
log.info(
"ID: {}, NAME: {}, STATUS: {}",
threadInfo.getThreadId(),
threadInfo.getThreadName(),
threadInfo.getThreadState()
);
}
return "OK";
}
......
}
ThreadMXBean은 JMX(Java Management Extensions)에 속하는 인터페이스로, JVM 내의 쓰레드에 관한 정보를 제공합니다. 이를 통해 다음과 같은 정보를 알 수 있습니다.
- 쓰레드 개수 정보: 현재 실행 중인 쓰레드 개수, 데몬 쓰레드 개수, 최대 동시 실행된 쓰레드 개수 등
- 쓰레드 상세 정보: 스택 트레이스, 블록 시간, 대기 시간, 상태
- 데드락 감지: 데드락이 걸린 쓰레드 ID
The management interface for the thread system of the Java virtual machine. A Java virtual machine has a single instance of the implementation class of this interface. This instance implementing this interface is an MXBean that can be obtained by calling the ManagementFactory.getThreadMXBean() method or from the platform MBeanServer method.
톰캣의 쓰레드는 다음과 같이 볼 수 있습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
@Slf4j
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/threads")
public class ApiController {
private final ApiService apiService;
private final OutApiClient outApiClient;
private final ServletWebServerApplicationContext servletContext;
private final ThreadPoolTaskExecutor threadPoolTaskExecutor;
@GetMapping("/tomcat")
public String getTomcatThreadsInfo() {
TomcatWebServer tomcatWebServer = (TomcatWebServer) servletContext.getWebServer();
ThreadPoolExecutor threadPoolExecutor = (ThreadPoolExecutor) tomcatWebServer
.getTomcat()
.getConnector()
.getProtocolHandler()
.getExecutor();
log.info("<Tomcat Threads>");
log.info(
"Current active threads: {}, Total threads: {}, Max threads: {}",
threadPoolExecutor.getActiveCount(),
threadPoolExecutor.getPoolSize(),
threadPoolExecutor.getMaximumPoolSize()
);
return "OK";
}
}
2. 비동기 쓰레드 풀
스프링이 비동기 쓰레드 풀을 어떻게 관리하는지도 살펴보겠습니다. 이를 이해하기 위해서는 ThreadPoolTaskExecutor의 특징에 대해 알아야 합니다. 이는 쓰레드를 애플리케이션 초기화 때 미리 생성하지 않으며, 작업 요청에 따라 동적으로 쓰레드를 생성합니다. 이는 corePoolSize, maxPoolSize, queueCapacity 값에 따라 결정됩니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Configuration
public class AsyncConfiguration implements AsyncConfigurer {
@Override
public Executor asyncExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setThreadNamePrefix("async-threads");
executor.setCorePoolSize(25);
executor.setMaxPoolSize(50);
executor.setQueueCapacity(500);
executor.setKeepAliveSeconds(30);
executor.setRejectedExecutionHandler(rejectedExecutionHandler());
executor.initialize();
return executor;
}
private RejectedExecutionHandler rejectedExecutionHandler() {
return (runnable, executor) -> {
throw new RuntimeException("Async Exception");
};
}
}
이를 눈으로 확인해보겠습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
@Slf4j
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/threads")
public class ApiController {
private final ApiService apiService;
private final OutApiClient outApiClient;
private final ServletWebServerApplicationContext servletContext;
private final ThreadPoolTaskExecutor threadPoolTaskExecutor;
......
@GetMapping("/async")
public ResponseEntity<?> callAsyncAndBackground() {
long endTime = System.currentTimeMillis() + 3_000;
// 3초간 Hello World를 출력하는 비동기 메서드 실행
while (System.currentTimeMillis() < endTime) {
apiService.testAsync();
}
return ResponseEntity.ok()
.build();
}
......
}
1
2
3
4
5
6
7
8
@Service
public class ApiService {
@Async
public void testAsync() {
System.out.println("Hello Async!!");
}
}
초기 애플리케이션을 실행시켰을 때는 쓰레드 개수가 244개 입니다.
API를 요청하고 난 후 쓰레드 개수를 살펴보면 294개로 늘어난 것을 볼 수 있습니다. 즉, 실제 비동기 요청이 온 시점에 쓰레드 풀의 개수를 늘려 이를 관리하는 것입니다.
이 값은 설정에 따라 늘어나기도 하고, 줄어들기도 합니다.
3. 고려할 점
쓰레드 풀 크기를 적절하게 설정하는 것은 중요합니다. 너무 많은 쓰레드를 생성하면 자원과 비용이 낭비되며, 너무 적게 설정하면 CPU 사용률이 떨어지기 때문입니다. 적절한 쓰레드 풀 크기는 시스템의 CPU 수, 쓰레드의 작업 특성, 작업의 복잡성, 지속 시간, 그리고 시스템의 메모리 한계 등을 고려해 결정해야 합니다. 이 중 CPU 코어 개수, 쓰레드 작업 특성에 대해 살펴보겠습니다.
시스템 성능을 지속적으로 모니터링하고 필요에 따라 쓰레드 풀 크기를 동적으로 조절하는 것도 중요합니다. 여기에는 절대적인 공식은 없으며, 다양한 크기로 성능 테스트를 수행하며 최적의 크기를 찾아야 합니다.
3-1. CPU 코어 개수
CPU 코어 개수는 쓰레드 풀 크기를 결정하는 핵심적인 요소 중 하나입니다. 각 코어는 당 처리할 수 있는 최대치가 정해져 있기 때문입니다. 기본적으로 각 코어는 한 번에 하나의 쓰레드만 처리할 수 있으며, 만약 하이퍼 쓰레딩(Hyper-Threading)을 지원하는 최신 CPU의 경우, 한 코어가 여러 쓰레드를 동시에 처리할 수도 있습니다.
CPU 코어 개수 당 처리할 수 있는 작업이 제한되어 있기 때문에 이를 고려한 쓰레드 풀 크기 설정이 필요합니다. 여러 개의 코어가 있더라도, 이를 효과적으로 활용하지 않을 수 있기 때문입니다.
3-2. 작업 종류
쓰레드 풀을 생성할 때, CPU Bound 작업인지, I/O Bound 작업인지도 영향을 미치는데, 이에 대해 살펴보겠습니다.
CPU Bound: In computer science, a task, job or process is said to be CPU-bound (or compute-bound) when the time it takes for it to complete is determined principally by the speed of the central processor.I/O Bound: In computer science, I/O bound refers to a condition in which the time it takes to complete a computation is determined principally by the period spent waiting for input/output operations to be completed, which can be juxtaposed with being CPU bound.
3-2-1. CPU Bound Tasks
CPU 작업이 주가 될 때는 아무리 쓰레드가 많아도 한 번에 처리 가능한 작업은 CPU 코어 개수로 제한됩니다. 따라서 많은 쓰레드를 생성하더라도 실제 성능 향상에 도움이 되지 않으며, 메모리만 낭비하게 됩니다. 이런 작업에서는 쓰레드 풀의 크기를 CPU 코어 개수와 동일하게 설정하는 것이 바람직합니다.
파이썬으로 이에 관한 실험을 한 글이 있는데, 한 번 읽어보실 것을 권장드립니다.
3-2-2. I/O Bound Tasks
반면 I/O Bound Tasks 작업에서 입/출력 작업이 많을 경우, 외부 데이터를 기다리는 동안 CPU는 대기 상태가 됩니다. 이런 경우, 대기 시간 동안 다른 작업을 처리할 수 있도록 쓰레드를 추가 생성하는 것이 성능 향상에 도움이 됩니다. 이러한 IO 바운드 작업에서는 대기 시간과 CPU 코어 개수를 모두 고려해 쓰레드 풀의 크기를 결정해야 합니다.
4. 쓰레드 풀 공식
쓰레드 풀을 조절하는 몇 가지 공식이 있는데 이에 대해 살펴보겠습니다. 이는 절대적인 법칙이 아니며, 은탄환은 없습니다.
No Silver Bullet - Essence and Accident in Software Engineering
4-1. Brian Goetz
Brian Goetz의 Java Concurrency in Practice에서는 적정 쓰레드 풀 크기를 결정하는 공식을 제안했습니다. 이 공식은 대기 시간과 서비스 시간을 기반으로 연산 합니다. 대기 시간은 쓰레드가 WAITING 또는 TIMED_WAITED 상태로 대기 중인 시간을 나타내며, 서비스 시간은 실제 작업 수행 시간을 의미합니다.
적정 쓰레드 개수 = (사용 가능한 코어 개수) x (1 + 대기시간/서비스 시간)
CPU 중심의 작업에서는 대기 시간이 거의 없기 때문에, 적정 쓰레드 개수가 사용 가능한 코어 수와 유사하게 될 것이다. 그러나 IO 중심의 작업에서는 대기 시간에 따라 쓰레드 풀의 크기를 조절해야 한다. 만약 대기 시간이 길면 쓰레드 풀의 크기를 늘리고, 짧으면 줄여야 합니다. 하지만 이 공식 자체가 큰 의미가 없는데, 실무에서는 HTTP 커넥션 풀 뿐 아니라 데이터베이스 커넥션 풀 등과 같은 많은 고려요소가 있기 때문입니다. 따라서 여러 쓰레드 풀이 존재한다면 이에 맞게 수치를 조정해야 합니다.
적정 쓰레드 개수 = (사용 가능한 코어 개수) x (목표 CPU 사용률) x (1 + 대기시간/서비스 시간)
4-2. Little’s law
리틀의 법칙은 시스템의 성능 측정에서 사용되는 수학적 법칙입니다. MIT의 교수인 리틀이 처음 제시하였으며, 재고 산정에서부터 IT 분야의 성능 평가에 이르기까지 다양한 분야에서 활용됩니다.
이 법칙은 다음과 같은 수식으로 표현됩니다:
L = λ x W
각 변수는 다음을 나타냅니다. 예를 들어, 평균 응답 시간이 100ms이고 쓰레드 풀 크기가 200인 경우 L=200, W=0.1이기 때문에, λ(200/0.1)=2000이 되며, 시스템은 1초당 약 2,000개의 요청을 처리할 수 있습니다. 하지만 이 또한 시스템 마다 다르게 적용되며, 절대적인 공식은 존재하지 않습니다.
- L: 시스템에서 동시에 처리되는 요청의 개수.
- λ: 시스템이 처리할 수 있는 평균 처리랭.
- W: 평균 요청 처리 시간.
5. 정리
스프링은 하나의 프로세스로 내부에 여러개의 쓰레드를 가지고 있습니다. 이 중 톰캣 쓰레드는 application.yml을 통해 값을 설정할 수 있으며, 이를 통해 생성된 쓰레드는 커널 쓰레드와 대응돼 애플리케이션이 동작하는 동안 유지됩니다. 또한 비동기 쓰레드는 애플리케이션 초기화 시 쓰레드가 생성되는 것이 아닌, 실제 요청이 들어왔을 때 생성/관리 됩니다.
해당 포스팅은 쓰레드 풀 내부의 쓰레드가 커널 쓰레드인지, 유저 쓰레드인지를 확인하려다 시작하게 되었는데, 이를 구분하는 것은 큰 의미가 없다는 것을 깨달았습니다. 스프링 쓰레드 풀에 생성되는 쓰레드는 유저쓰레드 이지만, 이는 커널 쓰레드와 1:1로 매핑되어 관리되며, 애플리케이션이 동작합니다. 이 원리를 이해하는 것이 중요하지, 보이지도 않는 구현을 찾을 필요는 없기 때문입니다. 따라서 저같이 이를 찾으며 시간을 낭비하기 보다 보다, 쓰레드 풀의 핵심적인 동작 원리에 집중하는 것을 추천드립니다.
의진님과 같이 몇 시간을 이야기 하며 많은 인사이트 얻었는데, 덕분에 어느정도 결론을 내릴 수 있었습니다. 감사합니다. 😇