Spring Boot와 Redis를 활용하여 **처리율 제한 장치(Rate Limiter)**를 점진적으로 고도화하면서, 동시성(Concurrency), 분산 환경(Scale-out), 윈도우 경계값 폭주(Boundary Burst), 분산 원자성(Atomicity) 문제를 단계별로 재현하고 해결한 엔지니어링 프로젝트입니다.
- 1. 시스템 정책 및 개요
- 2. 단계별 발전 여정 (Phase 1 ~ Phase 7)
- 3. 핵심 엔지니어링 문제 해결 심층 분석
- 4. Locust 부하 테스트 결과 (Benchmarking)
- 5. 문서 및 가이드 링크
대상 API: GET /posts/{postId}
flowchart LR
Client([클라이언트 요청]) --> Interceptor[RateLimitInterceptor]
Interceptor --> Resolver[ClientKeyResolver]
Resolver --> CheckRole{요청자 역할 확인}
CheckRole -- ADMIN --> Bypass([무제한 통과])
CheckRole -- 로그인 사용자 --> UserLimit["분당 100회 제한 (User ID 키)"]
CheckRole -- 비로그인 사용자 --> IPLimit["분당 30회 제한 (Client IP 키)"]
UserLimit --> RateLimiterEngine[RateLimiter Algorithm]
IPLimit --> RateLimiterEngine
RateLimiterEngine -- 허용 --> HTTP200([HTTP 200 OK + RateLimit Headers])
RateLimiterEngine -- 초과 --> HTTP429([HTTP 429 Too Many Requests])
- 로그인 사용자:
X-User-Id기준 분당 100회 - 비로그인 사용자: Client IP 기준 분당 30회
- 관리자 (ADMIN):
X-User-Role: ADMIN요청은 제한 없이 Bypass
flowchart TD
P1["Phase 1: 로컬 HashMap<br>(단일 JVM)"] -->|동시성 레이스 컨디션 발생| P2["Phase 2: 로컬 ConcurrentHashMap + AtomicInteger<br>(단일 JVM 동시성 해결)"]
P2 -->|서버 3대 확장 시 허용량 3배 복제 문제| P3["Phase 3: 다중 서버 한계 분석<br>(서버 3대 시뮬레이션)"]
P3 -->|글로벌 공유 저장소 도입| P4["Phase 4: Redis Fixed Window<br>(INCR 기반 분산 제한)"]
P4 -->|12:00:59 + 12:01:00 경계 2배 burst 발생| P5["Phase 5: 경계값 문제 재현<br>(Clock 주입 분석)"]
P5 -->|가중 평균 알고리즘 도입| P6["Phase 6: Redis Sliding Window Counter<br>(Java App 레벨 GET-계산-INCR)"]
P6 -->|고동시성 시 비원자적 연산으로 초과 발생| P7["Phase 7: Redis Sliding Window + Lua Script<br>(단일 EVAL 원자성 확보)"]
style P1 fill:#ffebee,stroke:#c62828,stroke-width:1px
style P2 fill:#fff3e0,stroke:#ef6c00,stroke-width:1px
style P3 fill:#ffebee,stroke:#c62828,stroke-width:1px
style P4 fill:#e8f5e9,stroke:#2e7d32,stroke-width:1px
style P5 fill:#ffebee,stroke:#c62828,stroke-width:1px
style P6 fill:#fff3e0,stroke:#ef6c00,stroke-width:1px
style P7 fill:#e3f2fd,stroke:#1565c0,stroke-width:2px
| Phase | 카운터 저장소 / 핵심 기술 | 해결한 핵심 과제 | 남겨진 한계 / 확인된 문제 |
|---|---|---|---|
| Phase 1 | 단일 JVM HashMap |
기본 인터셉터 및 Fixed Window 카운팅 구조 | getOrDefault + put 비원자성으로 멀티스레드 동시 요청 시 10% 초과 |
| Phase 2 | 단일 JVM ConcurrentHashMap + AtomicInteger |
CAS 원자 연산으로 단일 서버 동시성 완벽 제어 (100/100) | JVM 로컬 메모리에 종속되어 서버 확장(Scale-out) 시 무력화 |
| Phase 3 | 로컬 메모리 (3대 시뮬레이션) | 다중 서버 환경에서 로컬 카운터의 구조적 한계 검증 | 서버가 3대면 전체 limit의 3배(300회)까지 허용 |
| Phase 4 | Redis INCR + EXPIRE |
Redis 글로벌 공유 저장소로 다중 서버 분산 제한 달성 | Fixed Window 경계값 문제: 분 경계에서 200회(2배) Burst 허용 |
| Phase 5 | Redis Fixed Window (경계값 분석) | Clock 주입을 통해 윈도우 전환 1초 간격 200회 통과 재현 |
고정 윈도우 알고리즘의 구조적 취약점 확인 |
| Phase 6 | Redis Sliding Window Counter (App 연산) | 이전 윈도우 가중 평균 반영으로 경계 Burst 억제 (100회 제한) | GET → 계산 → INCR이 비원자적이어서 200 동시 요청 시 200회 전부 통과 |
| Phase 7 | Redis Sliding Window Counter + Lua Script | Lua EVAL 단일 원자 연산으로 동시성 오차 0% 및 경계 Burst 완벽 차단 |
이전 윈도우 균등 분포 가정에 따른 근사치 특성 유지 |
- Phase 1의 문제:
int count = map.getOrDefault(key, 0) + 1; map.put(key, count);구조는 읽기(Read)와 쓰기(Write)가 분리된 비원자적 연산입니다. 동시 스레드가 같은 카운트를 읽어 limit을 초과하는 요청을 허용합니다. - Phase 2의 해결:
ConcurrentHashMap과AtomicInteger.incrementAndGet()(CAS 연산)을 적용하여 원자적 증가 및 단일 서버 동시성 100% 보장을 달성했습니다.
flowchart TD
subgraph MultiServerProblem["Phase 3: 메모리 기반의 한계 (서버별 독립 카운터)"]
LB1[Load Balancer] --> S1[Server 1: Map 카운트 100]
LB1 --> S2[Server 2: Map 카운트 100]
LB1 --> S3[Server 3: Map 카운트 100]
S1 --> Res1["총 300회 허용 (Limit 3배 초과 ❌)"]
S2 --> Res1
S3 --> Res1
end
subgraph RedisSolution["Phase 4: Redis 공유 카운터 도입"]
LB2[Load Balancer] --> S4[Server 1]
LB2 --> S5[Server 2]
LB2 --> S6[Server 3]
S4 -->|INCR| R[(Redis: rate_limit:user:123:20260901)]
S5 -->|INCR| R
S6 -->|INCR| R
R --> Res2["정확히 100회 허용 후 차단 ✅"]
end
Fixed Window 방식은 분 경계에 걸쳐 요청이 유입될 때 순간적으로 **2배의 트래픽(200회)**이 서버로 유입되는 취약점이 있습니다.
[ Fixed Window의 경계 Burst ]
12:00:00 ──────────────── 12:00:59 │ 12:01:00 ──────────────── 12:01:59
(유휴) 100회 │ 100회 (유휴)
▲ │ ▲
[윈도우 1 소진] │ [새 윈도우 시작]
│
🔥 1~2초 사이에 총 200회 허용!
이전 윈도우의 카운트를 현재 윈도우의 경과 시간에 비례하여 차감 반영합니다.
- 12:01:00 시점(경과 비율 0.0)에 유입 시:
$100 \times (1 - 0.0) + 0 = 100 \rightarrow$ 즉시 차단!
Phase 6은 공식은 올바르지만, Java 애플리케이션 레벨에서 GET → 계산 → INCR을 분리 호출하여 고동시성 상황에서 레이스 컨디션이 발생했습니다.
sequenceDiagram
autonumber
participant App1 as App (스레드 1)
participant App2 as App (스레드 2)
participant Redis as Redis Server
Note over App1,Redis: Phase 6: 비원자적 실행 (경쟁 조건 발생)
App1->>Redis: GET current_key & previous_key (0 반환)
App2->>Redis: GET current_key & previous_key (0 반환)
App1->>App1: 가중치 0 < 100 -> 허용 결정
App2->>App2: 가중치 0 < 100 -> 허용 결정
App1->>Redis: INCR current_key
App2->>Redis: INCR current_key
Note over App1,Redis: 200개 동시 요청 시 200개 모두 허용됨 ❌
Note over App1,Redis: Phase 7: Redis Lua Script (EVAL 원자 실행)
App1->>Redis: EVAL sliding-window-counter.lua (current, previous, limit, sec)
activate Redis
Note over Redis: Redis 단일 스레드로 GET -> 계산 -> INCR -> EXPIRE 일괄 처리
Redis-->>App1: 허용 (현재 카운트 반환)
deactivate Redis
App2->>Redis: EVAL sliding-window-counter.lua
activate Redis
Note over Redis: 이미 Limit(100) 도달 확인 -> -1 반환
Redis-->>App2: 차단 (-1 반환 -> 429 처리)
deactivate Redis
컨테이너 환경(App 1~3대, Redis, Nginx Load Balancer, Locust 부하 생성기)에서 진행한 테스트 결과입니다.
| 테스트 시나리오 | 검증 목표 | Phase 1 (HashMap) | Phase 2 (Atomic) | Phase 4 (Redis Fixed) | Phase 6 (Sliding App) | Phase 7 (Sliding Lua) |
|---|---|---|---|---|---|---|
| 순차 Quota (로그인) | 100회 허용 / 100회 차단 | 100 / 100 | 100 / 100 | 100 / 100 | 100 / 100 | 100 / 100 (0% 오차) |
| 순차 Quota (비로그인) | 30회 허용 / 30회 차단 | 30 / 30 | 30 / 30 | 30 / 30 | 30 / 30 | 30 / 30 (0% 오차) |
| 단일 서버 200 동시 요청 | 동시성 레이스 컨디션 검증 | 110 허용 (10% 초과) | 100 허용 | 100 허용 | 200 허용 (100% 초과) | 100 허용 (0% 오차) |
| 3대 분산 서버 300 동시 요청 | 분산 환경 글로벌 Limit 검증 | 300 허용 (300%) | 300 허용 (300%) | 100 허용 | 223 허용 (123% 초과) | 100 허용 (0% 오차) |
| 윈도우 경계 2 Wave Burst | 경계 2배 허용 억제 검증 | 200 허용 (2배 통과) | 200 허용 (2배 통과) | 200 허용 (2배 통과) | 100 허용 (억제 성공) | 100 허용 (억제 성공) |
| Throughput (1대 RPS) | 최대 처리량 | 119.2 RPS | 118.8 RPS | 119.2 RPS | 118.7 RPS | 118.9 RPS |
| Tail Latency (p99) | 꼬리 지연시간 | 19.0 ms | 17.0 ms | 16.0 ms | 16.0 ms | 17.0 ms |
메모리 기반(Phase 1, 2)은 서버가 3대로 늘어났을 때 허용량이 300으로 복제되지만, Redis 공유 저장소(Phase 4, 7)는 100회를 정확히 유지합니다.
Fixed Window(Phase 1, 2, 4)는 경계 구간에서 200회를 통과시키지만, Sliding Window(Phase 6, 7)는 이전 윈도우 가중치를 적용하여 100회로 엄격히 제한합니다.
순차 호출 조건에서 모든 Phase가 100회 허용 및 0% 오차율을 달성함을 보여줍니다.
Phase 7은 Lua 스크립트를 통해 앱과 Redis 간 네트워크 왕복을 1회(EVAL)로 압축하면서도 안정적인 CPU/메모리 사용량을 유지합니다.
RPS 증가에 따른 p95, p99 꼬리 지연시간이 전 구간에서 20ms 안팎으로 안정적으로 유지됩니다.
- 📄 전체 시나리오 명세서 (
scenario.md) - 📘 Phase 1: 단일 서버 메모리 기반 Fixed Window (
phase1-guide.md) - 📘 Phase 2: 동시성 문제 재현 및 개선 (
phase2-guide.md) - 📘 Phase 3: 다중 서버 환경 한계 분석 (
phase3-guide.md) - 📘 Phase 4: Redis 기반 Fixed Window (
phase4-guide.md) - 📘 Phase 5: Fixed Window 경계값 문제 분석 (
phase5-guide.md) - 📘 Phase 6: Sliding Window Counter 구현 (
phase6-guide.md) - 📘 Phase 7: Lua 스크립트로 Sliding Window 동시성 오차 제거 (
phase7-guide.md) - 📊 Locust 부하테스트 실행 가이드 (
docs/load-test/README.md) - 📈 Locust 부하테스트 상세 결과 보고서 (
docs/load-test/report.md)




