Load Test: Submission API
비기능 요구사항의 고확장성(최대 10,000명의 동시 제출 처리를 지원)을 만족하기 위해 테스트를 해봤습니다.
Case1. 동시 10,000 요청
문제가 발생하는지 확인하기 위해 k6를 통해 간단히 10,000개의 요청을 보냈습니다. CPU, 메모리, 커넥션 풀을 확인했지만 두가지 문제가 발생했습니다.
Dial I/O Time-out
클라이언트가 서버에 연결을 시도하는 TCP 커넥션 맺기 단계에서 일정 시간 안에 연결이 되지 않으면 발생하는 에러.
커넥션을 받을 수 없는 상황
Request Time-Out
연결은 성공했지만, 서버가 응답을 지정 시간 안에 보내지 않으면 발생하는 에러.
앞의 요청이 밀려서, 뒤에 요청이 처리가 안되는 상황
Dial I/O Time-out해결 시도
시도1: 톰캣 설정 수정 (채택O)
톰캣의 기본 설정은 아래와 같습니다.
server:
tomcat:
threads:
max: 200 # 최대 스레드 개수
min-spare: 10 # 최소 유휴 스레드 개수
max-connections: 8192 # 톰캣이 수락할 수 있는 최대 요청 연결 개수
accept-count: 100 # 운영체제에서 톰캣에게 전달되기 전에 대기시키는 최대 연결 개수
위 설정을 기반으로 예상되는 시나리오는:
초기 요청 유입 (0 ~ 10개 요청) →
server.tomcat.threads.min-spare
즉시 존재하는 스레드로 처리 후 응답한다.
스레드 풀 확장 (11 ~ 200개 요청) →
server.tomcat.threads.max
10개를 초과하는 요청이 들어오면, 톰캣은 200개까지 새로운 스레드를 생성하여 요청을 처리한다.
1번과 비교하여 스레드 생성 비용만 차이날 것이다.
최대 스레드 도달 및 연결 대기 (201 ~ 8392개 동시 요청) →
server.tomcat.max-connections
스레드 개수가 200개에 도달했으므로, 더 이상 스레드를 생성할 수 없다.
톰캣은 이 요청에 대한 연결을 8192개 까지 수락하고 해당 요청은 Blocking 되도록 한다.
최대 커넥션 도달 및 OS 대기 큐 활용 (8393 ~ 8492개 동시 요청) →
server.tomcat.accept-count
이 요청은 운영체제에 100개 까지 대기 큐에 쌓이게 된다.
이 큐에 있는 요청들은 톰캣에 수락된 요청 중 하나가 비워질 때까지 기다린다.
대기 큐 가득 참 및 연결 거부 (8493개 이상 동시 요청) → 처리 불가
더 이상 요청을 수락할 수 없기에 즉시 예외를 발생시키고 요청은 처리하지 않는다.
예외 발생 가능 시나리오는
2, 3번) 로직 처리 중에 설정된 timeout 시간을 넘어서면
read-timeout
예외가 발생한다.3, 4번) 요청이 설정된 timeout 시간만큼 대기하게 되면
connection-timeout
예외가 발생한다.5번) 더 이상 이 요청을 수락할 수 없기에 즉시
connection-refused
예외가 발생한다.
위 3가지 시나리오 모두 sandbox 코드 제출 로직 때문에 고려되는 엣지 케이스임을 알 수 있습니다.
이를 해결하기 위해 max-connection을 10,000으로 늘렸습니다. 10,000으로 늘린 후 부터는 Dial I/O Time-out
문제를 해결하였습니다.
하지만, 단순히 max-connection을 늘리면 아래와 같은 문제가 발생합니다.
커넥션 수가 많아질수록 스레드 부족, 병목, CPU 상승
max-connections
는 동시 TCP 연결 수입니다. 하지만 연결 수가 늘어도 요청 처리 스레드(max-threads
)는 제한되어 있습니다.
즉,
커넥션은 많지만 → 처리할 스레드가 없음 → 응답 지연 or 타임아웃 증가
연결은 계속 유지됨 (keep-alive 등) → 리소스(메모리, FD) 고갈
이젠, 응답지연 및 타임아웃 증가 문제와 메모리 관리가 중요해졌습니다.
Request Time-Out 시도
시도1: 서버 확장 (채택X)
단순히 스프링 서버 대수를 늘려서 가용 스레드를 늘리는 방안입니다.

장점
높은 처리량: 앞의 요청들이 빠르게 처리된다면, time-out이 발생하기전에 요청을 처리할 수 있고, 처리량도 높아집니다.
단점
요청에 비례하여 서버 대수를 계속 늘려야합니다. 비용과 Auto Scaling 설정 등 복잡도가 증가합니다.
서버 한대로 몇백 TPS를 견디기 힘든 상황인 만큼, 많은 서버가 필요하게 됩니다.
지금 당장은 문제를 단순하고 빠르게 해결할 수 있지만, 확실한 해결책은 아니고, 기술부채가 늘어나기때문에 다른 방안을 고려하기로 했습니다.
시도2: 톰캣 Connection Time-out 조정 (채택X)
두번째 방안은 Connection Time-out을 조정하는 것이었습니다. 앞에 처리가 늦어지고, 응답을 '시간'내에 못받아서 문제가 발생하고 있다면, 단순하게 '시간'을 늘리자 생각해봤습니다.
장점
간단 설정: 설정 값에 대해 단순한 계산 방식과 최소한의 노력으로 효과를 볼 수 있습니다.
단점
최적화가 아닌, 당장의 문제를 회피하는 방법입니다.
부하 상황이 계속되는 상황에서는 좋은 해결책이 아닙니다.
당장은 해결할 수 있지만, 신뢰성있는 서버가 되지는 않을것 같아서 다른 방안을 고려하기로 했습니다.
시도3: 메시지 큐 도입 (채택O)
코드 실행(sandbox) 담당 로직을 비동기로 처리하고, polling / SSE로 결과를 확인하는 방법을 생각했습니다.

장점
API의 속도가 빨라지며, '빠르게 처리하는 것처럼' 보이기 떄문에, 더 많은 요청을 받을 수 있습니다.
요청이 Queue에 적재되어서 Time-out이 발생하지는 않습니다.
단점
MQ 처리를 위한 설정이 중요해집니다.
서버를 분리해야하는 작업이 필요합니다.
Edge case 핸들링이 복잡해집니다.
불안 요소가 많기는 하지만, 이전 방안들 보다는 얻을 수 있는 이점이 많다고 판단하였고, 3번 방안을 선택했습니다.
Case2. 동시 10,000 요청 재시도
모든 요청이 성공적으로 처리되었습니다. 또한 지표를 봤을때,
Java app

CPU: 최대 81%로, 위험한 수준입니다.
Mem: 최대 52.3%로 메모리는 널널한 수준입니다.
Heap: 78.2%로 위험한 수준입니다. 하지만 수초(약 20초) 내에 60% 아래로 회복됨이 확인됩니다.

RDS


CPU: 33%으로 널널한 편입니다.
FreeableMemory(103M)이라서 Ram 가용공간이 많은 편입니다.
Connection Pool: 최대 설정한 커넥션수를 모두 사용하고 있습니다.
K6

요청 처리 시간:
avg (평균 응답 시간): 약 35~37초
p90 (90% 이하 요청의 응답 시간): 약 38초
p95: 약 39초
p99: 약 40초 이상
전반적으로 모든 지표가 선형 상승 중
요청이 많아질수록 대기열(queue)이 쌓이며 점진적 지연 발생
전체 요청의 성능 저하가 발생 중 (일부 요청만 느린 것이 아님)
API
요청이 처리되는 최대 시간: 5분 52초


요청이 잘 처리가되는것을 확인했습니다. 다만, 요청 처리 속도가 효율적이지 않습니다.


MQ에 publish 속도와 consume 속도를 비교했었을때, publish 속도(340/s)가 월등히 빠른것을 확인했습니다. Consume되고 다시 publish되는 속도가 부족(20/s)하다는 결론이 나왔고, Consumer 스레드 갯수를 증가시켜봤습니다.
Case3. 컨슈머 결과 처리 서버 분리 + Consumer 스레드 증가
pi 서버에서는 Consumer 스레드 갯수를 증가시키면, 메모리 고갈이 발생할 수 있어서 소비하는 역할을 하는 서버를 따로 만들었습니다.
그 후에, Consumer 스레드의 갯수를 5배로 늘려줬습니다.
AWS에서는 CPU credit 한계가 존재하기 때문에, CPU Burst를 무제한으로 일시적으로 바꿔줬습니다 (T3.small기준 시간당 $0.05)
...
factory.setPrefetchCount(1);
factory.setConcurrentConsumers(100); // 100으로 늘려줬습니다.
factory.setMaxConcurrentConsumers(200); // 200으로 늘려줬습니다.
...


마지막 요청 처리 시간이 2분 50초로, 훨씬 빨라진것을 확인했습니다. (처리속도가 2배 증가)
그러면 스레드를 2배로 늘리면 더 빨라질까? 라는 의문이 들었습니다.
...
factory.setPrefetchCount(1);
factory.setConcurrentConsumers(250); // 250으로 늘려줬습니다.
factory.setMaxConcurrentConsumers(250); // 250으로 늘려줬습니다.
...




훨씬 빨라진것을 확인했습니다. 처리속도가 또 2배로 증가했습니다. (그 이상도 테스트를 해봤지만, 빨라지진 않았습니다.)
결과
변경된 점
Max-Connections: 8142 -> 10000으로 변경
RabbitMQ 도입
sandbox 서버 분리
Consumer Thread 갯수 증가 -> 250으로 증가
장점
사용자 경험 개선: 코드 실행을 외부 샌드박스에서 처리하도록 분리해, 응답 지연을 줄임
기존 코드 실행 로직을 위임 했기에 사용자 경험 측면과 코드 제출 API 성능 측면에서 향상됨
메시지를 처리하는 스레드가 늘어나서 처리량이 높아짐 (CPU 를 사용하지 않는 메서드라서 가능)
단점
아키텍처 복잡도 증가:
기존의 단일 API 흐름 → 샌드박스 + MQ + 폴링 API로 다단계 처리
장애 지점(샌드박스, MQ, 폴링 API) 늘어남
모니터링 지점 증가
Edge Case 복잡도 증가
CPU를 사용하지 않는 Time.sleep()을 사용했기 때문에, CPU 부하가 발생하지 않음.
사실상 "무한대"의 sandbox 서버가 있는 것.
시스템 동작 흐름
API 요청:
사용자가 소스코드를 담아 채점 API(
POST /api/submissions
)를 호출합니다.
메시지 발행(Publish):
Controller
는 요청 데이터를SubmissionRequest
DTO로 변환하여 RabbitMQ의submission.request.queue
로 메시지를 전송합니다.그리고 즉시 submissionId를 사용자에게 응답을 보냅니다.
메시지 소비(Consume):
SubmissionConsumer
가request
큐를 구독하고 있다가 메시지를 수신합니다.
비즈니스 로직 처리:
SubmissionConsumer
는 수신한 메시지를SubmissionProcessor
에게 전달하여 실제 코드 채점 로직을 수행하도록 위임합니다.
결과 반환:
SubmissionProcessor
는 채점을 완료하고, 성공/실패 여부가 담긴SubmissionResult
객체를SubmissionConsumer
에게 반환합니다.
결과 메시지 발행(Publish):
SubmissionConsumer
는 전달받은SubmissionResult
를 RabbitMQ의submission.result.queue
로 전송합니다.
최종 결과 통보: 별도의 결과 처리 리스너가
result
큐의 메시지를 받아,Polling
을 통해 사용자에게 실시간으로 채점 결과를 알려줍니다.'
단일 호출 테스트
Submit API
ProblemController.submitCode(..) args=[1, SubmissionRequest@4286f192]
|-->ProblemService.submitProblem(..) args=[1, SubmissionRequest@4286f192]
|-->CrudRepository.findById(..) args=[1]
|<--CrudRepository.findById(..) args=[1] time=7ms
|-->CrudRepository.findById(..) args=[2]
|<--CrudRepository.findById(..) args=[2] time=2ms
|-->CrudRepository.save(..) args=[Submission@20f83daa]
|<--CrudRepository.save(..) args=[Submission@20f83daa] time=28ms
ProblemController.submitCode(..) args=[1, SubmissionRequest@4286f192] time=83ms
// 83ms 처리속도.
// 코드 실행 역할을 Thread.sleep에서 샌드박스로 위임하였기에 준수한 성능을 보임
Polling API
SubmissionController.getSubmissionStatus(..) args=[122252]
|-->SubmissionService.getSubmissionStatus(..) args=[122252]
|-->CrudRepository.findById(..) args=[122252]
|<--CrudRepository.findById(..) args=[122252] time=39ms
|<--SubmissionService.getSubmissionStatus(..) args=[122252] time=40ms
SubmissionController.getSubmissionStatus(..) args=[122252] time=53ms
// 53ms 처리속도
10000번 호출 테스트
10000번째 요청(마지막): 1분 7초. (52분 39초 - 53분 48초)
Last updated