실무 개발

API Rate Limiting - 2. 분산 환경에서의 Rate Limiter 구현

Jason of the Argos 2025. 8. 17. 18:33

Intro

MSA 환경에서 운영 중인 서비스에서 간헐적으로 자원 고갈이 발생하는 케이스가 있었다.

3rd Party에 제공하는 OpenAPI 서비스 중 리소스 heavy한 API로 특정 유저들이 요청을 몰아 보내면서 

db thread pool이 고갈됐고, 얼마 지나지 않아 전체 서비스가 다운됐다.

양상이 매우 비슷한 DDoS 공격 같은 경우 앞단 L4나 게이트웨이에서 IP기반으로 어뷰징 방지를 하고 있지만,

application level 정보인 user-id 기반의 제어가 필요하게 되었고, 

어플리케이션 내에서 Rate Limiter를 개발하게 되었다.

 

구현 내용

Rate Limit를 적용하는 방법은 요구사항에 따라 다양하다 (Rate Limiter란 무엇인가 참고)

이번 케이스에선 다음과 같이 설계하였다.

 

  • 어플리케이션에서 대응 (Spring)
  • Redis
  • Lua Script
  • Token Bucket Algorithm

redis + lua script로 구현한 rate limiter 구조

왜 애플리케이션 레벨?

user식별자 당 횟수 제한 -> 이 요구사항은 사실 어플리케이션이 아닌 gateway나 L7 LB 같은 곳에서 처리하는게 이상적이다.

 

  1. gateway에서 미리 정책 위반 요청들이 filter가 되기 때문에 어플리케이션이 보호 받게 되며
  2. gateway의 역할 중 고성능 I/O 및 커넥션 관리라는 부분에 있어서 자연스럽게 결합이 되기 때문이다.

어플리케이션 앞단 rate limiter

 

 

이런 이점에도 불구하고 gateway에 rate limiter를 구현하지 못한 이유는

gateway 외 다른 경로로 들어오는 요청에 대해서도 rate-limit 적용이 필요했기 때문이다.

 

rate-limit를 적용하고자 한 어플리케이션 서버가 오직 외부에 제공하는 API들만 있었다면 이상적이지만

이 서비스에선 Open API 외 내부에서도 호출 중인 API들도 섞여있고, 이 중 일부는 rate-limit를 적용이 필요했다.

이 내부 호출 API들은 gateway를 거치지 않고 Eureka를 통해 직접 호출하기 때문에

gateway에 rate-limit를 달면 트래픽 제어를 받지 못한다.

 

왜 Redis + Lua?

1. 가장 큰 이유는 분산 환경에서의 일관성이 보장되기 때문이다.

여러 인스턴스가 동시에 같은 키를 갱신 시도를 해도, Redis 에서 Lua Script는 단일 명령 처럼 원자적으로 실행된다.
즉, 동시성 문제 없이 정확한 토큰 계산/차감을 보장한다.

 

2. 간단하고 안전한 확장성
Redis는 싱글 스레드 명령 처리 + 수평 확장 사례가 풍부하다.
따라서 애플리케이션 인스턴스가 늘어나도 단일 진실원(SPoT)으로 레이트 리밋 상태를 공유 하기 쉽고,
트래픽 급증 시에도 계산은 Redis 내에서 빠르게 수행할 수 있다.

왜 Token Bucket?

이번에 발생한 장애상황을 분석해보면:

 

1. 리소스 heavy 한 API가 장애를 유발함

- 일정한 처리율을 보장하되, 너무 큰 버스트는 방지 

 

2. 특정 유저가 자원을 독차지함

- 공정한 리소스 할당

 

-> 토큰 버킷 알고리즘이 적합하다.


구현

1) Lua 스크립트

토큰 버킷 알고리즘을 lua 로 작성하여 매 요청마다 레디스로 이 스크립트를 실행하도록 했다.

 

resources/scripts/token-bucket.lua

-- Token Bucket Rate Limiting Script
-- KEYS[1]=bucket(tokens), KEYS[2]=ts
-- ARGV[1]=ratePerSec, ARGV[2]=burst, ARGV[3]=nowMs, ARGV[4]=ttlMs
-- return {allowed(0/1), tokens(float), retryAfterMs(int)}

local bucketKey = KEYS[1]
local tsKey = KEYS[2]
local r = tonumber(ARGV[1])
local b = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local ttl = tonumber(ARGV[4])

-- 초기 로드 (없으면 가득 찬 상태로 시작)
local tokens = tonumber(redis.call('GET', bucketKey) or b)
local lastTs = tonumber(redis.call('GET', tsKey) or now)

-- 시계 역행 방지
if lastTs > now then now = lastTs end

-- Δt 계산(초). 음수 방지
local delta = (now - lastTs) / 1000.0
if delta < 0 then delta = 0 end

-- 리필: r <= 0이면 리필 없음(0으로 나눔 방지)
local newTokens = tokens
if r and r > 0 then
  newTokens = tokens + (r * delta)
else
  -- r<=0인 경우: 리필이 없으므로 newTokens 그대로
  -- 아래에서 허용/거절 판단만 수행
end

-- 상/하한 캡 + 미세 부동오차 교정
if newTokens > b then newTokens = b end
if newTokens < 0 then newTokens = 0 end

-- 부동소수점 정밀도 개선 (매우 작은 값들을 0으로 처리)
if newTokens < 0.000001 then newTokens = 0 end

local allowed = 0
local retryAfter = 0

if newTokens >= 1.0 then
  newTokens = newTokens - 1.0
  allowed = 1
else
  -- 토큰이 부족한 경우 거절
  allowed = 0
  if r and r > 0 then
    local need = 1.0 - newTokens
    -- 재시도 시간은 올림(ceiling)
    retryAfter = math.ceil((need / r) * 1000.0)
  else
    -- r<=0이면 충전이 없으니 사실상 계속 거절.
    -- 정책에 따라 큰 값이나 ttl을 반환 (여기서는 ttl 사용)
    retryAfter = ttl
  end
end

-- 저장 (유휴 시 자동 만료)
redis.call('SET', bucketKey, newTokens, 'PX', ttl)
redis.call('SET', tsKey, now, 'PX', ttl)
return {allowed, tostring(newTokens), retryAfter}
  • 평균 처리율을 보장하면서 일시적인 버스트를 일정 범위까지 허용
  • 요청을 처리하기 위한 비용 = 토큰
  • 현재 처리 가능한 요청의 양 = 버킷
  • 버킷이 다 비워지면 다시 토큰이 찰 때 까지 기다려야함

자잘한 예외처리를 제외하고 토큰 버킷 로직이 구현된 부분은 다음과 같다.

 

Step 1: 초기 상태 로드

local tokens = tonumber(redis.call('GET', bucketKey) or b)  -- 현재 토큰 수
local lastTs = tonumber(redis.call('GET', tsKey) or now)    -- 마지막 업데이트 시간

 

Step 2: 시간 경과에 따른 토큰 충전

if newTokens > b then newTokens = b end  -- 최대 용량 초과 방지
if newTokens < 0 then newTokens = 0 end  -- 음수 방지

 

Step 3:  버킷 용량 제한

if newTokens > b then newTokens = b end  -- 최대 용량 초과 방지
if newTokens < 0 then newTokens = 0 end  -- 음수 방지

 

Step 4: 요청 처리 판단

if newTokens >= 1.0 then
  newTokens = newTokens - 1.0  -- 토큰 1개 소모
  allowed = 1                  -- 요청 허용
else
  allowed = 0                  -- 요청 거부
  -- 재시도 시간 계산
end

결론

  • 평균 속도 제어: 장기적으로는 r 언저리로 수렴.
  • 버스트 허용: 초기나 한동안 쉬었다면 최대 b개까지 연속 처리가 가능.
  • 단순·효율적: 상태는 tokens와 last_timestamp 두 값이면 충분. O(1) 연산.

 

2) AOP - Annotation 기반 실행

앞서 설명한 lua script를 @TrafficGuard라는 어노테이션이 붙은 API들에 한해 실행하도록 AOP를 적용하였다.
핵심 컴포넌트를 간략하게 설명하자면:
 

🔄 핵심 컴포넌트별 역할

1. @TrafficGuard

  • 역할: AOP 포인트컷 정의
  • 위치TrafficGuardAspect.around()
  • : 메서드 실행 전후로 트래픽 제어 로직 적용

2. @UserRateLimit

  • 역할: Rate Limiting 정책 정의
  • 파라미터ratetimeUnitburstuserHeaderuserBodyField
  • 기능: 사용자별 세밀한 제한 정책 설정

3. DefaultTrafficKeyResolver

  • 역할: 사용자 식별 및 리소스 키 생성
  • 기능: Header/Body에서 사용자 ID 추출, 고유 키 생성

4. UserRateLimitPolicy

  • 역할: Rate Limiting 정책 실행
  • 기능: 어노테이션 파싱, Redis 호출, 예외 처리

5. RedisGuard

  • 역할: Redis 기반 토큰 버킷 알고리즘 실행
  • 기능: Lua 스크립트로 원자적 토큰 관리

 

각 클래스끼리의 실행 흐름은 다음과 같다.

 

자세한 소스는 다음 github를 참고 하기 바란다. https://github.com/insu0929/traffic-guard

 

4) 이후 해야할 것들...

UserRateLimiter 만으로는 완벽한 리소스 방어에는 한계가 있다.

현재까지는 과도한 트래픽이 발생하는 API가 없기 때문에 UserRateLimit 로만으로도 충분히 대응이 되지만,
리소스가 많이 사용되거나 과도한 트래픽이 발생할 경우 여전히 장애가 날 수 있다.

 

따라서 트래픽 방어 방식에 대한 요구사항이 변경될 수 있기 때문에, @TrafficGuard를 확장성 있게 가져가고자 했다.

→ @TrafficGuard에는 다중 GuardPolicy를 적용할 수 있음

 

Chain of Responsibility 패턴과 Strategy 패턴을 결합

 

Step 1: Spring의 의존성 주입

public TrafficGuardAspect(List<GuardPolicy> policies, TrafficKeyResolver resolver) {
    this.policies = policies.stream()
            .sorted(Comparator.comparingInt(GuardPolicy::order))  // 우선순위로 정렬
            .collect(Collectors.toList());
}

TrafficGuardAspect.java에서 GuardPolicy의 모든 구현체를 주입함.

 

Step 2: 정책 필터링

@Around("@annotation(vine.fulfillment.common.traffic.annotation.TrafficGuard)")
public Object around(ProceedingJoinPoint pjp) throws Throwable {
    Method method = ((MethodSignature) pjp.getSignature()).getMethod();
 
    try {
        List<GuardPolicy> chain = policies.stream()
                .filter(p -> p.supports(method))
                .collect(Collectors.toList());

GuardPolicy구현체의 supports() 메서드로 실행여부를 필터링함.
→ supports() 구현은 지정한 어노테이션이 있는지 확인

 
@Override
public boolean supports(Method method) {
    return AnnotationUtils.findAnnotation(method, UserRateLimit.class) != null;
}

 

Step 3: before() 메서드로 트래픽 가드 정책 실행

for (GuardPolicy p : chain) p.before(method, ctx);

 

이렇게 하면 @TrafficGuard 내 @UserRateLimit 와 함께 다른 제한 정책을 함께 chaining 하여 사용할 수 있다.