1. 글을 작성하게 된 계기
톰캣 설정값 변경 에 따른 TPS 처리량 변화 를 테스트하며 알게된 내용을 기록하기 위해 글을 작성하게 되었습니다.
1
2
3
4
5
6
7
8
9
10
11
server:
tomcat:
accept-count: ${ACCEPT_COUNT}
max-connections: ${MAX_CONNECTIONS}
connection-timeout: 10000
uri-encoding: UTF-8
background-processor-delay: 10
threads:
max: ${MAX}
min-spare: ${MIN_SPARE}
port: 8080
2. 프로젝트 세팅
테스트 목적은 톰캣 설정값 변경 에 따른 TPS 처리량 변화 를 살펴보는 것 입니다. 이를 위해 간단히 Health Check API 하나를 등록합니다. 전체 코드는 해당 레포지토리를 참조해 주세요.
1
2
3
4
5
6
7
8
@RestController
public class HealthCheckApi {
@GetMapping("/api/health-check")
public ResponseEntity<String> ok() {
return ResponseEntity.ok("OK");
}
}
빌드 후, 배포, 모니터링, 부하 테스트 설정 등의 과정이 있는데, 이는 생략하도록 하겠습니다. 모니터링은 꼭 필요하진 않지만, 테스트 중 CPU 사용률을 보기 위해 연동했습니다.
3. 설정 값 살펴보기
테스트 전, 설정값들에 대해 살펴보겠습니다. 여기서 우리가 살펴볼 값들은 accept-count, max-connections, threads.max, threads.min-spare 입니다.
1
2
3
4
5
6
7
8
9
10
11
server:
tomcat:
accept-count: 100
max-connections: 600
connection-timeout: 10000
uri-encoding: UTF-8
background-processor-delay: 10
threads:
max: 600
min-spare: 50
port: 8080
각 설정 값에 대한 내용은 다음과 같습니다.
accept-count: 최대 대기열 크기를 정합니다. 이를 통해 서버가 모든 쓰레드를 사용 중일 때, 추가적인 연결 요청을 대기열에 쌓아둘 수 있습니다.max-connections: 서버가 동시에 처리할 수 있는 최대 연결 수입니다. 이를 통해 네트워크 레벨에서 서버가 동시에 커넥션을 맺는 총연결 수를 조절할 수 있습니다.threads.max: 서버가 처리할 수 있는 최대 쓰레드 수입니다.threads.min-spare: 서버가 항상 유지해야 하는 최소 쓰레드 수입니다.
이는 그림을 보면 쉽게 이해가 쉬운데, 사용자의 요청이 최대 쓰레드 수(threads.max)를 초과하면, 요청은 대기열(accept-count)에 쌓입니다. 이 대기열도 가득 차게 되면 추가 요청은 거부됩니다. 최대 쓰레드 수와 최대 커넥션 수가 헷깔릴 수 있는데, 최대 쓰레드 수는 동시에 처리할 수 있는 요청의 수 를 제어하며, 최대 커넥션 수는 동시에 허용할 수 있는 네트워크 연결의 수 를 제어합니다.
예를 들어, 최대 쓰레드 수가 200개, 최대 커넥션 수가 500개 라면, 최대 500개의 연결을 허용하지만, 동시에 처리할 수 있는 요청은 200개로 제한됩니다. 나머지 300개의 요청은 대기열로 들어가죠.
최대 커넥션 수를 초과한 요청이 올 경우, 새로운 연결은 더 이상 수락되지 않습니다.
4. 테스트
사용자 요청을 처리할 충분한 쓰레드가 존재할 때, 사용자 요청을 처리할 충분한 쓰레드가 존재하지 않을 때 두 가지 경우를 테스트 해보겠습니다. 후자는 다시 한 번 두 가지 경우로 나뉘는데, 하나씩 살펴보죠.
- 사용자 요청을 처리할 충분한 쓰레드가 존재할 때
- 사용자 요청을 처리할 충분한 쓰레드가 존재하지 않을 때
4-1. 충분한 쓰레드가 존재할 때
먼저 사용자 요청을 처리할 충분한 쓰레드가 존재할 경우 입니다. 최대 쓰레드 갯수(threads.max)를 600개, 최대 커넥션 갯수(max-connections)를 600개, 대기열( accept-count)은 100개로 설정했습니다. 즉, 한 번에 최대 600개의 커넥션을 맺을 수 있으며, 이를 초과하더라도 100개의 요청을 대기 큐에 쌓아둘 수 있는 설정입니다.
1
2
3
4
5
6
7
8
9
10
11
server:
tomcat:
accept-count: 100
max-connections: 600
connection-timeout: 10000
uri-encoding: UTF-8
background-processor-delay: 10
threads:
max: 600
min-spare: 350
port: 8080
NGrinder로 TPS가 600이 넘지 않도록 10분간 부하를 넣으면 다음과 같이 모든 요청이 성공하는 것을 볼 수 있습니다. 최대 TPS가 600에도 미치지 않기 때문에 모든 요청이 깔끔하게 떨어진 것이죠.
CPU나 쓰레드 수를 보더라도 별다른 문제가 없습니다. 서버 스펙에 비해 많은 쓰레드를 사용하고는 있지만, 무난하게 처리했습니다.
4-2. 충분한 쓰레드가 존재하지 않을 때
문제는 사용자 요청을 처리할 충분한 쓰레드가 존재하지 않을 때 인데요, 여기서 대기열(accept-count) 을 기준으로 두 가지 테스트를 진행해 보겠습니다.
- 대기열을 포함해도 요청을 처리할 수 없는 경우
- 대기열을 포함할 때, 요청을 처리할 수도 있는 경우
4-2-1. 대기열을 포함해도 요청을 처리할 수 없는 경우
먼저 대기열을 포함해도 요청을 처리할 수 없는 경우입니다. 가상 유저를 3,000명으로 늘린 후 테스트하면 다음과 같이 그래프가 꺾이는 지점 을 볼 수 있습니다. 10분간 부하를 줬지만, 30초도 안 돼서 에러 때문에 테스트가 중단됐죠. 즉, 우리 애플리케이션은 600명 까지는 한 번에 요청을 처리하고 100명을 대기시킬 수 있지만, 이를 훨씬 초과하는 요청이 들어오며 테스트가 중단된 것입니다.
CPU 사용률을 보면 순간적으로 급격하게 값이 증가한 것을 볼 수 있습니다. 시간이 얼마 안 되고, 도중에 테스트가 중단돼, 값이 빠르게 내려갔지만, CPU 사용률이 임계점을 넘어가면 애플리케이션이 다운될 수도 있습니다.
테스트 중 CPU 사용률이 지속적으로 100%를 초과하며 애플리케이션이 종료된 적이 몇 번 있었습니다.
여튼, 에러가 발생했을 때의 로그를 보면 다음과 같이 Connection refused 가 발생한 것을 볼 수 있습니다. 즉, 최대 연결 가능한 수를 초과하며, 요청을 거부 한 것이죠.
1
2
3
4
5
6
7
8
2024-08-07 08:34:44,511 ERROR java.util.concurrent.ExecutionException: java.net.ConnectException: Connection refused
java.net.ConnectException: Connection refused
at org.apache.hc.core5.reactor.InternalConnectChannel.onIOEvent(InternalConnectChannel.java:64)
at org.apache.hc.core5.reactor.InternalChannel.handleIOEvent(InternalChannel.java:51)
at org.apache.hc.core5.reactor.SingleCoreIOReactor.processEvents(SingleCoreIOReactor.java:179)
at org.apache.hc.core5.reactor.SingleCoreIOReactor.doExecute(SingleCoreIOReactor.java:128)
at org.apache.hc.core5.reactor.AbstractSingleCoreIOReactor.execute(AbstractSingleCoreIOReactor.java:85)
at org.apache.hc.core5.reactor.IOReactorWorker.run(IOReactorWorker.java:44)
프로세스 자체의 CPU 사용률은 순간적으로 꽤 높았지만, 서버 CPU 사용률은 무난했습니다. 5%도 사용 안 한 것을 볼 수 있죠. 이를 정리하면 최대 연결 가능한 커넥션을 초과해서 대기열까지 사용했지만, 이조차 넘어가는 경우, 뒤에 오는 사용자 요청은 거부된다. 입니다.
CPU 사용률을 체크한 것은, 현재 서버 스펙에 비해 많은 쓰레드를 사용하고 있기 때문에
컨텍스트 스위칭(Context Switching)이 잦을 것이라 판단해, CPU 사용률을 체크할 필요가 있었기 때문입니다.
4-2-2. 대기열을 포함할 때, 요청을 처리할 수도 있는 경우
다음은 대기열까지 사용할 경우, 사용자의 요청을 처리할 수도 있는 경우입니다. 최대 연결 수는 초과했지만, 대기열까지 사용하면 조금 늦게라도 이를 처리할 수는 있는 경우 죠. 현재 설정은 한 번에 600개의 요청은 잘 처리하며, 100개의 추가 요청은 대기 큐에 저장할 수 있는 상태죠?
1
2
3
4
5
6
7
8
9
10
11
server:
tomcat:
accept-count: 100
max-connections: 600
connection-timeout: 10000
uri-encoding: UTF-8
background-processor-delay: 10
threads:
max: 600
min-spare: 350
port: 8080
TPS를 700 근처 가 나오도록 설정한 후, 부하를 걸면 다음과 같은 결과가 나옵니다. 중간에 에러 때문에 테스트는 중단됐지만, 여기서 살펴볼 부분이 있습니다.
그래프에서 에러가 급격하게 발생한 지점 과 그 직전 구간 을 보면 TPS가 700을 초과하는 것을 볼 수 있습니다. 최대 600명까지 요청을 받고, 100명을 대기시킬 수 있지만, 이를 초과한 사용자 요청 때문에 에러가 발생한 것입니다. 크게 에러가 발생한 후, 잠깐 정상적으로 사용자 요청을 처리했지만 TPS가 700을 초과하며 에러가 증가했고, 누적 에러 때문에 테스트가 중단된 것을 볼 수 있습니다.
TPS가 700을 넘었는데, 바로 에러가 발생하지 않고 요청을 처리한 것은 타임아웃(connection-timeout) 설정 때문입니다. 600을 초과한 사용자 요청이 오더라도, 대기열에 들어가기 때문에 타임아웃 설정값 동안은 연결이 유효한 것이죠.
1
2
3
4
5
server:
tomcat:
accept-count: 100
max-connections: 600
connection-timeout: 10000 # 타임아웃 대기
추가로 몇 번의 실험을 더 했을 때, TPS를 700 조금 아래로 두면, 요청이 밀리며 에러가 발생하지만, 어느정도 잘 처리하는 것을 볼 수 있었습니다.
실험을 통해 톰캣 설정 에 따른 사용자 요청 처리량 변화 를 살펴보았습니다. 그렇다면 쓰레드 개수를 설정할 때, 어떤 점을 고려해야 하는지도 추가로 알아보죠.
1
2
3
4
5
6
7
8
9
10
11
server:
tomcat:
accept-count: ${ACCEPT_COUNT}
max-connections: ${MAX_CONNECTIONS}
connection-timeout: 10000
uri-encoding: UTF-8
background-processor-delay: 10
threads:
max: ${MAX}
min-spare: ${MIN_SPARE}
port: 8080
5. 고려할 점
톰캣의 쓰레드 수 설정은 어떤 기준 으로 해야 할까요? 다양한 기준이 있겠지만 일반적으로 I/O Bound, CPU Bound, 일반적인 법칙, 테스트를 통한 적절 수치 찾기 세 가지를 통해 적정 수치를 찾아야 합니다.
- I/O Bound, CPU Bound
- 일반적인 법칙
- 테스트를 통한 적절 수치 찾기
5-1. I/O Bound, CPU Bound
먼저 애플리케이션이 I/O 작업이 많은지, CPU 작업이 많은지를 판단해야 합니다.
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.CPU Bound: In computer science, a task, job or process is said to be CPU-bound when the time it takes for it to complete is determined principally by the speed of the central processor.
일반적으로 웹 기반 서비스 는 I/O Bound 애플리케이션 에 해당하는 경우가 많습니다. 이는 네트워크 통신, 데이터베이스 통신, 파일 입출력 등의 I/O 작업이 많기 때문입니다. 반면, 데이터 처리나 계산 작업이 많은 애플리케이션은 CPU Bound에 해당하는 경우가 많습니다.
이는 서비스 특성마다 다르기 때문에 절대적인 기준이 없습니다.
I/O 작업이 많은 경우, CPU를 최대한 활용하기 위해 상대적 으로 더 많은 쓰레드 를 할당하는 것이 좋습니다. CPU가 I/O 작업으로 인해 대기 상태에 있을 때, 다른 쓰레드가 CPU를 사용할 수 있어 전체 처리 효율 이 높아지기 때문입니다. 반면, CPU 연산이 많은 경우, CPU를 빼앗기지 않고 연속적 으로 사용하는 것이 좋기 때문에 쓰레드 수를 적게 설정해 컨텍스트 스위칭을 최소화하는 것이 좋습니다.
5-2. 일반적인 법칙
적정 쓰레드를 찾는 것에는 몇 가지 공식이 존재하는데, 이에 대해 살펴보겠습니다.
- 리틀의 법칙
- Brian Goetz가 제시한 이론
5-2-1. 리틀의 법칙
리틀의 법칙(Little’s Law)은 대기열 이론 의 기본적인 정리 중 하나로, 시스템에서의 평균 대기 수(L)가 도착률(λ)과 시스템에 머무는 평균 시간(W)의 곱과 같다는 법칙입니다. 시스템에서 처리 중인 평균 작업의 수를 알면, 시스템의 성능을 평가하고 필요한 조처를 할 수 있다고 가정하는 이론이죠.
이를 식으로 나타내면 L = λ × W 과 같으며, 각 항목이 나타내는 의미는 다음과 같습니다.
L(Average number of items in the system): 시스템 내의 평균 작업의 수.λ(Arrival rate): 단위 시간당 시스템에 도착하는 작업 평균 수(작업 도착률).W(Average time in the system): 작업이 시스템에 머무르는 평균 시간.
예를 들어, 서버가 초당 평균 10개의 요청(λ=10 requests/second)을 받고, 각 요청이 서버에서 처리되어 응답하는 데 평균 0.5초(W=0.5 seconds)가 걸린다고 가정하면, 이 서버는 평균적으로 처리 중인 요청의 수(L)가 다음과 같이 계산됩니다. 이론적으로는 가능하지만, 실제로는 다양한 상황이 존재하기 때문에, 이를 그대로 적용하는 것은 어렵습니다.
𝐿 = 𝜆 × 𝑊 = 10 × 0.5 = 5
5-2-2. Brian Goetz
Brian Goetz가 Java Concurrency in Practice 에 소개한 내용으로, 적정 쓰레드 풀 크기를 계산하는 공식입니다.
N_threads = N_cpu * U_cpu * (1 + W/C)
공식의 변수들이 나타내는 의미는 다음과 같으며, 이는 최적의 쓰레드 수를 추정할 방법을 제시합니다.
N_threads: 최적의 성능을 위한 쓰레드의 개수입니다. CPU 사용률과 작업 대기 시간을 균형 있게 활용하여 계산됩니다.N_cpu: 사용할 수 있는 CPU 코어 개수입니다.U_cpu: 목표로 하는 CPU 사용률입니다.W/C: 작업이 실행 중인 시간을 기준으로, 대기 시간과 실제 처리 시간의 비율을 나타냅니다.
예를 들어, CPU 코어 2개, 목표 CPU 사용률 60%, 대기 시간/서비스 시간을 각 0.25초라고 가정하면, 적정 쓰레드 개수는 약 2~3개가 나옵니다. 이 또한 이론적으론 좋지만, 현실에선 다양한 상황이 존재하기 때문에 이를 통해 적정 쓰레드 개수를 찾는 것은 어려울 수 있습니다.
해당 식대로 계산하면 스프링 쓰레드 풀에 3개를 할당해야 합니다.
5-3. 테스트를 통한 적절 수치 찾기
따라서 테스트를 통해 운영 중인 서비스의 적정 수치를 찾는 것이 중요합니다. 각 서비스/애플리케이션마다의 특성, 보유 중인 리소스 가 다르기 때문에 일반화가 힘들기 때문입니다. 즉, 이론/공식만으로는 최적의 쓰레드 풀 개수를 설정할 수 없으며, 일정 주기로 테스트 를 진행하며, 운영 중인 서비스의 가장 이상적인 수치를 찾아야 합니다.
글에서 구체적인 수치나 쓰레드의 개수를 언급하지 않은 이유는 이 때문입니다.
6. 정리
톰캣 설정과 사용자 요청을 처리하는 비율에 대해 살펴보았습니다. 과정이 조금 길지만, 결론은 기본적인 법칙은 암기 하고, 테스트를 통해 자신의 상황에 맞는 적정 수치를 찾는 것 입니다. 정답은 없기 때문에 반복적인 테스트를 통해 운영 중인 서버에 맞는 최적의 값을 찾아보시죠.