Skip to content

About

가상 면접 사례로 배우는 대규모 시스템 설계 기초 1권 - 실습 1

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

🚀 Rate Limiter 점진적 구현 및 부하테스트 분석

Spring Boot와 Redis를 활용하여 **처리율 제한 장치(Rate Limiter)**를 점진적으로 고도화하면서, 동시성(Concurrency), 분산 환경(Scale-out), 윈도우 경계값 폭주(Boundary Burst), 분산 원자성(Atomicity) 문제를 단계별로 재현하고 해결한 엔지니어링 프로젝트입니다.


📌 목차


1. 시스템 정책 및 개요

대상 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])
Loading
  • 로그인 사용자: X-User-Id 기준 분당 100회
  • 비로그인 사용자: Client IP 기준 분당 30회
  • 관리자 (ADMIN): X-User-Role: ADMIN 요청은 제한 없이 Bypass

2. 단계별 발전 여정 (Phase 1 ~ Phase 7)

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
Loading

📋 Phase 요약표

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 완벽 차단 이전 윈도우 균등 분포 가정에 따른 근사치 특성 유지

3. 핵심 엔지니어링 문제 해결 심층 분석

3.1. 단일 서버 동시성 제어 (Phase 1 → Phase 2)

  • Phase 1의 문제: int count = map.getOrDefault(key, 0) + 1; map.put(key, count); 구조는 읽기(Read)와 쓰기(Write)가 분리된 비원자적 연산입니다. 동시 스레드가 같은 카운트를 읽어 limit을 초과하는 요청을 허용합니다.
  • Phase 2의 해결: ConcurrentHashMap과 AtomicInteger.incrementAndGet()(CAS 연산)을 적용하여 원자적 증가 및 단일 서버 동시성 100% 보장을 달성했습니다.

3.2. 분산 환경 카운터 공유 (Phase 2/3 → Phase 4)

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
Loading

3.3. 윈도우 경계 Burst 억제 (Phase 4/5 → Phase 6)

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회 허용!

Sliding Window Counter 가중 평균 공식

이전 윈도우의 카운트를 현재 윈도우의 경과 시간에 비례하여 차감 반영합니다.

$$\text{가중 요청 수} = \text{이전 윈도우 카운트} \times \left(1 - \frac{\text{현재 초}}{60}\right) + \text{현재 윈도우 카운트}$$

  • 12:01:00 시점(경과 비율 0.0)에 유입 시: $100 \times (1 - 0.0) + 0 = 100 \rightarrow$ 즉시 차단!

3.4. Redis 연산 원자성 확보 (Phase 6 → Phase 7)

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
Loading

4. Locust 부하 테스트 결과 (Benchmarking)

컨테이너 환경(App 1~3대, Redis, Nginx Load Balancer, Locust 부하 생성기)에서 진행한 테스트 결과입니다.

4.1. 정량적 지표 요약

테스트 시나리오 검증 목표 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

4.2. 성능 및 동작 시각화

📊 1. 단일 서버 vs 다중 서버 허용량 비교

메모리 기반(Phase 1, 2)은 서버가 3대로 늘어났을 때 허용량이 300으로 복제되지만, Redis 공유 저장소(Phase 4, 7)는 100회를 정확히 유지합니다.

Single-server vs Multi-server Allowed Count


📊 2. Fixed Window vs Sliding Window 경계 Burst 비교

Fixed Window(Phase 1, 2, 4)는 경계 구간에서 200회를 통과시키지만, Sliding Window(Phase 6, 7)는 이전 윈도우 가중치를 적용하여 100회로 엄격히 제한합니다.

Fixed vs Sliding Window Boundary Allowed Total


📊 3. 로그인 Quota 정확도

순차 호출 조건에서 모든 Phase가 100회 허용 및 0% 오차율을 달성함을 보여줍니다.

Login Quota Accuracy


📊 4. 리소스 및 Redis 호출 비용 비교

Phase 7은 Lua 스크립트를 통해 앱과 Redis 간 네트워크 왕복을 1회(EVAL)로 압축하면서도 안정적인 CPU/메모리 사용량을 유지합니다.

Resource and Redis Cost


📊 5. 처리량(Throughput) 및 지연시간(Latency)

RPS 증가에 따른 p95, p99 꼬리 지연시간이 전 구간에서 20ms 안팎으로 안정적으로 유지됩니다.

Throughput and Tail Latency


5. 문서 및 가이드 링크

About

가상 면접 사례로 배우는 대규모 시스템 설계 기초 1권 - 실습 1

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages