한 파트너의 폭주에서 우리 자원 지키기: Open API Rate Limit 만들기
들어가며
안녕하세요, 모비두 개발본부 백엔드팀 김동균입니다.
이번에 파트너사가 사용하는 Open API 앞단에 게이트웨이를 두고, 파트너별 요청량을 제어하는 Rate Limit을 구현했습니다. 한 파트너의 요청이 순간적으로 몰리더라도 같은 서버를 사용하는 다른 파트너와 내부 서비스에 영향이 가지 않도록 트래픽을 제어하는 것이 목표였습니다.
구현 과정에서는 단순히 요청 횟수를 제한하는 것에서 끝나지 않고, 어떤 알고리즘을 선택할지, Rate Limit을 어디에서 처리할지, Redis Cluster 환경에서 어떻게 원자적으로 처리할지, 그리고 Rate Limit을 담당하는 시스템 자체에 장애가 발생했을 때 어떻게 동작해야 할지까지 고민해야 했습니다.
이번 글에서는 이러한 고민을 바탕으로 Rate Limit을 설계하고 구현했던 과정을 공유해 보려고 합니다.
1. 배경: 왜 필요했고, 무엇을 요구했나
저희는 파트너사가 자사 시스템에서 데이터를 가져갈 수 있도록 Open API를 제공하고 있습니다.
이 API는 파트너 전용 서버가 따로 있는게 아니라, 다양한 백엔드에 퍼져있고, 같은 리소스를 바라보고 있습니다.
그리고 API를 외부에서 사용하도록 하면 호출량은 저희가 아니라 파트너의 코드에서 결정되게 됩니다. 파트너의 코드에서 잘못 구성된 코드 하나가 초당 수백 건의 요청을 보내기 시작하면, 그 부하는 해당 파트너에게만 머물지 않고 같은 자원을 사용하는 모든 곳으로 번지게 됩니다.
요구사항
제한은 파트너 단위이며, 추후에는 서비스별로 따로 설정할 수 있어야 했습니다. 파트너 식별은 인증 토큰을 사용해 식별하고, 제한을 설정하지 않은 파트너는 기존과 똑같이 제한없이 통과하게 됩니다. 그리고 제한은 두 단계로 나눠져있습니다.
글로벌 제한은 파트너와 서비스 단위로 전체 요청량을 묶습니다. 예를들어 A 파트너사의 특정 서비스에 20 RPS(초당 요청 수)를 설정했다면, 그 서비스에 들어오는 모든 API 요청의 합이 최대 20건을 넘지않게 됩니다.
엔드포인트 제한은 특정 API에만 더 낮은 한도를 설정하는 것입니다. 예를들어 글로벌 설정에서는 20 RPS를 허용하면서 상품 등록 API인 POST /products에는 2 RPS만 허용하는 식입니다.
목적은 무거운 API만 따로 통제하는 것인데, 여기서 중요한 것은 POST /products가 429로 막히는 동안에도 다른 가벼운 API는 글로벌 한도 안에서 계속 호출될 수 있어야 한다는 점입니다.
마지막으로 제한에 걸린 요청에는 429 Too Many Requests를 반환하면서 파트너가 스스로 속도를 조절할 수 있도록 헤더에 현재 설정된 제한과 남은 요청량 등을 알 수 있도록 X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset, Retry-After를 제공하도록 했습니다.
2. 첫 번째 고민: 어떤 알고리즘을 쓸 것인가
Rate Limit 알고리즘에는 대표적으로 Token Bucket, Leaky Bucket, Fixed Window, Sliding Window Log가 있습니다.
알고리즘을 선택할 때 중요한 것은 어떤 알고리즘이 더 좋은가가 아니라, 지금 우리가 해결하려는 문제에 대해 어떤 알고리즘이 적합한가를 고려해야합니다. 그래서 저는 해결하고자 상황이 어떤게 있는가에 대해 고민하였고, 아래 상황으로 정리해보았습니다.
순간적으로 몰리는 요청을 얼마나 허용할 것인가?
시간 경계에서 한도가 예상보다 많이 허용되는 문제를 막아야 하는가?
파트너와 엔드포인트가 늘어나도 저장 공간을 감당할 수 있는가?
한도를 넘은 요청을 즉시 거절할 것인가, 기다리게 할 것인가?
우선 각 알고리즘이 어떻게 동작하는지 간단히 살펴보겠습니다.
Token Bucket은 버킷에 토큰을 일정한 속도로 채우고, 요청이 들어올 때마다 토큰을 하나씩 소비합니다. 버킷에 토큰이 남아 있다면 그만큼 요청을 처리할 수 있고, 토큰이 부족하다면 요청을 거절하게 됩니다.
Fixed Window는 정해진 시간마다 카운터를 초기화하고, 해당 구간에 유입된 요청수를 카운트합니다.
Leaky Bucket은 요청을 큐에 넣고 일정한 속도로 꺼내 처리합니다. 요청이 순간적으로 몰리더라도 일정한 속도로 요청을 처리할 수 있습니다.
Sliding Window Log는 요청이 들어온 시간을 기록해두고, 요청이 들어올 때마다 최근 시간 구간에 몇 건의 요청이 있었는지를 계산하게 됩니다. 시간 경계를 기준으로 요청이 갑자기 몰리는 문제를 방지하면서 비교적 정확하게 요청량을 제한할 수 있습니다.
질문 | Token Bucket | Fixed Window | Leaky Bucket | Sliding Window Log |
|---|---|---|---|---|
순간 폭주 허용 | 버킷 용량만큼 허용 | 윈도우 내 허용 | 큐에서 대기 | 설정한 윈도우 내 한도 적용 |
시간 경계 문제 | 방어 | 취약 | 방어 | 방어 |
키당 저장 공간 | 토큰 수 + 마지막 갱신 시각, O(1) | 카운터 1개, O(1) | 큐, O(N) | 요청 시각 기록, O(N) |
초과 요청 처리 | 즉시 429 | 즉시 429 | 대기 후 처리 | 즉시 429 |
Fixed Window 탈락
예를 들어 초당 10건으로 제한한다고 가정해 보겠습니다.
0.9초에 10건이 들어오고, 1.1초에 다시 10건이 들어온다면 두 요청 묶음은 서로 다른 윈도우에 속하기 때문에 모두 통과할 수 있습니다. 결과적으로 실제로는 0.2초 사이에 20건의 요청이 백엔드에 도달할 수 있습니다.
초당 10건이라는 제한을 설정했는데 짧은 시간에 그보다 훨씬 많은 요청을 허용할 수 있기 때문에, 백엔드 리소스를 보호한다는 목적에는 적합하지 않다고 판단했습니다.
Sliding Window Log 탈락
Sliding Window Log는 요청마다 발생 시각을 기록하기 때문에 시간 경계 문제를 피하면서 정확하게 요청량을 계산할 수 있습니다. 하지만 요청이 들어올 때마다 그 시각을 저장해야 하기 때문에 Rate Limit 대상이 많아질수록 저장해야하는 데이터도 함께 늘어나는 문제가 있습니다.
Leaky Bucket 탈락
Leaky Bucket은 요청을 큐에 넣고 일정한 속도로 꺼내 처리하는 방식입니다. 이 방식은 처리량을 일정하게 유지할 수 있지만 저희가 원하는 동작과는 달랐습니다.
Token Bucket 선택
이렇게 요구사항을 기준으로 하나씩 비교해보니 Token Bucket을 선택하게 되었습니다.
파트너의 요청은 항상 일정한 간격으로 들어오는게 아니라 순간적으로 몰릴 수 있기 때문에 이러한 모든 상황을 차단하기 보다는 일정 수준의 버스트는 허용하면서 지속적으로 한도를 초과하는 트래픽을 제한하는 것이 저희가 원하는 동작에 가까웠습니다.
결국 저희가 선택한 것은 단순히 Token Bucket이 더 좋은 알고리즘이어서가 아니라 순간적인 폭주는 어느정도 허용하면서 지속적인 초과 요청은 즉시 거절하고, 여러 파트너와 엔드포인트의 상태를 효율적으로 관리해야 한다는 요구사항에 가장 적합했기 때문입니다.
3. 두 번째 고민: Rate Limit을 어디에서 처리할 것인가
3.1 서버 측 vs 게이트웨이
알고리즘을 Token Bucket으로 결정하고 나니 이제 Rate Limit을 어디에서 처리할 건가에 대한 고민이 생겼습니다.
처음에는 각 백엔드 서비스에서 Rate Limit을 처리하는 방법을 생각할 수 있습니다.
하지만 각 백엔드 서비스마다 Rate Limit을 구현한다면 같은 정책을 여러 곳에서 관리해야 합니다. 새로운 서비스가 추가될 때마다 해당 서비스에도 Rate Limit 로직을 추가해야 하고, 파트너 단위의 전체 요청량을 제한하려면 여러 서비스에서 발생하는 요청량을 하나의 기준으로 판단할 수 있도록 별도의 상태 공유도 필요합니다.
반대로 게이트웨이에 Rate Limit을 두면 외부에서 들어오는 요청을 한 곳에서 먼저 판단한 후, 제한을 통과한 요청만 적절한 백엔드 서비스로 전달할 수 있습니다.
특히 이번 Rate Limit의 목적 중 하나는 특정 파트너의 요청이 급증하더라도 백엔드 서비스의 리소스가 영향을 받지 않도록 보호하는 것이었습니다.
따라서 백엔드에 요청이 도달한 이후에 제한하는 것보다, 게이트웨이에서 먼저 요청을 차단하는 것이 목적에 더 적합하다고 판단했습니다.
그래서 저는 게이트웨이에 두었으며, 만약 아래와 같은 상황이 있다면 특정 백엔드에 두어야 하지 않을까 고민해볼 수 있을 것 같습니다.
결제에 N번 실패한 사용자는 1분에 1번만 요청할 수 있다.
이러한 정책은 사용자의 결제 실패 이력이라는 도메인 상태가 필요하기 때문에 판단을 내리기 위해서는 백엔드의 DB나 도메인 로직을 게이트웨이에서 알아야 한다면, 오히려 해당 도메인을 가장 잘 알고 있는 서버에서 처리하는 게 자연스럽습니다.
결국 Rate Limit의 위치를 결정할 때 제한 여부를 판단하는데 어떤 정보가 필요한가를 기준으로 생각했습니다.
그래서 이번에 구현하게 된 Rate Limit은 Access Token에서 파트너의 고유 식별자를 추출하고, HTTP Method와 Path를 이용해 제한 정책을 결정합니다.
즉, 백엔드의 도메인 상태를 조회하지 않고, 요청 자체에 포함된 정보만을 가지고 제한 여부를 판단할 수 있기 때문에 게이트웨이에 두게 되었습니다.
3.2 왜 WebFlux가 아니라 가상 스레드인가
게이트웨이에 Rate Limit을 구현하기로 결정한 다음 어떤 방식으로 동작시킬까를 고민하였습니다.
Spring Cloud Gateway는 WebFlux 기반의 Reactive 게이트웨이로 잘 알려져 있지만, 서블릿 기반으로 동작하는 Spring Cloud Gateway Server MVC도 제공합니다.
저희는 Server MVC를 선택하고, 동시성은 가상 스레드(Virtual Thread)로 확보하는 방식을 택했습니다.
Spring Boot에서는 spring.threads.virtual.enabled: true 설정 한 줄로 요청 처리 스레드를 가상 스레드로 바꿀 수 있습니다.
그 이유는 Rate Limit 요청 하나를 처리하는 과정에서 Redis에 접근해 Token Bucket의 상태를 확인하고, 이후 조건에 따라 백엔드로 요청을 전달하게 됩니다.
저는 이 과정을 Reactive 체계로 전환하기보다는 기존의 동기식 프로그래밍 모델을 유지하면서 I/O 비용을 줄이는 방향을 선택하였습니다.
또한 WebFlux는 비동기·논블로킹 프로그래밍 모델을 기반으로 하기 때문에 Reactive Streams와 비동기 실행 흐름에 대한 이해가 높아야하고, 러닝 커브가 높고 디버깅이 어렵다고 생각하였습니다.
반면 가상 스레드를 사용하면 기존의 동기식 코드 구조를 크게 변경하지 않으면서도 I/O 대기가 많은 환경에서 많은 수의 동시 요청을 처리할 수 있다는 장점이 있었습니다.
그래서 이번 프로젝트에서는 다음과 같은 이유로 가상 스레드를 선택하게 되었습니다.
기존 동기식 프로그래밍 모델을 유지할 수 있다.
Redis와 같은 I/O 작업에서 스레드가 대기하는 비용을 줄일 수 있다.
Reactive 체계에 맞춰 전체 코드를 변경하지 않고 동시성 처리 모델을 가져갈 수 있다.
팀에서 코드의 흐름을 이해하고 디버깅하기 쉬운 모델을 유지할 수 있다.
또한 가상 스레드를 사용할 때는 JDK 버전에 대해서도 고민할 필요가 있습니다.
가상 스레드는 JDK 21에서 정식으로 제공되었지만, 초기 버전에서는 synchronized 블록이나 특정 상황에서 가상 스레드가 캐리어(Carrier) 스레드에 고정되는 pinning 문제를 고려해야 했습니다.
가상 스레드는 작업을 수행할 때 플랫폼 스레드인 캐리어 스레드에 올라가 실행되는데, pinning이 발생하면 가상 스레드가 캐리어 스레드에서 블로킹되는 동안에도 캐리어 스레드에서 언마운트되지 못해, 캐리어 스레드를 다른 가상 스레드에 넘겨주지 못하게 됩니다. 이로 인해 캐리어 스레드의 활용성이 떨어지고, 동시성이 높은 환경에서는 성능에 영향을 줄 수 있습니다.
synchronized로 인한 pinning은 JDK 24(JEP 491)에서 개선되었고, 저희는 이 개선이 포함된 LTS 버전인 JDK 25를 선택했습니다.
정리하자면 이번 프로젝트에서는 Rate Limit의 판단 자체는 Gateway에서 중앙화하고, 애플리케이션의 동시 요청 처리는 Virtual Thread 기반으로 가져가는 방향을 선택하게 되었습니다.
4. 세 번째 고민: Redis Cluster에서 어떻게 원자적으로 처리할 것인가
게이트웨이는 여러 대가 실행되고 있고, 각 인스턴스는 동시에 수많은 요청을 처리할 수 있습니다. 이때 사용 가능한 토큰을 확인하고, 토큰 차감 로직을 원자적으로 처리하지 않으면 동시성 문제가 발생할 수 있습니다.
한 인스턴스 안이라면 락으로 순서를 보장할 수 있지만, 게이트웨이는 여러 대가 같은 버킷을 공유합니다. JVM 안의 락으로는 다른 인스턴스의 요청을 막을 수 없기 때문에, 원자성은 버킷이 저장된 Redis에서 보장해야 했습니다.
결국 토큰 확인과 차감을 하나의 원자적인 작업으로 만드는 것인데, 이때 분산 락을 사용하는 방법을 생각할 수 있습니다. 하지만 모든 Rate Limit 요청마다 락을 획득하고 해제해야 하므로 모든 요청이 거쳐가는 게이트웨이의 처리 과정에 추가적인 Redis 요청과 대기 시간이 발생하게 됩니다.
그래서 저는 Redis의 Lua Script를 사용하기로 하였습니다. Redis의 Lua Script는 하나의 원자적인 실행 단위로 처리되기 때문에, 스크립트가 실행되는 동안 다른 클라이언트의 명령이 중간에 끼어들 수 없습니다.
따라서 별도의 분산락 없이 토큰 조회부터 차감까지 하나의 로직을 원자적인 작업으로 묶을 수 있습니다.
읽기부터 차감까지, Lua Script 하나로
읽기, 충전량 계산, 차감, TTL 갱신까지 필요한 작업을 하나의 Lua Script 안으로 넣었습니다.
이제 게이트웨이는 Redis에 여러번 요청하는 대신 “이 요청을 허용해도 되는가?”를 한 번 요청하고 결과를 받을 수 있습니다.
List<?> result = redisTemplate.execute(
SCRIPT,
List.of(globalKey, endpointKey),
String.valueOf(nowMillis),
String.valueOf(globalRps),
String.valueOf(globalCapacity),
String.valueOf(endpointRps),
String.valueOf(endpointCapacity)
);그리고 이 스크립트는 다음 네 가지의 값을 반환하게 됩니다.
{ allowed, limit, remaining, reset_seconds }allowed가 0이면 게이트웨이에서 바로 429 Too Many Requests를 반환합니다.
나머지 값은 파트너에게 현재 Rate Limit 상태를 알려주는 데 사용하게 됩니다.
limit→X-RateLimit-Limitremaining→X-RateLimit-Remainingreset_seconds→Retry-After현재 시각 + reset_seconds인 Unix timestamp→X-RateLimit-Reset
또한 Redis 스크립트 사용 시 내부에서 TIME 명령어를 사용할 수 있지만 String.valueOf(nowMillis)을 애플리케이션에서 인자로 전달하였습니다.
이유는 테스트 가능성을 높이기 위해서인데, 토큰 버킷은 시간의 경과에 따라 토큰이 충전되기 때문에 시간 제어가 중요합니다. 만약 스크립트 내부에서 현재 시간을 가져온다면 테스트에서 시간을 원하는 시점으로 조작하기 어렵고 실제로 시간이 흐르기를 기다려야 합니다.
반면 애플리케이션에서 nowMillis를 전달하면 Clock을 주입하는 방식으로 테스트 시간을 제어할 수 있습니다.
토큰은 저장된 값이 아니라 계산된 값입니다
Token Bucket을 처음 구현할 때는 일정한 주기로 토큰을 충전하는 스케줄러를 생각할 수도 있습니다.
하지만 파트너 수와 엔드포인트 수가 늘어날수록 모든 버킷을 주기적으로 갱신하는 것은 비효율적입니다.
그래서 저는 토큰을 미리 충전하지 않고, 요청이 들어온 순간에 경과 시간을 이용해 충전량을 계산하도록 하였습니다.
Redis에는 tokens와 last_updated 두 값만 저장합니다. 마지막으로 요청이 처리된 시간과 당시의 토큰 잔량을 알고 있다면 경과 시간 × RPS = 충전된 토큰 수를 이용해 현재 토큰을 계산할 수 있습니다.
또한 게이트웨이가 여러 대로 구성되어 있기 때문에 각 인스턴스의 시스템 시간이 완전히 동일하다고 가정하기 어렵습니다.
예를 들어 이전 요청을 처리한 게이트웨이의 시간이 현재 요청을 처리하는 게이트웨이보다 조금 미래라면 now - last_updated < 0이 될 수 있습니다. 이를 그대로 사용하면 경과 시간이 음수가 되고, 토큰 충전량 역시 음수가 될 수 있습니다. 이 값이 현재 토큰 수에 반영되면 토큰이 충전되는 대신 오히려 감소하는 비정상적인 결과가 발생할 수 있습니다.
따라서 작은 시간 오차가 Rate Limit 상태에 영향을 주지 않도록 경과 시간을 항상 0 이상으로 보정했습니다.
local elapsed_sec = math.max(0, now - last_updated) / 1000
이렇게 하면 게이트웨이 인스턴스 간 미세한 시간 차이로 인해 토큰이 의도치 않게 감소하는 것을 방지할 수 있습니다.
또한 Redis에 버킷 정보가 없다면 해당 버킷을 아직 사용하지 않았거나 TTL이 만료된 경우입니다. 이 경우에는 가득 찬 버킷인 capacity에서 시작하도록 처리했습니다.
function current_tokens(key, rps, capacity)
local data = redis.call('HMGET', key, 'tokens', 'last_updated')
local tokens = tonumber(data[1])
local last_updated = tonumber(data[2])
if tokens == nil or last_updated == nil then
return capacity
end
-- 다중 인스턴스 시계 오차로 경과 시간이 음수가 될 수 있어 0으로 클램프
local elapsed_sec = math.max(0, now - last_updated) / 1000
local refilled = elapsed_sec * rps
return math.min(capacity, tokens + refilled)
end
차단할 때는 아무것도 저장하지 않습니다
Token Bucket에서 토큰이 부족한 요청은 다음과 같이 처리합니다. 여기서 중요한 부분은 차단된 요청에서는 Redis에 상태를 저장하지 않는다는 점입니다. 차단된 요청은 백엔드까지 전달되지 않기 때문에 토큰을 소비하지 않습니다.
따라서 차단된 요청에서는 버킷 상태를 변경하지 않습니다. 토큰을 소비하지 않은 요청 때문에 last_updated까지 변경되면, 실제 토큰 소비 시점과 충전 기준 시점이 달라져 Token Bucket의 상태가 실제 요청 흐름과 어긋날 수 있기 때문입니다.
function try_consume(key, rps, capacity)
local tokens = current_tokens(key, rps, capacity)
if tokens < 1 then
-- 차단: 아무것도 저장하지 않는다. reset = 토큰 1개가 충전될 때까지 남은 시간(초, 올림)
local reset_seconds = math.ceil((1 - tokens) / rps)
return {0, rps, 0, reset_seconds}
end
redis.call('HSET', key, 'tokens', tostring(tokens - 1), 'last_updated', tostring(now))
redis.call('PEXPIRE', key, ttl_millis(rps, capacity))
return {1, rps, math.floor(tokens - 1), 0}
end
키가 없다는 것은 가득 찬 버킷이라는 뜻입니다
버킷의 상태를 Redis에 계속 남겨둘 수도 있지만, 파트너와 엔드포인트가 늘어나면 사용하지 않는 버킷까지 계속 메모리를 차지하게 됩니다. 그래서 유휴 버킷은 TTL을 이용해 삭제하도록 하였습니다. 하지만 버킷을 언제 삭제해도 괜찮을까? 즉 TTL을 어떻게 설정하는게 좋을까라는 고민을 하게되었습니다.
그래서 저는 Redis에 버킷 키가 없으면 가득 찬 버킷으로 간주하겠다라는 정의를 두게되었습니다.
그렇다면 키를 삭제해도 되는 시점은 버킷이 어차피 가득 차 있을 시점이어야 합니다.
Token Bucket의 완전 충전 시간은 다음과 같이 계산할 수 있습니다.
완전 충전 시간 = capacity / rps
따라서 TTL이 완전 충전 시간보다 충분히 길다면 키가 만료되어 없음 = 키가 존재하고 tokens = capacity라는 등가 관계를 만들 수 있습니다.
즉, 토큰을 소비할 때마다 TTL이 갱신되므로, 키가 만료되어 사라졌다면 그동안 토큰을 소비한 요청이 없었다는 뜻이고, 그렇다면 토큰은 이미 최대 용량까지 충전되어 있어야 합니다.
그래서 TTL의 설정은 동적으로 계산하도록 하였습니다.
function ttl_millis(rps, capacity)
local full_refill_sec = math.ceil(capacity / rps)
return math.max(10000, full_refill_sec * 2 * 1000)
end완전 충전 시간만큼만 TTL을 설정하면 경계 시점의 시간 오차나 반올림으로 인해 키가 예상보다 빨리 만료될 수 있기 때문에, 완전 충전 시간의 2배를 사용해 여유를 두었습니다. 또한 최소 TTL은 10초로 설정했습니다.
Redis Cluster에서는 키의 위치도 고려해야 합니다
Redis Cluster에서는 모든 키가 하나의 Redis에 저장되는 것이 아니라 해시 슬롯을 기준으로 여러 노드에 분산됩니다.
그런데 Lua Script에서 여러 키를 함께 사용하려면 해당 키들이 같은 해시 슬롯에 있어야 합니다. 그렇지 않으면 CROSSSLOT 오류가 발생합니다.
글로벌 버킷과 엔드포인트 버킷을 하나의 Lua Script에서 함께 처리하기 때문에 두 키가 같은 슬롯에 배치되도록 Redis의 Hash Tag를 사용했습니다.
rate-limit:bucket:{mobidoo:SAUCE_SERVICE}:global
rate-limit:bucket:{mobidoo:SAUCE_SERVICE}:POST:/productsRedis Cluster는 {} 안의 값을 기준으로 해시 슬롯을 결정하기 때문에 위 두 키는 같은 슬롯에 배치됩니다.
이렇게 파트너와 서비스 단위로 Hash Tag를 고정하면 같은 파트너·서비스의 Rate Limit 버킷은 같은 슬롯에서 원자적으로 처리하면서, 다른 파트너의 버킷은 서로 다른 슬롯으로 분산할 수 있습니다.
5. 네 번째 고민: Rate Limit 시스템 자체가 죽으면 어떻게 할 것인가
파트너의 모든 요청이 게이트웨이를 거치기 때문에, Rate Limit을 판단하는 과정 자체가 장애를 일으키면 Rate Limit이 서비스의 새로운 장애 지점이 될 수 있습니다.
그렇기 때문에 Redis가 죽었을 때 요청을 막을 것인지, 통과시킬 것인지 결정해야 했습니다.
방식 | Redis 장애 시 | 장점 | 단점 |
|---|---|---|---|
fail-closed | 요청 차단 | 백엔드 보호 | Rate Limit 장애가 서비스 장애로 이어질 수 있음 |
fail-open | 요청 통과 | 기존 서비스 동작 유지 | 제한 없이 트래픽이 들어올 수 있음 |
저희는 fail-open을 선택했습니다.
Rate Limit은 기존 서비스에 없던 보호 장치입니다. 따라서 Rate Limit 장애로 인해 기존에 정상적으로 처리되던 요청까지 막는 것은 피하고 싶었습니다.
설정 캐시는 DB로 폴백한다
Rate Limit 설정은 Redis 캐시에 저장하고, 원본은 MySQL에서 관리합니다.
따라서 캐시 조회에 실패하면 DB에서 설정을 다시 조회하도록 했습니다.
여기서 한 가지 문제가 있었습니다.
Rate Limit이 설정되지 않은 파트너는 Redis에 저장할 값 자체가 없기 때문에 매 요청마다 DB를 조회하게 됩니다.
이를 해결하기 위해 설정이 없다는 사실도 캐시에 저장했습니다.
캐시 상태 | 의미 | 동작 |
|---|---|---|
설정 정보 | 설정 있음 | 캐시 사용 |
__NOT_FOUND__ | 설정 없음 | DB 조회 없이 통과 |
키 없음 | 아직 조회하지 않음 | DB 조회 후 캐시 |
이렇게 하면 정상적인 요청에서는 대부분 Redis에서 처리가 끝나고, DB는 캐시가 만료되거나 캐시를 사용할 수 없을 때만 조회합니다.
6. 검증과 전환
여기까지가 Rate Limit을 어떻게 설계하고 구현할 것인가에 대한 이야기였습니다.
이제 남은 것은 실제로 파트너 트래픽 앞에 게이트웨이를 세우는 일이었습니다.
저희는 기존 API를 변경하는 대신, 파트너용 Open API를 새로운 도메인으로 구성하고 게이트웨이를 새로운 진입점으로 두는 방식을 선택했습니다.
이미 운영 중인 트래픽을 옮길 필요는 없었지만, 반대로 처음부터 인증·라우팅·프록시·Rate Limit이 모두 정상적으로 동작해야 했습니다.
프록시는 생각보다 투명하지 않습니다
헤더, 바디, CORS, 쿼리스트링, 리다이렉트 등 여러 요소가 프록시를 거치면서 달라질 수 있었습니다.
증상 | 원인 | 해결 |
|---|---|---|
게이트웨이를 거칠 때만 401 |
| 백엔드 호출을 HTTPS로 변경, HTTP 클라이언트 명시 |
폼 바디가 비어 있음 | 서블릿 필터가 바디를 먼저 소비 | 프록시 경로에 바디 복원 처리 |
브라우저에서만 CORS 오류 | preflight 및 429 응답의 CORS 처리 문제 | 게이트웨이를 CORS 단일 책임자로 구성 |
가장 오래 걸렸던 문제는 401이었습니다.
백엔드를 직접 호출하면 200인데, 같은 토큰으로 게이트웨이를 거치면 401이 발생했습니다.
처음에는 Host 헤더가 원인이라고 생각했습니다. 게이트웨이가 전달하는 Host를 수정했지만 문제가 해결되지 않았습니다.
추적해 보니 백엔드 앞의 로드밸런서가 HTTP 요청을 HTTPS로 리다이렉트하고 있었고, 게이트웨이가 백엔드를 HTTP로 호출하면서 이 리다이렉트가 클라이언트까지 전달되고 있었습니다.
이 과정에서 호스트가 변경되면서 Authorization 헤더가 제거됐고, 결국 토큰이 없는 요청이 백엔드에 도착하고 있었습니다.
게이트웨이가 백엔드를 HTTPS로 호출하도록 변경해 해결했습니다.
실제 저장소로 검증하기
Rate Limit의 핵심은 Redis Lua Script에 있기 때문에 Mock만으로는 충분하지 않았습니다.
토큰 충전과 차감, TTL, 키 생성 등을 실제 Redis에서 확인하기 위해 Testcontainers로 MySQL과 Redis를 실행하는 통합 테스트를 구성했습니다.
테스트마다 컨테이너를 새로 실행하면 느려지기 때문에 테스트 JVM에서 한 번만 실행하도록 구성했습니다.
백엔드는 WireMock으로 구성하고, 게이트웨이가 실제로 어떤 요청을 전달했는지를 확인했습니다.
이를 통해 Rate Limit뿐 아니라 프록시의 헤더·바디 전달까지 함께 검증했습니다.
테스트 도구가 문제를 숨길 수도 있다
대괄호가 포함된 쿼리스트링이 게이트웨이에서 거부되는 문제를 테스트하면서 또 하나의 문제를 발견했습니다.
일반 HTTP 클라이언트로는 이 문제가 재현되지 않았습니다. 클라이언트가 요청을 보내기 전에 대괄호를 자동으로 인코딩했기 때문입니다.
결국 소켓을 직접 열어 실제 요청 형태를 만들어 테스트했습니다.
테스트 도구가 요청을 대신 보정해주면 실제 클라이언트에서 발생하는 문제를 오히려 가릴 수 있었습니다.
따라서 프록시처럼 요청 자체가 중요한 기능은 테스트 클라이언트가 요청을 어떻게 변경하는지도 확인해야 했습니다.
private String rawGet(String pathWithQuery) throws IOException {
try (Socket socket = new Socket("localhost", port)) {
OutputStream out = socket.getOutputStream();
String request = "GET " + pathWithQuery + " HTTP/1.1\r\n"
+ "Host: " + GATEWAY_HOST + "\r\n"
+ "Authorization: token \r\n"
+ "Connection: close\r\n"
+ "\r\n";
out.write(request.getBytes(StandardCharsets.UTF_8));
out.flush();
try (BufferedReader reader = new BufferedReader(new InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8))) {
return reader.readLine(); // 상태 줄 (예: HTTP/1.1 200)
}
}
}
헬스체크도 장애 정책과 맞춰야 한다
위에서 정한 fail-open 정책은 인프라 설정에도 반영해야 했습니다.
Redis가 장애 나더라도 게이트웨이는 요청을 계속 처리해야 하는데, 헬스체크가 Redis 상태까지 확인하면 로드밸런서가 정상적으로 동작하는 태스크를 비정상으로 판단할 수 있습니다.
그래서 로드밸런서의 헬스체크는 외부 저장소 상태가 아닌 애플리케이션의 liveness만 확인하도록 구성했습니다.
7. 마치며
이번 작업은 "파트너별로 요청 수를 제한한다"는 한 문장에서 출발했습니다.
하지만 실제로 오래 고민했던 것은 요청을 세는 방법 자체가 아니라, 그 판단을 어디에서, 어떤 기준으로, 그리고 실패했을 때 어떻게 처리할 것인가였습니다.
돌아보면 네 가지 선택 모두 기존 서비스 동작은 해치지 않으면서 백엔드를 보호한다는 기준으로 내렸습니다.
고민별로 정리하면 아래와 같습니다.
고민 | 선택 | 선택 기준 |
|---|---|---|
어떤 알고리즘을 사용할 것인가 | Token Bucket | 순간적인 폭주는 허용하되 지속적인 초과 요청은 제한할 것 |
어디에서 처리할 것인가 | Gateway | 백엔드에 도달하기 전에 트래픽을 차단하고, 요청에 포함된 정보만으로 판단할 것 |
어떻게 원자적으로 처리할 것인가 | Redis Lua Script | 검사와 차감 사이에 다른 요청이 개입하지 못하도록 할 것 |
Rate Limit 자체가 실패하면 어떻게 할 것인가 | fail-open | 보호 장치 때문에 서비스 자체가 중단되지 않도록 할 것 |
Rate Limit은 결국 요청을 거절하기 위한 기능입니다.
하지만 이번 작업에서 더 중요했던 것은 얼마나 많이 거절할 것인가가 아니라, 언제 거절해야 하는가였습니다.
트래픽을 보호하면서도 정상적인 요청은 최대한 통과시키고, Rate Limit 자체의 장애가 서비스 장애로 이어지지 않도록 만들고, 제한에 걸린 파트너에게는 다시 요청할 수 있는 정보를 제공하는 것.
결국 Rate Limit의 역할은 단순히 요청을 막는 것이 아니라, 서비스와 파트너가 함께 안정적으로 사용할 수 있는 경계를 만드는 것이라고 생각합니다.
읽어주셔서 감사합니다.