
FortiShop은 장애 복원력을 갖춘 MSA 기반 이커머스 플랫폼이다. (이전에도 계속 이 프로젝트에 대한 개선을 작업하는 중이다)
이 프로젝트는 실전에서 발생 가능한 장애 시나리오를 설계하고 직접 실험해보는 데 그 목적을 가지고 한 프로젝트이다.
이번에는 프로젝트 개선의 막바지로써 나는 트래픽 폭주 상황에서의 대응 전략에 집중했다. 예를 들어, 인증 API나 적립금 전송 API와 같은 민감한 요청은 과도한 호출이 발생할 경우 시스템에 과부하를 줄 수 있다. WebFlux 환경이라면 Spring Cloud Gateway에서 제공하는 RedisRateLimiter를 사용하면 되지만, FortiShop은 Spring MVC 기반이었기 때문에 직접 Rate Limiter를 구현해야 했다.
Rate Limiting 전략은 여러 가지가 있지만, 나는 Token Bucket 알고리즘을 선택했다. 이 방식은 일정 주기로 버킷에 토큰을 채우고 요청 시 하나씩 차감하며, 남은 토큰이 없으면 요청을 거부하는 구조이다. 요청 수를 제한하는 것보다 순간적인 폭주는 허용하면서도 지속적인 남용은 막을 수 있다는 점에서 효율적이었다.

위 그림은 Token Bucket 알고리즘의 동작 흐름을 시각적으로 표현한 것이다.
capacity)을 갖는 토큰 저장소로, 미리 충전된 토큰이 저장되어 있는 공간이다. 이 버킷이 가득 찬 상태에서는 더 이상 토큰이 쌓이지 않는다.Rate Limiter는 분산 환경에서 모든 서버가 동일한 기준으로 요청을 제어해야 하므로 공유 저장소가 필요하다. 이때 Redis는 단일 노드로도 충분히 빠르며, Lua Script를 통해 원자적(Atomic)으로 제어할 수 있는 강력한 수단을 제공한다. 이러한 이유로 Redis를 선택했고 Redis 내에 다음과 같은 구조로 데이터를 저장했다.
tokenbucket:member:{memberId} 또는 tokenbucket:guest:{ip}tokens: 현재 남아있는 토큰 수last_refill: 마지막으로 토큰을 채운 시간(epoch millis)Token Bucket 알고리즘을 분산 환경에서 안정적으로 구현하기 위해 가장 중요한 것은 토큰 충전과 소모 연산이 반드시 원자적으로 이루어져야 한다는 점이다. 만약 여러 서버 인스턴스가 동시에 같은 Redis 키를 읽고 수정한다면, 토큰이 중복 소비되거나 누락되는 문제가 발생할 수 있다.
이를 해결하기 위해 Redis의 Lua Script 기능을 활용했다. Lua Script는 Redis 내부에서 단일 명령으로 실행되므로, 여러 명령을 하나의 트랜잭션처럼 Atomic하게 처리할 수 있다.
아래는 실제로 사용한 Lua Script이며, 각 줄을 하나씩 해석해보자.
local bucket = redis.call('HMGET', KEYS[1], 'tokens', 'last_refill')
tokens)와 마지막으로 리필한 시간(last_refill)을 동시에 가져온다.HMGET은 하나의 해시 키에서 여러 필드를 한꺼번에 읽는 명령이다.KEYS[1]은 "tokenbucket:member:5" 같은 형태의 Redis 키가 된다.local tokens = tonumber(bucket[1])
local last_refill = tonumber(bucket[2])
tonumber()를 사용해 정수로 변환한다.nil이 들어오므로 이후 처리에서 nil 체크가 필요하다.local now = tonumber(ARGV[1])
local capacity = tonumber(ARGV[2])
local refill_rate = tonumber(ARGV[3])
System.currentTimeMillis()), 최대 토큰 수 (capacity), 초당 리필 속도 (refill_rate)다.if tokens == nil then
tokens = capacity
last_refill = now
end
last_refill을 초기화한다.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
else
tokens = tokens - 1
redis.call('HMSET', KEYS[1], 'tokens', tokens, 'last_refill', now)
return 1
end
tokens와 last_refill 값을 갱신한다.1을 반환한다.이 Lua 스크립트는 모든 핵심 로직을 Redis 서버 내부에서 처리하기 때문에, Race Condition 없이 동시성 문제를 해결할 수 있다.
또한 서버 재시작이나 여러 인스턴스가 존재하는 환경에서도 일관된 속도로 토큰을 충전하고 소비할 수 있다는 점에서 실전 운영 환경에 매우 적합하다.
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/members/login 같은 민감한 엔드포인트만 제한 가능Retry-After 헤더를 추가하여 클라이언트가 언제 다시 시도할 수 있는지 알 수 있도록 개선할 수 있음이번 구현을 통해 얻은 가장 큰 교훈은 "Rate Limiting은 단순한 기능이지만 운영 환경에서의 신뢰성을 결정짓는 중요한 축"이라는 점이었다. 비동기 구조, 분산 환경, JWT 인증 흐름과 잘 어울리도록 설계한 이 Rate Limiter는 실제 서비스 수준에서도 충분히 활용 가능할 정도로 안정적이었다.
직접 구현하고, 테스트하고, 실험하면서 장애 복원력 있는 시스템 설계 능력을 키우는 데 큰 도움이 되었다.
앞으로도 FortiShop 프로젝트 내 다른 기능들과 연계하여 다양한 실험을 진행할 예정이다.
아마 다음 기능으로는 CircuitBraker를 도입한 포스팅이 아닐까 싶다.