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" new file mode 100644 index 0000000..5590f65 --- /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/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" @@ -0,0 +1,818 @@ +# 4장. 처리율 제한 장치 설계 (Rate Limiter) + +> **1차 설계안** — 정인철 + +--- + +## 설계 사고 흐름 + +> 면접이나 실무에서 이 문제를 접했을 때 어떤 순서로 생각해야 하는가. + +``` +1. 왜 필요한가? (목적 파악) + │ + ▼ +2. 요구사항이 뭔가? (범위 확정) + → 어디에 둘 건가? 무엇 기준으로 제한? 분산 환경? + │ + ▼ +3. 어떤 알고리즘이 적합한가? (트레이드오프 판단) + → burst 허용 여부? 정확도 vs 메모리? 구현 난이도? + │ + ▼ +4. 어디에 카운터를 저장하나? (저장소 선택) + → DB(느림) vs 캐시(빠름) → Redis + │ + ▼ +5. 분산 환경에서 뭐가 문제인가? (엣지 케이스 탐색) + → Race Condition → 원자성 보장 방법은? + → 서버 여러 대 → 동기화는? + │ + ▼ +6. 성능·운영 관점에서 추가로 고려할 것은? (깊이 추가) + → 멀티 데이터센터, 모니터링, Hard vs Soft +``` + +### 핵심 판단 기준: "무엇이 문제인가"를 먼저 정의하라 + +처리율 제한은 결국 **"얼마나 많이"를 어떻게 세느냐**의 문제다. + +- 세는 단위가 다르면 → 알고리즘이 달라진다 +- 저장소가 단일 서버면 → 단순 카운터로 충분하다 +- 저장소가 분산이면 → 원자성·동기화 문제가 생긴다 +- 알고리즘이 카운터 기반이냐 vs 로그 기반이냐 → Redis 자료구조 선택이 달라진다 + +이 흐름을 따라가면 각 설계 결정이 "왜 그렇게 했는가"로 연결된다. + +--- + +## 0. 왜 필요한가? + +처리율 제한 장치(Rate Limiter)는 **클라이언트 또는 서비스가 보내는 트래픽의 처리율을 제어**하는 장치다. + +| 목적 | 설명 | +|------|------| +| DoS 공격 방어 | 자원 고갈(resource starvation) 방지 | +| 비용 절감 | 서버 수를 줄이거나 우선순위 높은 API에 자원 집중 | +| 서버 과부하 방지 | 잘못된 이용 패턴이 유발하는 트래픽 차단 | + +**실제 사례** +- 사용자는 초당 2회 이상 새 글을 올릴 수 없다 +- 같은 IP 주소로는 하루에 10개 이상의 계정을 생성할 수 없다 +- 요청 횟수가 임계치(threshold)를 넘으면 추가 요청은 모두 block + +--- + +## 1. 문제 이해 및 설계 범위 확정 + +설계 전 아래 질문으로 요구사항을 구체화한다. + +``` +Q. 어떤 종류의 처리율 제한 장치인가? 서버 측? 클라이언트 측? +Q. 어떤 기준으로 제한하는가? IP 주소? 사용자 ID? 전역? +Q. 시스템 규모는? 스타트업 수준? 대규모 트래픽? +Q. 분산 환경인가? 모놀리틱인가? +Q. 독립 서비스로 구현? 아니면 코드에 종속? +Q. 요청이 거부된 경우 사용자에게 알려야 하는가? +``` + +**본 설계의 가정** +- 서버 측 API 처리율 제한 장치 +- 분산 환경 지원 필요 +- 사용자 ID + IP 주소 기준 제한 +- 거부 시 클라이언트에 HTTP 헤더로 알림 + +--- + +## 2. 개략적 설계안 + +### 2-1. 처리율 제한 장치의 위치 + +``` +┌─────────────────────────────────────────────────────────┐ +│ Client │ +└─────────────────────────┬───────────────────────────────┘ + │ + ┌───────────▼───────────┐ + │ Rate Limiter │ ← 미들웨어 (추천) + │ (API Gateway) │ + └───────────┬───────────┘ + │ + ┌───────────▼───────────┐ + │ API Servers │ + └───────────────────────┘ +``` + +| 위치 | 장점 | 단점 | 추천 여부 | +|------|------|------|-----------| +| 클라이언트 측 | 구현 단순 | 위변조 쉬움, 통제 어려움 | ✗ | +| 미들웨어 (API Gateway) | 언어 무관, 중앙화 | 추가 네트워크 홉 | ✓ | +| 서버 측 | 알고리즘 자유 선택 | 서버마다 구현 필요 | △ | + +**선택: 미들웨어 (API Gateway)** +- 마이크로서비스 환경에서 API Gateway가 이미 존재하는 경우 적합 +- 처리율 제한 장치 전담 개발 인력이 없을 때도 적합 + +--- + +### 2-2. 처리율 제한 알고리즘 비교 + +#### 토큰 버킷 (Token Bucket) + +``` +토큰 공급기 (일정 속도로 토큰 추가) + │ + ▼ + ┌─────────┐ + │ ● ● ● ● │ ← 버킷 (용량 고정) + │ ● ● ● │ 꽉 차면 토큰 버림 (overflow) + └────┬────┘ + │ 요청 시 토큰 1개 소비 + ▼ + [토큰 있음] → 요청 처리 + [토큰 없음] → 요청 버림 +``` + +**파라미터 (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 — 초당 요청 제한에 토큰 버킷 사용 + +--- + +#### 누출 버킷 (Leaky Bucket) + +``` + 새 요청 → ┌──────────┐ + │ FIFO 큐 │ ← 가득 차면 새 요청 버림 + │ ■ ■ ■ │ + └────┬─────┘ + │ 고정 속도로 꺼냄 + ▼ + [API 서버] +``` + +**파라미터 (2개)** +| 파라미터 | 설명 | +|---------|------| +| `bucket_size` | 큐 최대 크기 (요청 수) | +| `outflow_rate` | 초당 꺼내는 요청 수 (고정) | + +**장점** +- 안정적 출력(stable outflow rate) 보장 → 다운스트림 서버에 일정한 부하 +- 메모리 효율적 (큐 크기 고정) + +**단점** +- 트래픽 급증 시 큐가 오래된 요청으로 꽉 차고, **최신 요청이 드롭됨** +- 두 파라미터 튜닝이 까다로움 + +> 실제 사용: **Shopify** — 초당 outflow rate 기반으로 API 요청 처리 + +--- + +#### 고정 윈도 카운터 (Fixed Window Counter) + +``` +시간축: ────[ 윈도 1 ]────[ 윈도 2 ]────[ 윈도 3 ]──── + + 0초 1초 2초 3초 + │ count=3 │ count=1 │ count=5 │ + └────────────┘────────────┘────────────┘ + 임계치=5 → 윈도 3에서 초과 요청 차단 +``` + +**파라미터 (2개)** +| 파라미터 | 설명 | +|---------|------| +| `window_size` | 윈도 크기 (예: 1분) | +| `max_requests` | 윈도당 최대 허용 요청 수 | + +**장점**: 메모리 효율, 구현 쉬움, 특정 트래픽 패턴 처리에 적합 +**단점**: **윈도 경계 부근**에서 순간적으로 2배 트래픽 허용 가능 + +``` +예) 임계치 = 분당 5건 + +타임라인: 00:59 01:00(새 윈도) 01:01 + ───────────────────────────────── + ||||| ||||| + (5건) (5건) + ← 1분 기준 각각 허용 → + ↑ 실제 1:59 ~ 1:01, 2초 안에 10건 처리됨! +``` + +> 이 경계 문제(boundary condition)는 고정 윈도의 구조적 결함이다. +> 정확도가 중요한 서비스에서는 이 알고리즘을 피해야 한다. + +--- + +#### 이동 윈도 로깅 (Sliding Window Log) + +``` +현재 시각: 1:00:30 + + [로그] + 1:00:05 ✓ + 1:00:12 ✓ ← 윈도 시작(1:00:00) 이후이므로 유효 + 1:00:25 ✓ + 1:00:30 ✓ (새 요청) + + 만료 타임스탬프 제거 후 로그 크기 ≤ 임계치 → 요청 허용 +``` + +**동작 방식** +1. 새 요청이 오면 현재 타임스탬프를 Sorted Set에 추가 +2. 만료된 타임스탬프 (현재시각 - 윈도 크기 이전) 제거 +3. Sorted Set 크기(요청 수) ≤ 임계치 → 허용 / 초과 → 거부 + (거부된 경우에도 타임스탬프는 로그에 유지) + +**장점**: 어느 순간이든 처리 한도를 넘지 않음 (정확) + +**단점**: 거부된 요청의 타임스탬프도 보관 → **메모리를 많이 사용** + +``` +예) 유저 1명이 분당 10건 제한 받고 1만 요청 시도한다면? +→ 거부된 9,990건의 타임스탬프도 Redis Sorted Set에 적재됨 +→ 대규모 DDoS 상황에서 Redis 메모리 급증 위험 +``` + +--- + +#### 이동 윈도 카운터 (Sliding Window Counter) + +고정 윈도 카운터 + 이동 윈도 로깅의 장점을 결합한 하이브리드 방식. + +**공식** + +``` +이전 윈도 요청 수 = 8 +현재 윈도 경과 비율 = 25% (현재 윈도 시작 후 15초 경과 / 60초) + +현재 시점의 예상 요청 수 += (이전 윈도 요청 수 × (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%만 경계 오차 발생 + +--- + +#### 알고리즘 비교 요약 + +| 알고리즘 | 메모리 효율 | Burst 허용 | 정확도 | Redis 자료구조 | +|----------|-----------|-----------|--------|----------------| +| 토큰 버킷 | ✓ 좋음 | ✓ 허용 | 보통 | String (카운터) | +| 누출 버킷 | ✓ 좋음 | ✗ 불허 | 보통 | List (FIFO 큐) | +| 고정 윈도 카운터 | ✓ 좋음 | △ 경계 위험 | 낮음 | String (카운터) | +| 이동 윈도 로깅 | ✗ 나쁨 | ✗ 불허 | 높음 | **Sorted Set** (타임스탬프) | +| 이동 윈도 카운터 | ✓ 좋음 | ✗ 불허 | 높음 | String (카운터) | + +> **선택: 토큰 버킷** — burst traffic 허용, 구현 단순, 업계 표준 + +--- + +### 2-3. 개략적 아키텍처 + +``` + ┌──────────────────────┐ + │ 규칙 저장소 │ + │ (Config Files/Disk) │ + └──────────┬───────────┘ + │ 규칙 로드 +Client ──→ Rate Limiter Middleware ─┤ + │ │ + │ ┌────▼────┐ + │ │ Redis │ ← 카운터 저장 + │ └─────────┘ + │ + ┌──────▼──────┐ + │ 한도 초과? │ + └──┬───────┬──┘ + YES│ │NO + ▼ ▼ + 429 반환 API Server + (+ 헤더) +``` + +**카운터 저장소로 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, Lyft ratelimit, Kong) + +**Lyft ratelimit 스타일 규칙 예시** + +```yaml +domain: messaging # 규칙이 적용되는 도메인 +descriptors: + # 마케팅 메시지: IP당 하루 5건 제한 + - key: message_type + value: marketing + descriptors: + - key: to_number + rate_limit: + unit: day + requests_per_unit: 5 + + # 인증 API: 클라이언트 IP당 분당 5건 + - key: auth + descriptors: + - key: ip_address + rate_limit: + 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) + +#### HTTP 응답 헤더로 현황 전달 + +``` +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-Reset` | 한도가 재설정되는 UTC 에포크 타임스탬프 | +| `X-RateLimit-Retry-After` | 몇 초 후 재시도 가능 (429 응답 시에만) | + +> 거부되지 않은 정상 응답에도 `X-RateLimit-Limit`, `X-RateLimit-Remaining`은 포함하는 것이 좋다. +> 클라이언트가 자신의 사용량을 사전에 파악하여 스로틀링 전략을 세울 수 있도록. + +#### 처리 방법 + +``` +처리율 초과 요청 + │ + ┌────▼────┐ + │ 버릴까? │──YES──→ 429 즉시 반환 + └────┬────┘ + │NO + ▼ + 메시지 큐에 보관 → 나중에 재처리 +``` + +--- + +### 3-3. 분산 환경 설계 + +#### 경쟁 조건 (Race Condition) 문제 + +``` +Thread A: Redis 읽기 → counter = 4 +Thread B: Redis 읽기 → counter = 4 (동시에!) +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 스크립트는 경쟁 관계가 아니다. +> 어떤 알고리즘을 선택했냐에 따라 쓰는 도구가 달라진다. + +``` +알고리즘이 카운터 기반인가? 알고리즘이 타임스탬프 로그 기반인가? +(토큰버킷, 고정윈도, 이동윈도카운터) (이동 윈도 로깅) + │ │ + ▼ ▼ + 읽기→수정→쓰기를 타임스탬프를 점수(score)로 + 원자적으로 실행해야 함 Sorted Set에 저장 + │ │ + ▼ ▼ + Lua 스크립트 Sorted Set + (실행 메커니즘) (자료구조) +``` + +| | Lua 스크립트 | Sorted Set | +|--|------------|-----------| +| **역할** | 원자적 실행 보장 | 타임스탬프 관리 자료구조 | +| **해결 대상** | race condition (읽기-수정-쓰기) | 이동 윈도 로깅 구현 자체 | +| **적용 알고리즘** | 토큰 버킷, 고정/이동 윈도 카운터 | 이동 윈도 로깅 | +| **함께 쓰나?** | Sorted Set + Lua로 같이 쓸 수 있음 | 원자성 필요 시 Lua와 조합 | + +**본 설계(토큰 버킷) → 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 -- 토큰 없음 → 거부 +end +redis.call('SET', KEYS[1], tokens - 1) +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 현재시각 요청UUID ← 새 요청 기록 (score=타임스탬프) +ZCARD key ← 현재 요청 수 확인 +→ ZCARD 결과 ≤ 임계치이면 허용 +``` + +**MULTI/EXEC(트랜잭션) vs Lua 스크립트 비교** + +| | MULTI/EXEC | Lua 스크립트 | +|--|-----------|------------| +| 원자성 | 완전하지 않음 (다른 클라이언트가 중간에 읽기 가능) | 완전한 원자성 | +| 조건부 실행 | 불가 (WATCH로 우회 가능) | 가능 (if/else 가능) | +| 성능 | 왕복 2회 필요 | 단일 왕복 | +| 권장 여부 | △ | **✓ 권장** | + +--- + +#### 동기화 (Synchronization) 문제 + +여러 처리율 제한 장치(서버)가 동일 사용자의 요청을 처리할 때, 각자 다른 카운터를 보면 제한이 무의미해진다. + +``` +사용자가 rate_limiter_1, rate_limiter_2에 각각 5건씩 요청 +→ 각 서버는 "5건 미만"으로 판단하여 모두 허용 +→ 실제로는 10건이 처리됨 (임계치 = 5건 위반) +``` + +**해결책 비교** + +``` +[고정 세션 방식 (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 수용 가능) +``` + +#### 멀티 데이터센터 지원 + +``` +사용자 (서울) 사용자 (뉴욕) + │ │ + ▼ ▼ +에지 서버 (Seoul PoP) 에지 서버 (NY PoP) + + 로컬 Redis + 로컬 Redis + │ │ + └──────────┬──────────────┘ + ▼ + [글로벌 Redis Cluster] + (최종 일관성 모델) +``` + +- 가까운 에지 서버에서 처리율 제한 → **지연 시간 최소화** +- 서울 사용자가 우연히 뉴욕 서버로 라우팅되어도, 글로벌 Redis에 집계되어 일관성 유지 +- 제한 장치 간 데이터 동기화: **최종 일관성 모델(Eventual Consistency)** 채택 + - 짧은 시간(수백 ms) 동안 약간의 초과 허용 가능 + - 보안 민감 API(결제 등)에는 강한 일관성이 필요 → 지역 Redis만 사용하거나 Global Redis에 직접 접근 + +--- + +### 3-5. 모니터링 + +``` +모니터링 대상: + ├── 알고리즘 효과성 + │ ├── 실제 트래픽 패턴 vs. 설정 임계치 적합성 + │ └── burst 트래픽 발생 빈도 + └── 규칙 효과성 + ├── 정상 요청이 과도하게 차단되지 않는지 (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 → 처리율 제한 장치 자체가 병목 + +--- + +## 4. 마무리 및 추가 논의 + +### 4-1. 경성 vs. 연성 처리율 제한 + +| 구분 | 정의 | 사용 사례 | +|------|------|-----------| +| 경성 (Hard) | 임계치를 절대 넘을 수 없음 | 결제 API, 보안 민감 API | +| 연성 (Soft) | 잠시 동안 임계치를 넘을 수 있음 | 일반 콘텐츠 조회 API | + +--- + +### 4-2. OSI 계층별 처리율 제한 + +``` +Layer 7 (Application) ─ HTTP 헤더, URL, 사용자 ID 기반 제한 ← 주로 여기 +Layer 4 (Transport) ─ IP + TCP 포트 기반 제한 +Layer 3 (Network) ─ IP 주소 기반 제한 (iptables 등) +``` + +--- + +### 4-3. 클라이언트 설계 가이드 (처리율 제한 회피 방법) + +``` +✓ 캐시 활용 → API 호출 횟수 자체를 줄이기 +✓ 짧은 시간에 요청 집중 금지 → 요청 분산 +✓ 예외 처리 코드 도입 → 429 수신 시 graceful 복구 +✓ 재시도 시 충분한 백오프 시간 (Exponential Backoff) +``` + +**Exponential Backoff 예시** + +``` +1차 실패 → 1초 대기 후 재시도 +2차 실패 → 2초 대기 후 재시도 +3차 실패 → 4초 대기 후 재시도 +n차 실패 → 2^n초 대기 (최대 한도 설정) +``` + +--- + +## 5. 최종 아키텍처 요약 + +``` + ┌──────────────────┐ + │ 규칙 파일 │ + │ (YAML/Disk) │ + └────────┬─────────┘ + │ +Client → [Load Balancer] → [Rate Limiter Middleware] + │ + ┌──────────┴──────────┐ + │ │ + ┌────▼────┐ ┌─────▼─────┐ + │ Redis │ │ Message │ + │ Cluster │ │ Queue │ + └─────────┘ └───────────┘ + │ + ┌────▼────┐ + │ 한도 초과?│ + └──┬───┬──┘ + YES NO + │ │ + 429 반환 API Server + + 응답 헤더 +``` + +--- + +--- + +## 추가 논의 포인트 (면접 심화) + +### 처리율 제한 장치 자체의 고가용성 + +``` +처리율 제한 장치가 다운되면? + +선택 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개 소비 + +→ 토큰 버킷 알고리즘에서 자연스럽게 표현 가능 +``` + +--- + +## 참고: 주요 의사결정 흐름 + +### 알고리즘 선택 + +``` +burst traffic을 허용해야 하나? + YES → 토큰 버킷 + NO → 출력 속도가 일정해야 하나? + YES → 누출 버킷 + NO → 정확도가 중요한가? + HIGH → 이동 윈도 로깅 (메모리 비쌈) + 또는 이동 윈도 카운터 (메모리 저렴, 약간 느슨) + LOW → 고정 윈도 카운터 +``` + +### Redis 자료구조 선택 + +``` +알고리즘이 카운터 기반인가? + YES (토큰버킷, 고정/이동윈도 카운터) + → Redis String + Lua 스크립트 (원자적 읽기-수정-쓰기) + +알고리즘이 타임스탬프 로그 기반인가? + YES (이동 윈도 로깅) + → Redis Sorted Set (score = 타임스탬프) + 원자성 필요 시 Lua 스크립트와 조합 가능 +``` 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 diff --git "a/06-\355\202\244\352\260\222-\354\240\200\354\236\245\354\206\214/\354\240\225\354\235\270\354\262\240/01_\355\202\244\352\260\222_\354\240\200\354\236\245\354\206\214_\354\204\244\352\263\204_1\354\260\250.md" "b/06-\355\202\244\352\260\222-\354\240\200\354\236\245\354\206\214/\354\240\225\354\235\270\354\262\240/01_\355\202\244\352\260\222_\354\240\200\354\236\245\354\206\214_\354\204\244\352\263\204_1\354\260\250.md" new file mode 100644 index 0000000..db64f8c --- /dev/null +++ "b/06-\355\202\244\352\260\222-\354\240\200\354\236\245\354\206\214/\354\240\225\354\235\270\354\262\240/01_\355\202\244\352\260\222_\354\240\200\354\236\245\354\206\214_\354\204\244\352\263\204_1\354\260\250.md" @@ -0,0 +1,265 @@ +# 6장. 키-값 저장소 설계 — 1차 설계 + +## 1. 요구사항 정리 + +키-값 저장소를 설계한다. 기본 연산은 두 가지다. + +- `put(key, value)` — 키-값 쌍을 저장 +- `get(key)` — 키에 해당하는 값을 조회 + +### 제약 조건 + +| 항목 | 조건 | +|------|------| +| 키-값 쌍 크기 | 10KB 이하 | +| 데이터 규모 | 대량 저장 가능해야 함 | +| 가용성 | 일부 장애에도 빠르게 응답 | +| 확장성 | 트래픽에 따라 서버 자동 증설/삭제 | +| 일관성 | 수준 조정 가능 | +| 응답 지연 | 짧아야 함 | + +### 요구사항 해석 + +"데이터 일관성 수준은 조정이 가능해야 한다"는 조건이 핵심이라고 판단했다. 모든 상황에서 강한 일관성을 요구하는 게 아니라, 용도에 따라 일관성 ↔ 가용성 사이에서 선택할 수 있어야 한다는 의미로 이해했다. + +또한 "트래픽에 따라 자동 증설/삭제"라는 조건은 단일 서버로는 불가능하고, 분산 환경이 전제되어야 한다고 판단했다. + +--- + +## 2. 핵심 API / 기능 흐름 + +### 기본 API + +``` +PUT /store/{key} + Body: { "value": "..." } + → 키-값 쌍 저장. 이미 존재하면 덮어쓰기. + +GET /store/{key} + → 키에 해당하는 값 반환. 없으면 404. + +DELETE /store/{key} + → 키-값 쌍 삭제. +``` + +### 쓰기 흐름 + +``` +Client → 요청 수신 노드(코디네이터) + → 해시 함수로 담당 노드 결정 + → 담당 노드 + N개 복제 노드에 쓰기 + → W개 이상 성공 응답 받으면 클라이언트에 성공 반환 +``` + +### 읽기 흐름 + +``` +Client → 요청 수신 노드(코디네이터) + → 해시 함수로 담당 노드 결정 + → R개 노드에서 읽기 + → 가장 최신 버전의 데이터를 클라이언트에 반환 +``` + +--- + +## 3. 데이터 저장 구조 + +### 단일 노드 내부 구조 + +``` +[쓰기 요청] + → Write-Ahead Log (WAL) 기록 — 장애 복구용 + → 메모리 테이블 (MemTable)에 저장 — 빠른 읽기/쓰기 + → MemTable이 임계치 도달 → 디스크(SSTable)로 flush + +[읽기 요청] + → MemTable 확인 → 있으면 바로 반환 + → 없으면 → SSTable에서 조회 (Bloom Filter로 빠르게 탐색) +``` + +이 구조를 선택한 이유: 모든 데이터를 메모리에 두면 빠르지만 용량 한계가 있고, 디스크만 쓰면 느리다. MemTable + SSTable 조합이면 자주 쓰이는 데이터는 메모리에서 빠르게 읽고, 오래된 데이터는 디스크에서 읽되 Bloom Filter로 불필요한 디스크 접근을 줄일 수 있다. + +### 데이터 파티셔닝 — 안정 해시 (Consistent Hashing) + +``` +해시 링: + + key1 → 노드A + key2 → 노드B + key3 → 노드C + +노드 추가 시: 해시 링에 새 노드 삽입 → 인접 키만 재배치 +노드 삭제 시: 삭제된 노드의 키가 다음 노드로 이동 +``` + +안정 해시를 선택한 이유: 일반 해시(key % N)는 서버 수가 바뀌면 거의 모든 키가 재배치된다. 안정 해시는 노드 추가/삭제 시 평균 K/N개의 키만 이동하면 되므로 확장성 요구사항에 적합하다. + +가상 노드(Virtual Node)를 활용해서 데이터 쏠림도 방지한다. 물리 노드 1대당 가상 노드 여러 개를 해시 링에 배치하면 데이터가 고르게 분산된다. + +### 데이터 복제 (Replication) + +``` +복제 전략: + 각 키를 해시 링에서 시계방향으로 N개 노드에 복제 + + 예: N=3이면 + key1 → 노드A(원본), 노드B(복제1), 노드C(복제2) + +주의: + 가상 노드 사용 시 같은 물리 서버에 중복 저장되지 않도록 체크 필요 + 서로 다른 데이터센터에 분산 배치 → 자연재해/정전 대비 +``` + +--- + +## 4. 전체 아키텍처 + +``` +┌─────────┐ +│ Client │ +└────┬────┘ + │ + ▼ +┌──────────────┐ +│ Coordinator │ ← 아무 노드나 코디네이터 역할 가능 (리더 없음) +│ (요청 라우팅) │ +└──────┬───────┘ + │ 안정 해시로 담당 노드 결정 + ▼ +┌──────────────────────────────────────┐ +│ 해시 링 (Hash Ring) │ +│ │ +│ 노드A ──── 노드B ──── 노드C │ +│ │ │ │ │ +│ MemTable MemTable MemTable │ +│ SSTable SSTable SSTable │ +│ WAL WAL WAL │ +│ │ +│ 각 노드는 담당 키 + 복제 키를 보유 │ +└──────────────────────────────────────┘ +``` + +### 설계 핵심 결정 + +**리더 없는 구조(Leaderless)를 선택한 이유:** + +리더-팔로워 구조는 리더가 단일 장애 지점(SPOF)이 된다. 요구사항에 "높은 가용성"이 있으므로, 모든 노드가 읽기/쓰기를 처리할 수 있는 리더 없는 구조가 적합하다고 판단했다. + +**코디네이터 역할:** + +클라이언트 요청을 받은 노드가 코디네이터가 된다. 해시 함수로 담당 노드를 찾고, 복제 노드에도 요청을 전달한 뒤, 정족수(W/R) 응답을 모아서 클라이언트에 반환한다. + +--- + +## 5. 일관성 제어 — 정족수 (Quorum) + +요구사항에 "일관성 수준 조정 가능"이 있으므로, N/W/R 값으로 조절하는 방식을 설계했다. + +``` +N = 복제본 수 (예: 3) +W = 쓰기 성공 응답 필요 수 +R = 읽기 성공 응답 필요 수 +``` + +| 설정 | 특성 | 용도 | +|------|------|------| +| W=1, R=1 | 빠르지만 일관성 약함 | 로그, 조회수 | +| W=N, R=1 | 읽기 빠름, 쓰기 느림 | 읽기 집중 서비스 | +| W=1, R=N | 쓰기 빠름, 읽기 느림 | 쓰기 집중 서비스 | +| W+R > N | 강한 일관성 보장 | 금융, 결제 | + +**W+R > N이면 강한 일관성인 이유:** 읽기와 쓰기가 반드시 최소 1개 이상의 공통 노드를 거치게 되므로, 최신 데이터를 반드시 읽을 수 있다. + +--- + +## 6. 병목 / 장애 가능성 + +### 장애 감지 + +단순히 한 노드가 "저 노드 죽었어요"라고 해서 바로 장애 처리하면 안 된다. 네트워크 일시 장애일 수 있기 때문. + +**가십 프로토콜(Gossip Protocol) 채택:** + +``` +각 노드가 주기적으로 박동(heartbeat)을 교환 +→ 일정 시간 동안 박동이 안 오면 장애로 판정 +→ 여러 노드가 동의해야 최종 장애 확정 +``` + +이 방식을 선택한 이유: 모든 노드 간 직접 통신(멀티캐스팅)은 노드 수가 많아지면 O(N²)으로 비효율적이다. 가십은 무작위 노드끼리만 교환하므로 확장성이 좋다. + +### 일시적 장애 처리 — Hinted Handoff + +``` +노드C 장애 발생 +→ 노드C 담당 요청을 노드D가 임시로 처리 +→ 노드D에 "이건 노드C 꺼야"라는 힌트를 남겨둠 +→ 노드C 복구되면 노드D → 노드C로 데이터 인계 +``` + +### 영구적 장애 처리 — 머클 트리 (Merkle Tree) + +``` +각 노드가 자기 데이터의 해시 트리를 관리 +→ 두 노드의 머클 트리 루트 해시를 비교 +→ 다르면 하위 노드를 재귀적으로 비교 +→ 불일치하는 데이터만 동기화 + +장점: 전체 데이터를 비교하지 않고 차이점만 빠르게 찾음 +``` + +### 데이터 충돌 — 벡터 시계 (Vector Clock) + +``` +리더 없는 구조에서 동시 쓰기 시 충돌 가능: + +서버1: put(key1, "A") → 노드A에 기록 +서버2: put(key1, "B") → 노드B에 기록 (동시에) + +벡터 시계로 해결: + D([S1, v1]) vs D([S2, v1]) + → 서로 선후관계가 없음 → 충돌 감지 + → 클라이언트에게 두 버전을 보내서 해소 요청 +``` + +--- + +## 7. 고민한 트레이드오프 + +### 일관성 vs 가용성 (CAP) + +분산 시스템에서 네트워크 파티션은 피할 수 없으므로 P는 필수. 결국 C(일관성)와 A(가용성) 중 선택해야 한다. + +| 선택 | 장점 | 단점 | 적합한 서비스 | +|------|------|------|-------------| +| CP (일관성 우선) | 모든 노드에서 같은 데이터 보장 | 장애 시 쓰기 중단 가능 | 은행, 결제 | +| AP (가용성 우선) | 장애에도 항상 응답 | 일시적으로 옛날 데이터 반환 가능 | SNS, 캐시 | + +이 설계에서는 **AP를 기본으로 하되, 정족수(W/R) 조절로 일관성 수준을 높일 수 있는 구조**를 택했다. 요구사항에 "일관성 수준 조정 가능"이 있었기 때문. + +### 메모리 vs 디스크 + +전부 메모리에 두면 빠르지만 비용이 높고 용량 한계가 있다. 전부 디스크에 두면 느리다. MemTable(메모리) + SSTable(디스크)의 LSM Tree 구조가 쓰기 성능과 저장 용량의 균형점이라고 판단했다. + +### 강한 일관성 vs 최종 일관성 + +강한 일관성은 모든 복제본에 쓰기가 반영될 때까지 읽기/쓰기를 차단해야 한다. 고가용성 요구사항과 충돌. 최종 일관성(Eventual Consistency)을 기본으로 채택하고, 벡터 시계로 충돌을 감지/해소하는 방향으로 설계했다. + +### 벡터 시계의 한계 + +[서버:버전] 순서쌍이 계속 늘어나서 메타데이터가 커질 수 있다. 임계치를 두고 오래된 순서쌍을 제거하는 방식으로 대응하지만, 이 경우 버전 간 선후관계 판단 정확도가 떨어질 수 있다. + +--- + +## 8. 내가 이 구조를 선택한 이유 — 요약 + +| 결정 | 선택 | 이유 | +|------|------|------| +| 아키텍처 | 리더 없는 분산 구조 | 높은 가용성, SPOF 제거 | +| 파티셔닝 | 안정 해시 + 가상 노드 | 노드 추가/삭제 시 최소 재배치 | +| 복제 | N개 노드 복제 | 가용성 + 내구성 확보 | +| 일관성 제어 | 정족수 (N/W/R) | 요구사항의 "일관성 수준 조정" 대응 | +| 일관성 모델 | 최종 일관성 + 벡터 시계 | 가용성 우선, 충돌은 감지 후 해소 | +| 장애 감지 | 가십 프로토콜 | 확장성 좋은 분산 장애 감지 | +| 장애 처리 | Hinted Handoff + 머클 트리 | 일시적/영구적 장애 각각 대응 | +| 저장 구조 | WAL + MemTable + SSTable | 쓰기 성능과 저장 용량의 균형 | diff --git "a/06-\355\202\244\352\260\222-\354\240\200\354\236\245\354\206\214/\354\240\225\354\235\270\354\262\240/02_\355\202\244\352\260\222_\354\240\200\354\236\245\354\206\214_\355\232\214\352\263\240.md" "b/06-\355\202\244\352\260\222-\354\240\200\354\236\245\354\206\214/\354\240\225\354\235\270\354\262\240/02_\355\202\244\352\260\222_\354\240\200\354\236\245\354\206\214_\355\232\214\352\263\240.md" new file mode 100644 index 0000000..8460fd1 --- /dev/null +++ "b/06-\355\202\244\352\260\222-\354\240\200\354\236\245\354\206\214/\354\240\225\354\235\270\354\262\240/02_\355\202\244\352\260\222_\354\240\200\354\236\245\354\206\214_\355\232\214\352\263\240.md" @@ -0,0 +1,24 @@ +# 6장. 키-값 저장소 설계 — 회고 + +## 어려웠던 것 + +- 처음엔 그냥 "HashMap 크게 만들면 되는 거 아닌가?" 생각했는데 분산 환경 고려하니까 생각보다 복잡했음 +- CAP 이론을 이론으로는 알았는데, 실제로 W/R/N 숫자로 일관성 수준을 조절한다는 게 처음엔 잘 와닿지 않았음 +- 벡터 시계가 특히 어려웠음 — 충돌 감지까지는 이해했는데 "그래서 클라이언트가 어떻게 해소해?" 부분은 아직도 명확하지 않음 + +## 새로 알게 된 것 + +- 안정 해시를 실제 DB(Cassandra, DynamoDB 등)가 쓴다는 걸 알게 되니까 갑자기 현실감이 생겼음 +- Hinted Handoff + Gossip Protocol 조합이 "아 이게 그냥 이론이 아니라 실제 Redis Cluster나 Cassandra에 들어가 있는 거구나" 싶었음 +- LSM Tree(MemTable + SSTable) 구조가 왜 쓰기에 유리한지 처음 이해했음 + +## 아직 불명확한 것 + +- 벡터 시계로 충돌 감지 후 클라이언트가 해소하는 구체적인 UX/로직 +- 가상 노드 개수를 실제로 어떻게 결정하는지 (몇 개가 적당한지 기준이 없음) +- 머클 트리 동기화 주기를 얼마나 자주 돌려야 하는지 + +## 느낀 점 + +- 평소에 Redis 쓸 때 그냥 캐시 도구로만 봤는데 이제 내부가 조금 보이는 것 같음 +- 트레이드오프를 명시적으로 고민하는 연습이 됐음 — 설계에 "정답"이 없고 요구사항마다 다르다는 걸 체감했음 diff --git "a/06-\355\202\244\352\260\222-\354\240\200\354\236\245\354\206\214/\354\240\225\354\235\270\354\262\240/03_\354\212\244\355\204\260\353\224\224_\355\206\240\353\241\240_\354\247\210\353\254\270.md" "b/06-\355\202\244\352\260\222-\354\240\200\354\236\245\354\206\214/\354\240\225\354\235\270\354\262\240/03_\354\212\244\355\204\260\353\224\224_\355\206\240\353\241\240_\354\247\210\353\254\270.md" new file mode 100644 index 0000000..ca47bb8 --- /dev/null +++ "b/06-\355\202\244\352\260\222-\354\240\200\354\236\245\354\206\214/\354\240\225\354\235\270\354\262\240/03_\354\212\244\355\204\260\353\224\224_\355\206\240\353\241\240_\354\247\210\353\254\270.md" @@ -0,0 +1,208 @@ +# 6장. 키-값 저장소 설계 — 스터디 토론 질문 + +## Q1. 키-값 저장소는 왜 탄생했을까? + +> 관계형 DB(MySQL, PostgreSQL 등)가 이미 있는데, 왜 키-값 저장소가 따로 만들어졌을까요? +> 어떤 요구사항이나 한계가 있었길래 새로운 저장소가 필요했을까요? + +### 배경 + +2000년대 초 인터넷 서비스가 폭발적으로 성장하면서 RDB가 감당하기 어려운 상황들이 생겼다. + +- **Amazon**: 수억 명의 사용자 장바구니, 상품 정보를 RDB 하나로 감당하기 어려워짐 +- **Google**: 웹 전체를 크롤링한 데이터를 저장해야 하는데, 테이블 구조로 맞추기 어려움 +- **Facebook**: 친구 관계, 피드, 좋아요 등 비정형 데이터가 폭발적으로 증가 + +이런 배경에서 Amazon은 2007년 **Dynamo 논문**을, Google은 2006년 **Bigtable 논문**을 발표했고, 이게 현대 키-값 저장소의 시초가 됐다. + +### RDB의 한계 — 어디서 막혔을까? + +| 한계 | 설명 | +|------|------| +| 수평 확장 어려움 | RDB는 기본적으로 단일 서버 가정. 서버를 늘리려면 샤딩이 필요한데 매우 복잡함 | +| 스키마 경직성 | 컬럼 추가/변경 시 마이그레이션 비용이 크고, 비정형 데이터에 취약 | +| JOIN 비용 | 데이터가 수억 건이 되면 JOIN이 병목 | +| 가용성 | 리더 장애 시 서비스 중단 위험 | + +### 토론 + +- "RDB로 해결이 안 됐던 게 뭔지" 각자 하나씩 얘기해보기 +- 반대로 키-값 저장소가 RDB보다 못한 점은 뭐가 있을까? +- 지금 내가 다루는 서비스에서 RDB 대신 키-값 저장소를 쓰면 좋을 게 있을까? + +--- + +## Q2. 노드 장애를 어떻게 감지할까? + +> 설계에서는 가십 프로토콜을 썼는데, 만약 가십 없이 "한 노드가 죽으면 즉시 다른 노드가 알 수 있게" 하려면 어떻게 해야 할까요? +> 그 방식의 문제점은 뭘까요? + +### 배경 + +분산 시스템에서 "노드가 죽었다"는 사실을 알리는 것은 생각보다 어렵다. 단순히 ping을 쏴보면 되지 않냐고 생각할 수 있지만, 수십~수백 개의 노드가 서로를 감시하는 구조에서는 **어떻게 알릴 것인가** 자체가 설계 문제가 된다. + +### 가십 없이 장애를 감지하는 방법들 + +| 방식 | 설명 | 문제점 | +|------|------|--------| +| **중앙 모니터 노드** | 마스터 노드 하나가 모든 노드에 주기적으로 heartbeat 전송 | 마스터 노드 자체가 SPOF(단일 장애점)가 됨 | +| **All-to-All heartbeat** | 모든 노드가 다른 모든 노드에게 heartbeat 전송 | 노드 N개일 때 메시지가 N²로 폭발적으로 증가 | +| **링 구조** | 각 노드가 옆 노드만 감시 | 연쇄 장애 시 감지 지연, 링 분단 문제 | + +### 가십 프로토콜이 선택된 이유 + +가십(Gossip) 프로토콜은 각 노드가 **랜덤하게 선택한 소수의 이웃**에게만 자신의 상태를 전파한다. + +``` +노드 A → 랜덤하게 B, D에게 heartbeat 목록 전달 +노드 B → 랜덤하게 A, C에게 전달 +... 이렇게 소문처럼 퍼짐 +``` + +- 메시지 수가 O(log N) 수준으로 유지됨 +- 마스터 노드 없이도 전체 상태 동기화 가능 +- 네트워크 단절이 있어도 나중에 복구 시 수렴 + +### 토론 + +- All-to-All heartbeat로 충분히 되지 않을까? 노드가 몇 개쯤 되면 문제가 생길까? +- 가십 프로토콜에서 "죽었다고 판단하는 기준"은 어떻게 잡을까? (타임아웃? 몇 번 연속 실패?) +- 실제로 쓰는 서비스에서 노드 장애를 감지하는 방식이 있다면 어떤 방식인지 얘기해보기 + +--- + +## Q3. Redis는 리더가 있을까, 없을까? + +> 이번 설계에서는 리더 없는(Leaderless) 구조를 선택했는데, +> 실제로 Redis Cluster는 어떤 구조일까요? (찾아보거나 아는 대로) +> 리더가 있으면 뭐가 편하고, 없으면 뭐가 편할까요? + +### 배경 + +분산 키-값 저장소는 크게 두 가지 복제 구조로 나뉜다. + +- **리더 기반(Leader-based)**: 쓰기는 리더에게만, 리더가 팔로워에게 복제 +- **리더 없는(Leaderless)**: 아무 노드에나 쓰기 가능, 쿼럼으로 일관성 보장 + +이번 장의 설계는 Leaderless 방식이었지만, 실제 Redis Cluster는 다른 선택을 했다. + +### Redis Cluster의 실제 구조 + +Redis Cluster는 **리더 기반**이다. 다만 단순한 단일 리더가 아니라, 슬롯(slot) 단위로 리더가 분산되어 있다. + +``` +슬롯 0~5000 → Primary A (+ Replica A1, A2) +슬롯 5001~10000 → Primary B (+ Replica B1, B2) +슬롯 10001~16383 → Primary C (+ Replica C1, C2) +``` + +- 클라이언트는 키의 해시값으로 어느 Primary에 쓸지 계산 +- Primary가 죽으면 해당 Replica 중 하나가 Primary로 승격 + +### 리더 유무에 따른 트레이드오프 + +| | 리더 기반 | 리더 없는 | +|--|---------|---------| +| **일관성** | 강함 (쓰기 순서 보장) | 약함 (충돌 가능) | +| **쓰기 가용성** | 리더 장애 시 일시 중단 | 어느 노드로든 계속 가능 | +| **구현 복잡도** | 상대적으로 단순 | 충돌 해소 로직 필요 | +| **지연 시간** | 리더 경유로 살짝 느릴 수 있음 | 가까운 노드로 바로 쓰기 가능 | +| **대표 시스템** | Redis Cluster, Kafka | Cassandra, DynamoDB | + +### 토론 + +- Redis가 Leaderless 대신 리더 기반을 선택한 이유가 뭘까? +- 내가 만드는 서비스에서 "리더 노드가 죽는 순간"에 어떤 일이 일어나는지 생각해보기 +- Cassandra는 Leaderless인데, Redis와 어떤 용도에서 선택이 갈릴까? + +--- + +## Q4. 내가 설계자라면 N, W, R을 얼마로 잡을까? + +> N=3 기준으로 아래 중 본인이라면 어떤 걸 고를지, 이유도 함께: +> +> | | W | R | +> |---|---|---| +> | A | 1 | 1 | +> | B | 2 | 2 | +> | C | 3 | 1 | +> | D | 1 | 3 | +> +> 서비스 종류에 따라 달라진다면 어떤 서비스에 어떤 조합이 맞을지도 얘기해보기 + +### 배경 + +쿼럼(Quorum) 조건이란, **W + R > N** 을 만족하면 읽기와 쓰기가 적어도 한 노드에서 겹친다는 보장이다. 이 조건을 만족해야 "가장 최신 데이터를 반드시 읽을 수 있다"고 할 수 있다. + +``` +N = 복제 노드 수 +W = 쓰기 성공으로 인정할 최소 노드 수 +R = 읽기 성공으로 인정할 최소 노드 수 + +W + R > N → 강한 일관성 보장 +W + R ≤ N → 최종 일관성 (빠르지만 오래된 데이터 읽힐 수 있음) +``` + +### 각 조합 분석 (N=3 기준) + +| 조합 | W | R | W+R > N? | 특징 | 적합한 서비스 | +|------|---|---|----------|------|------------| +| **A** | 1 | 1 | 1+1=2, **아님** | 가장 빠름, 일관성 포기 | 실시간 로그 수집, 방문자 카운터 | +| **B** | 2 | 2 | 2+2=4, **만족** | 균형 잡힌 선택 | 일반적인 API 서버, 사용자 프로필 | +| **C** | 3 | 1 | 3+1=4, **만족** | 쓰기에 엄격, 읽기는 빠름 | 금융 거래, 재고 차감 | +| **D** | 1 | 3 | 1+3=4, **만족** | 쓰기는 빠름, 읽기에 엄격 | 읽기 중심 + 정확성 필요한 서비스 | + +### 현실에서의 힌트 + +- **DynamoDB**: 기본값 W=1, R=1 (빠른 응답 우선, 강한 일관성은 옵션) +- **Cassandra**: 기본 QUORUM = N/2+1, 즉 N=3이면 W=2, R=2 + +### 토론 + +- 내가 만드는 서비스에서 "오래된 데이터를 읽어도 괜찮은 경우"가 있을까? 어떤 경우? +- W=3 (모든 노드에 써야 성공)으로 잡으면 어떤 문제가 생길까? +- 쓰기가 많은 서비스와 읽기가 많은 서비스에서 선택이 어떻게 달라질까? + +--- + +## Q5. 벡터 시계로 충돌이 났을 때 클라이언트가 해소한다는 게 실제로 어떤 모습일까? + +> "두 버전을 클라이언트에 보내서 해소 요청"이라고 했는데, +> 실제 앱이라면 사용자에게 어떻게 보여줄까요? +> (힌트: 구글 Docs 동시 편집, 카카오톡 메시지 순서 등 생각해보기) + +### 배경 + +벡터 시계는 "어떤 버전이 먼저인가"를 판단하기 위한 논리적 시계다. 두 버전을 비교했을 때 어느 쪽도 다른 쪽보다 앞서지 않으면 **충돌(conflict)** 이 발생한다. + +``` +버전 A: [서버1=2, 서버2=1] → 서버1에서 2번, 서버2에서 1번 변경 +버전 B: [서버1=1, 서버2=2] → 서버1에서 1번, 서버2에서 2번 변경 + +A와 B 중 어느 쪽이 최신? → 판단 불가 → 충돌! +``` + +이때 서버는 두 버전을 모두 클라이언트에 넘기고 "알아서 합쳐라"고 한다. + +### 실제 서비스에서 충돌 해소 방식 + +| 서비스 | 충돌 상황 | 해소 방법 | +|--------|---------|---------| +| **Amazon 장바구니** | 두 기기에서 동시에 상품 추가/삭제 | 두 장바구니를 **합집합**으로 병합 (삭제가 무시될 수 있음) | +| **Google Docs** | 두 사람이 같은 문단 동시 편집 | OT(Operational Transform) 알고리즘으로 자동 병합 | +| **Notion / 협업 툴** | 오프라인 후 동기화 시 충돌 | "충돌 발생" 표시 후 사용자에게 선택 요청 | +| **Git** | 같은 파일 같은 줄 동시 수정 | `<<<<<<` 마커로 표시, 사용자가 직접 해소 | +| **카카오톡 메시지 순서** | 서버 시간 오차로 순서 뒤바뀜 | 서버 수신 시간 기준으로 강제 정렬 (충돌 포기) | + +### 충돌 해소 전략의 종류 + +- **Last Write Wins (LWW)**: 타임스탬프가 나중인 것 채택 → 단순하지만 데이터 유실 가능 +- **자동 병합**: 충돌 없이 합칠 수 있으면 자동으로 (Google Docs 방식) +- **사용자에게 위임**: 두 버전 모두 보여주고 선택하게 함 (Git, Notion 방식) +- **도메인 규칙 적용**: 장바구니 합집합처럼, 비즈니스 로직으로 결정 + +### 토론 + +- Amazon이 장바구니 충돌을 "합집합"으로 해소한 게 맞는 선택일까? 삭제한 상품이 다시 살아나면? +- 내가 만드는 서비스에서 충돌이 날 수 있는 데이터가 있다면? 그건 어떻게 해소할까? +- "자동으로 합치는 것"과 "사용자에게 선택하게 하는 것" 중 언제 어떤 걸 쓰는 게 맞을까?