Nginx API Gateway 개념 시리즈

1. 마이크로서비스와 API Gateway

2. Nginx 리버스 프록시의 동작 원리

3. Rate Limiting과 트래픽 제어 (현재 문서)

4. Docker로 마이크로서비스 배포하기

API를 외부에 노출하면 누군가는 반드시 과도한 요청을 보낸다. 의도적인 공격일 수도 있고, 클라이언트의 버그일 수도 있다. Rate Limiting은 이런 상황에서 서비스를 보호하는 첫 번째 방어선이다. Nginx가 이를 어떻게 처리하는지, 그 메커니즘을 이해한다.

Rate Limiting이 필요한 이유

Rate Limiting은 특정 시간 동안 허용할 요청 수를 제한하는 기술이다. "분당 20회까지만 허용하겠다"처럼 상한선을 정하고, 초과하면 거부한다.

왜 이런 제한이 필요한가?

  • 서비스 보호 : 한 클라이언트가 초당 수천 건의 요청을 보내면 서버 리소스가 고갈된다. 다른 정상 사용자까지 영향을 받는다.
  • DDoS 완화 : 대량의 요청으로 서비스를 마비시키려는 공격을 1차적으로 걸러낸다.
  • 비용 제어 : 클라우드 환경에서는 요청 수가 곧 비용이다. 과도한 요청을 차단하면 불필요한 비용을 줄일 수 있다.
  • 공정한 사용 : 한 사용자가 리소스를 독점하지 못하게 해서, 모든 사용자에게 일정한 품질을 보장한다.

Rate Limiting이 없는 상태에서 악의적인 요청이 들어오면 어떻게 되는지 보면 필요성이 체감된다.

sequenceDiagram autonumber participant A as 악의적 클라이언트 participant GW as API Gateway participant S as 백엔드 서비스 rect rgb(255, 230, 230) Note over A, S: Rate Limiting 없음 — 모든 요청이 백엔드까지 도달 loop 초당 1000회 요청 A->>GW: GET /api/users GW->>S: GET /api/users S-->>GW: 200 OK GW-->>A: 200 OK end Note over S: CPU 100%, 메모리 고갈
정상 사용자도 응답 불가 end

분홍 영역 전체가 문제 상황이다. 모든 요청이 여과 없이 백엔드까지 전달되면서 서비스가 마비된다.

Rate Limiting을 적용하면 Gateway 단에서 초과 요청을 차단한다.

sequenceDiagram autonumber participant A as 악의적 클라이언트 participant GW as API Gateway participant S as 백엔드 서비스 rect rgb(232, 248, 232) Note over A, GW: Rate Limiting 적용 — 허용 범위 내 요청만 통과 A->>GW: GET /api/users (1번째) GW->>S: GET /api/users S-->>GW: 200 OK GW-->>A: 200 OK end rect rgb(255, 230, 230) Note over A, GW: 초과 요청은 Gateway에서 즉시 거부 A->>GW: GET /api/users (21번째) GW--xA: 429 Too Many Requests Note over GW: 백엔드까지 도달하지 않음 end

초록 영역에서는 허용 범위 내 요청이 정상 처리된다. 분홍 영역에서는 초과 요청이 Gateway 단에서 즉시 429 응답으로 거부되고, 백엔드 서비스까지 도달하지 않는다.

Nginx의 Leaky Bucket 알고리즘

Nginx의 Rate Limiting은 Leaky Bucket 알고리즘을 기반으로 한다. 이름 그대로 "새는 양동이"다.

양동이에 물을 부으면 바닥의 구멍으로 일정한 속도로 물이 빠져나간다. 물을 붓는 속도가 빠져나가는 속도보다 빠르면 양동이에 물이 차오르고, 넘치면 버린다. 여기서 물은 요청, 빠져나가는 속도는 처리 속도, 양동이의 크기는 버스트 허용량이다.

이 알고리즘의 핵심은 요청 처리 속도를 일정하게 유지한다는 것이다. 순간적으로 많은 요청이 몰려도, 처리는 설정한 속도대로 균일하게 이루어진다.

flowchart TD REQ(["요청 도착"]) REQ --> CHECK{"양동이에
빈자리?"} CHECK -->|"있음"| ADD["양동이에 추가"] ADD --> DRAIN["일정 속도로 처리
(rate에 따라)"] DRAIN --> DONE(["처리 완료"]) CHECK -->|"꽉 참"| REJECT["429 거부"] style REQ fill:#E8F4F8,stroke:#2196F3,stroke-width:2px,color:#000 style CHECK fill:#FFF3E0,stroke:#FF9800,stroke-width:2px,color:#000 style ADD fill:#E8F8E8,stroke:#4CAF50,stroke-width:2px,color:#000 style DRAIN fill:#E8F8E8,stroke:#4CAF50,stroke-width:2px,color:#000 style DONE fill:#E8F8E8,stroke:#4CAF50,stroke-width:2px,color:#000 style REJECT fill:#FFE6E6,stroke:#F44336,stroke-width:2px,color:#000

limit_req_zone — 제한 규칙 정의

Nginx에서 Rate Limiting을 설정하는 첫 번째 단계는 limit_req_zone으로 규칙을 정의하는 것이다.

limit_req_zone $binary_remote_addr zone=users_limit:10m rate=20r/m;

이 한 줄에 세 가지 설정이 담겨 있다.

  • $binary_remote_addr : 어떤 기준으로 요청을 구분할 것인가. 이 변수는 클라이언트의 IP 주소를 바이너리 형태로 저장한다. 같은 IP에서 오는 요청을 하나의 그룹으로 묶어 제한한다.
  • zone=users_limit:10m : 공유 메모리 영역의 이름과 크기. users_limit이라는 이름의 10MB 영역을 할당한다. 이 메모리에 각 클라이언트의 요청 카운터가 저장된다.
    • 10MB는 약 16만 개의 IP 주소를 추적할 수 있다
  • rate=20r/m : 허용하는 요청 빈도. 분당 20회, 즉 3초에 1회 요청을 허용한다.

실습 프로젝트에서는 서비스별로 다른 Rate Limit을 적용한다.

limit_req_zone $binary_remote_addr zone=users_limit:10m rate=20r/m;
limit_req_zone $binary_remote_addr zone=orders_limit:10m rate=15r/m;
limit_req_zone $binary_remote_addr zone=payments_limit:10m rate=10r/m;

결제 서비스는 분당 10회로 가장 엄격하고, 사용자 서비스는 분당 20회로 상대적으로 여유롭다. 서비스의 민감도와 리소스 비용에 따라 차등 제한을 거는 것이 실무에서 일반적인 패턴이다.

rate=20r/m의 실제 의미

rate=20r/m은 "분당 20회"이지만, Nginx 내부에서는 이것을 3초에 1회로 변환해서 처리한다. 60초 ÷ 20 = 3초 간격이다. 따라서 3초 안에 2번째 요청이 오면 거부된다. "분당 20회를 어느 시점에 다 써도 된다"는 뜻이 아니라, "3초마다 1회씩 균일하게 허용한다"는 뜻이다. 이 차이를 모르면 테스트할 때 예상과 다른 결과를 보게 된다.

limit_req — 제한 적용

limit_req_zone은 규칙을 정의할 뿐, 실제로 적용하려면 location 블록 안에서 limit_req를 선언해야 한다.

location /api/users {
    limit_req zone=users_limit burst=5 nodelay;

    proxy_pass http://user-service;
    # ...
}

여기서 burstnodelay가 핵심이다.

burst — 순간 트래픽 허용

burst=5는 양동이의 크기를 5로 설정한다는 뜻이다. rate를 초과하는 요청이 와도, 5개까지는 대기열에 넣고 순서대로 처리한다.

burst가 없으면 3초에 1번 이상의 요청은 모두 즉시 거부된다. 실제 서비스에서는 사용자가 페이지를 열 때 여러 API를 동시에 호출하는 경우가 많기 때문에, burst 없이 운영하면 정상적인 사용자도 429를 보게 된다.

nodelay — 대기 없이 즉시 처리

burst=5만 쓰면 대기열의 요청이 rate 속도에 맞춰 지연 처리된다. 3초 간격으로 하나씩 꺼내서 처리한다는 뜻이다. 응답이 3초, 6초, 9초 뒤에 순차적으로 돌아온다.

nodelay를 추가하면 burst 범위 내의 요청을 지연 없이 즉시 처리한다. 5개의 요청이 동시에 왔으면 5개 모두 바로 처리한다. 단, 그 이후에는 rate에 따라 양동이가 비워질 때까지 기다려야 한다.

burst와 nodelay의 조합이 요청을 어떻게 처리하는지 흐름으로 보면 이해가 쉽다.

sequenceDiagram autonumber participant C as 클라이언트 participant N as Nginx participant B as 백엔드 Note over C, B: rate=20r/m (3초당 1회), burst=5, nodelay rect rgb(232, 248, 232) Note over C, N: 1~6번째 요청 — burst 범위 내, 즉시 처리 C->>N: 요청 1 (rate 허용) N->>B: 전달 B-->>N: 200 OK N-->>C: 200 OK C->>N: 요청 2~6 (burst 소진) N->>B: 즉시 전달 (nodelay) B-->>N: 200 OK N-->>C: 200 OK end rect rgb(255, 230, 230) Note over C, N: 7번째 요청 — burst 초과, 거부 C->>N: 요청 7 N--xC: 429 Too Many Requests end rect rgb(255, 243, 224) Note over C, N: 3초 후 — burst 슬롯 1개 회복 C->>N: 요청 8 N->>B: 전달 B-->>N: 200 OK N-->>C: 200 OK end

초록 영역에서 처음 6개 요청이 한꺼번에 통과한다. rate 허용분 1개 + burst 5개 = 총 6개다. 분홍 영역에서 7번째는 즉시 거부된다. 주황 영역에서 보이듯, 3초가 지나면 burst 슬롯이 1개 회복되어 다시 요청을 받을 수 있다.

burst 슬롯의 회복 속도

burst 슬롯은 rate 속도로 회복된다. rate=20r/m이면 3초에 1개씩 회복된다. burst=5를 모두 소진했다면, 5개가 전부 회복되려면 15초가 걸린다.

429 상태 코드 설정

Nginx의 Rate Limiting 기본 거부 상태 코드는 503이다. 하지만 HTTP 표준에서 Rate Limiting 초과를 나타내는 적절한 코드는 429 Too Many Requests다. 실습에서는 이를 명시적으로 설정한다.

limit_req_status 429;

이 설정이 없으면 클라이언트가 503 Service Unavailable을 받게 되는데, 이는 서버 장애와 혼동될 수 있다. 429를 반환하면 "요청이 너무 많다"는 의미가 명확해지고, 클라이언트가 재시도 로직을 적절히 구현할 수 있다.

429 응답과 Retry-After 헤더

운영 환경에서는 429 응답에 Retry-After 헤더를 함께 보내는 것이 좋은 관행이다. 이 헤더는 클라이언트에게 "몇 초 후에 다시 시도하라"는 정보를 전달한다. Nginx에서는 add_header Retry-After 3 always; 같은 설정으로 추가할 수 있다.

서비스별 차등 Rate Limiting 전략

모든 API에 같은 Rate Limit을 적용하는 것은 비효율적이다. 서비스마다 리소스 소비량, 보안 민감도, 사용 빈도가 다르기 때문이다.

실습 프로젝트의 차등 전략을 보면 이런 원칙을 따르고 있다.

  • user-service (20r/m, burst=5) : 사용자 조회는 가벼운 연산이다. 상대적으로 넉넉하게 허용한다.
  • order-service (15r/m, burst=3) : 주문은 비즈니스 로직이 좀 더 복잡하다. 중간 수준으로 제한한다.
  • payment-service (10r/m, burst=3) : 결제는 가장 민감하고 비용이 높은 연산이다. 가장 엄격하게 제한한다.
flowchart LR subgraph RL ["Rate Limiting 정책"] direction TB U["user-service
20r/m, burst=5"] O["order-service
15r/m, burst=3"] P["payment-service
10r/m, burst=3"] end style U fill:#E8F8E8,stroke:#4CAF50,stroke-width:2px,color:#000 style O fill:#FFF3E0,stroke:#FF9800,stroke-width:2px,color:#000 style P fill:#FFE6E6,stroke:#F44336,stroke-width:2px,color:#000 style RL fill:#f8f8f8,stroke:#999

초록은 여유로운 제한, 주황은 중간, 빨간은 엄격한 제한이다. 이런 차등 전략을 설계할 때는 해당 API가 호출되었을 때 서버에 얼마나 부하를 주는가를 기준으로 삼는다.

자주 하는 실수

burst 없이 Rate Limiting 적용하기

limit_req zone=users_limit;만 쓰고 burst를 생략하면, rate를 초과하는 모든 요청이 즉시 거부된다. 현실에서 사용자는 페이지 로드 시 여러 API를 동시에 호출한다. burst 없는 Rate Limiting은 정상 사용자를 차단하는 원인이 된다.

[!DANGER] rate 값을 초 단위로 오해하기

rate=20r/m은 "분당 20회까지 쌓아두고 한꺼번에 처리"가 아니라 "3초에 1회씩 균일하게 처리"라는 뜻이다. 이 차이를 모르면 "분당 20회인데 왜 2초 만에 거부되지?"라는 혼란에 빠진다.

[!DANGER] 모든 API에 동일한 Rate Limit 적용하기

헬스체크 API에 분당 10회 제한을 걸면 모니터링 시스템이 오작동한다. 읽기 전용 API와 쓰기 API, 내부용 API와 외부용 API는 각각 다른 제한을 가져야 한다. limit_req_zone을 서비스별로 분리하는 이유가 여기에 있다.