본문 바로가기
🚀 데브 노트 (IT 지식 & 팁)

429 에러 방어막! 대용량 트래픽을 견디는 API 처리율 제한(Rate Limiting) 알고리즘 완벽 가이드

by 데브프리 2026. 8. 4.

안녕하세요, 여러분의 든든한 IT 길잡이 데브프리입니다.

 

오픈 API를 연동하거나 대규모 선착순 이벤트를 진행할 때, 백엔드 개발자의 가장 큰 골칫거리 중 하나는 '무자비한 요청(Traffic)'입니다. 악의적인 매크로 봇이 1초에 수천 번씩 새로고침을 누르거나, 특정 사용자가 과도하게 API를 호출하여 서버 자원과 데이터베이스(DB) 커넥션을 모두 고갈시켜 버리는 일이 실무에서는 비일비재하게 일어납니다.

 

이럴 때 우리 서버를 보호해 주는 가장 강력하고 필수적인 방패가 바로 '처리율 제한(Rate Limiting)'입니다. HTTP 상태 코드 429 Too Many Requests를 반환하며 우아하게 트래픽을 튕겨내는 이 기술은 어떻게 동작하는 걸까요?

 

오늘은 백엔드 아키텍처 설계에서 절대 빠질 수 없는 처리율 제한 알고리즘의 원리와, 실무에서 Redis를 활용해 직접 구현할 때 흔히 마주치는 동시성 에러 및 해결 과정(트러블슈팅)을 명확하게 파헤쳐 드리겠습니다.

 

1. 처리율 제한(Rate Limiting)의 핵심 알고리즘 2가지

트래픽을 제어하는 방법에는 여러 가지가 있지만, 실무(특히 AWS, Stripe, Spring Cloud Gateway 등)에서 가장 널리 쓰이는 두 가지 1티어 알고리즘을 먼저 이해해야 합니다.

① 토큰 버킷 (Token Bucket) 알고리즘

가장 보편적이고 범용적으로 사용되는 방식입니다. 아마존(AWS)과 스트라이프(Stripe) 같은 글로벌 IT 기업들이 API 제한에 이 방식을 사용하고 있습니다.

  • 원리: 사용자마다 하나의 '버킷(바구니)'이 부여됩니다. 이 버킷에는 지정된 시간마다 일정량의 '토큰'이 차곡차곡 채워집니다. 사용자가 API를 1번 호출할 때마다 토큰이 1개씩 소모되며, 버킷에 토큰이 하나도 남아있지 않다면 해당 요청은 429 에러와 함께 즉시 버려집니다.
  • 장점: 짧은 시간에 요청이 확 몰리는 버스트(Burst) 트래픽을 유연하게 처리할 수 있습니다. 예를 들어 1분당 60개의 토큰이 차오르는 설정이라면, 1초 만에 60번을 호출하는 극단적인 몰아치기도 버킷 용량만 넉넉하다면 허용해 줍니다.

토큰 버킷(Token Bucket) 처리율 제한 알고리즘 동작 원리
토큰 버킷 방식은 바구니에 토큰이 남아있는 한 트래픽을 허용하므로, 일시적으로 트래픽이 폭증하는 상황을 부드럽게 넘길 수 있습니다.

② 누수 버킷 (Leaky Bucket) 알고리즘

토큰 버킷과 반대로, 물(트래픽)이 떨어지는 양을 일정하게 조절하는 방식입니다. Nginx의 기본 처리율 제한 방식(limit_req)이 이를 채택하고 있습니다.

  • 원리: 요청이 들어오면 일단 큐(Queue) 모양의 버킷에 담습니다. 그리고 버킷 바닥에 난 구멍을 통해 '항상 일정한 속도'로 요청을 서버로 흘려보냅니다. 만약 요청이 너무 빨리 들어와서 버킷 위로 물이 넘쳐흐르면(Overflow), 그 넘친 요청들은 가차 없이 버려집니다.
  • 장점: 서버가 처리하는 트래픽의 양이 항상 일정하게 유지되므로, 시스템이 매우 안정적으로 동작합니다(Traffic Shaping). 단점으로는 버스트 트래픽을 허용하지 않으므로, 갑작스러운 트래픽 폭증 시 사용자 입장에서는 응답이 늦어지거나 쉽게 차단당한다고 느낄 수 있습니다.

 

2. 실전 트러블슈팅: Redis로 토큰 버킷을 만들다 터진 대형 사고

스프링 부트(Spring Boot)로 다중 서버 환경(Scale-out)을 구축했다면, 각 서버의 메모리 대신 중앙 집중형 저장소인 Redis를 활용해 처리율 제한을 구현하는 것이 정석입니다.

 

자신만만하게 사용자 ID를 키(Key)로 삼아 Redis의 GET과 SET 명령어로 토큰 버킷 로직을 짰습니다.

Java

// 🚨 [초보적인 실수] 실서버에서 동시성 문제로 터지는 코드
int tokens = redisTemplate.opsForValue().get(userId + ":tokens");
if (tokens > 0) {
    redisTemplate.opsForValue().decrement(userId + ":tokens");
    return "API 호출 성공";
} else {
    throw new TooManyRequestsException("429 Too Many Requests");
}

개발 서버에서는 기가 막히게 작동했습니다. "초당 5회 제한"을 걸었더니 정확히 6번째부터 차단되었죠. 하지만 실서버에 배포하고 이벤트가 시작된 순간, 매크로 봇이 1초에 100번의 요청을 동시에 쏘아대자 초당 5회 제한이 뚫리고 100번의 요청이 모두 성공해 버리는 대형 버그가 터집니다.

왜 뚫렸을까? (경쟁 상태, Race Condition)

너무나도 전형적인 동시성 문제입니다. 100개의 스레드가 거의 동일한 밀리초(ms)에 Redis의 GET을 호출했습니다. 100개의 스레드 모두 현재 토큰 개수를 '5개'라고 읽어온 것이죠.

그 결과 모두가 "토큰이 있네?"라고 판단하고 로직을 통과해버린 뒤, 각자 -1을 수행하며 DB를 쑥대밭으로 만들었습니다.

 

3. 해결 베스트 프랙티스: Lua Script와 Bucket4j 라이브러리

이 동시성 문제를 해결하려면 토큰을 확인(GET)하고, 토큰을 차감(DECR)하는 과정을 분리하지 말고 절대 끊어지지 않는 하나의 원자적(Atomic) 연산으로 묶어주어야 합니다.

① Redis Lua Script 직접 작성 (원초적 해결)

Redis 내장 스크립트 언어인 Lua Script를 사용하면, 스크립트가 실행되는 동안 Redis는 다른 명령어를 일절 처리하지 않고 싱글 스레드로 완벽하게 차단해 줍니다.

Lua

-- 원자성이 보장되는 Redis Lua Script 예시
local tokens = tonumber(redis.call('get', KEYS[1]))
if tokens > 0 then
    redis.call('decr', KEYS[1])
    return 1 -- 허용
else
    return 0 -- 차단 (429)
end

하지만 이 토큰 충전 로직(시간 계산 등)을 일일이 Lua 스크립트로 짜는 것은 실무에서 유지보수 지옥을 열게 됩니다.

② 실무 표준: Bucket4j 라이브러리 도입

현재 Java/Spring 진영에서 처리율 제한을 구현할 때 가장 널리 쓰이는 압도적 1위 라이브러리는 Bucket4j입니다.

복잡한 Lua 스크립트를 직접 짤 필요 없이, 라이브러리 내부에서 완벽한 원자성을 보장하며 Redis(또는 Hazelcast, Caffeine 등)와 연동해 줍니다.

Java

// ✅ Bucket4j를 활용한 깔끔하고 안전한 실무 코드
Bucket bucket = bucketPlan.resolveBucket(userId); // Redis와 연동된 버킷 

if (bucket.tryConsume(1)) {
    // 토큰 1개 소모 성공 -> 정상 로직 실행
    return ResponseEntity.ok("요청이 처리되었습니다.");
} else {
    // 토큰 소모 실패 -> 429 에러 반환
    return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS)
                         .body("요청이 너무 많습니다. 잠시 후 다시 시도해주세요.");
}

이렇게 tryConsume() 메서드 하나만 호출하면, 동시성 이슈를 완벽하게 방어하면서 아주 우아하게 토큰 버킷 알고리즘을 실서버에 안착시킬 수 있습니다.

 

💡 [데브프리의 실무 꿀팁: 429 에러를 뱉을 때 프론트엔드를 위한 최소한의 매너, 'HTTP 헤더']

백엔드에서 트래픽을 성공적으로 차단하고 429 Too Many Requests 상태 코드를 반환하는 것까지는 좋습니다. 하지만 진짜 시니어 개발자는 여기서 한 발 더 나아가 'HTTP 응답 헤더(Headers)'에 친절한 안내문을 남깁니다.

프론트엔드가 무작정 재시도하지 않도록, 통상적으로 아래와 같은 커스텀 헤더를 함께 내려주는 것이 실무 API 설계의 국룰(표준)입니다.

  • X-RateLimit-Limit: 당신에게 허용된 총 요청 수 (예: 60)
  • X-RateLimit-Remaining: 현재 남은 요청 수 (예: 0)
  • Retry-After: 몇 초 뒤에 다시 요청해야 하는지 (예: 30)

에러 응답 객체에 이 헤더들을 살짝 얹어주면, 프론트엔드 개발자는 Retry-After 시간을 읽고 정확히 그 시간만큼 사용자에게 "잠시 후 다시 시도해 주세요"라는 타이머를 보여줄 수 있답니다!

 

4. 전문가의 확고한 결론 (Verdict)

처리율 제한(Rate Limiting), 실무에 어디서 어떻게 도입해야 할까요?

  • 👍 API Gateway 레벨에서의 도입 (가장 추천): MSA(마이크로서비스) 환경이라면, 백엔드 애플리케이션 서버까지 트래픽이 도달하기도 전에 Spring Cloud Gateway나 Nginx, AWS API Gateway 최전단에서 차단하는 것이 최고의 베스트 프랙티스입니다. 특히 Spring Cloud Gateway는 내부에 RedisRateLimiter(토큰 버킷 기반)를 기본 내장하고 있어, application.yml 설정 몇 줄만으로도 전체 서비스에 대한 훌륭한 방패를 세울 수 있습니다. 악성 트래픽은 애플리케이션에 닿기 전에 입구컷을 하는 것이 서버 자원을 아끼는 길입니다.
  • 🛑 애플리케이션 코드 레벨에서의 도입 시 주의점: "프리미엄 요금제 회원은 1초에 100번, 무료 회원은 1초에 5번"처럼 비즈니스 로직(사용자 등급, 결제 상태)과 딥하게 엮인 세밀한 제어가 필요할 때만 스프링 부트 코드 내부에 Bucket4j 같은 라이브러리를 사용해 구현하세요. 만약 단순히 DDos성 무차별 공격을 막는 것이 목적이라면, 코드 레벨에서 막는 것은 이미 늦습니다. 반드시 앞단(Gateway나 WAF)으로 제어권을 넘기시길 바랍니다.

API가 아무리 빠르고 완벽하게 짜여 있어도, 넘치는 트래픽을 제어하지 못하면 그 서버는 언젠가 반드시 무너집니다. 429 에러를 적절히 뱉어내는 서버야말로 진정으로 단단하고 건강한 서버입니다.

 

오늘 알려드린 토큰 버킷의 원리와 Bucket4j의 강력함을 여러분의 실무 프로젝트에 꼭 적용해 보시길 바랍니다. 다음 포스팅에서도 팩트에 기반한 유용한 아키텍처 꿀팁으로 찾아뵙겠습니다!

 

👉 프론트엔드랑 연결할 때 에러가 난다면? 1초 만에 끝내는 스프링 부트 CORS 설정 코드 생성기 [링크]