2GB 서버를 지키는 가장 싼 방법 Rate Limiter 도입기

저희 서비스에 요청률 제한을 넣으며 어디에, 어떤 알고리즘으로, 초과 시 어떻게 결정한 기록입니다. 그리고 실제로 부하를 걸어 검증한 글입니다.

 

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

각 위치에서의 트레이드 오프를 생각해본다면 다음과 같았습니다.

  1. NGINX에서 거절 → 가장 쌉니다. NGINX에 하게 된다면 초과 요청에 429만 돌려주면 끝이라, JVM 스레드도 DB 커넥션도 건드리지 않습니다.
  2. Spring까지 도달 → 이미 톰캣 스레드 하나를 점유합니다. 스레드 풀은 유한한데 요청 파싱, 필터 체인, JWT 인증을 다 거친 뒤라 거절해도 그 비용은 이미 지불한 상태입니다.
  3. PostgreSQL까지 도달 → 가장 비쌉니다. DB 커넥션 풀의 한 자리를 잡게 됩니다

즉 요청이 깊이 들어갈 수록 더 자원을 소모합니다 그래서 NINGX에서 진행하기로 하였습니다.

 

또한, 어떤 기준으로 셀 것인지도 고민해보았습니다.

  1. IP 기준 → NGINX에서 바로 적용하기 쉽다는 장점이 있지만 하나의 공유기나 회사 네트워크처럼 여러 같은 IP를 사용하는 경우 정상 사용자들이 함께 제한될 수 있었습니다. 또한, 공격자가 여러 IP를 바꿔가며 요청하면 IP 기준 제한을 우회할 수도 있습니다.
  2. 사용자 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