Spring MVC에서 Redis 기반 Token Bucket Rate Limiter 구현하기

Lord·2025년 7월 1일

Problem Solving Skills

목록 보기
17/17
post-thumbnail

FortiShop은 장애 복원력을 갖춘 MSA 기반 이커머스 플랫폼이다. (이전에도 계속 이 프로젝트에 대한 개선을 작업하는 중이다)
이 프로젝트는 실전에서 발생 가능한 장애 시나리오를 설계하고 직접 실험해보는 데 그 목적을 가지고 한 프로젝트이다.

이번에는 프로젝트 개선의 막바지로써 나는 트래픽 폭주 상황에서의 대응 전략에 집중했다. 예를 들어, 인증 API나 적립금 전송 API와 같은 민감한 요청은 과도한 호출이 발생할 경우 시스템에 과부하를 줄 수 있다. WebFlux 환경이라면 Spring Cloud Gateway에서 제공하는 RedisRateLimiter를 사용하면 되지만, FortiShop은 Spring MVC 기반이었기 때문에 직접 Rate Limiter를 구현해야 했다.


Token Bucket 알고리즘

Rate Limiting 전략은 여러 가지가 있지만, 나는 Token Bucket 알고리즘을 선택했다. 이 방식은 일정 주기로 버킷에 토큰을 채우고 요청 시 하나씩 차감하며, 남은 토큰이 없으면 요청을 거부하는 구조이다. 요청 수를 제한하는 것보다 순간적인 폭주는 허용하면서도 지속적인 남용은 막을 수 있다는 점에서 효율적이었다.

위 그림은 Token Bucket 알고리즘의 동작 흐름을 시각적으로 표현한 것이다.

  • 왼쪽의 초록색 아이콘은 주기적으로 토큰을 채워주는 "리필 속도(refill rate)"를 의미한다. 예를 들어 초당 5개의 토큰이 주입된다고 가정하면, 1초마다 버킷 안에 최대 5개까지 토큰이 쌓인다.
  • 중앙의 버킷은 일정 용량(capacity)을 갖는 토큰 저장소로, 미리 충전된 토큰이 저장되어 있는 공간이다. 이 버킷이 가득 찬 상태에서는 더 이상 토큰이 쌓이지 않는다.
  • 오른쪽의 요청 큐(Requests)는 실제 클라이언트의 요청을 의미하며, 각 요청이 토큰 하나를 소모하면서 버킷에서 빠져나간다. 즉, 토큰이 있을 때만 요청을 처리할 수 있고, 토큰이 모두 소진되면 이후 요청은 차단된다(HTTP 429).

Redis를 선택한 이유

Rate Limiter는 분산 환경에서 모든 서버가 동일한 기준으로 요청을 제어해야 하므로 공유 저장소가 필요하다. 이때 Redis는 단일 노드로도 충분히 빠르며, Lua Script를 통해 원자적(Atomic)으로 제어할 수 있는 강력한 수단을 제공한다. 이러한 이유로 Redis를 선택했고 Redis 내에 다음과 같은 구조로 데이터를 저장했다.

  • Key: tokenbucket:member:{memberId} 또는 tokenbucket:guest:{ip}
  • Value (Hash)
    • tokens: 현재 남아있는 토큰 수
    • last_refill: 마지막으로 토큰을 채운 시간(epoch millis)

Lua Script로 Atomic 처리

Token Bucket 알고리즘을 분산 환경에서 안정적으로 구현하기 위해 가장 중요한 것은 토큰 충전과 소모 연산이 반드시 원자적으로 이루어져야 한다는 점이다. 만약 여러 서버 인스턴스가 동시에 같은 Redis 키를 읽고 수정한다면, 토큰이 중복 소비되거나 누락되는 문제가 발생할 수 있다.

이를 해결하기 위해 Redis의 Lua Script 기능을 활용했다. Lua Script는 Redis 내부에서 단일 명령으로 실행되므로, 여러 명령을 하나의 트랜잭션처럼 Atomic하게 처리할 수 있다.

아래는 실제로 사용한 Lua Script이며, 각 줄을 하나씩 해석해보자.

local bucket = redis.call('HMGET', KEYS[1], 'tokens', 'last_refill')
  • 지정한 Redis 키에서 현재 남은 토큰 수(tokens)와 마지막으로 리필한 시간(last_refill)을 동시에 가져온다.
  • HMGET은 하나의 해시 키에서 여러 필드를 한꺼번에 읽는 명령이다.
  • KEYS[1]"tokenbucket:member:5" 같은 형태의 Redis 키가 된다.
local tokens = tonumber(bucket[1])
local last_refill = tonumber(bucket[2])
  • Redis에서 조회한 값은 문자열이기 때문에, tonumber()를 사용해 정수로 변환한다.
  • 값이 없다면 nil이 들어오므로 이후 처리에서 nil 체크가 필요하다.
local now = tonumber(ARGV[1])
local capacity = tonumber(ARGV[2])
local refill_rate = tonumber(ARGV[3])
  • Java에서 Lua Script 실행 시 넘겨준 인자들을 받는다.
  • 각각 현재 시간 (System.currentTimeMillis()), 최대 토큰 수 (capacity), 초당 리필 속도 (refill_rate)다.
if tokens == nil then
    tokens = capacity
    last_refill = now
end
  • 만약 Redis에 아무 값도 없는 첫 요청이라면, 버킷을 꽉 채우고 지금을 기준으로 last_refill을 초기화한다.
  • 즉, 새로운 회원이거나, Redis TTL로 인해 키가 만료된 경우 이 로직이 작동한다.
local elapsed = math.max(0, now - last_refill)
local refill = math.floor(elapsed / 1000 * refill_rate)
  • 마지막 리필 이후 경과한 시간(elapsed)을 기반으로 얼마나 많은 토큰을 보충할 수 있는지를 계산한다.
  • 초당 refill_rate개의 토큰을 채우기 때문에, 초 단위로 환산해서 채워야 할 토큰 수(refill)를 정수로 계산한다.
  • math.floor()를 사용하는 이유는 실수로 토큰이 채워지는 것을 방지하기 위함이다.
tokens = math.min(capacity, tokens + refill)
  • 현재 토큰 수에 새로 채워질 토큰 수를 더하되, 최대 허용량(capacity)을 넘지 않도록 한다.
  • 버킷이 넘치지 않도록 하는 안전장치이다.
if tokens < 1 then
    return 0
  • 남은 토큰이 없다면, 더 이상 요청을 허용할 수 없으므로 0을 반환한다.
  • Java 코드에서는 이 반환 값을 받아서 HTTP 429 응답을 보낸다.
else
    tokens = tokens - 1
    redis.call('HMSET', KEYS[1], 'tokens', tokens, 'last_refill', now)
    return 1
end
  • 토큰이 하나라도 남아있다면, 1개를 차감한 뒤 tokenslast_refill 값을 갱신한다.
  • 이후 요청은 정상적으로 통과시키기 위해 1을 반환한다.

실제 운영 흐름에서의 의미

이 Lua 스크립트는 모든 핵심 로직을 Redis 서버 내부에서 처리하기 때문에, Race Condition 없이 동시성 문제를 해결할 수 있다.
또한 서버 재시작이나 여러 인스턴스가 존재하는 환경에서도 일관된 속도로 토큰을 충전하고 소비할 수 있다는 점에서 실전 운영 환경에 매우 적합하다.


Spring MVC에서의 적용 방식

WebFlux의 Gateway 필터와 달리, Spring MVC에서는 javax.servlet.Filter를 기반으로 필터를 구현하고 직접 등록해야 한다.

@Slf4j
@Component
@RequiredArgsConstructor
public class RateLimitingFilter extends OncePerRequestFilter {
    private final StringRedisTemplate redisTemplate;
    private final JwtProperties jwtProperties;

    private static final int CAPACITY = 5;
    private static final int REFILL_RATE = 5;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
            throws ServletException, IOException {
        String key = resolveRateLimitKey(request);
        String redisKey = "tokenbucket:" + key;
        long now = System.currentTimeMillis();

        String luaScript = "..."; // 위 Lua 스크립트 삽입

        Long allowed = redisTemplate.execute(
            RedisScript.of(luaScript, Long.class),
            Collections.singletonList(redisKey),
            String.valueOf(now), String.valueOf(CAPACITY), String.valueOf(REFILL_RATE)
        );

        if (allowed == null || allowed == 0L) {
            log.warn("❌ 요청 차단됨 (RateLimit 초과)");
            response.setStatus(429);
            response.getWriter().write("Too Many Requests");
            return;
        }

        filterChain.doFilter(request, response);
    }

    private String resolveRateLimitKey(HttpServletRequest request) {
        String token = request.getHeader("Authorization");
        if (token != null && token.startsWith("Bearer ")) {
            String accessToken = token.substring(7);
            String memberId = extractMemberId(accessToken);
            if (memberId != null) return "member:" + memberId;
        }
        return "guest:" + request.getRemoteAddr();
    }

    private String extractMemberId(String token) {
        Claims claims = Jwts.parserBuilder()
            .setSigningKey(Keys.hmacShaKeyFor(jwtProperties.getSecret().getBytes(StandardCharsets.UTF_8)))
            .build()
            .parseClaimsJws(token)
            .getBody();
        return claims.get("memberId", String.class);
    }
}

회원이 인증된 경우 JWT에서 memberId를 추출해 "member:5" 같은 키를 사용하고, 비회원이라면 "guest:192.168.0.1" 형태로 구분하여 Redis에 저장하는 방식을 선택하였다.


필터 등록 방식

필터는 Spring Security FilterChain에 포함시키지 않고, FilterRegistrationBean을 통해 가장 앞단에 등록했다. 이렇게 하면 Security에 의존하지 않고 모든 요청에 적용할 수 있다.

@Bean
public FilterRegistrationBean<RateLimitingFilter> rateLimitingFilterRegister(RateLimitingFilter rateLimitingFilter) {
    FilterRegistrationBean<RateLimitingFilter> registration = new FilterRegistrationBean<>();
    registration.setFilter(rateLimitingFilter);
    registration.setOrder(Ordered.HIGHEST_PRECEDENCE);
    registration.addUrlPatterns("/api/*");
    return registration;
}

테스트 및 로그 검증

통합 테스트는 Testcontainers 환경에서 Redis, SpringBootTest를 활용해 진행했다. JWT를 발급하고, 동일한 사용자로 5번 요청은 통과, 6번째 요청은 429 Too Many Requests가 반환되는지 검증했다.

@Test
@DisplayName("Rate Limiting: 1초에 6번 요청 시 마지막 요청은 429 발생")
void rateLimiting_tooManyRequests() {
    for (int i = 0; i < 5; i++) {
        assertThat(request().getStatusCode()).isEqualTo(HttpStatus.OK);
    }
    assertThat(request().getStatusCode()).isEqualTo(HttpStatus.TOO_MANY_REQUESTS);
}

실제 로그에서도 다음과 같은 흐름이 출력되며, 정확한 제어가 이루어지고 있음을 확인할 수 있었다.

요청자 구분 키: member:5
Redis 키: tokenbucket:member:5
현재 시각(ms): 1719746523741
Redis 스크립트 결과 (allowed=1이면 통과, 0이면 차단): 0
❌ 요청 차단됨 (RateLimit 초과)

개선 포인트

  • 사용자 등급별로 CAPACITY, REFILL_RATE를 다르게 설정 가능
  • API별로 키를 분리하여 /api/members/login 같은 민감한 엔드포인트만 제한 가능
  • Retry-After 헤더를 추가하여 클라이언트가 언제 다시 시도할 수 있는지 알 수 있도록 개선할 수 있음

마무리

이번 구현을 통해 얻은 가장 큰 교훈은 "Rate Limiting은 단순한 기능이지만 운영 환경에서의 신뢰성을 결정짓는 중요한 축"이라는 점이었다. 비동기 구조, 분산 환경, JWT 인증 흐름과 잘 어울리도록 설계한 이 Rate Limiter는 실제 서비스 수준에서도 충분히 활용 가능할 정도로 안정적이었다.

직접 구현하고, 테스트하고, 실험하면서 장애 복원력 있는 시스템 설계 능력을 키우는 데 큰 도움이 되었다.

앞으로도 FortiShop 프로젝트 내 다른 기능들과 연계하여 다양한 실험을 진행할 예정이다.
아마 다음 기능으로는 CircuitBraker를 도입한 포스팅이 아닐까 싶다.

profile
다재다능한 Backend 개발자에 도전하는 개발자

0개의 댓글