diff --git "a/04-\354\262\230\353\246\254\354\234\250-\354\240\234\355\225\234\354\236\245\354\271\230/\354\240\225\354\235\270\354\262\240/01_\354\262\230\353\246\254\354\234\250_\354\240\234\355\225\234\354\236\245\354\271\230_\354\204\244\352\263\204_1\354\260\250.md" "b/04-\354\262\230\353\246\254\354\234\250-\354\240\234\355\225\234\354\236\245\354\271\230/\354\240\225\354\235\270\354\262\240/01_\354\262\230\353\246\254\354\234\250_\354\240\234\355\225\234\354\236\245\354\271\230_\354\204\244\352\263\204_1\354\260\250.md" index 6ae5973..5590f65 100644 --- "a/04-\354\262\230\353\246\254\354\234\250-\354\240\234\355\225\234\354\236\245\354\271\230/\354\240\225\354\235\270\354\262\240/01_\354\262\230\353\246\254\354\234\250_\354\240\234\355\225\234\354\236\245\354\271\230_\354\204\244\352\263\204_1\354\260\250.md" +++ "b/04-\354\262\230\353\246\254\354\234\250-\354\240\234\355\225\234\354\236\245\354\271\230/\354\240\225\354\235\270\354\262\240/01_\354\262\230\353\246\254\354\234\250_\354\240\234\355\225\234\354\236\245\354\271\230_\354\204\244\352\263\204_1\354\260\250.md" @@ -133,9 +133,25 @@ Q. 요청이 거부된 경우 사용자에게 알려야 하는가? [토큰 없음] → 요청 버림 ``` -- **버킷 단위**: API endpoint별, IP별, 전역 등 -- 장점: 구현 쉬움, 메모리 효율적, burst traffic 허용 -- 단점: 버킷 크기·토큰 공급률 튜닝이 까다로움 +**파라미터 (2개)** +| 파라미터 | 설명 | 예시 | +|---------|------|------| +| `bucket_size` | 버킷 최대 토큰 수 | 4 | +| `refill_rate` | 초당 추가되는 토큰 수 | 4/s | + +**버킷 단위 설계** +- API endpoint별 버킷: `/post/new` — 분당 1개, `/search` — 분당 5개 +- IP별 버킷: IP 주소당 하루 10만 요청 +- 전역 버킷: 전체 시스템 초당 요청 상한 + +**장점** +- 구현 쉽고, 메모리 효율적 +- **burst traffic 허용**: 짧은 시간 내 토큰이 충분하면 한꺼번에 처리 가능 + +**단점** +- `bucket_size`와 `refill_rate` 두 파라미터를 적절히 튜닝하기 어려움 + +> 실제 사용: **Amazon API Gateway**, **Stripe** API — 초당 요청 제한에 토큰 버킷 사용 --- @@ -151,8 +167,21 @@ Q. 요청이 거부된 경우 사용자에게 알려야 하는가? [API 서버] ``` -- 장점: 안정적 출력(stable outflow rate) 보장, 메모리 효율적 -- 단점: 트래픽 급증 시 큐에 오래된 요청 적체 → 새 요청 드롭 +**파라미터 (2개)** +| 파라미터 | 설명 | +|---------|------| +| `bucket_size` | 큐 최대 크기 (요청 수) | +| `outflow_rate` | 초당 꺼내는 요청 수 (고정) | + +**장점** +- 안정적 출력(stable outflow rate) 보장 → 다운스트림 서버에 일정한 부하 +- 메모리 효율적 (큐 크기 고정) + +**단점** +- 트래픽 급증 시 큐가 오래된 요청으로 꽉 차고, **최신 요청이 드롭됨** +- 두 파라미터 튜닝이 까다로움 + +> 실제 사용: **Shopify** — 초당 outflow rate 기반으로 API 요청 처리 --- @@ -167,17 +196,29 @@ Q. 요청이 거부된 경우 사용자에게 알려야 하는가? 임계치=5 → 윈도 3에서 초과 요청 차단 ``` -- 장점: 메모리 효율, 구현 쉬움, 특정 트래픽 패턴 처리에 적합 -- 단점: **윈도 경계 부근**에서 2배 트래픽 허용 가능 +**파라미터 (2개)** +| 파라미터 | 설명 | +|---------|------| +| `window_size` | 윈도 크기 (예: 1분) | +| `max_requests` | 윈도당 최대 허용 요청 수 | + +**장점**: 메모리 효율, 구현 쉬움, 특정 트래픽 패턴 처리에 적합 +**단점**: **윈도 경계 부근**에서 순간적으로 2배 트래픽 허용 가능 ``` 예) 임계치 = 분당 5건 - 59초 60초(새 윈도) 61초 - |||| ||||| - (5건) (5건) → 실제로는 2초 안에 10건 처리됨! +타임라인: 00:59 01:00(새 윈도) 01:01 + ───────────────────────────────── + ||||| ||||| + (5건) (5건) + ← 1분 기준 각각 허용 → + ↑ 실제 1:59 ~ 1:01, 2초 안에 10건 처리됨! ``` +> 이 경계 문제(boundary condition)는 고정 윈도의 구조적 결함이다. +> 정확도가 중요한 서비스에서는 이 알고리즘을 피해야 한다. + --- #### 이동 윈도 로깅 (Sliding Window Log) @@ -194,25 +235,61 @@ Q. 요청이 거부된 경우 사용자에게 알려야 하는가? 만료 타임스탬프 제거 후 로그 크기 ≤ 임계치 → 요청 허용 ``` -- 장점: 어느 순간이든 처리 한도를 넘지 않음 (정확) -- 단점: 거부된 요청의 타임스탬프도 보관 → **메모리 많이 사용** +**동작 방식** +1. 새 요청이 오면 현재 타임스탬프를 Sorted Set에 추가 +2. 만료된 타임스탬프 (현재시각 - 윈도 크기 이전) 제거 +3. Sorted Set 크기(요청 수) ≤ 임계치 → 허용 / 초과 → 거부 + (거부된 경우에도 타임스탬프는 로그에 유지) + +**장점**: 어느 순간이든 처리 한도를 넘지 않음 (정확) + +**단점**: 거부된 요청의 타임스탬프도 보관 → **메모리를 많이 사용** + +``` +예) 유저 1명이 분당 10건 제한 받고 1만 요청 시도한다면? +→ 거부된 9,990건의 타임스탬프도 Redis Sorted Set에 적재됨 +→ 대규모 DDoS 상황에서 Redis 메모리 급증 위험 +``` --- #### 이동 윈도 카운터 (Sliding Window Counter) +고정 윈도 카운터 + 이동 윈도 로깅의 장점을 결합한 하이브리드 방식. + +**공식** + ``` -이전 윈도(1분) 요청 수 = 8 -현재 윈도(1분) 경과 비율 = 25% +이전 윈도 요청 수 = 8 +현재 윈도 경과 비율 = 25% (현재 윈도 시작 후 15초 경과 / 60초) -현재 윈도에서의 예상 요청 수 -= (8 × (1 - 0.25)) + 현재 윈도 요청 수 +현재 시점의 예상 요청 수 += (이전 윈도 요청 수 × (1 - 현재 윈도 경과 비율)) + 현재 윈도 요청 수 += (8 × 0.75) + α = 6 + α ``` -- 고정 윈도 카운터 + 이동 윈도 로깅의 장점 결합 -- 장점: 짧은 시간 트래픽 대응, 메모리 효율적 -- 단점: 이전 윈도 요청이 균등 분포라고 가정 (느슨하지만 실험적으로 무해) +**구체적 예시** + +``` +임계치: 분당 10건 + +이전 윈도 (00:00 ~ 01:00) 요청 = 8건 +현재 윈도 (01:00 ~ 02:00)에서 01:15 시점에 새 요청 도착 + → 경과 비율 = 15/60 = 25% + +예상 요청 수 = (8 × 0.75) + (현재 윈도 기존 요청 수 α) + = 6 + α + +α = 3이면 → 6 + 3 = 9 < 10 → 허용 +α = 4이면 → 6 + 4 = 10 = 10 → 거부 +``` + +**장점**: 메모리 효율적 (카운터 2개만 유지), 짧은 시간 트래픽 급증 완화 +**단점**: 이전 윈도의 요청이 균등 분포라는 가정이 있음 (느슨하지만 실험적으로 무해) + +> 실제 사용: **Cloudflare** — 동일 원리로 초당 요청 제한 적용 +> Cloudflare 실험 결과: 4억 건 샘플 중 0.003%만 경계 오차 발생 --- @@ -254,36 +331,76 @@ Client ──→ Rate Limiter Middleware ─┤ ``` **카운터 저장소로 Redis를 선택하는 이유** -- DB(디스크 접근)는 너무 느림 -- Redis는 인메모리 → 빠름 +- DB(디스크 접근)는 너무 느림 → 매 요청마다 DB 조회는 병목 +- Redis는 인메모리 → 빠름 (싱글 스레드 → 자체적으로 race condition 없음) +- `INCR` 명령으로 원자적 카운터 증가 가능 - `EXPIRE` 명령으로 시간 기반 만료 정책 구현 가능 +**규칙 로딩 아키텍처 (Rule Cache)** + +``` +┌──────────────┐ (1) 주기적으로 규칙 로드 +│ 규칙 저장소 │ ─────────────────────→ ┌───────────────┐ +│ (YAML/Disk) │ │ Rate Limiter │ +└──────────────┘ │ Worker │ + │ (메모리 캐시) │ + └───────┬───────┘ + │ (2) 요청 시 캐시에서 규칙 조회 + ▼ + ┌───────────────┐ + │ Redis Counter │ + └───────────────┘ +``` + +- 규칙 파일은 디스크에 저장, **워커 스레드가 주기적으로 갱신** +- 요청 처리 시 디스크 I/O 없이 **인메모리 캐시에서 규칙 조회** +- Redis에는 규칙이 아닌 **카운터만 저장** (역할 분리) + --- ## 3. 상세 설계 ### 3-1. 처리율 제한 규칙 -설정 파일(YAML) 형태로 디스크에 저장, 오픈소스 활용 가능 (e.g. Envoy, Kong) +설정 파일(YAML) 형태로 디스크에 저장, 오픈소스 활용 가능 (e.g. Envoy, Lyft ratelimit, Kong) + +**Lyft ratelimit 스타일 규칙 예시** ```yaml -# 예시 규칙 설정 -rate_limits: - - domain: auth +domain: messaging # 규칙이 적용되는 도메인 +descriptors: + # 마케팅 메시지: IP당 하루 5건 제한 + - key: message_type + value: marketing descriptors: - - key: user_id + - key: to_number rate_limit: - unit: minute + unit: day requests_per_unit: 5 - - domain: post + # 인증 API: 클라이언트 IP당 분당 5건 + - key: auth descriptors: - key: ip_address rate_limit: - unit: day - requests_per_unit: 10 + unit: minute + requests_per_unit: 5 + + # 전역 로그인 시도: 사용자당 하루 3건 + - key: user_login + rate_limit: + unit: day + requests_per_unit: 3 ``` +**규칙 설계 원칙** +- `domain`: 규칙을 논리적으로 그룹화 (API 도메인별 분리) +- `descriptors`: 제한 기준 (IP, user_id, 요청 타입 등 조합 가능) +- `unit`: `second` / `minute` / `hour` / `day` +- **계층적 적용 가능**: 전역 제한 + 사용자별 제한을 동시에 적용 + +**실제 운영 팁**: 로그인 시도 제한, 가입 제한, SMS 발송 제한은 별도 domain으로 분리하는 것이 운영 편의상 유리함 + --- ### 3-2. 제한 초과 요청 처리 (Throttle) @@ -295,14 +412,19 @@ HTTP/1.1 429 Too Many Requests X-RateLimit-Limit: 100 X-RateLimit-Remaining: 0 +X-RateLimit-Reset: 1609459200 X-RateLimit-Retry-After: 30 ``` | 헤더 | 의미 | |------|------| | `X-RateLimit-Limit` | 허용 최대 요청 수 | -| `X-RateLimit-Remaining` | 남은 요청 수 | -| `X-RateLimit-Retry-After` | 몇 초 후 재시도 가능 | +| `X-RateLimit-Remaining` | 현재 윈도에서 남은 요청 수 | +| `X-RateLimit-Reset` | 한도가 재설정되는 UTC 에포크 타임스탬프 | +| `X-RateLimit-Retry-After` | 몇 초 후 재시도 가능 (429 응답 시에만) | + +> 거부되지 않은 정상 응답에도 `X-RateLimit-Limit`, `X-RateLimit-Remaining`은 포함하는 것이 좋다. +> 클라이언트가 자신의 사용량을 사전에 파악하여 스로틀링 전략을 세울 수 있도록. #### 처리 방법 @@ -330,6 +452,29 @@ Thread A: counter+1 = 5, 저장 Thread B: counter+1 = 5, 저장 ← 실제로는 6이어야 함! ``` +**왜 Redis INCR만으론 부족한가?** + +`INCR`는 원자적이지만, **"읽기 → 판단 → 증가"** 흐름은 원자적이지 않다. + +``` +# 문제 시나리오 (임계치 = 5) +GET counter → 4 (A가 읽음) +GET counter → 4 (B가 읽음, 동시) +INCR counter → 5 (A가 증가, 5 ≤ 5이므로 허용) +INCR counter → 6 (B가 증가, 하지만 판단은 이미 4 기준으로 했음!) + → 6이지만 B도 허용되어 버림! +``` + +**EXPIRE와 INCR의 원자성 문제** + +``` +# 처음 키 생성 시 INCR → EXPIRE 사이에 서버 크래시 나면? +INCR counter → 1 +── 여기서 크래시 ── +EXPIRE counter 60 → (실행 안 됨) +→ counter 키가 만료 없이 영구 존재! +``` + **해결책 선택 기준: 알고리즘에 따라 다르다** > Sorted Set과 Lua 스크립트는 경쟁 관계가 아니다. @@ -357,8 +502,14 @@ Thread B: counter+1 = 5, 저장 ← 실제로는 6이어야 함! **본 설계(토큰 버킷) → Lua 스크립트 선택** +Lua 스크립트는 Redis 서버에서 단일 명령처럼 원자적으로 실행된다. +읽기 → 판단 → 쓰기 사이에 다른 명령이 끼어들 수 없다. + ```lua -- Redis에서 원자적으로 실행 (읽기-수정-쓰기가 끊기지 않음) +-- KEYS[1]: 버킷 키 (예: "rate:user:123") +-- ARGV[1]: 버킷 최대 토큰 수 (bucket_size) +-- ARGV[2]: TTL (초) local tokens = tonumber(redis.call('GET', KEYS[1]) or ARGV[1]) if tokens < 1 then return 0 -- 토큰 없음 → 거부 @@ -368,42 +519,92 @@ redis.call('EXPIRE', KEYS[1], ARGV[2]) return 1 -- 허용 ``` +**Redis 키 설계 예시** + +``` +rate:{domain}:{identifier}:{window} + +예시: +rate:auth:user:42:1609459200 ← 사용자 42의 1분 윈도 카운터 +rate:post:ip:192.168.1.1:1609459200 ← IP 기반 카운터 +rate:global:2024-01-01 ← 전역 일별 카운터 + +키 설계 원칙: +- TTL을 반드시 설정 → 윈도 종료 후 자동 삭제 +- identifier는 해싱 처리 (PII 보호) +``` + **만약 이동 윈도 로깅을 선택했다면 → Sorted Set 사용** ``` +# 세 명령을 Lua로 묶어 원자적으로 실행 ZREMRANGEBYSCORE key 0 (현재시각 - 윈도크기) ← 만료 항목 제거 -ZADD key 현재시각 요청ID ← 새 요청 기록 -ZCARD key ← 현재 요청 수 확인 -→ 임계치 초과 여부 판단 +ZADD key 현재시각 요청UUID ← 새 요청 기록 (score=타임스탬프) +ZCARD key ← 현재 요청 수 확인 +→ ZCARD 결과 ≤ 임계치이면 허용 ``` +**MULTI/EXEC(트랜잭션) vs Lua 스크립트 비교** + +| | MULTI/EXEC | Lua 스크립트 | +|--|-----------|------------| +| 원자성 | 완전하지 않음 (다른 클라이언트가 중간에 읽기 가능) | 완전한 원자성 | +| 조건부 실행 | 불가 (WATCH로 우회 가능) | 가능 (if/else 가능) | +| 성능 | 왕복 2회 필요 | 단일 왕복 | +| 권장 여부 | △ | **✓ 권장** | + --- #### 동기화 (Synchronization) 문제 +여러 처리율 제한 장치(서버)가 동일 사용자의 요청을 처리할 때, 각자 다른 카운터를 보면 제한이 무의미해진다. + ``` -처리율 제한 장치 A ─────┐ - ├──→ 각자 다른 카운터? -처리율 제한 장치 B ─────┘ +사용자가 rate_limiter_1, rate_limiter_2에 각각 5건씩 요청 +→ 각 서버는 "5건 미만"으로 판단하여 모두 허용 +→ 실제로는 10건이 처리됨 (임계치 = 5건 위반) ``` **해결책 비교** ``` -[고정 세션 방식] [중앙 집중형 저장소 방식] -Client A → 장치 A (항상) Client A ─┐ -Client B → 장치 B (항상) ├──→ [Redis] - Client B ─┘ -장점: 단순 장점: 확장 가능, 일관성 보장 -단점: 확장성 없음 단점: Redis 단일 장애점 +[고정 세션 방식 (Sticky Session)] [중앙 집중형 저장소 방식] +Client A → 장치 A (항상) Client A ─┐ +Client B → 장치 B (항상) ├──→ [Redis] + Client B ─┘ +장점: 단순, 카운터 분산 없음 장점: 수평 확장 가능, 일관성 보장 +단점: 확장성 없음, 단점: Redis가 단일 장애점 + 장치 장애 시 세션 유실 Redis Cluster로 해결 가능 ``` > **선택: 중앙 집중형 저장소 (Redis Cluster)** +> +> Redis Cluster(최소 3 마스터)로 단일 장애점 제거. +> 클러스터 장애 시 처리율 제한을 일시적으로 우회할지, 차단할지는 정책으로 결정. --- ### 3-4. 성능 최적화 +#### 로컬 캐시 + 비동기 동기화 + +중앙 Redis로의 매 요청 네트워크 왕복을 줄이기 위해 **로컬 캐시**를 도입한다. + +``` +[요청 흐름] +요청 → Rate Limiter + │ + ▼ + [로컬 인메모리 캐시] + (카운터를 로컬에서 먼저 증가) + │ 비동기로 주기적 동기화 + ▼ + [중앙 Redis Cluster] + +장점: Redis 네트워크 왕복 제거 → 지연 시간 감소 +단점: 동기화 지연 동안 임계치를 약간 초과할 수 있음 (Soft Limit 수용 가능) +``` + #### 멀티 데이터센터 지원 ``` @@ -411,15 +612,19 @@ Client B → 장치 B (항상) ├──→ [Redis] │ │ ▼ ▼ 에지 서버 (Seoul PoP) 에지 서버 (NY PoP) + + 로컬 Redis + 로컬 Redis │ │ └──────────┬──────────────┘ ▼ - [Redis Cluster] + [글로벌 Redis Cluster] (최종 일관성 모델) ``` - 가까운 에지 서버에서 처리율 제한 → **지연 시간 최소화** +- 서울 사용자가 우연히 뉴욕 서버로 라우팅되어도, 글로벌 Redis에 집계되어 일관성 유지 - 제한 장치 간 데이터 동기화: **최종 일관성 모델(Eventual Consistency)** 채택 + - 짧은 시간(수백 ms) 동안 약간의 초과 허용 가능 + - 보안 민감 API(결제 등)에는 강한 일관성이 필요 → 지역 Redis만 사용하거나 Global Redis에 직접 접근 --- @@ -428,15 +633,39 @@ Client B → 장치 B (항상) ├──→ [Redis] ``` 모니터링 대상: ├── 알고리즘 효과성 - │ └── 실제 트래픽 패턴 vs. 설정 임계치 적합성 + │ ├── 실제 트래픽 패턴 vs. 설정 임계치 적합성 + │ └── burst 트래픽 발생 빈도 └── 규칙 효과성 - ├── 정상 요청이 과도하게 차단되지 않는지 - └── DDoS 패턴이 실제로 차단되는지 + ├── 정상 요청이 과도하게 차단되지 않는지 (false positive) + └── DDoS 패턴이 실제로 차단되는지 (false negative) +``` + +**핵심 메트릭** + +| 메트릭 | 설명 | 알람 기준 | +|--------|------|-----------| +| `rate_limited_requests_total` | 429 반환된 요청 수 | 갑자기 급증 시 규칙 재검토 | +| `rate_limit_hit_ratio` | 전체 요청 중 제한된 비율 | 5% 초과 시 임계치 조정 검토 | +| `redis_latency_p99` | Redis 응답 지연 99th percentile | 5ms 초과 시 Redis 부하 점검 | +| `rule_evaluation_latency` | 규칙 평가 소요 시간 | 1ms 초과 시 캐시 미스 점검 | + +**운영 시나리오** + +``` +[시나리오 1] 플래시 세일 이벤트 +→ 정상 유저의 요청도 급증 → 429가 과도하게 발생 +→ 조치: 이벤트 기간 임시 임계치 상향 (규칙 파일 핫리로드) + +[시나리오 2] 알고리즘 변경 후 +→ 토큰버킷 → 이동윈도카운터로 교체 +→ burst traffic 패턴이 달라질 수 있음 +→ 교체 전후 429 비율 비교 모니터링 필수 ``` **알람 기준 예시** - 429 응답 비율이 갑자기 급증 → 규칙 재검토 - 특정 IP의 429 응답 집중 → DDoS 가능성 확인 +- Redis p99 지연 > 5ms → 처리율 제한 장치 자체가 병목 --- @@ -509,6 +738,57 @@ Client → [Load Balancer] → [Rate Limiter Middleware] --- +--- + +## 추가 논의 포인트 (면접 심화) + +### 처리율 제한 장치 자체의 고가용성 + +``` +처리율 제한 장치가 다운되면? + +선택 1. Fail Open (장치 장애 시 모든 요청 허용) + → 서비스 가용성 우선 + → 장애 시간 동안 DDoS 취약 + +선택 2. Fail Closed (장치 장애 시 모든 요청 차단) + → 보안 우선 + → 서비스 다운타임 발생 + +→ 대부분의 서비스: Fail Open 선택 (헬스체크 + 알람으로 빠른 복구) +→ 금융/보안 민감 서비스: Fail Closed 고려 +``` + +### 처리율 제한 장치 우회 (Bypass) 설계 + +내부 서비스 간 호출, 관리자 API 등은 처리율 제한을 우회할 수 있어야 한다. + +```yaml +# 규칙에 whitelist 추가 +bypass_rules: + - key: service_name + value: internal-payment-service # 내부 서비스 식별자 + action: bypass # 처리율 제한 건너뜀 + + - key: user_role + value: admin + action: bypass +``` + +### 요청 비용 차등 적용 (Weighted Rate Limiting) + +모든 요청이 동일한 "비용"을 갖지 않는다. + +``` +일반 GET 요청 → 토큰 1개 소비 +대용량 파일 업로드 → 토큰 10개 소비 +AI 추론 API 호출 → 토큰 50개 소비 + +→ 토큰 버킷 알고리즘에서 자연스럽게 표현 가능 +``` + +--- + ## 참고: 주요 의사결정 흐름 ### 알고리즘 선택 diff --git "a/04-\354\262\230\353\246\254\354\234\250-\354\240\234\355\225\234\354\236\245\354\271\230/\354\240\225\354\235\270\354\262\240/02_\354\262\230\353\246\254\354\234\250_\354\240\234\355\225\234\354\236\245\354\271\230_\354\204\244\352\263\204_2\354\260\250.md" "b/04-\354\262\230\353\246\254\354\234\250-\354\240\234\355\225\234\354\236\245\354\271\230/\354\240\225\354\235\270\354\262\240/02_\354\262\230\353\246\254\354\234\250_\354\240\234\355\225\234\354\236\245\354\271\230_\354\204\244\352\263\204_2\354\260\250.md" new file mode 100644 index 0000000..b877e9c --- /dev/null +++ "b/04-\354\262\230\353\246\254\354\234\250-\354\240\234\355\225\234\354\236\245\354\271\230/\354\240\225\354\235\270\354\262\240/02_\354\262\230\353\246\254\354\234\250_\354\240\234\355\225\234\354\236\245\354\271\230_\354\204\244\352\263\204_2\354\260\250.md" @@ -0,0 +1,732 @@ +# 4장. 처리율 제한 장치 설계 (Rate Limiter) + +--- + +## 문제 이해 및 설계 범위 확정하기 + +키 포인트: 면접 초반에 반드시 질문으로 요구사항을 좁히기 + +``` +지원자: 클라이언트 측 제한인가요, 서버 측 제한인가요? +면접관: 서버 측입니다. + +지원자: 어떤 기준으로 요청을 제한하나요? IP? 사용자 ID? 전역? +면접관: 여러 기준을 유연하게 지원할 수 있어야 합니다. + +지원자: 시스템 규모는 어느 정도인가요? +면접관: 대규모 분산 시스템입니다. 가용성과 일관성 모두 중요합니다. + +지원자: 처리율 제한 장치를 독립 서비스로 만드나요, 아니면 서비스 코드에 넣나요? +면접관: 독립 컴포넌트로 만들어 주세요. + +지원자: 제한된 요청을 클라이언트에게 알려야 하나요? +면접관: 네, 알려야 합니다. +``` + +**확정된 요구사항** + +- 서버 측 처리율 제한 장치 +- IP, 사용자 ID, 전역 등 다양한 기준 지원 +- 대규모 분산 환경, 낮은 지연 시간 필수 +- 가능한 한 메모리를 적게 사용 +- 분산 환경 지원 (여러 서버에서 상태 공유) +- 제한 초과 시 HTTP 헤더로 사용자에게 명확히 안내 +- 높은 내결함성 (처리율 제한 장치 장애가 전체 시스템에 영향 미치면 안 됨) + +--- + +## 개략적 설계안 + +### 처리율 제한 장치의 위치 + +처리율 제한 장치는 클라이언트, 서버, 미들웨어 세 곳에 둘 수 있다. + +``` +┌─────────────────────────────────────────────────────┐ +│ Client │ +└─────────────────────────┬───────────────────────────┘ + │ + ┌───────────▼───────────┐ + │ API Gateway │ ← 여기를 선택 + │ (Rate Limiter 내장) │ + └───────────┬───────────┘ + │ + ┌───────────▼───────────┐ + │ API Servers │ + └───────────────────────┘ +``` + +**클라이언트 측을 선택하지 않는 이유** +- 클라이언트 요청은 위변조가 가능 (신뢰 불가) +- 클라이언트가 모든 구현을 통제하기 어려움 + +**서버 측(코드 내 구현)을 선택하지 않는 이유** +- 언어·프레임워크마다 구현 중복 +- 마이크로서비스 환경에서 N개 서비스 각각 구현 필요 + +**API Gateway(미들웨어)를 선택하는 이유** +- 이미 SSL 종단, 인증, IP 화이트리스트 등이 집중된 단일 진입점 +- 처리율 제한을 언어 무관하게 중앙에서 관리 +- 마이크로서비스 환경에서 표준 구성 + +> 단, 처리율 제한 알고리즘을 직접 제어해야 하거나, +> 자체 인증 서버가 없는 소규모 팀이라면 서버 측 구현이 더 적합할 수 있다. + +--- + +### 처리율 제한 알고리즘 5종 + +#### 1. 토큰 버킷 (Token Bucket) + +**파라미터 2개**: `bucket_size` (최대 토큰 수), `refill_rate` (초당 추가 토큰 수) + +``` + refill_rate (토큰/초) + │ + ▼ + ┌──────────────┐ + │ ● ● ● ● ● │ capacity = bucket_size + │ ● ● ● │ 꽉 차면 추가 토큰 버림 (overflow) + └──────┬───────┘ + │ 요청마다 토큰 1개 소비 + ▼ + 토큰 있음 → 요청 통과 + 토큰 없음 → 요청 버림 (429) +``` + +**버킷 단위 전략** +- API endpoint별: `/v1/orders` — 분당 1건, `/v1/search` — 분당 5건 +- 사용자별 전역: 사용자 ID당 하루 10만 건 +- IP별: IP당 초당 50건 + +**장점**: 구현 단순, 메모리 효율, **burst traffic 허용** (토큰이 쌓여 있으면 한꺼번에 처리) +**단점**: `bucket_size`와 `refill_rate` 튜닝이 까다로움 + +> 실사용: Amazon API Gateway, Stripe + +--- + +#### 2. 누출 버킷 (Leaky Bucket) + +**파라미터 2개**: `bucket_size` (큐 최대 크기), `outflow_rate` (초당 꺼내는 요청 수) + +``` + 새 요청 → ┌──────────────┐ + │ FIFO Queue │ ← 가득 차면 새 요청 즉시 버림 + │ ■ ■ ■ ■ │ + └──────┬───────┘ + │ outflow_rate (고정 속도) + ▼ + [API Server] +``` + +**장점**: 다운스트림 서버에 일정한 부하 (stable outflow rate), 메모리 효율적 +**단점**: 트래픽 급증 시 큐가 오래된 요청으로 채워지고, **최신 요청이 드롭됨** + +> 실사용: Shopify (초당 2건 출력 제한) + +--- + +#### 3. 고정 윈도 카운터 (Fixed Window Counter) + +**파라미터 2개**: `window_size` (윈도 크기, 예: 1분), `max_requests` (윈도당 최대 요청 수) + +``` +시간축: ──[ 윈도 1 ]──[ 윈도 2 ]──[ 윈도 3 ]── + 0s 60s 60s 120s 120s 180s + count=3 count=1 count=5 + 임계치=5 → 초과 차단 +``` + +**치명적 단점: 윈도 경계 공격** + +``` +임계치 = 분당 5건 + + 00:59 ─────────── 01:00 ─────────── 01:01 + ||||| ||||| + (5건, 윈도1 기준 허용) (5건, 윈도2 기준 허용) + ↑ + 2초 안에 실제 10건 처리됨 → 2배 허용! +``` + +**장점**: 구현 단순, 메모리 효율, 특정 패턴에서 적합 +**단점**: 윈도 경계 부근 트래픽 급증 허용 (설계적 결함) + +--- + +#### 4. 이동 윈도 로깅 (Sliding Window Log) + +**파라미터 2개**: `window_size` (윈도 크기), `max_requests` (최대 요청 수) + +**동작 방식** +1. 요청이 오면 현재 타임스탬프를 Sorted Set에 추가 (score = 타임스탬프) +2. `현재시각 - window_size` 이전의 만료된 항목 제거 +3. Sorted Set의 크기 ≤ max_requests → 허용 / 초과 → 거부 +4. **거부된 요청의 타임스탬프도 Sorted Set에 유지** + +``` +현재시각: 1:00:30, 윈도 = 1분, 임계치 = 2건 + + Sorted Set: + score=1:00:05 ○ (1분 전~현재 사이 → 유효) + score=1:00:30 ○ (새 요청) + + 크기 = 2 ≤ 2 → 허용 + + 다음 요청 (1:00:40): + 크기 = 3 > 2 → 거부 (하지만 1:00:40도 Sorted Set에 추가됨) +``` + +**장점**: 어느 순간이든 임계치를 정확히 준수 (높은 정확도) +**단점**: 거부된 요청의 타임스탬프도 보관 → **메모리 낭비** + +``` +예) 사용자 1명이 분당 5건 제한, 1만 건 시도 +→ 거부된 9,995건의 타임스탬프도 Redis에 저장 +→ DDoS 상황에서 Redis 메모리 급증 +``` + +--- + +#### 5. 이동 윈도 카운터 (Sliding Window Counter) + +고정 윈도 카운터(메모리 효율) + 이동 윈도 로깅(정확도)의 하이브리드. + +**파라미터 2개**: `window_size`, `max_requests` + +**공식** + +``` +현재 윈도 요청 수 예측값 += (이전 윈도 요청 수 × (1 - 현재 윈도 경과율)) + 현재 윈도 요청 수 +``` + +**예시** + +``` +임계치: 분당 7건 +이전 윈도 (00:00 ~ 01:00) 요청 = 5건 +현재 윈도 시작(01:00) 후 30초 경과 → 경과율 = 30/60 = 50% +현재 윈도 요청 = 3건 + +예측값 = (5 × 0.5) + 3 = 2.5 + 3 = 5.5 < 7 → 허용 + +현재 윈도 요청이 4건이면: +예측값 = 2.5 + 4 = 6.5 < 7 → 허용 + +현재 윈도 요청이 5건이면: +예측값 = 2.5 + 5 = 7.5 > 7 → 거부 +``` + +**가정**: 이전 윈도의 요청이 균등하게 분포했다고 가정 +→ 느슨하지만 실험적으로 무해 (Cloudflare 데이터: 4억 건 중 0.003%만 오차) + +**장점**: 메모리 효율적 (카운터 2개만 유지), 고정 윈도의 경계 문제 해결 +**단점**: 균등 분포 가정 (burst + 경계 조합의 극단적 케이스에서 약간 부정확) + +> 실사용: Cloudflare + +--- + +#### 알고리즘 비교 요약 + +| 알고리즘 | 메모리 | Burst 허용 | 정확도 | Redis 자료구조 | 추천 상황 | +|----------|--------|-----------|--------|----------------|-----------| +| 토큰 버킷 | ✓ 좋음 | ✓ 허용 | 보통 | String + Lua | **범용 (기본 선택)** | +| 누출 버킷 | ✓ 좋음 | ✗ 불허 | 보통 | List (FIFO) | 일정 출력이 필요한 경우 | +| 고정 윈도 카운터 | ✓ 좋음 | △ (경계 위험) | 낮음 | String + Lua | 단순성이 최우선일 때 | +| 이동 윈도 로깅 | ✗ 나쁨 | ✗ 불허 | 높음 | Sorted Set | 정확도 최우선, 소규모 | +| 이동 윈도 카운터 | ✓ 좋음 | ✗ 불허 | 높음 | String + Lua | 정확도 + 메모리 균형 | + +**알고리즘 선택 트리** + +``` +burst를 허용해야 하나? + YES → 토큰 버킷 + NO → 출력이 일정해야 하나? + YES → 누출 버킷 + NO → 메모리가 충분한가? + NO → 고정 윈도 카운터 (경계 문제 감수) + YES → 정확도가 중요한가? + HIGH → 이동 윈도 로깅 + MEDIUM → 이동 윈도 카운터 (권장) +``` + +> **본 설계 선택: 토큰 버킷** — burst traffic 허용, 구현 단순, 업계 표준 + +--- + +### 개략적 아키텍처 + +``` + ┌──────────────────────┐ + │ 규칙 저장소 │ + │ (YAML/Disk) │ + └──────────┬───────────┘ + │ (1) 주기적 로드 + ▼ +Client ──→ Rate Limiter ──────[Rule Cache] + │ (Worker Thread) + │ │ (2) 캐시에서 규칙 조회 + │ ┌────▼────┐ + │ │ Redis │ ← 카운터만 저장 + │ └─────────┘ + │ + ┌──────▼──────┐ + │ 한도 초과? │ + └──┬───────┬──┘ + YES│ │NO + ▼ ▼ + [선택 A] 즉시 429 [API Server] + [선택 B] 메시지 큐에 적재 +``` + +**역할 분리 원칙** +- 규칙 저장소: 디스크의 YAML 파일 (변경 빈도 낮음) +- Rule Cache: 워커 스레드가 주기적으로 갱신하는 메모리 캐시 (요청 처리 시 I/O 없음) +- Redis: 카운터만 저장, 규칙은 저장하지 않음 + +--- + +## 상세 설계 + +### 처리율 제한 규칙 + +Lyft ratelimit 오픈소스 형식을 참고한 YAML 규칙 예시 + +```yaml +domain: auth +descriptors: + # 로그인 시도: 분당 5건 + - key: user_login + rate_limit: + unit: minute + requests_per_unit: 5 + + # 계정 생성: IP당 하루 10건 + - key: account_create + descriptors: + - key: ip_address + rate_limit: + unit: day + requests_per_unit: 10 + + # 마케팅 SMS: 수신자당 하루 5건 + - key: message_type + value: marketing + descriptors: + - key: to_number + rate_limit: + unit: day + requests_per_unit: 5 +``` + +**규칙 설계 원칙** +- domain으로 논리적 그룹화 → 운영 편의 +- descriptors 중첩으로 복합 조건 표현 가능 +- 규칙 변경 → 디스크 파일만 수정 → 워커가 자동 갱신 + +--- + +### 제한 초과 응답 처리 + +#### HTTP 응답 헤더 + +``` +HTTP/1.1 429 Too Many Requests +Content-Type: application/json + +X-RateLimit-Limit: 100 +X-RateLimit-Remaining: 0 +X-RateLimit-Reset: 1609459260 +X-RateLimit-Retry-After: 30 +``` + +| 헤더 | 의미 | 포함 시점 | +|------|------|-----------| +| `X-RateLimit-Limit` | 윈도당 최대 요청 수 | 모든 응답 | +| `X-RateLimit-Remaining` | 남은 요청 수 | 모든 응답 | +| `X-RateLimit-Reset` | 리셋 UTC 에포크 타임스탬프 | 모든 응답 | +| `X-RateLimit-Retry-After` | 재시도 가능 시간(초) | 429 응답에만 | + +> 정상 응답에도 `Limit`, `Remaining`, `Reset` 헤더를 포함해야 클라이언트가 사전에 대응 가능. + +#### 초과 요청 처리 방법 + +``` +초과 요청 도착 + │ + ┌───▼────────────────┐ + │ 즉시 버릴까? │──YES──→ 429 즉시 반환 + └───┬────────────────┘ + │NO (나중에 처리해도 되는 요청) + ▼ + 메시지 큐에 적재 → 나중에 재처리 + (배치 작업, 이메일 발송 등) +``` + +--- + +### 상세 처리 흐름 + +``` +요청 수신 + │ + ▼ +[1] 처리율 제한 규칙 확인 + (Rule Cache에서 조회, 캐시 미스 시 디스크 로드) + │ + ▼ +[2] Redis에서 카운터 조회 (Lua 스크립트로 원자적 실행) + ├── GET counter + ├── 임계치 초과? → YES → 429 반환 + 응답 헤더 설정 + │ NO → 계속 + ├── INCR counter + └── EXPIRE counter (첫 요청인 경우) + │ + ▼ +[3] 요청을 API 서버로 포워딩 + │ + ▼ +[4] 응답 헤더에 현재 사용량 추가 후 클라이언트 반환 +``` + +--- + +### 분산 환경에서의 문제와 해결 + +#### 경쟁 조건 (Race Condition) + +**문제 재현** + +``` +임계치 = 5, 현재 counter = 4 + +Thread A: GET counter → 4 (읽기) +Thread B: GET counter → 4 (동시에 읽기) +Thread A: 4 < 5 → 허용, SET counter 5 +Thread B: 4 < 5 → 허용, SET counter 5 ← 실제론 6이어야 하는데 5로 기록됨 +→ 두 요청 모두 허용됨 (의도한 동작 아님) +``` + +**INCR만으로 부족한 이유** + +`INCR`는 원자적이지만 "읽기 → 판단 → 증가"의 전체 흐름은 원자적이지 않다. + +``` +# 올바른 순서이지만 원자성 없음 +1. GET counter → 4 +2. 4 < 5이므로 허용 결정 +3. INCR counter → 5 +← 2번과 3번 사이에 다른 스레드가 끼어들 수 있음 +``` + +**INCR + EXPIRE 사이의 크래시 문제** + +``` +INCR counter → 1 (키 생성) +─── 서버 크래시 ─── +EXPIRE counter 60 → (실행 안 됨) +→ counter 키가 TTL 없이 영구 존재 → 카운터가 리셋되지 않음! +``` + +**해결책: Lua 스크립트** + +Lua 스크립트는 Redis 서버에서 단일 명령처럼 원자적으로 실행된다. + +```lua +-- 토큰 버킷: Lua로 원자적 실행 +-- KEYS[1]: Redis 키 (예: "rate:user:42:60") +-- ARGV[1]: bucket_size (최대 토큰 수) +-- ARGV[2]: TTL (초) + +local key = KEYS[1] +local capacity = tonumber(ARGV[1]) +local ttl = tonumber(ARGV[2]) + +local tokens = tonumber(redis.call('GET', key)) + +if tokens == nil then + -- 키가 없으면 (첫 요청) 버킷 초기화 + tokens = capacity +end + +if tokens < 1 then + return 0 -- 토큰 없음 → 거부 +end + +-- 원자적으로 토큰 차감 및 TTL 설정 +redis.call('SET', key, tokens - 1) +redis.call('EXPIRE', key, ttl) +return 1 -- 허용 +``` + +**이동 윈도 로깅 알고리즘이라면 → Sorted Set** + +```lua +-- Sorted Set으로 이동 윈도 로깅 구현 (Lua로 원자적 실행) +local key = KEYS[1] +local now = tonumber(ARGV[1]) +local window = tonumber(ARGV[2]) +local limit = tonumber(ARGV[3]) +local request_id = ARGV[4] + +-- 만료된 항목 제거 +redis.call('ZREMRANGEBYSCORE', key, 0, now - window) + +-- 현재 요청 수 확인 +local count = redis.call('ZCARD', key) + +if count >= limit then + return 0 -- 거부 +end + +-- 새 요청 기록 +redis.call('ZADD', key, now, request_id) +redis.call('EXPIRE', key, window) +return 1 -- 허용 +``` + +**MULTI/EXEC vs Lua 스크립트** + +| | MULTI/EXEC | Lua 스크립트 | +|--|-----------|------------| +| 원자성 | 불완전 (다른 클라이언트가 중간 읽기 가능) | 완전한 원자성 | +| 조건부 실행 | 불가 (WATCH + 재시도로 우회) | if/else 가능 | +| 네트워크 왕복 | 2회 이상 | 1회 | +| **권장 여부** | △ | **✓ 권장** | + +--- + +#### 동기화 (Synchronization) + +여러 처리율 제한 서버가 각자 다른 카운터를 보면 제한이 무의미해진다. + +``` +사용자가 분당 10건 제한, 서버 2대에 각각 5건 요청 +→ 서버 A: "5건 미만 → 허용" +→ 서버 B: "5건 미만 → 허용" +→ 실제 10건 처리 (제한 무력화) +``` + +**해결책 비교** + +``` +[고정 세션 (Sticky Session)] [중앙 집중형 저장소] +Client A → 서버 A (고정) Client A ─┐ +Client B → 서버 B (고정) ├──→ [Redis Cluster] + Client B ─┘ + +장점: 구현 단순 장점: 수평 확장, 일관성 보장 +단점: 확장성 없음, 서버 장애 시 유실 단점: Redis Cluster 운영 필요 +``` + +> **선택: 중앙 집중형 저장소 (Redis Cluster)** +> Redis Cluster는 최소 3개의 마스터 노드로 구성, 단일 장애점 제거. + +--- + +**Redis 키 설계** + +``` +rate:{domain}:{dimension}:{identifier}:{window_start} + +예시: +rate:auth:user:42:1609459200 ← 사용자 42, 특정 분 윈도 +rate:auth:ip:192.168.1.1:1609459200 ← IP 기반 카운터 +rate:post:global:1609459200 ← 전역 카운터 + +설계 원칙: +- TTL은 반드시 window_size와 동일하게 설정 +- identifier는 해싱 처리 (개인정보 보호) +- 멀티 기준 제한 시 키를 분리하여 독립적으로 관리 +``` + +--- + +### 성능 최적화 + +#### 로컬 캐시 + 비동기 동기화 + +Redis 왕복을 줄여 지연 시간을 최소화하는 전략. + +``` +요청 → Rate Limiter + │ + ▼ + [로컬 인메모리 카운터] ← 빠름 (네트워크 없음) + │ + │ 비동기 주기적 동기화 (예: 100ms마다) + ▼ + [중앙 Redis Cluster] + +장점: 지연 시간 대폭 감소 +단점: 동기화 지연 동안 임계치를 약간 초과할 수 있음 + → Soft Limit 허용 가능한 API에만 적용 +``` + +#### 멀티 데이터센터 + +``` +사용자 (서울) 사용자 (뉴욕) + │ │ + ▼ ▼ + Seoul Edge Node NY Edge Node + (로컬 Redis + 캐시) (로컬 Redis + 캐시) + │ │ + └────────────┬─────────────┘ + ▼ + [글로벌 Redis Cluster] + (최종 일관성 모델) +``` + +- 사용자와 가까운 에지 서버에서 처리율 제한 → 지연 최소화 +- 에지 서버 간 데이터 동기화: **최종 일관성 (Eventual Consistency)** +- 수백 ms의 동기화 지연 동안 임계치를 약간 초과할 수 있음 (허용 범위) +- 보안 민감 API (결제, 인증)는 글로벌 Redis에 직접 접근하여 강한 일관성 보장 + +--- + +### 모니터링 + +**핵심 메트릭** + +| 메트릭 | 설명 | 알람 임계치 | +|--------|------|------------| +| `rate_limited_requests_total` | 429 반환 요청 수 | 급증 시 규칙 재검토 | +| `rate_limit_hit_ratio` | 전체 요청 대비 제한 비율 | 5% 초과 | +| `redis_op_latency_p99` | Redis 작업 99th percentile 지연 | 5ms 초과 | +| `rule_cache_miss_rate` | 규칙 캐시 미스율 | 1% 초과 | + +**운영 시나리오** + +``` +[시나리오 1] 429 비율 갑자기 급증 +→ 정상 트래픽 패턴이 변했나? or 임계치가 너무 낮나? +→ 조치: 규칙 임계치 조정 (YAML 수정 → 워커 자동 갱신) + +[시나리오 2] 특정 IP에 429 집중 +→ DDoS 가능성 +→ 조치: IP 차단 규칙 추가, WAF 연동 + +[시나리오 3] 알고리즘 교체 후 +→ burst 패턴 허용 여부가 바뀌므로 429 비율 변동 가능 +→ 교체 전후 메트릭 비교 필수 +``` + +--- + +## 마무리 및 추가 논의 + +### 경성 vs. 연성 처리율 제한 + +| 구분 | 정의 | 사용 사례 | +|------|------|-----------| +| 경성 (Hard) | 임계치를 절대 초과할 수 없음 | 결제 API, 인증 API | +| 연성 (Soft) | 짧은 시간 동안 임계치를 약간 초과 허용 | 일반 콘텐츠 조회 | + +### 처리율 제한 장치의 고가용성 + +처리율 제한 장치 자체가 다운되면 어떻게 할 것인가? + +``` +[Fail Open] [Fail Closed] +장치 장애 시 모든 요청 허용 장치 장애 시 모든 요청 차단 + +장점: 서비스 가용성 유지 장점: 보안 정책 유지 +단점: 장애 시간 동안 DDoS 취약 단점: 서비스 다운타임 발생 + +→ 대부분의 서비스: Fail Open +→ 금융/보안 서비스: Fail Closed 검토 +``` + +### 내부 서비스 Bypass + +내부 서비스 간 호출, 관리자 API는 처리율 제한을 우회할 수 있어야 한다. + +```yaml +bypass_rules: + - key: service_name + value: internal-payment-service + action: bypass + + - key: user_role + value: admin + action: bypass +``` + +### 요청 비용 차등 적용 (Weighted Rate Limiting) + +요청마다 소비하는 토큰 수를 다르게 설정. + +``` +일반 GET 요청 → 토큰 1개 소비 +대용량 파일 업로드 → 토큰 10개 소비 +AI 추론 API 호출 → 토큰 50개 소비 + +→ 토큰 버킷에서 자연스럽게 표현 가능 +→ API 비용과 부하를 공정하게 제어 +``` + +### OSI 계층별 처리율 제한 + +``` +Layer 7 (Application) ─ HTTP URL, 헤더, 사용자 ID 기반 ← 본 설계 +Layer 4 (Transport) ─ IP + TCP 포트 기반 +Layer 3 (Network) ─ IP 주소 기반 (iptables 등) +``` + +### 클라이언트 설계 가이드 (429 대응) + +``` +✓ 캐시 활용 → API 호출 횟수 자체를 줄이기 +✓ 요청을 짧은 시간에 집중하지 않기 (분산) +✓ 429 수신 시 Exponential Backoff 적용 + + 1차 실패 → 1초 대기 + 2차 실패 → 2초 대기 + 3차 실패 → 4초 대기 + n차 실패 → min(2^n, 최대대기시간)초 대기 + +✓ X-RateLimit-Remaining 헤더를 사전에 모니터링 + → 0에 가까워지면 요청 속도를 줄이는 로직 구현 +``` + +--- + +## 최종 아키텍처 + +``` + ┌──────────────────┐ + │ 규칙 파일 │ + │ (YAML / Disk) │ + └────────┬─────────┘ + │ 주기적 로드 (Worker Thread) + ▼ +Client → [Load Balancer] → [Rate Limiter Middleware] + │ + ┌──────────┼──────────┐ + │ │ │ + [Rule Cache] [Redis ] [Message] + (메모리) [Cluster ] [Queue ] + │ + [한도 초과?] + ┌─────┴─────┐ + YES NO + │ │ + 429 반환 API Server + + 응답 헤더 +``` + +**설계 요약** + +| 결정 항목 | 선택 | 이유 | +|-----------|------|------| +| 위치 | API Gateway | 중앙화, 언어 무관 | +| 알고리즘 | 토큰 버킷 | burst 허용, 단순, 업계 표준 | +| 저장소 | Redis Cluster | 인메모리 속도, TTL 지원, HA | +| 원자성 | Lua 스크립트 | 완전한 원자적 실행 | +| 규칙 관리 | YAML + Worker 캐시 | 핫리로드, I/O 분리 | +| 동기화 | 중앙 집중형 Redis | 일관성 보장, 수평 확장 | +| 다중 데이터센터 | 최종 일관성 | 지연 최소화, 실용적 | diff --git "a/05-\354\225\210\354\240\225 \355\225\264\354\213\234/\354\240\225\354\235\270\354\262\240/01_\354\225\210\354\240\225_\355\225\264\354\213\234_\354\204\244\352\263\204_1\354\260\250.md" "b/05-\354\225\210\354\240\225 \355\225\264\354\213\234/\354\240\225\354\235\270\354\262\240/01_\354\225\210\354\240\225_\355\225\264\354\213\234_\354\204\244\352\263\204_1\354\260\250.md" new file mode 100644 index 0000000..e8a0dc7 --- /dev/null +++ "b/05-\354\225\210\354\240\225 \355\225\264\354\213\234/\354\240\225\354\235\270\354\262\240/01_\354\225\210\354\240\225_\355\225\264\354\213\234_\354\204\244\352\263\204_1\354\260\250.md" @@ -0,0 +1,366 @@ +# 5장. 안정 해시 설계 (Consistent Hashing) + +--- + +## 문제 이해 및 설계 범위 확정하기 + +수평적 확장을 위해서는 요청 또는 데이터를 서버들에 균등하게 나누는 것이 중요하다. +이 장의 목표는 다음 두 가지를 동시에 만족하는 분배 방법을 찾는 것이다. + +**요구사항** + +- 데이터(키)를 서버들에 균등하게 분배 +- 서버가 추가/삭제될 때 **재배치되는 키를 최소화** +- 대규모 분산 캐시/스토리지 환경에서 동작 +- 특정 서버로 키가 쏠리는 핫스팟 방지 + +--- + +## 해시 키 재배치 문제 + +### 나머지 연산 방식 (hash % N) + +N개의 서버에 부하를 나누는 가장 단순한 방법. + +``` +인덱스 = hash(key) % N // N = 서버의 개수 +``` + +서버 풀의 크기가 고정되어 있고 데이터 분포가 균등하면 잘 동작한다. +문제는 **서버가 추가되거나 삭제되는 순간**이다. + +### 치명적 단점: 대규모 재배치 + +``` +서버 4대 → 3대로 축소되는 경우 + + key0: hash=18 → 18 % 4 = 2 (서버2) 18 % 3 = 0 (서버0) ← 이동 + key1: hash=26 → 26 % 4 = 2 (서버2) 26 % 3 = 2 (서버2) ← 유지 + key2: hash=35 → 35 % 4 = 3 (서버3) 35 % 3 = 2 (서버2) ← 이동 + key3: hash=50 → 50 % 4 = 2 (서버2) 50 % 3 = 2 (서버2) ← 유지 + ... + + → % 4와 % 3의 결과가 일치할 확률은 약 25% + → 즉 약 75%의 키가 재배치됨 +``` + +**캐시 환경에서의 장애 시나리오** + +``` +서버 1대 다운 + │ + ▼ +키의 대부분이 다른 서버로 재매핑 + │ + ▼ +대량 캐시 미스 발생 + │ + ▼ +요청이 전부 DB(원본 저장소)로 쏟아짐 + │ + ▼ +cache stampede → DB 과부하 → 연쇄 장애 +``` + +> 서버 1대가 죽었을 뿐인데 DB까지 함께 죽는 구조. +> 4장의 처리율 제한 장치가 "요청 폭주"를 막는 장치였다면, +> 이 장은 "노드 변경으로 인한 내부 폭주"를 막는 문제의식이다. + +--- + +## 안정 해시 (Consistent Hashing) + +해시 테이블의 크기가 조정될 때 평균적으로 **k/n개의 키만 재배치**하는 해시 기법. + +- k: 키의 개수 +- n: 슬롯의 개수 (실질적으로는 노드/서버 수로 이해) + +### 해시 링 (Hash Ring) + +해시 공간(0 ~ 2^160 - 1)의 양 끝을 구부려 맞닿게 하면 해시 링이 만들어진다. +링 위에 **서버의 위치**와 **데이터의 해시 키**를 함께 배치한다. + +``` + 0 (= 2^160) + │ + ┌───────┴───────┐ + │ │ + k3 ○ │ │ ● S0 + │ │ + S3 ● │ Hash │ ○ k0 + │ Ring │ + │ │ ● S1 + k2 ○ │ │ + └───────┬───────┘ ○ k1 + │ + S2 ● + + ● = 서버 노드, ○ = 데이터 키 +``` + +### 키 → 서버 조회 + +각 키는 자신의 위치에서 **시계 방향으로 탐색하여 처음 만나는 서버**에 저장된다. + +``` + k0 → 시계 방향 → S1 담당 + k1 → 시계 방향 → S2 담당 + k2 → 시계 방향 → S3 담당 + k3 → 시계 방향 → S0 담당 +``` + +### 서버 추가 + +``` +S0과 S1 사이에 S4 추가 + + 변경 전: ── S0 ──── k0 ──── S1 ── (k0은 S1 담당) + 변경 후: ── S0 ─ k0 ─ S4 ─── S1 ── (k0만 S4로 이동) + + → 새 서버 위치에서 반시계 방향으로 이전 서버(S0)를 만나기 전까지의 + 키들만 새 서버(S4)로 재배치 + → 나머지 키는 전부 그대로 +``` + +### 서버 제거 + +``` +S1 제거 + + 변경 전: ── S0 ── k0 ── S1 ── S2 ── (k0은 S1 담당) + 변경 후: ── S0 ── k0 ──────── S2 ── (k0만 S2로 이동) + + → 제거된 서버가 담당하던 키들만 시계 방향의 다음 서버로 재배치 +``` + +**핵심**: 영향 범위가 **인접 구간으로 국한**된다. % N처럼 전체가 뒤섞이지 않는다. + +--- + +### 기본 안정 해시의 두 가지 문제점 + +#### 1. 파티션 크기의 불균등 + +서버 추가/삭제가 반복되면 각 서버가 담당하는 링 구간(파티션)의 크기가 균등하게 유지되지 않는다. + +``` + S1이 제거되는 경우: + + ── S0 ──[ 작은 구간 ]── S1 ──[ 작은 구간 ]── S2 ── + │ + ▼ S1 제거 + ── S0 ──[ 매우 큰 구간 ]── S2 ── + + → S2의 담당 구간이 다른 서버 대비 2배로 커짐 +``` + +#### 2. 키의 불균등 분포 (쏠림) + +서버가 링 위에 몰려 배치되면 특정 서버로 키가 집중된다. + +``` + S0 ● ● S1 ● S2 ← 서버 3대가 한쪽에 몰림 + ┌──────────────┐ + │ │ + │ 나머지 │ ← 이 넓은 구간의 키 전부가 + │ 절반의 링 │ 시계 방향 첫 서버 하나에 쏠림 + │ │ + └──────────────┘ + + 서버 3대면 링 위의 점이 3개뿐 + → 운이 나쁘면 한 서버가 링의 절반을 담당 +``` + +--- + +## 가상 노드 (Virtual Node / Replica) + +위 두 문제를 해결하는 기법. 하나의 물리 서버를 링 위에 **여러 개의 가상 노드**로 배치한다. + +``` + 물리 서버 2대 (S0, S1), 서버당 가상 노드 3개 + + ── S0_vn0 ── S1_vn0 ── S0_vn1 ── S1_vn1 ── S0_vn2 ── S1_vn2 ── + + → 각 서버의 담당 구간이 링 전체에 잘게 분산됨 + → 조회 방식은 동일: 시계 방향 탐색, 처음 만난 가상 노드의 실제 서버에 저장 +``` + +### 왜 균등해지는가 + +가상 노드 수가 많아질수록 각 서버가 담당하는 구간 합이 통계적으로 평균에 수렴한다 (**대수의 법칙**, 표준편차 감소). + +``` + 가상 노드 수 키 분포의 표준편차 + ──────────── ───────────────── + 100개 평균 대비 약 10% + 200개 평균 대비 약 5% + + → 가상 노드 ↑ = 표준편차 ↓ = 분포 균등 +``` + +### Trade-off + +| 항목 | 내용 | +|------|------| +| 균등성 | 가상 노드 수 ↑ → 분포 균등 ↑ | +| 메모리 | 가상 노드 V개 저장 필요 → O(V) | +| 조회 비용 | 정렬 자료구조에서 이진 탐색 → O(log V) | +| 노드 증감 비용 | 서버 1대 추가/제거 시 V개의 점을 넣고 빼야 함 | +| 실무 타협점 | 서버당 **100~200개**가 일반적 | + +### 서버 제거 시 데이터는? + +링은 **라우팅 규칙**일 뿐, 데이터 자체를 보존하지 않는다. + +``` + 캐시 용도 → 캐시 미스 후 원본에서 재적재 (자연 회복) + 스토리지 용도 → 별도 복제(replication) 없으면 데이터 유실 + (Dynamo: 시계 방향 다음 N개 노드에 복제) +``` + +--- + +## 구현 (Java + TreeMap) + +핵심은 `ceilingEntry()` 두 줄이다. + +```java +public class ConsistentHashRouter { + + // 링 위의 위치(해시값) -> 실제 서버. 정렬된 맵이라 시계 방향 탐색 가능 + private final TreeMap ring = new TreeMap<>(); + private final int virtualNodeCount; // 서버당 가상 노드 개수 + + public ConsistentHashRouter(List servers, int virtualNodeCount) { + this.virtualNodeCount = virtualNodeCount; + servers.forEach(this::addServer); + } + + public void addServer(String server) { + // 물리 서버 하나를 가상 노드 여러 개로 링 위에 배치 + for (int i = 0; i < virtualNodeCount; i++) { + ring.put(hash(server + "#VN" + i), server); + } + } + + public void removeServer(String server) { + for (int i = 0; i < virtualNodeCount; i++) { + ring.remove(hash(server + "#VN" + i)); + } + } + + public String route(String key) { + long h = hash(key); + // ceilingEntry = 시계 방향으로 처음 만나는 가상 노드 + Map.Entry entry = ring.ceilingEntry(h); + // 없으면 링을 한 바퀴 돌아 처음(firstEntry)으로 + return (entry != null ? entry : ring.firstEntry()).getValue(); + } + + private long hash(String input) { + // String.hashCode()는 분포가 나쁨 → MurmurHash 등 균등 분포 해시 사용 + return Hashing.murmur3_128() + .hashString(input, StandardCharsets.UTF_8).asLong(); + } +} +``` + +**구현 포인트** + +``` +[1] TreeMap.ceilingEntry(h) = "시계 방향 탐색" +[2] null이면 firstEntry() = "링을 한 바퀴 도는" 처리 +[3] 해시 함수는 분포가 균등한 것 사용 (MurmurHash, MD5 등) + → String.hashCode()는 분포가 나빠 부적합 +``` + +--- + +## 방식 비교 + +| 방식 | 노드 변경 시 재배치 | 분포 균등성 | 메모리 | 구현 복잡도 | +|------|--------------------|------------|--------|------------| +| hash % N | ✗ 대부분 재배치 (~75%) | ✓ 균등 | ✓ 없음 | ✓ 매우 단순 | +| 안정 해시 (기본) | ✓ 평균 k/n | ✗ 쏠림 가능 | ✓ 노드 수만큼 | 보통 | +| 안정 해시 + 가상 노드 | ✓ 평균 k/n | ✓ 통계적 균등 | △ O(V) | 보통 | +| 고정 해시 슬롯 (Redis) | ✓ 슬롯 매핑만 이동 | ✓ 균등 | ✓ 슬롯 테이블 | 보통 | + +**선택 트리** + +``` +노드가 동적으로 추가/제거되는가? + NO → hash % N으로 충분 + YES → 노드 수를 미리 고정할 수 있는가? + YES → 고정 해시 슬롯 (Redis Cluster 방식) + NO → 안정 해시 + 가상 노드 +``` + +--- + +## 실무 적용 사례 + +| 시스템 | 적용 방식 | +|--------|----------| +| Amazon DynamoDB | 파티션 키 분산에 안정 해시 계열 사용 (Dynamo 논문이 원조) | +| Apache Cassandra | 클러스터 내 데이터 파티셔닝 | +| Nginx | `hash ... consistent` (ketama 알고리즘) | +| Envoy | Ring Hash / Maglev 로드 밸런싱 | +| Discord, Akamai CDN | 트래픽/콘텐츠 분산 | + +### Redis Cluster는 안정 해시가 아니다 + +``` +Redis Cluster의 방식: + + 슬롯 = CRC16(key) % 16384 ← 슬롯 개수를 16384로 "고정" + + ┌─────────────┐ 슬롯 0 ~ 5460 → 노드 A + │ 16384 슬롯 │ 슬롯 5461 ~ 10922 → 노드 B + └─────────────┘ 슬롯 10923 ~ 16383 → 노드 C + + 노드 추가 시: 키 재해싱 없이 "슬롯 → 노드 매핑"만 이동 +``` + +- 슬롯 개수를 고정해서 % 연산의 재배치 문제 자체를 회피한 **다른 접근** +- 안정 해시 대비 운영이 단순 (슬롯 단위 마이그레이션) + +> **4장과의 연결**: 처리율 제한 장치에서 카운터 저장소로 쓴 Redis Cluster가 +> 내부적으로 "카운터 키를 어느 노드에 둘 것인가"를 푸는 방식이 곧 이 장의 문제의식이다. + +--- + +## 마무리 및 추가 논의 + +### 안정 해시의 이점 정리 + +- 서버 추가/삭제 시 재배치되는 키 최소화 (평균 k/n) +- 데이터가 균등 분포되므로 수평적 규모 확장에 유리 +- 핫스팟(hotspot) 문제 완화 — 특정 키가 몰리는 서버 분산 + +### 스터디 논의 주제 + +``` +[논의 1] 가상 노드 개수 산정 +→ 서버당 100~200개라는 수치의 근거는? (균등성 vs 메모리의 실측 기반 타협) + +[논의 2] Redis Cluster의 선택 +→ 안정 해시 대신 고정 슬롯을 택한 이유는? +→ 운영 단순성(슬롯 단위 관리) vs 유연성(임의 노드 수) + +[논의 3] 캐시 vs 스토리지 +→ 스토리지 용도라면 복제 전략까지 필요 (Dynamo의 N-replica) +→ 어디까지가 라우팅의 책임이고 어디부터 복제의 책임인가 +``` + +--- + +## 설계 요약 + +| 결정 항목 | 선택 | 이유 | +|-----------|------|------| +| 분배 방식 | 안정 해시 | 노드 증감 시 재배치 최소화 (평균 k/n) | +| 균등성 보완 | 가상 노드 (서버당 100~200개) | 파티션 불균등·키 쏠림 해소 | +| 자료구조 | TreeMap (정렬 맵) | ceilingEntry로 시계 방향 탐색 O(log V) | +| 해시 함수 | MurmurHash 등 | 균등 분포 (hashCode 부적합) | +| 데이터 보존 | 캐시: 자연 회복 / 스토리지: 복제 필요 | 링은 라우팅 규칙일 뿐 | \ No newline at end of file diff --git "a/05-\354\225\210\354\240\225 \355\225\264\354\213\234/\354\240\225\354\235\270\354\262\240/02_\355\232\214\354\202\254_\354\240\201\354\232\251_\353\266\204\354\204\235.md" "b/05-\354\225\210\354\240\225 \355\225\264\354\213\234/\354\240\225\354\235\270\354\262\240/02_\355\232\214\354\202\254_\354\240\201\354\232\251_\353\266\204\354\204\235.md" new file mode 100644 index 0000000..7aed27e --- /dev/null +++ "b/05-\354\225\210\354\240\225 \355\225\264\354\213\234/\354\240\225\354\235\270\354\262\240/02_\355\232\214\354\202\254_\354\240\201\354\232\251_\353\266\204\354\204\235.md" @@ -0,0 +1,336 @@ +# 5장. 안정 해시 — 회사 시스템 적용 분석 + +> 팀 비즈니스 구조를 기반으로 우리 시스템에서 안정 해시가 필요한 지점을 분석. +> 구조: **현황 분석 → 적용 지점 도출 → 확인 체크리스트** + +--- + +## 전제: 우리 팀 시스템 현황 + +| 항목 | 내용 | +|------|------| +| 도메인 | 이동통신사 백오피스 | +| 팀 모듈 | 판매 / 재고 / 정산 / 기준정보(사용자관리) / RM(여신) / IF | +| 담당 파트 | **정산** — 대리점 퇴근 시 일일 정산, 관리자용 백오피스 운영/유지보수 | +| 인프라 | Redis(캐시), RabbitMQ(배치 트리거, 알림 방송, 주문 이벤트) | +| 사용자 | 대리점/상담원/내부 관리자 (일반 대중 트래픽 아님) | +| 순서 보장 요구 | 휴대폰 판매 시 **같은 주문의 이벤트는 순서대로** 처리되어야 함 | + +**트래픽 특성** (백오피스 + 정산 도메인) + + +![img.png](img.png) + +``` +사용자 = 전국 대리점 + 상담원 + 내부 관리자 → 동시 사용자 수천 명 수준 +캐시 조회: 수백 ~ 수천 QPS 수준 + +정산 도메인의 특성: + 평시에는 트래픽 낮음 + 대리점 퇴근/마감 시간대에 전국 대리점의 정산 요청이 "동시에" 몰림 + → 시간대 집중형(peak-concentrated) 트래픽 + +판매 도메인의 특성: + 평시 낮음, 신규 단말 출시일에 주문 이벤트 수십 배 폭증 가능 +``` + +![img_1.png](img_1.png) + +**정산 업무 흐름 (비즈니스 관점)** + +``` +일마감: 대리점 퇴근 시 + 당일 판매 건 대사 → 수납/입금 확인 → 재고 이동 반영 → 일마감 확정 + +월마감: 월말 + 일마감 데이터 집계 → 판매 수수료(리베이트) 정산 → 본사 지급 확정 + → 일마감이 하나라도 밀리면 월마감 전체가 지연되는 종속 구조 + +후속 연계 (IF): + 마감 확정 데이터는 본사 ERP 일배치로 전송 + → 배치 cut-off 시각 전에 마감이 끝나야 함 (사실상의 SLA) +``` + +![img_2.png](img_2.png) + +**피크가 겹치는 최악 시나리오** + +``` +월말 마감 + 신규 단말 출시일 + 프로모션(보조금 정책) 변경일이 겹치는 날 + → 판매 폭증 → 마감 대상 건수 폭증 → 마감 시간대 처리량이 평시의 수십 배 + → 단일 워커 구조의 한계가 드러나는 지점 +``` + +--- + +## 분석 1: Redis 캐시 — 안정 해시가 필요 없는 지점 + +### 분석 + +**Standalone 또는 Sentinel(HA용) 구성으로 판단. 키 분배 자체가 없으므로 안정 해시 미사용.** + +### 근거 + +1차 문서의 선택 트리에 대입: + +``` +노드가 동적으로 추가/제거되는가? + → NO (단일 노드로 충분한 규모) + → hash % N조차 필요 없음. 분배 문제 자체가 없음 +``` + +- 캐시 대상이 요금제, 단말기 정보, 기준정보(코드성 데이터) 등 → 단일 노드 메모리를 넘을 이유가 없음 +- 백오피스 QPS 수준에서 수평 샤딩의 동기가 없음 +- 필요한 것은 분배가 아니라 **가용성** → Sentinel로 해결 + +### 확장 시나리오 (만약 커진다면) + +- 캐시 데이터가 단일 노드 메모리 초과 시 → **Redis Cluster (고정 해시 슬롯)** 선택이 자연스러움 +- 안정 해시를 직접 구현할 일은 없음 (1차 문서: Redis Cluster는 안정 해시가 아니라 CRC16 % 16384 고정 슬롯) + +--- + +## 분석 2: RabbitMQ 배치 트리거 / 알림 방송 — 분배가 아니라 전달 + +### 분석 + +**direct(배치 트리거) + fanout(알림 방송) exchange로 판단. 안정 해시와 무관.** + +### 근거 + +| 용도 | 패턴 | 필요한 것 | +|------|------|----------| +| 배치 트리거 (정산 배치 등) | 지정된 워커에게 전달 | direct exchange | +| 알림 방송 | 구독자 전원에게 전달 | fanout exchange | + +- 메시지를 여러 노드에 "나눠서(partition)" 처리할 필요가 없음 +- "지정된 곳" 또는 "전부"에게 보내는 패턴 → 해시 기반 분배의 문제의식 자체가 없음 + +--- + +## 분석 3: 판매 주문 이벤트 — ★ 안정 해시 도입 포인트 (1) + +### 현재 구조 + +**주문 큐 1개 + 컨슈머 1개 (또는 prefetch=1 단일 처리)로 판단.** + +- 백오피스 평시 트래픽이면 단일 컨슈머로 충분 +- 큐 1개 + 컨슈머 1개 = 전역 순서 보장이 공짜로 됨 → 안정 해시가 필요 없었음 + +### 문제 시나리오: 신규 단말 출시일 + +``` +아이폰 발매일: 평시 주문 대비 수십 배 폭증 + │ + ▼ +컨슈머 1개로 처리 지연 → 개통 대기 적체 + │ + ▼ +컨슈머를 3개로 늘리면? (competing consumers) + + 주문A-접수 → 컨슈머1 + 주문A-개통 → 컨슈머2 ← 접수 처리 전에 개통이 먼저 끝날 수 있음 + 주문A-완료 → 컨슈머3 + + → 같은 주문의 순서 깨짐 ✗ +``` + +**딜레마**: 처리량을 늘리려면 컨슈머를 늘려야 하는데, 늘리는 순간 순서가 깨진다. + +### 해결: Consistent Hash Exchange (`x-consistent-hash`) + +RabbitMQ 공식 플러그인 `rabbitmq-consistent-hash-exchange` 사용. + +``` + x-consistent-hash exchange + (routing key = 주문번호 해싱) + │ + ┌────────────────┼────────────────┐ + ▼ ▼ ▼ + 큐 Q1 큐 Q2 큐 Q3 + (컨슈머1) (컨슈머2) (컨슈머3) + + 주문A의 모든 이벤트 → hash(주문A) → 항상 Q2 → 항상 컨슈머2 + 주문B의 모든 이벤트 → hash(주문B) → 항상 Q1 → 항상 컨슈머1 + + → 주문 간 병렬 처리 ✓ + 주문 내 순서 보장 ✓ +``` + +**1차 문서 개념과의 매핑** + +| 1차 문서 개념 | RabbitMQ에서의 대응 | +|--------------|---------------------| +| 링 위의 서버 노드 | 바인딩된 큐 | +| 데이터 키 | routing key (주문번호) | +| 가상 노드 개수 | 바인딩 키의 숫자 (가중치) | +| 노드 추가/제거 시 k/n만 재배치 | 큐 추가/제거 시 일부 주문번호만 다른 큐로 이동 | + +- 큐를 늘리거나 줄일 때 **일부 라우팅 키만 재배치** → 안정 해시의 핵심 이점 그대로 +- Kafka의 파티션 키(`hash(key) % partitions`)와 동일한 문제의식의 RabbitMQ 버전 + +--- + +## 분석 4: 정산 마감 처리 — ★ 안정 해시 도입 포인트 (2, 담당 파트) + +### 정산 도메인의 순서 보장 요구 + +정산은 대리점 단위로 **선행 처리가 끝나야 다음 단계가 가능한** 흐름이다. + +``` +대리점 X의 마감 흐름 (예시): + + 판매 내역 집계 → 재고 차감 반영 → 여신(RM) 한도 정산 → 정산 마감 확정 + + → 같은 대리점의 정산 이벤트는 순서대로 처리되어야 함 + → 서로 다른 대리점끼리는 순서 무관 (병렬 처리 가능) +``` + +### 순서가 깨지면 생기는 비즈니스 사고 + +| 순서 위반 | 비즈니스 결과 | +|-----------|--------------| +| 판매 집계 전에 마감 확정 | 마감 금액 ≠ 실제 판매액 → **수수료 오지급** → 재정산 + 대리점 클레임 | +| 재고 차감 전에 마감 확정 | 장부 재고 ≠ 실물 재고 → 재고 실사 불일치, 익일 판매 가능 수량 오류 | +| 여신 정산 전에 추가 판매 승인 | 여신 한도 초과 판매 → **미수금 리스크** (RM 통제 무력화) | +| 일마감 지연 → ERP cut-off 초과 | 본사 회계 반영 누락 → 월마감 지연, 재무 보고 차질 | + +> 순서 보장은 기술 요구사항이기 전에 **돈이 걸린 정합성 요구사항**이다. +> 백오피스는 트래픽은 작아도 건당 금액 정확성의 무게가 크다. + +### 문제 시나리오: 마감 시간대 집중 + +``` +전국 대리점이 퇴근 시간대에 동시에 정산 요청 + │ + ▼ +정산 처리 워커 1개 → 마감 시간대에 처리 적체 + │ + ▼ +워커를 늘리면 같은 대리점의 정산 단계가 뒤섞일 위험 + → 판매 주문 이벤트와 완전히 동일한 딜레마 +``` + +### 해결: routing key = 대리점 코드 + +``` + x-consistent-hash exchange + (routing key = 대리점 코드 해싱) + │ + ┌────────────────┼────────────────┐ + ▼ ▼ ▼ + 정산워커1 정산워커2 정산워커3 + + 대리점 X의 모든 정산 이벤트 → 항상 워커2 → 단계 순서 보장 ✓ + 대리점 간에는 병렬 처리 → 마감 시간대 처리량 확보 ✓ +``` + +- 분석 3과 같은 패턴, 키만 다름 (주문번호 → 대리점 코드) +- **마감 시간대에만 워커를 늘리고 이후 줄이는 운영**도 가능 + → 워커 증감 시 일부 대리점만 재배치되는 안정 해시의 이점이 그대로 적용됨 + +### 주의점 (모의면접 대비) + +``` +[1] 재배치 순간의 순서 보장 + 큐 추가 시 이동하는 키(주문번호/대리점 코드)에 in-flight 메시지가 있으면 + 잠깐 순서가 어긋날 수 있음 → 배포 타이밍 조절 or 드레인 후 확장 + +[2] 큐:컨슈머 = 1:1 유지 필요 + 한 큐에 컨슈머 여러 개 붙이면 순서 보장이 다시 깨짐 + +[3] 핫스팟 + 대형 대리점(판매량 많은 지점)의 이벤트가 몰려도 대리점 단위로는 분산 불가 + → 키 단위 순서 보장이 필요한 이상 감수해야 하는 트레이드오프 + → 1차 문서의 "핫스팟 키 문제"가 실무에서 나타나는 형태 +``` + +--- + +## 분석 5: 다른 모듈로의 확장 — 순서 보장 단위와 키 후보 + +같은 패턴(키 단위 순서 보장 + 병렬 처리)이 팀 내 다른 모듈에도 그대로 적용된다. +핵심은 **"무엇 단위로 순서를 보장해야 하는가" = routing key 선정**이다. + +| 모듈 | 순서 보장 단위 | routing key 후보 | 순서 위반 시 사고 | +|------|---------------|-----------------|------------------| +| 판매 | 주문 | 주문번호 | 접수 전 개통 처리 등 상태 꼬임 | +| 정산 | 대리점 | 대리점 코드 | 수수료 오지급, 마감 금액 불일치 | +| 재고 | 단말 1대 | IMEI(시리얼) | 입고 전 판매, 이중 판매, 이동 이력 꼬임 | +| RM(여신) | 대리점 | 대리점 코드 | 한도 차감/복원 순서 꼬임 → 한도 초과 판매 | +| IF | 대상 외부 시스템 | 시스템 코드 (또는 전문 순번) | 외부 시스템에 역순 전문 도달 → 재전송 협의 비용 | + +**키 선정의 원칙** (1차 문서와 연결) + +``` +키 범위가 좁을수록 (IMEI > 주문번호 > 대리점 코드) + → 병렬성 ↑, 핫스팟 위험 ↓ +키 범위가 넓을수록 (대리점 코드) + → 순서 보장 범위 ↑, 대형 대리점 핫스팟 위험 ↑ + +→ "순서를 보장해야 하는 최소 단위"를 키로 잡는 것이 원칙 + (예: 정산은 대리점 간 순서가 무관하므로 대리점 코드면 충분) +``` + +--- + +## 도입 판단 기준 — 언제 도입할 것인가 + +지금 당장 도입하지 않는 이유와, 도입을 트리거할 지표를 함께 정의한다. + +**지금 도입하지 않는 이유 (비용 관점)** + +- 단일 컨슈머로 SLA(ERP cut-off 전 마감 완료)를 지키고 있다면, 플러그인 도입·큐 토폴로지 변경·운영 러닝커브는 순수 비용 +- 순서 보장이 "구조적으로 공짜"인 현재 방식(단일 큐)이 가장 단순하고 사고 확률이 낮음 + +**도입 트리거 지표 (운영 중 모니터링 대상)** + +``` +[1] 마감 완료 시각이 ERP cut-off에 근접 (여유 시간 지속 감소 추세) +[2] 마감 시간대 큐 적체(depth) 증가 추세 / 처리 지연 민원 발생 +[3] 출시일·월말에 마감 지연 이력 발생 +[4] 대리점 수 증가 계획 (신규 채널 확대 등) → 마감 대상 자체가 증가 + +→ [1]~[2]가 관측되는 시점 = x-consistent-hash 도입 검토 시작점 +``` + +**내가 생각한 모의면접 예상 질문..? -> "그 정도면 Kafka로 가야 하지 않나?"** + +``` +답변 포인트: + - Kafka 파티션 키도 동일한 문제의식 (키 단위 순서 + 파티션 병렬) + - 그러나 우리 트래픽은 백오피스 규모 → Kafka 클러스터 신규 운영 비용이 과함 + - 이미 운영 중인 RabbitMQ에 플러그인 하나로 동일 효과 + → "기술 선택은 문제 크기에 비례해야 한다"는 트레이드오프 논리 +``` + +--- + +## 종합: 선택 트리 대입 결과 + +| 시스템 | 분배 필요? | 현재 방식 | 안정 해시 관련성 | +|--------|-----------|----------|-----------------| +| Redis 캐시 | ✗ | Standalone/Sentinel | 불필요 (규모 미달) | +| MQ 배치/방송 | ✗ | direct/fanout | 무관 (분배 아닌 전달) | +| MQ 판매 주문 이벤트 | 현재 ✗ → 확장 시 ✓ | 단일 큐+단일 컨슈머 | 확장 시 x-consistent-hash (키=주문번호) | +| 정산 마감 처리 | 현재 ✗ → 확장 시 ✓ | 단일 워커 | 확장 시 x-consistent-hash (키=대리점 코드) | + +**결론**: 현재 우리 시스템은 안정 해시가 필요 없는 규모다. +단, "키 단위 순서 보장 + 병렬 처리"를 동시에 만족해야 하는 두 지점 — +판매 주문 이벤트(출시일 폭증)와 정산 마감 처리(마감 시간대 집중) — 에서 +컨슈머/워커를 확장하는 순간 안정 해시(x-consistent-hash)가 필요해진다. +그리고 그 확장의 트리거는 기술 지표가 아니라 **비즈니스 지표**(ERP cut-off 여유 감소, +마감 지연 이력, 대리점 수 증가)로 판단한다. + +--- + +## 확인 체크리스트 (실제 시스템에서 무엇을 확인했는지) + +- [ ] `redis-cli cluster info` → cluster_enabled 확인 +- [ ] 앱 설정에서 Redis 연결 방식 확인 (standalone / sentinel / cluster) +- [ ] `rabbitmq-plugins list | grep consistent` → 플러그인 여부 +- [ ] Management UI에서 exchange 타입 전수 확인 +- [ ] 주문 이벤트 큐의 컨슈머 수, prefetch 설정 확인 +- [ ] 정산 배치/이벤트의 처리 구조 확인 (단일 워커인지, 대리점 단위 병렬인지) +- [ ] 출시일/마감 시간대 처리 지연 이력 확인 (있었다면 도입 근거 강화) +- [ ] ERP 일배치 cut-off 시각과 실제 마감 완료 시각의 여유(gap) 확인 → 도입 트리거 지표 [1] +- [ ] 수수료 재정산/마감 정정 건 발생 이력 확인 (순서·정합성 사고의 실측 근거) diff --git "a/05-\354\225\210\354\240\225 \355\225\264\354\213\234/\354\240\225\354\235\270\354\262\240/img.png" "b/05-\354\225\210\354\240\225 \355\225\264\354\213\234/\354\240\225\354\235\270\354\262\240/img.png" new file mode 100644 index 0000000..6f00bf0 Binary files /dev/null and "b/05-\354\225\210\354\240\225 \355\225\264\354\213\234/\354\240\225\354\235\270\354\262\240/img.png" differ diff --git "a/05-\354\225\210\354\240\225 \355\225\264\354\213\234/\354\240\225\354\235\270\354\262\240/img_1.png" "b/05-\354\225\210\354\240\225 \355\225\264\354\213\234/\354\240\225\354\235\270\354\262\240/img_1.png" new file mode 100644 index 0000000..31ebcf9 Binary files /dev/null and "b/05-\354\225\210\354\240\225 \355\225\264\354\213\234/\354\240\225\354\235\270\354\262\240/img_1.png" differ diff --git "a/05-\354\225\210\354\240\225 \355\225\264\354\213\234/\354\240\225\354\235\270\354\262\240/img_2.png" "b/05-\354\225\210\354\240\225 \355\225\264\354\213\234/\354\240\225\354\235\270\354\262\240/img_2.png" new file mode 100644 index 0000000..f7675b2 Binary files /dev/null and "b/05-\354\225\210\354\240\225 \355\225\264\354\213\234/\354\240\225\354\235\270\354\262\240/img_2.png" differ