속도 제한
속도 제한은 받아 줄 요청의 양에 상한을 둡니다. 정해진 시간 동안 그 상한을 넘는 요청은 거절하거나 늦춥니다. 한쪽이 몰아쳐도 다른 사용자의 몫이 남습니다. 서버는 감당하는 만큼만 일합니다.
쉽고 빠른 이해
속도 제한은 「누가 · 얼마 동안 · 몇 번까지」를 미리 정해 두고 그 이상은 안 받는 장치입니다. 계정 하나가 1분에 60번까지만 조회하게 묶어 두는 것이 그런 예입니다.
이 장치가 없으면 한쪽의 폭주가 서비스 전체를 멈춰 세웁니다. 서버는 들어오는 대로 다 받으려다 줄이 길어지고, 아무 잘못 없는 다른 사용자까지 답을 못 받습니다.
어떻게 도나:
- 요청이 오면 누구 몫으로 셀지 정합니다
- 그 몫에 남은 양이 있는지 봅니다
- 남아 있으면 통과시키고 하나를 깎습니다. 없으면 거절하거나 기다리게 합니다
대가는 둘입니다. 상한을 낮게 잡으면 정상적인 몰림까지 같이 막힙니다. 서버 여러 대가 한 몫을 나눠 세려면 값을 한군데 모아야 해서 요청마다 저장소를 한 번 더 다녀옵니다. 믿는 사이의 내부 호출에는 잘 안 맞습니다.
상세
고속도로 진입 램프에 달린 신호등을 떠올려 봅시다. 본선이 밀리기 시작하면 이 신호등이 몇 초에 한 대씩만 차를 들여보냅니다. 램프에 차가 잠깐 늘어서지만, 본선 전체가 멈춰 서는 것은 피합니다.
상한은 「1분에 60번」처럼 시간과 횟수를 짝지어 정합니다. 그 짝을 누구에게 매길지도 같이 정합니다.
이 상한이 없으면 서버는 들어오는 대로 다 받으려 합니다. 처리 속도를 넘어선 요청은 큐에 쌓입니다. 큐는 아직 처리하지 못한 요청을 순서대로 세워 두는 줄입니다.
줄이 길어지면 기다리다 시간이 다 된 요청부터 실패합니다. 폭주를 일으킨 쪽만 실패하는 것이 아니라 그 서버를 쓰는 모두가 같이 당합니다. 속도 제한은 이 번짐을 막습니다.
그래서 이 장치는 두 가지를 동시에 노립니다. 하나는 서버를 자기 처리 능력 안에 묶어 두는 것이고, 다른 하나는 여럿이 나눠 쓰는 자원을 한쪽이 독차지하지 못하게 막는 것입니다.
요청을 세는 쪽과 바이트를 세는 쪽
속도 제한이라는 이름은 두 분야에서 쓰입니다. 세는 대상만 다르고 짜임은 같습니다.
웹 API(Application Programming Interface, 응용 프로그램 인터페이스) 쪽은 요청 건수를 셉니다. 횟수로 상한을 매기고, 넘으면 그 요청을 거절합니다.
네트워크 장비 쪽은 오간 바이트를 셉니다. 회선이 낼 수 있는 대역폭 안에서 초당 몇 비트까지만 내보내고, 넘치는 양은 버퍼에 쌓아 두거나 버립니다.
두 쪽 모두 단위 시간에 허용할 양을 정하고 넘친 양을 어떻게 할지 고르는 일입니다. 아래 설명은 요청 건수를 세는 쪽을 기준으로 합니다.
무엇을 세고 누구 몫으로 세나
상한 하나를 세우려면 세 가지를 정해야 합니다.
| 정하는 것 | 고르는 값 |
|---|---|
| 세는 대상 | 요청 건수 · 오간 바이트 · 만들어 낸 자원 개수 |
| 세는 기준 | 계정 · 발급한 키 · 접속 주소 · 엔드포인트 |
| 시간 창 | 1초 · 1분 · 하루 |
셋 중에 고르기 까다로운 것은 기준입니다. 기준은 이 요청을 누구 몫으로 셀 것인지를 정합니다.
계정이나 발급한 키를 기준으로 삼으면 셈이 또렷합니다. 로그인 전 요청처럼 신원이 없을 때는 접속 주소를 대신 씁니다. 다만 사무실이나 통신사 뒤의 여러 사용자가 같은 주소로 보이면 그 사람들이 한 몫을 나눠 쓰게 됩니다.
시간 창은 윈도우라고도 부릅니다. 짧게 잡을수록 몰림을 촘촘히 막고, 길게 잡을수록 잠깐의 몰림을 너그럽게 받습니다. 그래서 「1초에 10번, 하루에 1만 번」처럼 짧은 창과 긴 창을 겹쳐 두기도 합니다.
허용량을 재는 네 가지 방법
재는 방법은 두 갈래입니다. 시간을 창으로 끊어 그 안의 횟수를 세는 쪽이 하나입니다. 일정한 속도를 기준으로 삼는 쪽이 다른 하나입니다.
뒤쪽이 쓰는 토큰은 요청 한 번을 쓸 권리입니다. 같은 「1분에 100번」도 어느 쪽으로 재느냐에 따라 다르게 동작합니다.
| 방법 | 어떻게 세나 | 약한 곳 |
|---|---|---|
| 고정 윈도우 | 1분 단위로 칸을 끊고 칸마다 횟수를 셉니다 | 칸이 바뀌는 때에 두 배가 통과합니다 |
| 슬라이딩 윈도우 | 지금부터 뒤로 1분 동안의 요청을 셉니다 | 요청 시각을 들고 있어야 해서 메모리를 씁니다 |
| 토큰 버킷 | 일정 속도로 토큰을 채우고 요청마다 하나씩 씁니다 | 고인 토큰만큼 한꺼번에 몰아 쓸 수 있습니다 |
| 누출 버킷 | 들어온 요청을 줄에 세우고 일정 속도로 내보냅니다 | 줄에서 기다리는 만큼 응답이 늦어집니다 |
고정 윈도우가 약한 대목은 칸 경계를 그려 보면 뚜렷합니다. 분당 100번으로 정해 두었을 때입니다.
flowchart TD
subgraph W1["1분 칸 · 00:00~00:59"]
A["00:59 에 100번 통과"]
end
subgraph W2["1분 칸 · 01:00~01:59"]
B["01:00 에 100번 통과"]
end
A --> B
B --> S["칸 경계를 낀 2초 · 합계 200번"]
두 요청 묶음은 각각 제 칸의 상한을 지켰습니다. 그런데 서버가 맞은 것은 2초 동안 200번입니다. 슬라이딩 윈도우는 창을 고정된 칸으로 끊지 않고 지금 시점에서 뒤로 재기 때문에 이 빈틈이 없습니다.
토큰 버킷은 잠깐의 몰림을 어디까지 봐줄지를 숫자 하나로 정할 수 있어 널리 쓰입니다. 토큰은 버킷에 일정 속도로 채워집니다.
버킷에는 용량이 있어서 토큰이 끝없이 고이지는 않습니다. 한동안 요청이 없었다면 고여 있던 토큰만큼 한꺼번에 몰아 쓸 수 있습니다. 그 토큰을 다 쓰면 채워지는 속도만큼만 통과합니다.
아래는 네 방법 가운데 토큰 버킷 하나를 그린 것입니다.
flowchart TD
F["일정 속도로 토큰을 채운다"] --> B["버킷 · 용량만큼만 고인다"]
R["요청 한 건이 온다"] --> T{"토큰이 남았나"}
B --> T
T -->|남았다| P["통과시키고 토큰 하나를 쓴다"]
T -->|없다| D["거절하거나 기다리게 한다"]
채우는 흐름과 쓰는 흐름은 따로 돕니다. 토큰은 요청이 오든 안 오든 시간에 따라 채워지고, 요청은 그때 쌓여 있는 만큼만 가져갑니다. 용량을 키우면 몰림을 더 받아 주고, 줄이면 고르게 들어오는 요청만 통과합니다.
상한을 넘으면 무엇을 하나
넘친 요청을 어떻게 할지는 세는 방법과 따로 정하는 문제입니다. 크게 셋입니다.
넘친 요청을 그 자리에서 돌려보내는 것이 가장 흔합니다. 돌려보낼 때는 왜 막혔는지를 같이 알려 줍니다.
웹은 HTTP(HyperText Transfer Protocol, 하이퍼텍스트 전송 프로토콜)로 요청을 주고받습니다. 여기서 HTTP 429 상태 코드는 너무 많이 보냈다는 뜻입니다. 언제 다시 오면 되는지는 Retry-After 헤더에 실어 줍니다. 보낸 쪽은 그만큼 기다렸다 다시 보냅니다.
기다리게 하는 방법도 있습니다. 넘친 요청을 거절하지 않고 줄에 세워 두었다가 상한이 허락하는 속도로 내보냅니다. 응답이 늦어지는 대신 요청을 잃지 않습니다. 사람이 화면 앞에서 기다리는 일보다 뒤에서 도는 작업에 맞습니다.
셋째는 골라 버리기입니다. 넘친 요청을 아무거나 버리지 않고 덜 급한 것부터 버립니다. 결제는 통과시키고 목록 새로고침은 버리는 식입니다. 무엇이 덜 급한지를 서비스가 미리 정해 두어야 쓸 수 있습니다.
어느 쪽을 고르든 막혔다는 것을 보낸 쪽이 알아야 합니다. 남은 허용 횟수와 창이 다시 열리는 때를 응답에 실어 주면 됩니다. 그러면 클라이언트가 지수 백오프처럼 간격을 늘려 가며 다시 시도합니다.
이 정보가 없으면 막힌 쪽은 곧바로 다시 보냅니다. 그 재시도가 또 상한에 부딪혀 요청만 불어납니다.
flowchart TD
A["상한에 막혔다"] --> Q{"남은 허용 횟수와 다시 열리는 때를 받았나"}
Q -->|받았다| W["간격을 늘려 다시 보낸다 · 지수 백오프"]
Q -->|못 받았다| N["곧바로 다시 보낸다"]
N --> F["또 상한에 부딪힌다"]
F --> N
서버가 여러 대일 때
상한은 서비스 전체에 매기는데 요청은 서버 여러 대에 나뉘어 들어옵니다. 그래서 세는 값을 어디에 둘지가 문제가 됩니다.
각 서버가 따로 세면 셈이 서버 수만큼 부풀어 오릅니다. 분당 100번으로 정해 두어도 서버가 넷이면 서비스 전체로는 400번까지 통과합니다.
모든 서버가 같이 보는 공유 저장소에 세는 값을 두면 셈은 맞습니다. 대신 요청마다 저장소를 한 번 다녀옵니다. 그만큼 응답이 늦어집니다. 그 저장소가 멈추면 상한을 못 셉니다.
여러 서버는 같은 값을 동시에 깎습니다. 한 서버가 값을 읽은 뒤 더하기 전에 다른 서버가 끼어들면, 두 건을 셌는데 센 값은 한 번만 오릅니다. 그래서 읽기와 더하기는 쪼개지지 않는 한 덩어리로 처리해야 합니다.
sequenceDiagram
participant A as 서버A
participant B as 서버B
participant C as 공유 저장소
A->>C: 센 값을 읽는다 · 5
B->>C: 센 값을 읽는다 · 5
A->>C: 6 으로 쓴다
B->>C: 6 으로 쓴다
Note over A,C: 두 건을 셌는데 5 에서 6 으로 한 번만 올랐다
Note over A,C: 읽기와 더하기를 한 덩어리로 묶으면 이 끼어듦이 없다
상한을 서버 수로 나눠 각자 세게 하는 방법도 있습니다. 공유 저장소를 안 거치니 응답이 지체되지 않습니다. 대신 트래픽이 서버마다 고르게 안 나뉘면 어떤 서버는 할당이 남고 어떤 서버는 모자랍니다.
언제 쓰고 언제 안 쓰나
속도 제한은 누가 보냈는지 가릴 수 있고 요청 하나하나를 셀 수 있을 때 잘 맞습니다. 바깥에 열어 둔 API, 로그인 시도, 품이 많이 드는 조회가 대표적입니다.
남의 유료 서비스를 대신 호출하는 구간에도 둡니다. 그쪽이 정해 둔 상한을 넘기면 내 서비스가 통째로 막힙니다. 그래서 내 쪽에서 먼저 속도를 맞춰 둡니다.
서로 믿는 내부 서비스끼리는 잘 안 맞습니다. 이때 필요한 것은 상한을 미리 박아 두는 일이 아니라, 받는 쪽이 감당할 만큼만 보내라고 되미는 백프레셔입니다.
요청 하나하나의 무게가 크게 다를 때도 건수로 세는 상한은 어긋납니다. 값 하나를 읽는 조회 100번과 여러 표를 훑는 집계 100번이 같은 한 몫으로 세어지기 때문입니다. 이럴 때는 건수 대신 처리 시간이나 동시에 도는 건수를 세는 동시성 제한을 씁니다.
보안 장치로 기대는 것도 빗나갑니다. DDoS(Distributed Denial of Service, 분산 서비스 거부)처럼 여러 곳에서 나눠 보내는 공격은 주소마다 걸린 상한을 다 지키면서도 서비스를 무너뜨립니다.
상한을 얼마로 잡을지는 속도 제한 스스로 알려 주지 않습니다. 높게 잡으면 서버를 못 지키고, 낮게 잡으면 정상 사용자를 막습니다. 평소 쓰이는 양을 재 보고 정한 다음, 막힌 요청 수를 지켜보며 손봐야 합니다.
관련 항목
속도 제한이 허용량을 재는 데 쓰는 방법
토큰 버킷 · 누출 버킷 · 고정 윈도우 · 슬라이딩 윈도우 · 슬라이딩 윈도우 로그 · 카운터
속도 제한이 넘친 요청에 돌려주는 응답
HTTP 429 · Retry-After · RateLimit 헤더 필드 · 상태 코드 · 오류 응답
속도 제한과 같은 뿌리에서 부하를 누르는 수단
스로틀링 · 백프레셔 · 흐름 제어 · 혼잡 제어 · 동시성 제한 · 부하 제한 · 벌크헤드 · 서킷 브레이커
속도 제한을 얹어 두는 장비와 구간
API 게이트웨이 · 리버스 프록시 · 로드 밸런서 · 프록시 · 방화벽 · nginx
속도 제한이 상한을 매기는 단위
API 키 · 계정 · IP 주소 · 테넌트 · 세션 · 엔드포인트
속도 제한이 지키려는 지표
초당 요청 수 · 처리량 · 지연 · 대역폭 · 포화도 · 가용성
속도 제한에 막힌 쪽이 하는 대응
재시도 · 지수 백오프 · 지터 · 재시도 폭풍 · 큐 · 배치 처리
속도 제한을 여러 대에 걸쳐 셀 때 쓰는 장치
공유 카운터 · 원자적 연산 · 분산 락 · 단조 시계 · Redis
속도 제한과 짝지어 파는 이용 조건
할당량 · 쿼터 정책 · 서비스 한도 · 요금제 · 멀티테넌시 · 공정성
속도 제한이 막아 내려는 상황
DDoS · 썬더링 허드 · 과부하 · 크리덴셜 스터핑 · 스크래핑 · 봇 트래픽
다른 이름: 레이트 리미팅 · 요청 속도 제한 · 유량 제한