초당 100건은 서버 용량이 아니다: 결제 API 요청 제한을 다시 측정한 이유
목차
이 글의 질문은 “전역 요청 제한을 초당 100건으로 두면 서버 용량도 100건/초인가?”입니다. 답은 아닙니다. 100건/초는 요청을 안쪽으로 들여보낼지 결정하는 정적 진입 제한이고, 서버가 실제로 지속 처리할 수 있는 양은 별도 측정 대상입니다.
요청 제한(rate limiting)은 클라이언트나 데이터베이스에 둔 장치가 아닙니다. SecurityConfig에서 Bearer 인증 필터 뒤, 주문·결제 컨트롤러 앞에 등록한 Redis 요청 제한 필터입니다. 인증이 끝난 뒤 userId를 사용자별 키로 사용할 수 있고, 제한에 걸린 요청은 컨트롤러와 DB까지 내려가지 않고 429 Too Many Requests로 끝납니다.
클라이언트 ↓Bearer 인증 필터 ↓Redis 요청 제한 필터 ├─ 사용자별: 5건/초 └─ 전체 사용자: 100건/초 ↓주문·결제 컨트롤러 ↓MySQL 커넥션 풀이 필터를 켜고 스파이크를 줬더니 97.5%가 429로 거절됐고, 통과한 요청의 p95는 737.81ms에서 52.01ms로 줄었습니다. 그러나 이 숫자는 서버 용량을 보여주지 않았습니다. 첫 실험은 사용자 한 명의 제한만 만났고, 두 번째 실험은 서버가 느려질수록 부하도 줄어드는 닫힌 루프였습니다.
그래서 요청률 제한과 서버 용량을 분리해 다시 측정했습니다. 도착률을 고정한 개방 루프에서야 성능이 꺾이기 시작하는 지점을 확인할 수 있었습니다.
결론부터
- 사용자별·전역 요청 제한은 서버 용량을 측정하는 장치가 아니라, 요청을 애플리케이션 안으로 들여보낼지 결정하는 장치입니다.
- VU가 응답을 기다리는 닫힌 루프에서는 약 120건/초를 서버 용량으로 해석할 수 없습니다. 서버가 느려진 만큼 부하도 줄기 때문입니다.
- 도착률을 고정한 개방 루프에서는 100건/초까지 성공률 100.0%, 120건/초에서 92.4%가 나왔습니다. 이 환경에서 성능이 꺾이기 시작한 구간은 100~120건/초였습니다.
따라서 전역 100건/초는 이번 환경에서 사용할 진입 상한으로 유지했지만, 서버의 보편적인 처리 용량이라고 부르지 않았습니다. 운영 장비에서는 같은 방식으로 다시 측정해야 합니다.
요청 제한은 어디에서 동작하는가
결제 API에는 세 겹의 제어가 있습니다. 이번 글에서 실제로 측정한 것은 첫 번째 층인 Redis 요청 제한 필터입니다.
- Redis 기반 요청 수 제한: 사용자별 초당 5건, 전체 초당 100건
- 한정판 상품의 선착순 대기열
- 오래 기다리지 않고 실패하게 설정한 DB 커넥션 풀
사용자별 제한은 한 사용자에게 적용되는 상한이고, 전역 제한은 모든 사용자의 요청을 합산한 상한입니다. 두 제한은 같은 필터 안에서 순서대로 확인합니다. 사용자별 제한에서 거절된 요청은 전역 카운터를 소비하지 않으며, 어느 층에서 거절됐는지는 X-RateLimit-Scope: user|ip|global 헤더로 구분합니다.
한정판 상품에는 별도의 대기열 게이트가 있지만 이 글의 측정 대상이 아닙니다. DB 커넥션 풀은 요청 제한을 통과한 뒤의 마지막 방어선입니다. 따라서 요청 제한이 효과가 있다는 말은 “DB까지 내려가는 요청을 줄였다”는 뜻이지, 대기열이나 DB 풀의 용량까지 측정했다는 뜻은 아닙니다.
요청률 제한과 과부하 보호는 다른 제어다
사용자별·전역 제한은 일정 시간에 몇 건을 시작할지 제한합니다. 반면 동시 요청 제한은 현재 몇 건이 처리 중인지 제한하고, load shedding은 CPU·메모리·스레드·큐처럼 현재 자원 상태를 보고 요청을 거절합니다.
이 구분이 필요한 이유는 요청마다 비용이 다르기 때문입니다. 초당 100건이어도 가벼운 조회 100건과 결제·아웃박스 작업 100건은 같은 부하가 아닙니다. 전역 100건/초는 이번 결제 경로의 진입 상한일 뿐, 모든 요청 비용을 대표하는 자원 용량은 아닙니다.
따라서 이 글의 실험 질문은 “제어를 켜면 p95가 좋아지는가”가 아닙니다.
- 사용자별 제한을 통과한 요청이 전역 제한에 실제로 도달하는가?
- 전역 제한 100건/초가 실제 처리 용량의 어느 위치에 있는가?
처음 비교하려 한 것은 “요청 제한을 켰을 때 p95와 처리량이 어떻게 달라지는가”였습니다. 하지만 서버가 느려지면 부하 도구가 다음 요청을 늦게 보내는 조건에서 이 비교는 공정하지 않습니다.
따라서 부하 도구가 요청을 만든 속도, 요청이 애플리케이션에 도착한 결과, 서버가 성공적으로 처리한 결과를 분리해야 합니다. 이 셋을 섞으면 요청 제한이 서버를 보호한 것인지, 부하 도구가 스스로 느려진 것인지 알 수 없습니다.
모든 수치는 앱, MySQL, Redis, 부하 도구가 한 대의 맥에서 실행된 결과입니다. 따라서 아래에서 확인한 성능이 꺾이는 지점은 운영 장비의 용량이 아닙니다.
1. 전역 제한에 도달하지 못한 실험
측정 조건
제어 전후에 0에서 150 VU로 10초 만에 올라가는 스파이크를 줬습니다. VU는 부하 도구가 만든 가상 사용자입니다.
| 지표 | 제어 없음 | 제어 있음 |
|---|---|---|
| 유입 | 초당 304건, 전량 DB까지 처리 | 초당 398건 중 97.5%를 429로 거절 |
| DB에 닿은 요청 | 15,445건 | 약 510건 |
| 통과한 요청 p95 | 737.81ms | 52.01ms |
| 5xx | 0% | 0.55% |
표만 보면 요청 제한이 DB로 들어가는 양을 줄여 지연을 낮춘 것처럼 보입니다. 그런데 모든 요청을 한 계정으로 보냈습니다. 사용자별 제한은 초당 5건이고 경로는 주문 생성과 결제 승인 두 개였습니다. 따라서 이 계정이 통과시킬 수 있는 양은 초당 10건이 최대입니다.
실제 DB 도달량도 초당 10.2건이었습니다. 5건/초 × 경로 2개와 거의 같습니다. 전역 상한 초당 100건은 한 번도 시험하지 않았습니다.
따라서 첫 실험에서 확정할 수 있는 것은 두 가지뿐입니다.
- 사용자별 제한은 이 스파이크에서 DB로 들어가는 요청을 줄였습니다.
- 전역 제한이 처리 용량을 제한하는지는 아직 모릅니다.
97.5%라는 숫자는 정확했지만, 전역 제한이나 서버 용량의 근거는 아니었습니다. 비교 대상이 제한 없음과 전역 제한이 아니라, 제한 없음과 한 사람당 초당 5건이었기 때문입니다.
측정 중 이벤트를 저장하는 아웃박스 테이블 INSERT에서 MySQL 데드락(1213)이 113건 발생했습니다. 데드락은 롤백이 확정된 실패라 지터를 둔 최대 3회 재시 대상으로 분리했습니다. 반면 타임아웃은 요청이 처리됐는지 알 수 없으므로 같은 방식으로 재시도하지 않고 조회와 멱등성 검증이 필요합니다. 이 문제는 용량 측정의 핵심 결론과는 별도의 실패 모드입니다.
2. 전역 제한은 만났지만 용량은 측정하지 못한 실험
전역 제한을 시험할 조건 만들기
사용자별 제한을 받는 계정이 하나뿐이면 전역 상한에 도달할 수 없습니다. 전역 상한에 도달하려면 다음 조건이 필요합니다.
계정 수 × 5건/초 > 100건/초
따라서 계정이 21개 이상 필요했고, 40계정으로 다시 측정했습니다. 어느 제한에서 429가 발생했는지 구분하기 위해 X-RateLimit-Scope: user|ip|global 헤더도 추가했습니다.
어느 층에서 요청을 거절했는지 모르면 사용자별 제한과 전역 포화를 같은 숫자로 세게 됩니다. 하지만 두 경우의 대응은 다릅니다. 사용자별 제한은 해당 클라이언트가 요청을 줄이면 풀립니다. 전역 제한은 시스템이 포화됐다는 신호이므로 클라이언트 하나가 물러나는 것만으로는 해결되지 않습니다.
측정 결과
| 부하 | 제어 | 발생 요청 | 앱 도달(2xx) | 429 거절 | 통과 p95 | 5xx |
|---|---|---|---|---|---|---|
| 200 VU | 끔 | 120.4/s | 120.4/s | 0 | 1.46s | 0% |
| 200 VU | 켬 | 140.6/s | 109.9/s | 30.7/s | 1.68s | 0% |
| 600 VU | 끔 | 123.2/s | 123.2/s | 0 | 3.77s | 0% |
| 600 VU | 켬 | 301.1/s | 115.1/s | 185.9/s | 3.11s | 0% |
전역 층은 실제로 개입했습니다. 200 VU에서 1,355건, 600 VU에서 3,601건을 전역 상한으로 거절했습니다. 결과는 다음처럼 보였습니다.
- 네 조건 모두 5xx가 0%였습니다. 부하가 세 배가 되어도 서버는 실패하지 않고 느려졌습니다.
- 상대적으로 부하가 낮은 200 VU에서는 제어를 켜자 처리량이 8.7% 줄고 p95가 15.1% 늘었습니다.
- 부하를 크게 높인 600 VU에서는 처리량 6.6%를 내주고 p95를 17.5% 줄였습니다. 거절은 필터에서 1ms 안에 끝나 DB와 커넥션을 쓰지 않았습니다.
이 수치만 보면 이번 환경의 처리량이 초당 약 120건이고 전역 상한 100이 낮아 보입니다. 그러나 이 결론은 아직 확정할 수 없었습니다. 2회차의 부하는 VU가 응답을 받은 뒤 다음 요청을 보내는 닫힌 루프였기 때문입니다.
측정 중 이전 JVM이 남아 새 설정이 반영되지 않는 문제가 있었고, DB 초기화 여부에 따라서도 같은 조건의 처리량이 초당 75건에서 93건까지 달라졌습니다. 이후 실행 전 프로세스와 DB 상태를 초기화했습니다. 측정 조건을 통제하지 않으면 숫자가 있어도 비교 결과를 보장할 수 없습니다.
3. 도착률을 고정해 실제 판단 기준을 만든 실험
닫힌 루프가 숨긴 것
2회차의 VU 방식은 닫힌 루프(closed loop)입니다.
요청 전송 → 응답 대기 → 다음 요청 전송서버가 느려지면 각 VU가 다음 요청을 늦게 보내므로 전체 도착률도 줄어듭니다. 그래서 2회차의 초당 약 120건은 “서버가 처리할 수 있는 최대치”가 아니라 “그 VU 수가 서버에 밀어 넣을 수 있었던 양”이었습니다.
용량을 확인하려면 서버 응답과 관계없이 도착률을 고정해야 합니다. 개방 루프(open loop)는 다음처럼 동작합니다.
정해진 도착률로 요청 전송 → 서버 응답과 무관하게 다음 요청 전송| 2회차 | 3회차 | |
|---|---|---|
| 부하 방식 | VU 수를 올림(닫힌 루프) | 도착률을 고정(개방 루프) |
| 서버가 느려지면 | 부하도 같이 줄어든다 | 부하는 그대로이고 대기열이 자란다 |
도착률과 성공률을 함께 측정했다
이번에는 제어를 끄고 도착률을 계단식으로 올렸습니다.
| 도착률 | 성공률 |
|---|---|
| 40~80/s | 100.0% |
| 100/s | 100.0% |
| 120/s | 92.4% |
| 140/s 이상 | 55% → 28% |
성능이 꺾이기 시작한 지점은 초당 100건과 120건 사이였습니다. 전역 상한 100은 이번 환경에서 관측된 지점보다 훨씬 낮은 값이 아니라, 성능이 꺾이기 시작하는 구간 근처였습니다.
전역 상한 100을 유지한다는 판단은 같지만 근거가 달라졌습니다. 처음에는 로컬 환경의 측정이라 운영 기본값으로 일반화할 수 없다는 것만 확인했습니다. 개방 루프로 다시 측정한 뒤에는 이 환경에서 성능이 꺾이기 시작하는 구간에 가까운 값이라는 근거를 추가했습니다.
p95만 보면 실패를 놓친다
처음에는 지연이 급증하는 지점을 무릎으로 판정하려고 했습니다. 그런데 초당 120건에서 p95가 2.2초였다가 140건에서 287ms로 내려갔습니다. 부하를 더 줬는데 지연이 좋아진 것처럼 보였습니다.
이유는 성공률이 무너졌기 때문입니다. 느린 요청이 실패로 빠져나가면, p95에는 남은 성공 요청만 반영됩니다. 실패를 제외한 지연만 보면 시스템이 회복된 것으로 잘못 읽을 수 있습니다.
그래서 성능이 꺾이는 지점의 1차 기준을 “성공률이 온전한 마지막 단계”로 바꾸고, p95는 보조 지표로 내렸습니다.
성공률이 28%까지 떨어졌지만 DB 실패와 애플리케이션 예외는 0건이었습니다. 실패는 5xx가 아니라 status 0, 즉 k6가 HTTP 응답을 받지 못한 연결 단계 실패였습니다. 이 값만으로 요청이 실제 네트워크상 서버에 도착하지 않았다고 단정할 수는 없습니다. 이번 측정에서 확정할 수 있는 것은 애플리케이션 예외나 DB 실패가 아니라 HTTP 응답 부재로 실패했다는 사실입니다.
이 둘을 합쳐 세면 “서버가 깨졌다”고 판단하게 됩니다. 실제 상태는 다릅니다.
- 5xx: 요청이 애플리케이션까지 도달했고 내부 처리에서 실패함
status 0: HTTP 응답을 받지 못한 연결 단계 실패
따라서 5xx와 status 0을 같은 실패로 합치면 안 됩니다. 전자는 애플리케이션이나 DB 오류를 조사하고, 후자는 연결·스레드·큐 지표를 추가로 확인해야 합니다.
이 실험으로 남은 운영 기준
초기 구현에는 “상한은 배포 환경마다 다시 재야 한다”는 문장만 설정 주석으로 남아 있었습니다. 다시 실행할 수 있는 절차가 아니었고, 429를 지표로 내보내지도 않아 운영에서 한도를 바꿀 근거도 부족했습니다.
그래서 다음 세 가지를 코드와 지표로 남겼습니다.
- 어느 제한에서 거절됐는지 확인할 수 있도록 층과 경로별 거절 수(
ratelimit.rejected{scope, path})를 기록합니다. 사용자별 거절과 전역 거절은 대응이 다르기 때문입니다. - 전역 거절을 운영 신호로 사용합니다. 사용자별 거절이 많다는 이유만으로 전역 상한을 올리지 않습니다.
- 개방 루프 스크립트(
k6/capacity-knee.js)를 남겼습니다. 제어를 켠 채 측정하면 서버의 성능이 꺾이는 지점이 아니라 스스로 설정한 제한을 측정하게 되므로, 용량 측정은 제어를 끈 조건에서 시작합니다.
이 글의 결론이 깨지는 조건
이번 결론은 다음 범위 안에서만 유효합니다.
- 성능이 꺾이기 시작한 초당 100~120건은 앱, MySQL, Redis, 부하 도구가 한 대의 맥에서 실행된 결과입니다. 운영 장비에서는 같은 개방 루프 스크립트로 다시 측정해야 합니다.
- 1회차의 통과 p95 52.01ms는 사용자별 한도 하나가 만든 결과입니다. 유입 제어 전체의 효과로 인용하지 않습니다.
- 선착순 대기열은 세 겹의 제어 중 두 번째 층입니다. 이번 글에서는 그 효과를 측정하지 않았습니다.
- 데드락 1213은 확정된 실패라 재시도할 수 있었지만, 타임아웃처럼 결과를 모르는 상태는 별도 조회나 멱등성 검증이 필요합니다.
요청 제한은 서버 용량을 대신 측정해 주는 기능이 아닙니다. 사용자별 요청률, 전역 요청률, 현재 처리 중인 요청 수, 실제 자원 사용량은 서로 다른 제어 신호입니다. 이번 경로에서는 전역 100건/초를 진입 상한으로 유지할 근거를 확보했지만, 이 값이 서버의 영구적인 처리 용량이라고 결론 내리지는 않았습니다.
이 구분이 이 글의 결론입니다. 요청 제한을 설정하는 일과 서버가 어디에서 무너지는지 측정하는 일은 같은 일이 아닙니다.
참고
댓글
댓글 수정/삭제는 GitHub Discussions에서 가능합니다.