성능 최적화 24편 - Rate Limiting으로 과도한 트래픽으로부터 서버 보호하기
23편에서는 외부 시스템의 장애가 우리 서버로 전파되는 것을 막는 서킷 브레이커를 다뤘습니다. 이번 글에서는 방향을 조금 바꿔서, 특정 클라이언트나 특정 API에 요청이 과도하게 몰릴 때 서버 자체를 보호하는 Rate Limiting(요청 제한)을 정리합니다.
1. Rate Limiting이 필요한 상황
- 특정 사용자나 봇이 짧은 시간에 비정상적으로 많은 요청을 보내는 경우 (크롤링, 악의적 스크래핑, 무한 새로고침 등)
- 이벤트나 선착순 쿠폰 발급처럼, 순간적으로 트래픽이 폭증하는 시점
- 외부 API(11편의 결제, 알림 연동 등)를 호출할 때, 해당 API 제공사가 정해둔 초당 요청 제한을 초과하지 않도록 우리 쪽에서 미리 조절해야 하는 경우
Rate Limiting이 없으면, 한 클라이언트의 과도한 요청이 7편에서 다룬 커넥션 풀을 고갈시키거나, 정상적인 다른 사용자들의 요청 처리까지 지연시킬 수 있습니다.
2. 대표적인 알고리즘 - Token Bucket
가장 널리 쓰이는 방식은 Token Bucket(토큰 버킷)입니다. 일정한 속도로 토큰이 채워지는 양동이를 상상하면 됩니다.
버킷 용량 : 10개
토큰 충전 속도 : 초당 5개
요청이 들어올 때마다 토큰을 1개씩 소비
토큰이 없으면 요청 거부(429 Too Many Requests)
[정상 트래픽]
초당 3건 요청 -> 토큰이 충전되는 속도(5개/초)보다 적게 소비 -> 항상 통과
[트래픽 폭증]
1초 안에 20건 요청 -> 버킷에 있던 10개 토큰 모두 소진 -> 나머지 10건은 거부
Token Bucket의 장점은 평소에는 여유롭게 토큰을 쌓아뒀다가, 짧은 순간의 버스트(burst) 트래픽 정도는 허용하면서도 지속적인 과다 요청은 확실히 막아준다는 점입니다.
3. Bucket4j로 구현하기
implementation 'com.bucket4j:bucket4j-core:8.10.1'
implementation 'com.bucket4j:bucket4j-redis:8.10.1' // 분산 환경용
@Component
public class RateLimiterService {
public Bucket createBucket() {
Bandwidth limit = Bandwidth.classic(10, Refill.greedy(5, Duration.ofSeconds(1)));
return Bucket.builder().addLimit(limit).build();
}
}
@Component
@RequiredArgsConstructor
public class RateLimitInterceptor implements HandlerInterceptor {
private final RateLimiterService rateLimiterService;
private final Map<String, Bucket> bucketCache = new ConcurrentHashMap<>();
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
String clientId = request.getHeader("X-Client-Id");
Bucket bucket = bucketCache.computeIfAbsent(clientId, id -> rateLimiterService.createBucket());
if (bucket.tryConsume(1)) {
return true;
}
response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value());
response.setHeader("Retry-After", "1");
return false;
}
}
클라이언트별로 각각의 버킷을 유지하면서, 토큰 소비에 성공하면 요청을 통과시키고 실패하면 429 Too Many Requests로 즉시 거부합니다.
4. 여러 서버 환경에서는 Redis로 상태 공유
7편, 21편에서처럼 서버가 여러 대로 운영되는 환경에서는, 각 서버가 자기 메모리에만 버킷 상태를 들고 있으면 제한이 제대로 걸리지 않습니다. 사용자가 서버 A에서 한도를 다 채웠어도, 서버 B로 요청이 가면 다시 처음부터 카운트되기 때문입니다.
@Component
@RequiredArgsConstructor
public class RedisRateLimiterService {
private final RedisClient redisClient;
public Bucket resolveBucket(String clientId) {
byte[] key = ("rate-limit:" + clientId).getBytes();
RedisProxyManager<byte[]> proxyManager = LettuceBasedProxyManager
.builderFor(redisClient)
.build();
BucketConfiguration config = BucketConfiguration.builder()
.addLimit(Bandwidth.classic(10, Refill.greedy(5, Duration.ofSeconds(1))))
.build();
return proxyManager.builder().build(key, () -> config);
}
}
6편에서 다뤘던 것과 마찬가지로, 여러 서버 인스턴스에 걸친 상태 공유가 필요한 상황에서는 Redis 같은 중앙 저장소를 활용하는 것이 일반적인 해법입니다.
5. API Gateway 레벨에서 처리하기
애플리케이션 코드에서 직접 구현하는 대신, API Gateway(Spring Cloud Gateway, Nginx, Kong 등)에서 Rate Limiting을 처리하는 방법도 널리 쓰입니다.
spring:
cloud:
gateway:
routes:
- id: order-service
uri: lb://order-service
predicates:
- Path=/api/orders/**
filters:
- name: RequestRateLimiter
args:
redis-rate-limiter.replenishRate: 5
redis-rate-limiter.burstCapacity: 10
이 방식의 장점은, 개별 서비스마다 Rate Limiting 로직을 중복 구현할 필요 없이 게이트웨이 한 곳에서 전체 트래픽을 통제할 수 있다는 점입니다. 서비스가 여러 개로 나뉜 구조(23편의 MSA 환경)일수록 이 방식이 유리합니다.
6. 사용자 등급별로 제한을 다르게 적용하기
모든 사용자에게 같은 제한을 적용할 필요는 없습니다. 예를 들어 무료 사용자와 유료 사용자, 또는 일반 API 키와 파트너사 API 키에 서로 다른 한도를 부여할 수 있습니다.
public Bandwidth resolveLimit(ClientTier tier) {
return switch (tier) {
case FREE -> Bandwidth.classic(10, Refill.greedy(5, Duration.ofSeconds(1)));
case PREMIUM -> Bandwidth.classic(100, Refill.greedy(50, Duration.ofSeconds(1)));
case PARTNER -> Bandwidth.classic(1000, Refill.greedy(500, Duration.ofSeconds(1)));
};
}
7. 429 응답을 받은 클라이언트를 위한 배려
Rate Limiting을 적용할 때는 단순히 요청을 거부하는 것을 넘어, 클라이언트가 언제 다시 시도하면 되는지 알 수 있도록 응답 헤더를 함께 내려주는 것이 좋은 설계입니다.
HTTP/1.1 429 Too Many Requests
Retry-After: 1
X-RateLimit-Limit: 10
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1721539200
Retry-After나 X-RateLimit-* 같은 표준화된 헤더를 제공하면, 클라이언트(특히 외부 API를 연동하는 파트너사)가 무작정 재시도를 반복하는 대신 적절한 시점에 재요청하도록 유도할 수 있습니다.
8. 정리
- Rate Limiting은 과도한 트래픽으로부터 서버 자원을 보호하는 방어 장치로, Token Bucket 알고리즘이 널리 쓰인다
- 서버가 여러 대인 환경에서는 각 서버가 개별적으로 상태를 관리하면 제한이 제대로 걸리지 않으므로, Redis 등으로 상태를 공유해야 한다
- API Gateway 레벨에서 처리하면 여러 서비스에 중복 구현할 필요 없이 트래픽을 중앙에서 통제할 수 있다
- 클라이언트 등급에 따라 제한을 차등 적용하고, 429 응답 시 재시도 시점을 안내하는 헤더를 함께 제공하는 것이 좋은 설계다
23편의 서킷 브레이커가 "외부 장애로부터 우리를 보호"하는 것이었다면, 이번 편의 Rate Limiting은 "우리 자신을 과도한 요청으로부터 보호"하는 것입니다. 두 가지 모두 시스템이 "얼마나 빠른가"보다 "얼마나 안정적으로 버티는가"에 초점을 맞춘 최적화라는 공통점이 있습니다.
'성능 최적화' 카테고리의 다른 글
| 성능 최적화 STEP 26 - 가상 스레드(Virtual Threads) (0) | 2026.07.23 |
|---|---|
| 성능 최적화 STEP 25 - 분산 트레이싱(Distributed Tracing) (0) | 2026.07.23 |
| 성능 최적화 STEP 23 - Resilience4j (0) | 2026.07.21 |
| 성능 최적화 STEP 22 - 샤딩(Sharding) (1) | 2026.07.20 |
| 성능 최적화 STEP 21 - Read Replica (1) | 2026.07.20 |