저희 서비스에 요청률 제한을 넣으며 어디에, 어떤 알고리즘으로, 초과 시 어떻게 결정한 기록입니다. 그리고 실제로 부하를 걸어 검증한 글입니다.
1. 왜 넣게 되었는가
통합 검색을 개선하는 과정에서 조회를 최적화해도 동시 요청이 많아지면 결국 응답이 느려진다는 걸 체감했습니다.
(관련 글: https://wo-dbs.tistory.com/419)
통합 검색에서는 다음과 같이 SLO를 세웠었습니다.
DAU 1,000 규모(피크 동시 약 50명)에서, 통합검색을 p95 700ms 이내로 SLI 95% 이상 유지
개선 한 후 동시 접속자를 30 → 110명을 5분간 램프업하여 부하 테스트를 했을 때 요청은 전부 성공했지만 동시 사용자가 늘수록 응답이 점점 느려졌습니다.

| 동시 VU | SLI | 상태 |
| 30 | ~100% | 여유 |
| 50 | ~99.5% | 양호 |
| 70 | ~97% | 저하 시작 |
| 90 | ~86% | 저하 |
| 110 | ~57% | 붕괴 |
SLO 목표는 SLI 95%이상이었습니다. 피크로 예상하는 동시 50명까진 99.5%로 여유롭게 만족하고 70명까지도 97%로 버팁니다. 하지만 90명에서 86%로 SLO선을 깨고 110명에선 57%까지 추락하였습니다.
병목은 통합 검색의 LIKE 쿼리였습니다. 인덱스를 타지 못하는 LIKE '%keyword%'가 캐시에 올라온 행을 CPU로 훑으며 문자열을 하나씩 비교하다 보니 동시 요청이 늘수록 CPU 부하가 그만큼 커졌습니다. (자세한 분석은 위 글에 있습니다.)
병목은 통합 검색의 LIKE 쿼리였습니다. 인덱스를 타지 못하는 LIKE '%keyword%'가 캐시에 올라온 행을 CPU로 훑으며 문자열을 하나씩 비교하다 보니, 동시 요청이 늘수록 CPU 부하가 그만큼 커졌습니다. (자세한 분석은 위 글에 있습니다.)
실제로 이 부하 구간에서 CPU는 100%까지 완전히 포화됐습니다.

결론은 초과 트래픽은 하나의 API만 느려지는 것이 아니라 전체를 끌어내리게 됩니다. CPU를 나눠 쓰는 만큼 정상 범위의 사용자까지 같이 느려지고, 스파이크 한 번이 모두의 SLO를 깨게 됩니다.
그래서 Rate Limiter를 도입해, 감당 가능한 범위를 넘는 트래픽을 앞단에서 걸러 SLI를 지키기로 했습니다.
2. 어디에 넣을까
Rate Limiter를 넣을 수 있는 위치는 요청이 지나는 경로 마다 있습니다.
핵심 원칙은 요청은 뒤로 갈 수록 비싸다는 것입니다.
같은 요청 하나라도 어디까지 도달했다가 거절되느냐에 따라 이미 소모한 자원이 완전히 다릅니다.
현재 서버는 다음과 같이 구성되었습니다.
- 클라이언트 → NGINX → Spring → PostgreSQL
각 위치에서의 트레이드 오프를 생각해본다면 다음과 같았습니다.
- NGINX에서 거절 → 가장 쌉니다. NGINX에 하게 된다면 초과 요청에 429만 돌려주면 끝이라, JVM 스레드도 DB 커넥션도 건드리지 않습니다.
- Spring까지 도달 → 이미 톰캣 스레드 하나를 점유합니다. 스레드 풀은 유한한데 요청 파싱, 필터 체인, JWT 인증을 다 거친 뒤라 거절해도 그 비용은 이미 지불한 상태입니다.
- PostgreSQL까지 도달 → 가장 비쌉니다. DB 커넥션 풀의 한 자리를 잡게 됩니다
즉 요청이 깊이 들어갈 수록 더 자원을 소모합니다 그래서 NINGX에서 진행하기로 하였습니다.
또한, 어떤 기준으로 셀 것인지도 고민해보았습니다.
- IP 기준 → NGINX에서 바로 적용하기 쉽다는 장점이 있지만 하나의 공유기나 회사 네트워크처럼 여러 같은 IP를 사용하는 경우 정상 사용자들이 함께 제한될 수 있었습니다. 또한, 공격자가 여러 IP를 바꿔가며 요청하면 IP 기준 제한을 우회할 수도 있습니다.
- 사용자 ID 기준 → 정확하게 제한할 수 있다는 장점이 있습니다. 하지만 사용자 ID를 알기 위해서 Spring까지 들어와야합니다.
현재 목적은 서버 자원을 최대한 앞단에서 보호하는 것이라고 생각했습니다. 그래서 우선 NGINX에서 처리할 수 있는 IP 기준을 적용하기로 하였습니다.
3. 어떤 알고리즘으로 제한할 것인가?
알고리즘 후보를 놓고 비교했습니다.
| 알고리즘 | 특징 | 판단 |
| Fixed Window | 구현 쉬움, 경계에서 최대 2배 통과 | 허점 때문에 제외 |
| Sliding Log | 가장 정확, 메모리 큼 | 저사양엔 과함 |
| Sliding Counter | 정확·가벼움 절충 | 좋지만 NGINX 기본 아님 |
| Leaky Bucket | 출력 평탄화, NGINX limit_req 기본 | 채택 |
| Token Bucket | 버스트 허용 | nodelay로 유사 효과 |
NGINX limit_req는 내부적으로 Leaky Bucket입니다. 여기에 burst와 nodelay를 얹으면 정상적인 순간 버스트는 통과시키고 지속적인 남용만 막는 형태가 됩니다.
이 부분이 중요한 이유는 실제 앱이 켜지는 순간 프로필, 목록 등 API 여러 개를 동시에 호출합니다 이걸 초당 1개로 딱 잘라보리면 정상 사용자가 막히게 됩니다. nodelay로 버스트 한도 내 요청을 즉시 통과시키되 그 한도를 넘겨 계속 때리는 트래픽만 차단하도록 생각했습니다.
# http 블록: IP당 존 정의 (10m ≈ 16만 IP 추적)
limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;
server {
# 일반 API
location /api/ {
limit_req zone=api burst=20 nodelay;
limit_req_status 429;
proxy_pass http://spring_upstream;
}
# 헬스체크 및 메트릭은 제한에서 제외
location /actuator/ {
proxy_pass http://spring_upstream;
}
}
- rate=10r/s - 장기 평균 허용 속도
- burst=20 - 순간 몰림 20개까지 큐 허용
- nodelay - 버스트분을 지연 없이 즉시 통과 (Token Bucket처럼)
- limit_req_status 429 - 초과 시 기본 503 대신 429 반환
4. 초과하면 어떻게?
초과할 경우 HTTP 429 Too Many Requests 반환하도록 하였습니다. 503의 반환을 생각하였지만 서버 장애가 일어났다기 보다 요청이 그저 많다고 보여주는 게 더 좋다고 생각했습니다
또한, Retry-After 헤더로 언제 재시도할 수 있는지 알려주는 방면을 생각해보았고 health 및 정적 리소스는 제외시켰습니다.
429 카운트 메트릭으로 수집해서 얼마나 어느 엔드포인트에서 걸리는지 모니터링도 구축하였습니다. 그 후 임계값(rate/burst)을 튜닝을 할 수 있다고 생각하였습니다.
5. 테스트 - 실제로 부하를 걸어 검증
1장의 부하 테스트에서 CPU가 100%까지 포화됐던 것을, 이번엔 NGINX rate limit(IP당 10r/s, burst 20)을 건 상태로 같은 스크립트를 그대로 돌려 비교했습니다. k6는 한 대(= IP 하나)에서 쏘므로, 이 테스트는 곧 단일 클라이언트가 폭주하는 시나리오입니다.
| 항목 | 제한 전 | 제한 후 |
| 시스템 CPU (2 vCPU) | 100% 포화 | ~10–25% (idle 50–90%) |
| 통과(200) 요청 p95 | 962 ms (SLO 초과) | 137 ms (SLO 안 ✓) |
| 통과(200) 요청 p95 | 962 ms (SLO 초과 ✗) | 137 ms (SLO 안 ✓) |
| 429 (차단) | 0 | 99.6% |
| 메모리 | 574 MiB | 573 MiB (동일) |

폭주한 요청의 99.6%를 NGINX가 429로 컷하자 인덱스 없는 LIKE 스캔이 애초에 DB까지 도달하지 못했습니다. 그 결과 CPU가 포화에서 풀리고(idle 0% → 50~90%), 제한을 통과한 요청은 p95 137ms로 SLO(700ms) 한참 안쪽에서 응답했습니다.
즉 1차의 "전부 받아서 다 같이 962ms로 느려지기"가, 2차에서는 "초과는 429로 버리고, 통과분은 137ms로 빠르게" 로 바뀌었습니다. Rate Limiter가 SLO를 지켜내었습니다.
'프로젝트 > toIT' 카테고리의 다른 글
| Rate Limiter (0) | 2026.07.04 |
|---|---|
| Docker 및 Docker Compose 도입 - 7/1 (0) | 2026.07.02 |
| 통합검색 성능 개선기 (0) | 2026.07.02 |
| Spring의 요청 처리 순서 (0) | 2026.07.02 |
| OSIV (0) | 2026.07.02 |