분산 환경의 레이트 리밋은 경합한다

초당 100회로 걸어 둔 제한이 서버를 다섯 대로 늘리자 500회가 됐어요. 한곳에 모아 세니 이번엔 그게 병목이 되고요. 여러 서버에서 횟수를 셀 때 정확함과 속도 중 무엇을 고를지 정리했어요.

API 에 초당 100회 제한을 걸었어요. 서버 한 대일 때는 완벽하게 동작했어요.

서버를 다섯 대로 늘렸더니 실제 허용치가 초당 500회가 됐어요. 각 서버가 자기 몫만 세고 있었거든요.

그래서 공유 저장소로 옮겼어요. 이번엔 제한은 정확해졌는데, 그 저장소가 병목이 됐어요. 모든 요청이 카운터 하나를 두고 다투니까요.

세는 곳을 하나로 모으면 정확하지만 느리고, 나누면 빠르지만 부정확해요.

이 사이에서 무엇을 고를지가 이 편의 주제예요. 그리고 미리 말하자면, 대부분의 서비스에서 완벽한 정확도는 애초에 필요하지 않습니다.

왜 그런가

레이트 리밋은 결국 "이 키가 최근에 몇 번 왔는가"를 세는 일이에요. 서버가 여러 대면 그 수를 어디에 둘지가 문제가 됩니다.

각 서버가 따로 세면 정확도가 서버 수만큼 벗어나요. 그리고 로드 밸런서가 요청을 어떻게 나누느냐에 따라 실제 허용치가 달라져서 예측도 안 돼요.

공유 저장소에 두면 정확해지는데 대가가 커요. 모든 요청이 그 저장소를 한 번씩 다녀와야 하니 왕복 시간이 붙고, 그 저장소가 단일 실패 지점이 돼요. 그리고 앞 편에서 본 핫 키 문제가 여기서 그대로 나타나요 — 인기 있는 키의 카운터 하나에 요청이 몰립니다.

여기에 원자성 문제도 있어요. 읽고, 더하고, 쓰는 세 단계 사이에 다른 요청이 끼어들면 앞서 본 잃어버린 갱신이 생겨요. 그래서 원자적 증가 연산이나 스크립트로 한 번에 처리해야 해요.

그리고 알고리즘 선택도 결과를 바꿔요. 고정된 시간 창으로 세면 창 경계에서 두 배가 통과할 수 있어요. 앞 창 끝에 100번, 뒤 창 시작에 100번이면 짧은 구간에 200번이 지나가는 거죠.

정리하면 이 문제는 정확도, 지연, 가용성 사이의 선택이에요. 셋 다 최대로 가질 수는 없습니다.

코드로 보면

원자적으로 세고, 창 경계를 부드럽게:

코드
 읽고 → 더하고 → 쓰기 (세 단계)
 그 사이에 끼어들면 카운트가 어긋난다

· 원자적 증가 + 만료를 한 번에
 INCR rate:user:123:1723000000
 EXPIRE rate:user:123:1723000000 60   -- 첫 증가일 때만
 두 명령을 스크립트로 묶어 원자적으로

· 고정 창은 경계에서 두 배가 통과한다
 창 A 끝에 100 + 창 B 시작에 100 = 짧은 구간에 200
 슬라이딩 창: 이전 창의 값을 비율로 섞어 보정
 또는 토큰 버킷: 시간에 따라 토큰이 차오르는 방식

· 저장소가 죽었을 때를 정한다
 열어 줄 것인가(가용성) 막을 것인가(보호)

✕ 문제 지점 · ✓ 해법 · 그대로 돌려 보기 전에 자기 환경에 맞게 고쳐 주세요.

실전에서는

실무적인 절충이 몇 가지 있어요.

가장 흔한 게 몫을 나누되 여유를 두는 방식이에요. 서버 다섯 대에 전체 한도 100 이면 각 서버에 20 씩 주는 거죠. 공유 저장소가 필요 없어 빠르고, 정확도도 그럭저럭이에요. 다만 트래픽이 고르게 안 나뉘면 어떤 서버는 남고 어떤 서버는 모자라요. 그래서 실제로는 몫을 조금 넉넉히 주고 총합이 한도를 조금 넘는 걸 감수합니다.

두 번째가 지역 카운터 + 주기적 동기화예요. 각 서버가 자기 몫을 세다가 짧은 주기로 중앙과 맞춰요. 왕복이 요청마다가 아니라 주기마다라 훨씬 싸요. 정확도는 그 주기만큼 벗어나고요.

세 번째가 계층 구조예요. 대략적인 제한은 앞단에서 빠르게 걸러 내고, 정확해야 하는 소수의 키만 공유 저장소로 확인해요. 대부분의 요청이 첫 관문에서 통과하거나 걸러지니 부담이 크게 줄어요.

알고리즘 쪽에서는 토큰 버킷이 실무에서 가장 널리 쓰여요. 시간에 따라 토큰이 차오르고 요청이 하나씩 소비하는 방식인데, 짧은 폭주를 어느 정도 허용하면서 평균 속도를 지켜요. 그리고 마지막 접근 시각과 남은 토큰만 저장하면 되니 저장 비용도 작아요.

그리고 저장소가 죽었을 때의 정책을 반드시 정해야 해요. 못 세면 전부 통과시킬 것인가, 전부 막을 것인가. 통과시키면 보호가 사라지고, 막으면 저장소 장애가 서비스 장애가 돼요. 앞 편의 서킷 브레이커 판단과 같은 성격이에요. 대개는 통과시키되 다른 방어선(부하 흘리기)이 있다는 전제로 갑니다.

마지막으로, 제한에 걸렸을 때 무엇을 돌려줄지도 설계예요. 남은 횟수와 언제 초기화되는지를 알려 주면 클라이언트가 협조할 수 있어요. 아무 정보도 안 주면 즉시 재시도하면서 부하만 늘립니다.

처방

  1. 원자적 연산으로 세세요. 읽고 더하고 쓰는 세 단계는 반드시 어긋납니다.
  2. 정확도와 지연 사이에서 무엇을 고를지 정하세요. 셋 다 가질 수는 없어요.
  3. 대부분의 경우 몫 나누기나 주기적 동기화로 충분합니다. 완벽한 정확도가 정말 필요한지 물어보세요.
  4. 계층 구조를 검토하세요. 앞단에서 빠르게 걸러 내고 소수만 정확히 확인하세요.
  5. 고정 창 대신 토큰 버킷이나 슬라이딩 창을 쓰세요. 경계에서 두 배가 통과하는 문제가 없어집니다.
  6. 저장소가 죽었을 때의 정책을 정하세요. 통과시킬지 막을지 미리 정해 두지 않으면 장애 중에 결정하게 됩니다.
  7. 남은 횟수와 초기화 시각을 응답에 담으세요. 클라이언트가 협조할 수 있게요.
  8. 제한 대상 키의 분포를 보세요. 특정 키에 몰리면 앞 편의 핫 키 문제가 그대로 생깁니다.
  9. 제한을 애플리케이션이 아니라 앞단 프록시나 게이트웨이에서 거세요. 요청을 받아 파싱하고 인증한 뒤에 막으면 그 비용은 이미 낸 것입니다.

지금 확인해 보세요

제한이 실제로 지켜지는가:

코드
· 서버를 여러 대로 늘린 뒤 실제 허용치를 재 본다
 부하를 주면서 실제로 몇 번이 통과하는지 센다
 설정값 × 서버 수가 나오면 각자 세고 있는 것이다

· 창 경계에서 두 배가 통과하는지
 창이 바뀌는 시점 전후로 몰아서 보내 본다

· 카운터 저장소의 부하
 초당 명령 수 · 응답 시간 · 특정 키 집중도
 전역 제한이면 키가 하나라 반드시 뜨거워진다

· 제한에 걸린 응답이 정보를 주는가
 남은 횟수 · 초기화 시각을 안 주면
 클라이언트가 즉시 재시도해 부하만 는다

· 저장소가 죽었을 때의 동작을 시험한다
 정하지 않았으면 장애 중에 결정하게 된다

핵심 한 줄정확하게 세면 느려지고 나눠 세면 어긋난다. 무엇을 포기할지 정하라.

자주 묻는 것

고정 창 레이트 리밋은 뭐가 문제인가요?

창 경계에서 한도의 두 배가 통과할 수 있어요. 앞 창 끝에 100번, 뒤 창 시작에 100번이면 짧은 구간에 200번이 지나가요. 슬라이딩 창이나 토큰 버킷으로 보완해요.

브루니가 적어요

색을 바꾸며 숨는 카멜레온이에요. 운영에서 실제로 터지는 문제를 한 편씩 적어요. 같은 실수를 두 번 하지 않으려고 쓰는 기록이에요.

장애·확장 글 23편 모두 보기 →