1. Rate Limit를 어떻게 정의할까?
Rate Limit는 간단하게 말하면 요청 제한 횟수를 정의한다고 볼 수 있다.
개념을 더 확장한다면 Key에 대해 일정 기간/속도 내 요청량을 제한하여 서버 안정성과 악용을 방지하는 정책을 말한다.
예를 들어 사람은 1초에 1번 정도, 잘못 눌러서 3번 까지만 요청이 가능한데 매크로의 경우 1초에 수십에서 많으면 수백번까지 동일한 요청을 할 수 있다.
Rate Limit를 만들기 위해서는 최소 4가지를 먼저 정의해야 한다.
1. Key (누구 기준으로 셀지)
sessionId, memberNo, IP 등을 기준으로 잡고 특정 시간 안에 얼마나 요청을 보냈는지 확인하고 매크로로 판단할 수 있다.
단일 Key로 충분한 경우도 있지만 공격/공유 IP/다중 디바이스 특성 때문에 복수 Key 또는 계층형 Key를 고려하는 것도 필요하다.
예를 들어 sessionId만 두고 생각했을 때 컴퓨터, 휴대폰 등 동일한 아이디로 여러 매체에 접근한다면 sessionId는 여러개가 나올 수 있다.
이렇게 여러개의 매체로 접근했을 때 sessionId로만 막는다면 정상 사용자도 매크로로 판단할 수도 있다.
memberNo로만 판단할 때 A, B, C 사용자가 각각 정상 유저의 횟수만큼만 데이터 요청을 보냈을 지라도 동일한 IP에서 동시에 요청을 보냈다면 이건 매크로로 의심할 여지가 있다.
그 외에 어떤것을 요청했는지도 같이 확인해서 매크로 여부를 좀 더 확실하게 판단할 수 있다.
2. Window(어떤 시간 구간 안에서 셀지)
간단하게 얼마만큼의 시간을 두고 셀지를 말하는 것이다.
그러나 깊게 들어가면 이 기준 시간을 잡는 방법은 여러가지가 있다.
- Fixed Window(고정 윈도우)
- 몇시부터 몇시까지로 시간이 고정되어 있는것을 말한다.
- 쉽게 말해서 12:00:00 ~ 12:00:59 를 하나의 window로 두고 12:01:00~12:01:59을 다음 window로 둬서 횟수를 계산하는 방식이다.
- 시간의 경계선에 요청을 보내면 제한을 우회해버릴 가능성이 있다.
- Sliding Window(슬라이딩)
- 현재 시각(now) 기준으로 최근 N초 동안의 요청을 계산하는 방식이다.
- 가장 자연스럽고 매크로 사용이 어려워지는 방식이지만 개발 난이도가 높고 자원을 많이 사용한다는 단점이 있다.
- Token Bucket
- Token Bucket은 fixed/sliding처럼 ‘창’을 자르는 방식이라기보단 시간에 따라 토큰이 회복되는 속도 기반(rate-based) 방식이다.
- 각 sessionId또는 IP에 일정 갯수의 토큰을 주고 토큰이 있으면 요청 허용, 토큰이 없으면 요청 차단을 통해 요청 횟수를 제한하는 방식을 말한다.
- Token Bucket에는 3가지 주요 요소가 존재한다.
- refill rate (리필 속도): 토큰이 채워지는 속도
- capacity (버킷 최대 수용량): 버킷이 담을 수 있는 토큰의 최대 개수
- tokens (현재 남아 있는 토큰 수)
- Sliding Window 방식보다 구현이 간단하다.
3. Limit (허용 최대 횟수)
허용 최대 횟수는 허용된 시간 내 요청 가능 횟수로 판단하면 된다.
중요한 부분은 limit는 단순하게 최대 요청 허용 가능 횟수로 생각하는게 아니라 시스템 전체 동작을 바꾸는 기준점이 된다는 점이다.
이 숫자를 기준으로 시스템이 어떻게 반응할지 결정하기 때문이다.
이 숫자를 결정하는건 개발자 마음대로 정하는 것이 아니라 보안(매크로 차단), UX(정상 유저 불편 최소화), 서버 부하, 특정 자원 인기 폭주, 공공기관/학교/PC방 IP 공유 문제 등 여러가지 사유에 따라서 결정되어야 한다.
4. Action (넘으면 어떻게 할지)
요청 허용 최대 횟수를 초과했을 때 어떻게 처리할지에 대한 정책이다.
정책은 여러가지가 있지만 몇가지 소개하고자 한다.
- 차단
- 보통은 HTTP 429(Too Many Requests)로 바로 차단한다.
- 가장 많이 사용하는 정책이다.
- 가장 보안상 안전하고 서버의 부하도 줄일 수 있는 운영 담당자에게나 개발자에게나 아주 좋은 방식이다.
- 다만 사용자 UX적인 측면에서는 불편한 부분이 있을 수 있다.
- 슬로틀링
- queue에 넣거나 지연을 줘서 천천히 처리하는 방식이다.
- 매크로는 속도가 중요하기 때문에 매크로 사용자는 크게 손해를 볼 수 있다.
- 정상 유저는 잘 느끼지 못하기 때문에 UX적인 측면으로 손해를 보지 않는다.
- 로그
- limit가 초과되어도 차단하지는 않지만 대신 로그를 쌓는 방식이다.
- 정책 초기에 차단하기 보다는 일단 보내고 일반 사용자가 얼마나 호출하는지 모니터링 하는 방식이다.
- 공격 패턴을 분석할 때도 유용하게 사용할 수 있다.
- 알람
- limit 초과 시 차단 여부와 관계없이 알림 발송하는 방식이다.
- 실시간으로 공격에 대한 대응이 가능하다.
2. 예약 시스템에 적용하는 Rate Limit 시나리오
Rate Limit 시나리오는 공통적으로 다음과 같은 시나리오를 가진다
[사용자 브라우저]
|
| 1. HTTP 요청 (GET/POST /lecture/reserve)
v
[웹 서버 (Spring)]
|
| 2. RateLimitInterceptor.preHandle()
v
[RateLimiter] <-- 여기서 Fixed / Sliding / TokenBucket 로직이 다름
| |
| | (ALLOW) 3. 허용인 경우
| v
| [Controller] 4. 비즈니스 로직 (맥락/흐름/비즈니스 검증 등)
| |
| v
| [Service] -> [DB 조회/저장]
| |
| v
| [응답 데이터 생성]
| | 5. HTTP 200 응답
| v
| [브라우저: 화면에 성공 결과 표시]
|
| (BLOCK) 3. 차단인 경우
v
[RateLimiter에서 바로 종료]
|
v
[HTTP 429 (Too Many Requests) 등 에러 응답]
|
v
[브라우저: "요청이 너무 많습니다." 같은 메시지 표시]
사용자가 서버로 요청을 보내서 비즈니스 로직을 타기 전에 RateLimiter를 거쳐 요청을 허용해도 되는지 체크한다.
다시 말해 Interceptor부분에서 RateLimit 요청을 처리하는 것이다.
처리방식은 앞서 설명한 Fixed, Sliding, Tokenburcket 방식중 하나로 요청을 인터셉트 한 후 해당 요청을 허용해도 되는지 확인한다.
3. Rate Limit 예제(Token Bucket 방식)
Rate Limit 개발하는 방식으로 Fixed Window, Sliding Window, Token Bucket 3가지가 있는데 그 중 Token Bucket 만 구현하고자 한다.
Sliding Window는 분산 환경에서 구현/운영 비용이 크고 Fixed Window는 시간 경계 우회(버킷 경계 버스트) 문제가 있기 때문이다.
3.1. Token Bucket 구현
package example.token;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
public class TokenBucketModel {
/**
* 1) Bucket: 토큰 상태(현재 토큰, 마지막 리필 시각)
* 상태객체 클래스
*/
public static class Bucket {
private final double capacity; // 최대 토큰(버스트 허용량)
private final double refillPerSec; // 초당 리필 토큰 수
private double tokens; // 현재 토큰(현재는 메모리에 저장하지만 캐시 등에 저장할 수 있다.)
private long lastRefillNanos; // 마지막 리필 시각 (nanoTime)
public Bucket(double capacity, double refillPerSec) {
this.capacity = capacity;
this.refillPerSec = refillPerSec;
this.tokens = capacity; // 시작은 가득
this.lastRefillNanos = System.nanoTime();
}
// 요청 1회당 cost(보통 1)만큼 토큰 소비
public synchronized boolean allow(double cost) {
refill();
if (tokens >= cost) {
tokens -= cost;
return true;
}
return false;
}
// 현재 토큰 개수 확인
public synchronized double getTokens() {
refill();
return tokens;
}
/**
* 시간 경과에 따라 토큰 상태를 갱신한다.
* 마지막 리필 시각 이후 경과 시간을 기준으로
* 누적된 토큰 수를 계산하여 상태를 업데이트한다.
*
* 토큰 개수는 매번 새로 계산되는 값이 아니라
* 이전에 남아 있던 토큰 + 마지막 리필 이후 경과 시간만큼 추가된 토큰을 더해 저장되는 누적 상태다.
*/
private void refill() {
long now = System.nanoTime();
long elapsedNanos = now - lastRefillNanos;
if (elapsedNanos <= 0) return;
double elapsedSec = elapsedNanos / 1_000_000_000.0;
double refill = elapsedSec * refillPerSec;
tokens = Math.min(capacity, tokens + refill);
lastRefillNanos = now;
}
}
/**
* 2) 매크로 탐지의 핵심모델
*/
public static class TokenBucketRateLimiter {
private final double capacity;
private final double refillPerSec;
private final Map<String, Bucket> buckets = new ConcurrentHashMap<>();
public TokenBucketRateLimiter(double capacity, double refillPerSec) {
this.capacity = capacity;
this.refillPerSec = refillPerSec;
}
/**
* 요청 1회 허용 여부 판단
*
* key(user/ip/session)에 해당하는 Bucket을 조회한다.
* Bucket이 없으면, 정책(capacity, refillPerSec)에 따라 새로 생성한다.
* 해당 Bucket에 대해 토큰 1개 소비가 가능한지(allow)를 요청한다.
*
* @param key
* @return
*/
public boolean tryAllow(String key) {
Bucket bucket = buckets.computeIfAbsent(
key,
k -> new Bucket(capacity, refillPerSec)
);
return bucket.allow(1.0);
}
/**
* 남은 토큰 수 조회
* 내부적으로 getTokens의 refill 메서드를 실행하서 최신 토큰개수를 반환한다.
* 디버깅, 로그/모니터링, Rate Limit 상태 가시화를 위해 사용한다.
*
* @param key
* @return
*/
public double tokensRemaining(String key) {
Bucket bucket = buckets.get(key);
return bucket == null ? capacity : bucket.getTokens();
}
}
/**
* 3) 요청 처리 시뮬레이션
* spring에서는 filter에 해당하는 부분이다.
* @param limiter
* @param key
* @param path
*/
public static void handleRequest(
TokenBucketRateLimiter limiter,
String key,
String path
) {
System.out.println();
System.out.println("[User] -> (Request) -> [Server] path=" + path + ", key=" + key);
boolean allowed = limiter.tryAllow(key);
if (allowed) {
System.out.printf(
"[RateLimiter] ALLOW tokens=%.2f%n",
limiter.tokensRemaining(key)
);
System.out.println("[Controller] 비즈니스 로직 실행...");
System.out.println("[Server] -> (Response 200 OK) -> [User]");
} else {
System.out.printf(
"[RateLimiter] BLOCK tokens=%.2f%n",
limiter.tokensRemaining(key)
);
System.out.println("[Server] -> (Response 429 Too Many Requests) -> [User]");
}
}
}
3.2. Main 구현
package example.token;
public class TokenBucketMain {
public static void main(String[] args) throws Exception {
// 최대 토큰 5개, 초당 1개 리필
TokenBucketModel.TokenBucketRateLimiter limiter =
new TokenBucketModel.TokenBucketRateLimiter(5, 1);
String userKey = "user:1001";
// === 연속 요청 테스트 (버스트) ===
System.out.println("=== Burst Test ===");
for (int i = 1; i <= 7; i++) {
TokenBucketModel.handleRequest(
limiter,
userKey,
"/api/reserve"
);
}
// === 시간 경과 ===
System.out.println("\n=== Sleep 3 seconds ===");
Thread.sleep(3000);
// === 리필 후 재요청 ===
System.out.println("\n=== After Refill ===");
for (int i = 1; i <= 4; i++) {
TokenBucketModel.handleRequest(
limiter,
userKey,
"/api/reserve"
);
}
}
}
// 좀 더 복잡하게 실행하는 main
//package example.token;
//
//import java.util.concurrent.ThreadLocalRandom;
//
//public class TokenBucketMain {
//
// public static void main(String[] args) throws Exception {
//
// // 예: 최대 토큰 5, 초당 1개 리필
// TokenBucketModel.TokenBucketRateLimiter limiter =
// new TokenBucketModel.TokenBucketRateLimiter(5, 1);
//
// String[] keys = {
// "user:1001",
// "user:2001",
// "user:3001"
// };
//
// String[] paths = {
// "/api/reserve",
// "/api/search",
// "/api/detail"
// };
//
// int perUserRequests = 8;
//
// System.out.println("=== Token Bucket Test (3 users, each 6 requests, random speed) ===");
//
// for (String key : keys) {
// System.out.println("\n==============================");
// System.out.println("Start user key = " + key);
// System.out.println("==============================");
//
// for (int i = 1; i <= perUserRequests; i++) {
//
// // 랜덤 path (그냥 로그 다양화용)
// String path = paths[ThreadLocalRandom.current().nextInt(paths.length)];
//
// // 요청 처리 (예제에서는 Filter/Interceptor 역할)
// TokenBucketModel.handleRequest(limiter, key, path);
//
// // "속도 랜덤" = 다음 요청까지 랜덤 sleep
// // 0~1200ms 사이로 랜덤 지연 (원하면 범위 바꿔도 됨)
// int sleepMs = ThreadLocalRandom.current().nextInt(0, 300);
// System.out.println("[Client] next request in " + sleepMs + "ms");
// Thread.sleep(sleepMs);
// }
// }
//
// System.out.println("\n=== DONE ===");
// }
//}
3.3 Interceptor 역할 구현
// ---- 3) Interceptor: 요청에 대한 처리 ----
static void handleRequest(TokenBucketRateLimiter limiter, String key, String path) {
System.out.println();
System.out.println("[User] -> (Request) -> [Server] path=" + path + ", key=" + key);
// 요청을 허용해도 되는지 확인
boolean allowed = limiter.tryAllow(key);
if (allowed) {
System.out.printf("[RateLimiter] ALLOW tokens=%.2f%n", limiter.tokensRemaining(key));
System.out.println("[Controller] 비즈니스 로직 실행...");
System.out.println("[Server] -> (Response 200 OK) -> [User]");
} else {
System.out.printf("[RateLimiter] BLOCK tokens=%.2f%n", limiter.tokensRemaining(key));
System.out.println("[Server] -> (Response 429 Too Many Requests) -> [User]");
}
}
지금 보여준 예제는 단일 서버 기준(In-Memory)이며 서버가 여러 대면 제한이 분산될 수 있다.
이를 해결하기 위해 요청 횟수 상태를 한 곳에서 공유하는 중앙화 방식이 필요하다.
Redis를 중앙 저장소로 두고 모든 서버가 동일한 Rate Limit 상태를 조회·갱신하는 방식으로 중앙화 할 수 있다.
또 다른 방법으로는 애플리케이션 앞단(API Gateway)에서 Rate Limit을 먼저 수행하고 허용된 요청만 백엔드로 전달하는 방식을 사용할 수 있다.
4. Rate Limit은 매크로 판정(탐지)이 아니라 완화/방어
Rate Limit방식의 주된 목적은 매크로 탐지 및 차단이 아니라 악용 행위를 완화하고 시스템을 보호하는 것이다.
더 나아가 지금까지 공부한 내용은 매크로 탐지 및 차단 보다는 정상적인 방법으로 시스템을 사용할 수 있도록 강제하는 것이다.
매크로와 정상 사용자의 요청 패턴은 겹칠 수 있다.
빠르게 클릭하는 사용자, 여러 디바이스에서 동시에 접근하는 사용자, 공공기관·학교·PC방처럼 IP를 공유하는 환경에서는 정상 사용자도 높은 요청 빈도를 보일 수 있다.
따라서 Rate Limit만으로 “이 요청은 매크로다”라고 단정하는 것은 위험하고 다른 방어 기법과 함께 사용되어야 한다.
Rate Limit의 본질적인 목적은 다음과 같다.
- 서버가 감당할 수 없는 과도한 요청을 사전에 차단 또는 지연
- 악의적인 자동화가 시스템 자원을 독점하지 못하도록 속도를 제한
- 정상 사용자에게 제공되는 서비스 품질을 보호하는 것
Rate Limit은 ‘탐지’가 아니라 시스템 전체를 안정적으로 유지하기 위한 첫 번째 방어선(완화 장치)에 가깝다.
'IT정리 > 보안 기초' 카테고리의 다른 글
| 4. 행동 기반 탐지 (1) | 2026.01.18 |
|---|---|
| 2-2. 비즈니스 로직 보안 - 매크로를 막기 위한 추가 로직 보안 (1) | 2026.01.18 |
| 2-1. 비즈니스 로직 보안 - 액션 토큰에 대해서 (0) | 2026.01.18 |
| 1. 웹 보안 기초: HTTP·세션·인증/인가·CSRF 개념 정리 (0) | 2026.01.18 |
| 0. 매크로란 (0) | 2026.01.18 |