← Posts

Rate Limiting · Scaling · API

Rate Limiting - 넘치면 막는다

더 받는 법으로는 막을 수 없는 유입이 있다. 토큰 버킷 하나를 깊게 보고, 무엇을 기준으로 세고 서버가 여러 대일 때 카운터를 어디에 두는지까지 간다.

목차
  1. 한 클라이언트가 전체를 마비시켰다
  2. 늘리는 것으로는 못 막는다
  3. 토큰 버킷 - 통에 든 만큼만 쓴다
  4. 두 손잡이가 무엇을 정하나
  5. 코드로 보면
  6. 무엇을 기준으로 세나
  7. 막을 때 뭐라고 답하나
  8. 서버가 여러 대면 카운터도 흩어진다
  9. 어디에 두는가도 결정이다
  10. 시리즈를 관통한 것
  11. 정리

새벽에 알림이 울린다. 서비스 전체가 느리다. 로그를 보니 한 클라이언트가 같은 API를 쉬지 않고 부르고 있었다.

한 클라이언트가 전체를 마비시켰다

악의가 없는 경우가 더 많다. 연동 업체가 재시도 로직을 잘못 짜서 실패할 때마다 즉시 다시 부르고 있거나, 배치가 페이지네이션 없이 전체를 훑고 있거나, 프런트엔드 코드에 무한 루프가 생겼거나.

의도가 무엇이든 결과는 같다. 한 명이 낸 요청이 전체의 자원을 다 먹고, 나머지 모든 사용자가 피해를 본다.

늘리는 것으로는 못 막는다

여기서 지금까지의 도구들이 전부 소용없어진다.

캐시는 같은 답을 반복해 만드는 걸 막지만, 애초에 요청을 받아 스레드를 쓰고 캐시를 조회하는 비용은 그대로다. 서버를 늘리면 잠깐 버티지만 상대가 더 세게 부르면 다시 무너진다. 로드 밸런서는 이 요청들을 골고루 나눠줄 뿐 줄여주지 않는다.

diagramdiagram

Rate Limiting은 방향이 반대인 도구다. 더 받으려는 게 아니라, 정한 만큼만 받고 나머지는 거절한다. 시스템이 감당할 수 있는 범위 안에 유입을 묶어두는 것이다.

거절이 실패처럼 보이지만 그렇지 않다. 전부 같이 느려져 전부 실패하는 것보다, 일부를 명확히 거절하고 나머지를 정상 처리하는 편이 낫다. 앞 글들에서 반복해서 나온 “뒤에 있는 것을 보호한다”의 가장 앞단 버전이다.

토큰 버킷 - 통에 든 만큼만 쓴다

제한을 재는 방법은 여럿인데, 가장 널리 쓰이는 하나를 제대로 보는 게 낫다. 토큰 버킷이다.

그림 하나로 끝난다. 통이 있고, 그 안에 토큰이 들어 있다.

diagramdiagram

규칙은 셋뿐이다.

  • 토큰은 일정한 속도로 채워진다.
  • 통에는 용량 상한이 있어서, 넘치는 토큰은 버려진다.
  • 요청이 오면 토큰을 하나 꺼낸다. 없으면 거절한다.

이 단순한 장치가 두 가지를 동시에 해낸다.

장기적으로는 채우는 속도가 평균 처리율이 된다. 아무리 오래 불러도 초당 채워지는 만큼밖에 못 쓴다.

단기적으로는 몰아 쓰는 걸 허용한다. 한동안 안 부른 클라이언트의 통에는 토큰이 쌓여 있다. 그 클라이언트는 순간적으로 통에 든 만큼 한 번에 쓸 수 있다.

이 두 번째 성질이 중요하다. 실제 사용은 고르게 오지 않는다. 화면 하나를 여는 데 API를 여러 개 부르고, 그러고 나서는 한동안 잠잠하다. 엄격하게 균등한 간격만 허용하면 정상 사용자가 걸린다. 토큰 버킷은 평균은 지키면서 자연스러운 몰림은 통과시킨다.

두 손잡이가 무엇을 정하나

토큰 버킷을 설정한다는 건 결국 숫자 두 개를 정하는 일이다.

손잡이정하는 것크게 하면작게 하면
채우는 속도장기 평균 허용량계속 많이 쓸 수 있다오래 쓰면 확실히 묶인다
통 용량한 번에 몰아 쓸 수 있는 양순간 폭주를 허용한다조금만 몰려도 걸린다

두 손잡이가 서로 다른 것을 지킨다는 게 요점이다.

용량만 크게 잡으면 한 번에 크게 때리는 것을 못 막는다. 그 순간의 부하가 그대로 들어온다. 속도만 크게 잡고 용량을 아주 작게 하면 정상 사용자가 화면 하나 여는 것도 걸린다.

그래서 판단은 대체로 이렇게 간다. 용량은 “한 화면을 여는 데 필요한 요청 수” 정도로, 속도는 “이 클라이언트가 지속적으로 써도 되는 만큼”으로.

제한 값을 정할 근거가 없다면, 먼저 막지 말고 세기만 한다. 정상 클라이언트들이 실제로 얼마나 쓰는지 며칠 관찰한 뒤에 값을 정한다. 감으로 정한 제한은 공격자를 막기 전에 정상 사용자를 먼저 막는다.

코드로 보면

토큰 버킷의 구현에서 흔히 헷갈리는 게 있다. “일정 속도로 채운다”를 위해 타이머를 돌리지 않는다. 요청이 왔을 때 “마지막으로 본 뒤로 얼마나 지났나”를 계산해서 그만큼 채운다.

java
public class TokenBucket {
    private final long capacity;        // 통 용량
    private final double refillPerMs;   // ms당 채우는 토큰 수
    private double tokens;
    private long lastRefillAt;

    public TokenBucket(long capacity, double refillPerSecond) {
        this.capacity = capacity;
        this.refillPerMs = refillPerSecond / 1000.0;
        this.tokens = capacity;                      // 처음엔 가득
        this.lastRefillAt = System.currentTimeMillis();
    }

    public synchronized boolean tryConsume() {
        refill();
        if (tokens < 1) {
            return false;                            // 거절
        }
        tokens -= 1;
        return true;
    }

    private void refill() {
        long now = System.currentTimeMillis();
        double added = (now - lastRefillAt) * refillPerMs;
        tokens = Math.min(capacity, tokens + added);  // 넘치면 버린다
        lastRefillAt = now;
    }
}

refill()이 전부다. 지난 시간에 비례해 채우고, 용량을 넘으면 버린다. 타이머도 백그라운드 스레드도 없다. 클라이언트가 수만 명이어도 각자의 통은 그 클라이언트가 요청을 보낼 때만 계산된다.

Math.min(capacity, ...)이 통 용량이 하는 일이고, 이 한 줄이 없으면 오래 쉰 클라이언트가 무한정 토큰을 쌓아 한 번에 쏟아붓게 된다.

무엇을 기준으로 세나

토큰 버킷을 어디에 하나씩 둘 것인가. 이 선택이 제한의 성격을 완전히 바꾼다.

기준막는 것문제
전체 공용시스템 총 유입한 명 때문에 전원이 걸린다
IP별한 곳에서 오는 폭주여럿이 한 IP를 공유한다
API 키 · 클라이언트별연동 업체의 오작동키가 없는 요청은 못 센다
로그인 사용자별계정 단위 남용로그인 전 요청은 못 센다
엔드포인트별비싼 API 집중 호출관리할 규칙이 는다

첫 장면 같은 사고에는 클라이언트별이 맞는다. 문제를 낸 연동처만 걸리고 나머지는 멀쩡하다. 전체 공용 하나만 두면 폭주한 쪽이 통을 다 비워서 결국 모두가 거절당하는 원래 문제로 돌아간다.

로그인 전 요청(로그인 시도, 회원가입, 비밀번호 재설정)은 사용자를 특정할 수 없으니 IP로 셀 수밖에 없다. 그런데 IP에는 함정이 있다.

IP는 사람과 일대일이 아니다. 회사나 학교, 모바일 통신망에서는 수백 명이 같은 출구 IP를 쓴다. IP 기준 제한을 빡빡하게 걸면 그 조직 전체가 한 사람 취급을 받는다.

앞단이 여럿이면 IP가 앞단 주소로 보인다. 로드 밸런서 글에서 본 그 문제다. 전달 헤더를 반영하지 않으면 모든 요청이 한 명으로 집계돼 전체가 함께 막힌다.

주의

그렇다고 클라이언트가 보낸 X-Forwarded-For를 그대로 믿으면 제한이 무력해진다. 헤더 값을 매번 바꿔가며 부르면 새 통이 계속 생기기 때문이다. 믿을 수 있는 것은 우리가 통제하는 앞단이 붙인 값뿐이고, 그 경계를 정하는 게 이 기능의 전제다.

막을 때 뭐라고 답하나

거절도 응답이다. 상대가 다음에 무엇을 해야 할지 알 수 있어야 한다.

정해진 상태 코드가 있다. **429 Too Many Requests**다. 4xx인 것이 중요하다. 상태 코드 글의 기준으로 보면 4xx는 “요청 쪽을 고치면 통한다”는 뜻이고, 실제로 그렇다. 천천히 다시 부르면 성공한다. 서버가 고장 난 게 아니니 5xx가 아니다.

그리고 언제 다시 오면 되는지를 같이 준다.

http
HTTP/1.1 429 Too Many Requests
Retry-After: 30
Content-Type: application/json

{"code": "RATE_LIMITED", "message": "요청이 너무 잦습니다. 30초 뒤 다시 시도하세요."}

Retry-After가 없으면 상대는 언제 다시 올지 모른다. 그래서 즉시 다시 시도한다. 거절당한 요청이 곧바로 다시 와서 또 거절당하고, 거절을 처리하는 비용만 계속 든다. 첫 장면을 만든 잘못된 재시도 로직이 정확히 이 모양이다.

여유가 얼마나 남았는지 미리 알려주는 방법도 있다. 성공 응답에도 남은 토큰과 초기화 시각을 헤더로 붙이면, 잘 만든 클라이언트는 한도에 닿기 전에 스스로 속도를 줄인다. 거절당하고 나서 대응하는 것보다 훨씬 낫다.

서버가 여러 대면 카운터도 흩어진다

앞의 TokenBucket은 앱 메모리 안에 있다. 수평 확장 글의 표에 있던 문제가 그대로 재현된다.

서버가 세 대면 통도 세 개다. 각 서버가 “초당 10건”을 허용하면 실제로는 초당 30건이 통과한다. 게다가 로드 밸런서가 요청을 나누므로 한 클라이언트의 요청이 세 통에 흩어져서, 어느 통도 한도에 안 닿는다.

diagramdiagram

해결 방향은 이 시리즈에서 이미 나왔다. 상태를 서버 밖 공용 저장소로 꺼낸다. 원격 캐시 글에서 캐시를 꺼낸 것과 같은 이동이고, 같은 대가가 따라온다.

연산이 원자적이어야 한다. “읽고 - 계산하고 - 쓰기”를 앱에서 나눠 하면 동시에 들어온 요청들이 같은 값을 읽어서 둘 다 통과시킨다. 저장소 쪽에서 한 번에 처리되는 연산으로 만들어야 한다.

요청마다 왕복이 하나 는다. 모든 요청이 카운터를 확인해야 하니, 이 왕복은 절대 안 빠진다. 제한 장치가 느리면 그 자체가 새 병목이다.

저장소가 죽으면 어떻게 할지 정해야 한다. 확인할 수 없을 때 전부 통과시킬 것인가, 전부 막을 것인가. 통과시키면 보호가 사라지고, 막으면 저장소 장애가 곧 전면 장애다. 대개는 통과시키되 짧은 타임아웃을 두고, 그동안 서버별 로컬 통으로 대략이라도 막는 절충을 쓴다.

주의

이 판단은 목적에 따라 뒤집힌다. 서버를 지키려는 제한이면 통과시키는 쪽이 낫다 - 제한 장치 때문에 서비스가 멈추는 건 본말전도다. 하지만 로그인 시도처럼 남을 막는 게 목적인 제한은 반대다. 통과시키면 그 순간 비밀번호를 무제한으로 시도할 수 있게 된다. 같은 장치라도 무엇을 지키려는 제한인지에 따라 실패 방향을 다르게 정해야 한다.

참고

정확성 요구가 낮다면 로컬 통에 한도를 대수로 나눠 넣는 근사도 실무에서 쓰인다. 서버가 세 대면 각자 3분의 1을 허용하는 식이다. 왕복이 없어 싸지만, 로드 밸런서가 완전히 고르게 나눠주지 않으면 어긋나고 대수가 바뀔 때마다 값을 다시 잡아야 한다.

어디에 두는가도 결정이다

같은 제한이라도 어느 층에 두느냐에 따라 막아주는 범위가 다르다.

diagramdiagram

앞에 둘수록 싸게 막는다. 엣지에서 막으면 우리 서버는 그 요청이 있었는지도 모른다. 폭주 자체가 목표라면 앞이 낫다.

뒤에 둘수록 정교하게 막는다. 앱 안에서는 “이 사용자가 이 기능을 얼마나 썼는지”를 알 수 있다. 대신 그 판단을 하기까지 스레드를 쓰고 인증을 거친다.

그래서 대개는 겹쳐 쓴다. 앞단에서 거친 그물로 명백한 폭주를 걷어내고, 앱에서 사용자·기능 단위의 세밀한 제한을 건다.

앱에 둘 때는 비즈니스 로직 앞의 길목이 자리다. 미들웨어 글에서 본 “안 넘기면 거기서 멈춘다”가 그대로 쓰인다.

java
public class RateLimitFilter implements Filter {

    private final RateLimiter limiter;

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
            throws IOException, ServletException {
        var req = (HttpServletRequest) request;
        var res = (HttpServletResponse) response;

        String key = clientKey(req);            // API 키 · 사용자 · IP

        if (!limiter.tryConsume(key)) {
            res.setStatus(429);
            res.setHeader("Retry-After", "30");
            return;                             // 넘기지 않는다 = 여기서 끝
        }

        chain.doFilter(request, response);      // 통과한 요청만 다음으로
    }
}

인증 필터가 자격 없는 요청을 막던 것과 구조가 같다. 다른 건 판단 기준뿐이다. 누구인가가 아니라 얼마나 자주 왔는가를 본다.

직접 구현하는 대신 Java에는 토큰 버킷을 구현한 라이브러리(Bucket4j 등)가 있고, API 게이트웨이 제품들도 대개 이 기능을 내장하고 있다. 어느 쪽을 쓰든 설정 화면에 나오는 값은 결국 앞에서 본 두 손잡이다.

시리즈를 관통한 것

이 시리즈의 도구들은 서로 달라 보이지만 모두 같은 형식의 거래였다. 무엇을 사고 무엇을 파는가.

도구사는 것파는 것
캐시반복 계산을 안 하는 속도최신성
원격 캐시서버 전체가 공유하는 한 벌네트워크 왕복 · 직렬화 · 장애 지점 하나
수평 확장상한 없는 용량과 가용성무상태 제약과 운영 복잡도
로드 밸런서여러 대를 하나처럼 쓰는 입구앞단 한 겹과 그 이중화 부담
CDN거리와 오리진 대역폭공유 캐시의 위험과 무효화 부담
Rate Limiting전체를 지키는 상한일부 요청의 거절

공짜인 확장 수단은 없다. 그리고 첫 글의 규칙이 여기에 겹친다. 하나를 풀면 병목은 다음 자리로 옮겨 간다. 그래서 순서가 중요하다. 재고 고르고, 고른 대가를 알고 쓰고, 그다음 자리가 어디일지를 생각한다.

정리

더 받는 법으로는 못 막는 유입이 있다. 방향이 반대인 도구
토큰 버킷일정 속도로 채우고, 상한까지만 쌓이고, 없으면 거절
두 손잡이채우는 속도 = 장기 평균 / 통 용량 = 몰아 쓸 수 있는 양
구현타이머 없이, 요청이 올 때 지난 시간만큼 채운다
기준클라이언트·사용자별이 기본. IP는 공유되고 위조될 수 있다
응답429Retry-After. 없으면 즉시 다시 와서 또 거절당한다
여러 대통이 대수만큼 생겨 제한이 배로 늘어난다. 공용 저장소로 꺼낸다
위치앞에 둘수록 싸게, 뒤에 둘수록 정교하게 막는다

부하를 견디는 일은 결국 두 가지다. 감당할 수 있는 양을 늘리는 것, 그리고 그 양을 넘지 않게 지키는 것. 늘리기만 하면 언젠가 넘고, 지키기만 하면 성장할 수 없다. 둘 다 필요하고, 어느 쪽이든 병목이 어디인지 알고 나서 손대야 한다.

관련 글