0. Spring Boot의 Tomcat 설정
Spring Boot에는 내장 서블릿 컨테이너인 Tomcat이 존재합니다.
Tomcat의 설정과 관련된 설정 키워드는 다음과 같습니다.
- accept-count
- max-connections
- threads.max
- threads.min-spare
해당 설정들이 어떤 의미인지 알아보기 전에,
이해해야 할 개념들을 살펴봅시다.
1. Thread?
스레드의 개념은 다음과 같습니다.
실행중인 한 프로그램(프로세스) 내에서 구분지어진 실행 단위
하나의 프로세스에서 작업을 처리하기 위한 단위로 생각하면 될 것 같습니다.
실생활 예를 들면, 은행은 은행이라는 프로세스 내에서 여러 고객의 동시 거래 요청 작업을 처리해야합니다.
이때 은행이 병렬적으로 동시 거래 요청 작업을 처리하는데, 해당 작업이 이루어지는 단위가 스레드입니다.
컴퓨터의 CPU는 스레드 단위로 작업을 처리합니다.
CPU는 여러 스레드를 번갈아가며 실행해서 다중 작업을 처리합니다.
CPU를 엄청 짧은 시간으로 시분할해서 사용하기 때문에 우리 눈에는 동시에 처리되는 것처럼 보이지만,
사실은 여러 스레드를 번갈아가면서 작업을 실행하는 것입니다.
❓ Thread가 많으면 많을수록 좋을까?
그렇다면, CPU가 작업을 처리하는 단위인 스레드가 많으면 여러 작업을 처리할 수 있기 때문에 무조건적으로 좋을까요?
해당 질문에는 고려해야 할 요소가 있습니다.
1. Thread는 생성 시 메모리를 소모한다.
스레드가 생성되면, 해당 스레드는 실행에 필요한 메모리를 할당받습니다.
따라서 생성 시 메모리를 소모하게 됩니다.
스레드가 많아지면 각 스레드가 차지하는 메모리가 커져서 메모리가 부족해질 수 있습니다.
2. Thread가 많아지면 CPU의 자원 경합이 발생할 수 있다.
스레드가 많아지면 CPU 자원을 경합하는 경우가 발생할 수 있습니다.
하나 이상의 스레드가 데이터를 기록하려고 할 때 다른 스레드가 해당 데이터를 읽으려고 하는 경우입니다.
이렇게 경합이 발생하면 애플리케이션에 예상치 못한 동작이 발생할 수 있습니다.
3. Thread가 많아지면 컨텍스트 스위칭 비용이 커진다.
컨텍스트 스위칭이란, CPU에서 실행중이던 스레드가 다른 스레드로 바뀌는 것을 의미합니다.
스레드가 다른 스레드로 바뀌면서, 바뀐 스레드의 정보를 변경해야 하는데
이때 CPU가 해당 컨텍스트 스위칭을 수행하여 CPU의 시간을 소모합니다.
따라서, 컨텍스트 스위칭이 일어나면 CPU의 시간을 소모하게 됩니다.
스레드가 많으면 많을수록 스레드 간의 전환이 빈번해질 것이고,
이에 따라서 컨텍스트 스위칭 비용이 커질 것이므로 CPU의 시간을 소모하게 되고
더 나아가서는 CPU에 오버헤드가 발생하여 성능이 저하할 수 있습니다.
이렇듯, 스레드가 많으면 여러 작업을 처리할 수 있지만
위처럼 Side-Effect들이 존재하기 때문에 이를 고려해서 스레드의 개수를 정해야 합니다.
2. Thread Pool?
앞서 스레드에 대해서 살펴볼 때 '스레드는 생성 시 메모리를 소모한다'는 내용이 있었습니다.
스레드를 매번 생성한다면 매번 메모리를 사용하게 되겠죠?
이는 Thread Pool에 한번 생성한 스레드를 저장해놓는 방식으로 해당 문제를 보완할 수 있습니다.
따라서, 스레드 풀은 '생성한 스레드를 보관하는 저장소'느낌으로 이해하면 될 것 같습니다.
스레드는 생성될 때 스레드 풀에 저장되고, 작업 처리 후 소멸되는 것이 아니라 스레드 풀에 남아있습니다.
이후에 작업 처리가 필요하면 해당 스레드를 재사용하여 작업을 처리하게 됩니다.
이를 통해 성능을 향상시킬 수 있습니다.
자바의 스레드 풀은 다음과 같습니다.
작업 처리 과정을 간단히 설명해보면 다음과 같습니다.
- 스레드 풀에 작업 처리 요청 (Task Submitters)
- 작업 요청이 작업 큐에 쌓인다. (Task Queue)
- 작업 큐에 쌓인 작업들을 스레드 풀에 있는 스레드가 하나씩 맡아서 수행한다. (만약 처리할 스레드가 없다면 작업 큐에서 대기한다.)
스레드 풀에서 '작업 큐'라는 큐를 도입해서, 작업 처리 요청이 많아져도 작업 큐에서 대기하기 때문에
요청마다 스레드의 개수를 늘리지 않고 스레드의 전체 개수는 일정하며 성능이 저하되지 않습니다.
3. Tomcat 설정(Tomcat Thread Pool)
앞서 개념들을 살펴봤으니, 본격적으로 Tomcat에서 설정 요소를 살펴봅시다.
- threads.min-spare : 톰캣 스레드 풀에 대기 상태로 있는 스레드 개수
- threads.max : 스레드 풀이 '동시에' 사용할 수 있는 최대 스레드 개수
- max-connections : Tomcat 서버가 '동시에 처리할 수 있는 최대 클라이언트 연결 수'
- accept-count : max-connections 이상의 요청이 들어 왔을 때 사용하는 요청 대기열 큐의 사이즈
- 만약 max-connections 이상의 연결 시도라서 요청 대기열 큐에 저장되는데, 요청 대기열 큐의 사이즈인 accept-count 보다도 연결 시도가 많아지면 연결을 거부한다.
여기서, Java의 스레드 풀과 Tomcat의 스레드 풀의 차이는 'accept-count'과 관련이 있습니다.
설명을 다시 한번 살펴봅시다.
accept-count : max-connections 이상의 요청이 들어 왔을 때 사용하는 요청 대기열 큐의 사이즈
Java 스레드 풀의 Queue는 작업 큐로, 작업 요청이 들어오면 무조건 작업 큐를 거쳐서 스레드에 할당됐었습니다.
Tomcat에서 사용되는 Queue는 요청 대기열 큐로, 'max-connections 이상의 요청이 들어 왔을 때' 작업이 저장되게 됩니다.
위의 설명을 바탕으로, 아래 설정 파일을 분석해봅시다!
server:
tomcat:
accept-count: 5
max-connections: 150
threads:
max: 50
min-spare: 20
min-spare : 20
- 기본적으로 스레드 풀에 대기 상태로 존재하는 스레드가 20개 있다.
max : 50
- 스레드 풀이 동시에 사용할 수 있는 최대 스레드 개수로, 50개의 요청을 동시에 처리할 수 있다.
- 만약 동시에 50개의 요청이 오면, 대기 상태인 20개의 스레드를 제외하고 30개의 스레드가 생성되어 50개의 요청을 동시 처리한다.
- Tomcat Connector는 50개의 TCP Connection을 연결하고 있다.
accept-count : 5
- 만약 100개의 요청이 오면, 최대 스레드 개수인 50개 만큼 요청을 처리하고, 5개씩 작업 큐에 저장된다.
max-connections : 150
- 만약 200개의 요청이 오면, 150개의 요청이 수락되고 요청 대기열 큐 사이즈(accept-count)인 5만큼 요청이 수락되고, 나머지 45개의 요청은 거절된다. (이때, 요청 대기열 큐에 저장되는 작업들은 TCP Connection을 맺지 않고 있다.)
4. Tomcat 케이스별 설정해보기
위에서 살펴봤던 동시성 설정 요소의 수치를 케이스별로 다양하게 설정하고 결과를 살펴봅시다.
테스트하는 코드는 다음과 같습니다. (출처 : https://github.com/woowacourse/jwp-dashboard-http)
class AppTest {
private static final AtomicInteger count = new AtomicInteger(0);
/**
* 1. App 클래스의 애플리케이션을 실행시켜 서버를 띄운다.
* 2. 아래 테스트를 실행시킨다.
* 3. AppTest가 아닌 App의 콘솔에서 SampleController가 생성한 http call count 로그를 확인한다.
* 4. application.yml에서 설정값을 변경해보면서 어떤 차이점이 있는지 분석해본다.
* - 로그가 찍힌 시간
* - 스레드명(nio-8080-exec-x)으로 생성된 스레드 갯수를 파악
* - http call count
* - 테스트 결과값
*/
@Test
void test() throws Exception {
final var NUMBER_OF_THREAD = 10;
var threads = new Thread[NUMBER_OF_THREAD];
for (int i = 0; i < NUMBER_OF_THREAD; i++) {
threads[i] = new Thread(() -> incrementIfOk(TestHttpUtils.send("/test")));
}
for (final var thread : threads) {
thread.start();
Thread.sleep(50);
}
for (final var thread : threads) {
thread.join();
}
assertThat(count.intValue()).isEqualTo(2);
}
private static void incrementIfOk(final HttpResponse<String> response) {
if (response.statusCode() == 200) {
count.incrementAndGet();
}
}
}
주석에 적힌 설명대로, 애플리케이션을 실행 후에 해당 테스트를 실행하여 로그 결과를 분석해보겠습니다.
간단하게 테스트 코드를 분석해보면 스레드 수를 테스트 코드단에서 정해놓고 생성한 후
스레드에 count + 1을 하는 작업을 처리하도록 하는 코드입니다.
여러 케이스별로 결과를 살펴보겠습니다.
4-1. max-connections < threads.max
먼저 최대 동시 요청 수인 max-connections 보다 스레드의 개수인 threads.max가 큰 경우를 살펴보겠습니다.
application.yml
server:
tomcat:
accept-count: 1
max-connections: 5
threads:
max: 10
실행 결과를 분석해보면 최대 사용가능한 스레드 개수는 10개이지만,
sleep 시간이 적기 때문에 '동시에' 요청이 오는 것으로 판단하는 것으로 보입니다.
그래서 최대 동시 연결 수인 max-connections가 5개로 10개보다 적어서 count가 max-connections 수인 5까지 오른 후에,
accept-count 수인 1개 만큼 대기열 큐에 대기하다가, 작업이 끝난 스레드인 2번 스레드가
대기열 큐에 있는 작업을 가져가서 처리해서 count가 6이 된 것을 알 수 있다.
이때, 스레드의 sleep 시간을 0.05초에서 0.5초로 늘렸더니 다른 결과가 도출되었습니다.
max-connections가 5이지만, 스레드가 0.5초로 상대적으로 길게 sleep하기 때문에
다음 요청 시에 '동시' 요청으로 판단하지 않아서 동시 요청 수인 max-connections와 상관없이
새로운 스레드가 생성되어서 10개의 요청이 모두 처리되는 것으로 보입니다!
이때 스레드 풀의 스레드는 sleep 시간이 길기 때문에 재사용되지 않고 새로운 스레드를 생성해서 처리하는 것으로 보입니다!
4-2. max-connections > threads.max
이번에는 최대 동시 요청 수인 max-connections 보다 스레드의 개수인 threads.max가 적은 경우를 살펴보겠습니다.
application.yml
server:
tomcat:
accept-count: 1
max-connections: 10
threads:
max: 5
해당 경우에는 thread sleep 시간은 위와 같이 스레드와 count에 영향을 주지 않았습니다.
차이점은 시간을 보면 처리 속도만 sleep 시간에 따라 달라진 것을 알 수 있습니다.
0.05초 sleep 시에는 '동시' 요청으로 판단하지만,
최대 동시 요청 수인 max-connections이 10이기 때문에 count가 10까지 증가하는 것을 알 수 있습니다.
threads.max는 5이기 때문에 5번 요청 이후에는 스레드를 생성하지 않고 스레드 풀의 1~5 스레드를 재사용하는 것을 알 수 있습니다.
4-3. 작업 요청 수(10) > max-connections + accept-count
앞서 요소 설명 시 다음과 같이 언급했었습니다!
만약 max-connections 이상의 연결 시도라서 요청 대기열 큐에 저장되는데, 요청 대기열 큐의 사이즈인 accept-count 보다도 연결 시도가 많아지면 연결을 거부한다.
해당 상황을 살펴보겠습니다.
application.yml
server:
tomcat:
accept-count: 3
max-connections: 1
threads:
max: 10
현재 테스트 코드에서 요청 수는 10개입니다.
여기서 max-connections가 1이기 때문에 동시 요청 수는 1개가 최대이고,
1개 이후에 동시 요청이 오면 accept-count인 3개까지 작업 큐에서 대기하다가, 실행됩니다.
따라서 로그에서 count 1이 증가 후에, 약 2초 정도 작업 큐에 작업들이 저장되고 나서
작업 큐에서 작업들이 처리되기 시작해서 count가 4까지 증가하는 것을 알 수 있습니다.
이렇게 해서, 간단하게 Thread와 Thread Pool의 개념을 살펴보고
Tomcat의 설정 요소와 케이스별 설정 결과를 확인해봤습니다!
스레드, 스레드 풀의 개념과 Tomcat 설정 요소가 무엇을 의미하는지를 기반으로 해서
Tomcat 설정을 최적화하면 될 것 같습니다!
해당 Tomcat 설정 요소들을 어떻게 서비스 애플리케이션에 적절하게 최적화할 지는
실제로 애플리케이션에 적용해보고 나서 포스팅하도록 하겠습니다!!
아직도 이와 관련한 지식이 많이 부족하기 때문에 혹시 틀린 부분이 있으면 마음껏 지적 부탁드립니다!! 🙇🏻♂️
Reference
https://ojt90902.tistory.com/1536?category=932557
https://velog.io/@mooh2jj/Tomcat-Thread-Pool-%EC%A0%95%EB%A6%AC#thread%EB%9E%80
'Spring' 카테고리의 다른 글
[Spring] Spring에서 Session 저장소로 Redis 사용하기(feat. Redis Session Clustering) (6) | 2024.01.03 |
---|---|
[Spring] 스프링 동시성 처리 방법(feat. 비관적 락, 낙관적 락, 네임드 락) (4) | 2023.11.21 |
[Spring] 스프링 이벤트를 사용하여 도메인 의존성 분리하기 (2) | 2023.07.30 |
[Spring] 테스트 시 DB 데이터 초기화 Trouble Shooting (0) | 2023.05.28 |
[Spring] 스프링 HTTP API 요청 & 응답 시 역직렬화 직렬화 원리 (4) | 2023.04.18 |