멀티 스레드 스터디를 하고 있다가,
"멀티 스레드 환경에서 스레드의 적당 수는 어떻게 찾는걸까?" 에 대한 의문이 들었습니다.
의문을 해소하고 싶어서 서버를 만들고, JMeter로 부하를 줘서 멀티 스레드의 파라미터를 바꿔가면서 적당량의 파라미터를 찾아보는 실험을 해보기로 했습니다.
실험 코드는 아래 링크에 있습니다.
https://github.com/HwangRock/spring_multithread_test
서버에 부하 테스트를 줘서 모니터링을 통해 최적의 멀티 스레드 파라미터를 찾자.
실험은 아래와 같은 구조로 설계했습니다.
서버에는 3가지 API가 있습니다.
DB에 500개의 무작위 문자열 데이터를 넣어주는 /random,
DB의 모든 데이터를 삭제해주는 /delete,
DB에서 모든 데이터를 가지고 와서 알고리즘을 수행하는 /test.
(사용한 알고리즘은 https://www.acmicpc.net/problem/1431 이거입니다.)
실험은 동기와 비동기의 성능을 우선적으로 비교하고,
시나리오와 비기능 요구사항을 정하고 멀티 스레드 환경에서의 실험을 진행하고자 했습니다.
테스트 사나리오는 아래처럼 정했습니다.
1000명이 동시에 1분 동안 3초에 한번 씩 요청을 날립니다.
측정 메트릭은 RPS, TPS, error rate, 스레드 사용률.
이렇게 가졌습니다.
TPS와 error rate는 JMeter가 보여주는 메트릭으로 가졌고,
RPS와 스레드 사용률은 프로메테우스를 통해 메트릭을 측정하고
그라파나에 쿼리를 날리며 시각화를 했습니다.
<사용한 쿼리>
RPS - sum(rate(http_server_requests_seconds_count[1m]))
스레드 사용률 - executor_active_threads/executor_pool_max_threads
우선 동기와 비동기부터 비교해봤습니다.
CPU 작업을 할때와 I/O 작업을 할때.
2가지로 나눠서 비교해봤습니다.
CPU 작업은 3분 동안 부하를 넣을 때와
I/O 작업은 /random API로 DB에 데이터를 넣을 때로 사용했습니다.
먼저 DB에는 2500개의 문자열 데이터를 넣었습니다.
먼저 동기부터 했습니다.
18:27~18:30까지 테스트를 진행했습니다.
RPS는 아래처럼 변동이 큰 모습을 보였습니다.
동기 작업을 하기에 tomcat 스레드가 모든 작업을 수행해서
executor 스레드는 항상 비어있는 모습을 보이고,
클라이언트(JMeter)가 측정한 메트릭도 있습니다.
다음은 비동기를 봤습니다.
18:57~19:00까지 테스트를 진행했습니다.
동기와 다르게 RPS는 쭉 증가하다 떨어지는 모습을 볼 수 있습니다.
스레드 사용률은 거의 항상 꽉 차 있는 모습을 볼 수 있습니다.
클라이언트(JMeter)가 측정한 메트릭도 있습니다.
CPU 작업에서는 큰 차이가 없었습니다.
유의미한 차이는 RPS에서 동기는 변화가 크지만, 비동기는 일관된다는 점이 있습니다.
이 차이는 RPS에 영향을 주는 평균 응답 시간에 있다고 생각됩니다.
RPS= 처리 가능한 스레드 수 × (1 / 평균 응답 시간)
동기는 현재 스레드가 일을 할 수 없으면 대기를 하게 되므로 평균 응답 시간이 변하게 되지만,
비동기는 하나의 스레드에 의존하는 것이 아니므로 평균 응답 시간이 일정하다는 특징이 있기에 차이가 발생한다고 생각합니다.
하지만, 그것 말고는 CPU 작업에서는 큰 차이가 없습니다.
이유는 동기든 비동기든 CPU가 항상 일을 하기 때문입니다.
I/O 같이 CPU가 항상 일하지 않는 작업에서는 CPU가 다른 unit에게 일을 맡겼을 때 동기와 비동기에서 차이가 발생하지만,
CPU 작업에서는 CPU가 항상 일하기에 결과가 비슷합니다.
그럼 이제 DB에게 요청을 하는 I/O 작업을 비교해보겠습니다.
여기서는 부하 테스트를 하기에는 DB가 터질 위험이 있어서 하나의 API 작업만을 수행하는 단위 테스트로 응답 시간만을 비교해보겠습니다.
(이것만으로도 충분한 의미가 있었습니다.)
우선 동기부터 해봤습니다.
API 하나에 약 14초(13,910ms) 정도 걸린 모습이 보입니다.
이제 비동기 요청을 해보겠습니다.
여기는 약 0.05초(59ms)가 걸린 모습이 보입니다.
거의 235배 정도의 차이가 납니다.
원인은 tomcat 스레드의 작동에 있습니다.
동기 일때는 tomcat 스레드가
요청을 받고 작업을 하고 응답을 하는 과정을 가지지만,
비동기 일때는 tomcat 스레드가
요청을 받고 executor 스레드 하나를 깨우고 작업을 할당하고
응답하는 과정을 가진다는 차이가 있습니다.
비동기에서는 executor 스레드의 작업 완료 유무와 관련 없이
tomcat 스레드가 응답을 보내다보니 1가지 이슈도 확인할 수 있었습니다.
500개의 데이터를 넣는 /random을 하고,
/test를 했는데 500개의 데이터가 안오고 319개의 데이터만 온 모습입니다.
비동기에서는 tomcat 스레드가 executor 스레드의 작업 완료 여부와 관계 없이 응답을 보낸다는 것을 다시금 확인할 수 있었습니다.
동기와 비동기 중에 어떤 API에 뭘 써야할지 고민해볼만 했습니다.
외부 API나 DB 연결같이 CPU만이 작업하지 않는 로직에는 비동기를 붙여서 RPS를 높여볼 수 있고,
데이터 가공같은 CPU가 많이 쓰이는 작업은 굳이 비동기를 붙일 필요 없다는 것을 확인했습니다.
일단 DB에는 4000개의 문자열 데이터를 넣었습니다.
멀티 스레드 환경에서는 기본 스레드 수, 최대 스레드 수, 대기 큐 크기를 파라미터로 가졌습니다.
파라미터는 아래 표와 같이 변경했습니다.
기본 스레드 수 | 최대 스레드 수 | 대기 큐 크기 |
---|---|---|
20 | 100 | 500 |
30 | 200 | 1000 |
각각 2가지로 총 8가지의 케이스만 만들어봤습니다.
case 1 ~ case 8까지 대기 큐 크기를 먼저 바꾸고, 다음은 최대 스레드 수를 바꾸고, 다음은 기본 스레드 수를 바꾸는 순서로 case를 바꿔가겠습니다.
Metric / Case | Case1 | Case2 | Case3 | Case4 | Case5 | Case6 | Case7 | Case8 |
---|---|---|---|---|---|---|---|---|
RPS (per sec) | 95.6 | 92.5 | 71.4 | 89.5 | 107 | 72.6 | 98.9 | 53.8 |
TPS (per sec) | 96.2 | 83.3 | 42.9 | 97.8 | 100.8 | 69.7 | 94.7 | 51.4 |
Error rate (%) | 0.36 | 2.17 | 0.34 | 16.22 | 0 | 0 | 0 | 0 |
스레드 사용률 (%) | 100 | 20 | 100 | 10 | 100 | 30 | 100 | 15 |
가장 눈에 띄는 점은 case 5 ~ case 8은 에러 발생이 0이라는 점 입니다.
기본 스레드가 적을 때는 초기에 급격한 부하가 있을 때 에러가 발생하지만,
충분할 때는 급격한 부하가 있어도 기본으로 있는 스레드로 잘 버티는 모습을 볼 수 있습니다.
여기서 기본 스레드는 갑작스러운 부하(초기상태)에 잘 대응할 수 있도록 도와준다는 것을 볼 수 있습니다.
다음은 스레드 사용률이 100과 100이 아닌 것이 번갈아 나온 다는 점입니다.
여기에 영향을 주는 것은 대기 큐의 크기입니다.
대기 큐가 클때는 큐에 작업을 적재시키면 되므로 새로운 스레드를 사용하지 않지만,
대기 큐가 작을때는 작업을 적재시킬 수 없으므로 새로운 스레드를 사용하게 되므로 스레드 사용률이 높은 것을 볼 수 있습니다.
적당한 스레드 사용을 잡으려면 최대 스레드 수와 대기 큐 크기 사이에어 균형을 잡아야 할거 같습니다.
가장 이상적인 상황은 case 5였습니다.
적당한 기본 스레드도 초기에 갑작스러운 부하를 잘버텼고,
최대 스레드 수와 최대 대기 큐 사이에 균형이 잘 잡힌거 같습니다.
API 구현, 모니터링, 테스트 시나리오 설계 등 은근 손볼게 많았던 테스트였습니다.
매번 프로젝트에서 실험 환경을 구축하는 것보다 미리 테스트 시나리오 나 테스트 서버, DB를 구축하고 있으면 편할거 같았습니다.
직접 동기와 비동기를 비교하고,
비동기에서 파라미터를 다르게 하며 트러블 슈팅을 해보니 지식으로만 배웠던 CS를 직접 관측해보니 좋았습니다.
좀 더 안정적인 서버를 개발할 수 있는 개발자로 한 걸음 나아간거같은 기분이 듭니다.