diff --git "a/ch04-\354\262\230\353\246\254\354\234\250-\354\240\234\355\225\234/\354\234\244\354\234\240\355\203\201.md" "b/ch04-\354\262\230\353\246\254\354\234\250-\354\240\234\355\225\234/\354\234\244\354\234\240\355\203\201.md" index 46aab6b..8e4ed0f 100644 --- "a/ch04-\354\262\230\353\246\254\354\234\250-\354\240\234\355\225\234/\354\234\244\354\234\240\355\203\201.md" +++ "b/ch04-\354\262\230\353\246\254\354\234\250-\354\240\234\355\225\234/\354\234\244\354\234\240\355\203\201.md" @@ -185,3 +185,177 @@ 2. **fail-open의 타이밍이 하필.** Redis가 DoS 공격 한복판에서 죽으면? fail-open이라 다 통과시키는데 — 보호가 가장 필요한 순간에 보호가 꺼짐. 로컬 폴백 카운터가 있긴 한데 그게 완벽히 메워주나? 평상시엔 가용성 위해 fail-open이 맞는데, 공격 중에는 그 선택이 정확히 독이 되는 역설. 상태에 따라 fail 정책을 바꿔야 하나? 3. **로컬 폴백 카운터의 함정.** Redis 죽어서 게이트웨이마다 자기 로컬 카운터로 제한하면, 게이트웨이가 10대일 때 "분당 5회" 제한이 사용자한텐 실제로 분당 50회로 보임(각 게이트웨이가 자기 것만 세니까). 즉 폴백이 "대략적 보호 유지"라지만 실효 한도가 게이트웨이 수만큼 뻥튀기됨. 부정확한 정도가 생각보다 큼. + +--- +--- + +# 2차 보완 (책 4장 학습 후) + +> 1차 설계를 책 해설과 대조하고 보완. 핵심 발견: 이번엔 "내가 막힌 데 = 책이 깊게 판 데"가 아니라 **그 반대**였음. 1차에서 찜찜하다고 남긴 토론거리들이 정작 책은 안 푼 지점이었음. + +## 한 줄 요약 + +1차는 책의 **이동 윈도 카운터** 노선을 거의 그대로 재발명했고(공식·헤더 이름까지 일치), 1차에서 "자신 없음/토론하고 싶음"으로 남긴 3가지(Redis 핫 키 / fail-open 역설 / 폴백 뻥튀기)는 **책이 다루지 않은, 1차가 책보다 깊었던 지점**이었음. + +--- + +## 1. 1차가 맞춘 것 (책 용어로 매핑 — 모르고 재발명한 것 포함) + +거의 다 맞췄음. 책 용어로 매핑하면: + +| 1차에서 내가 한 것 | 책 용어 / 위치 | 비고 | +|---|---|---| +| 슬라이딩 윈도우, 추정식 `현재 + 직전×겹침비율` | **이동 윈도 카운터(sliding window counter)** | 공식이 글자 그대로 일치. 모르고 재발명 | +| "정확히 슬라이딩하려면 타임스탬프 다 들고 있어야 함 → 메모리 폭발" | **이동 윈도 로그(sliding window log)의 단점** | 책도 이 알고리즘을 거쳐 카운터로 넘어감. 전개 순서까지 똑같음 | +| 균등분포 가정 오차 감수 | 책: "직전 시간대 균등분포 가정 → 다소 느슨" | 책은 + Cloudflare 40억 요청 중 0.003%만 오차라는 실측 제시 | +| 읽기-검사-증가를 "한 덩어리 스크립트로 통째 실행" | **Lua script** (락 대안) | 이름만 몰랐지 개념 정확. 책은 + sorted set도 제시 | +| 카운터 읽기-증가 원자성 = INCR 류 | 책: Redis **INCR / EXPIRE** | TTL 자동 만료도 1차에서 맞춤 | +| sticky session "확장성·유연성 나빠 비추" | 책: 거의 같은 문장 | 중앙 Redis로 동기화도 동일 | +| `429` + `X-Ratelimit-Limit/Remaining/Retry-After` | 책: 헤더 3개 이름까지 일치 | 큐 보관 옵션(주문 예시)도 동일 | +| 규칙 원본 + 게이트웨이 로컬 캐시 + 워커 주기 갱신 | 책: "규칙은 디스크 설정파일, 워커가 수시로 읽어 캐시" | 거의 동일. **차이는 아래 2번 참고** | + +특히 **이동 윈도 카운터 공식을 책 안 보고 도출한 것**, **읽기-검사-증가를 스크립트로 통째 보낸 게 Lua script였던 것**이 가장 정확하게 맞은 지점. 그리고 **버킷 개수를 튜닝 다이얼로 일반화한 건 책보다 추상화가 한 단계 위**였음 — 책은 이동 윈도 카운터를 2버킷 고정으로만 설명함. + +규칙 저장의 사소한 차이: 나는 원본을 **Redis**에 뒀는데, 책은 원본을 **디스크의 설정 파일(YAML, Lyft ratelimit 예시)** 에 둠. 둘 다 "원본 따로 + 로컬 캐시 + 워커 갱신" 구조는 같음. 어차피 규칙은 읽기 폭발·쓰기 드묾이라 원본이 디스크든 Redis든 핫 패스엔 영향 없음. + +--- + +## 2. 1차에 아예 없던 거 (책이 추가한 것) + +- **알고리즘 4종을 안 봄** — 토큰 버킷, 누출 버킷, 고정 윈도 카운터, 이동 윈도 로그. 나는 이동 윈도 카운터로 직행함. 단, **1차에서 "버스트 스무딩 요구 생기면 다른 알고리즘 계열 검토"라고 예고한 게 정확히 토큰/누출 버킷**이었고, 책도 마무리에서 "깜짝 세일로 트래픽 급증 시엔 토큰 버킷이 적합"이라 함 → 예고가 적중. 알고리즘별 핵심: + - **토큰 버킷**: 버킷에 토큰 주기적 충전, 요청마다 1개 소비. 남은 토큰 있으면 버스트도 통과 → **짧게 몰리는 트래픽 처리에 강함.** (아마존·스트라이프) + - **누출 버킷**: FIFO 큐, **고정 처리율로 흘려보냄** → 안정적 출력이 필요할 때. 대신 버스트 때 오래된 요청이 쌓여 최신 요청이 버려짐. (쇼피파이) + - **고정 윈도 카운터**: 1차에서 내가 "윈도 경계 버스트로 2배 통과" 문제로 버린 그 방식. 책도 같은 단점 지적. +- **성능 최적화** — (1) 에지 서버: 여러 데이터센터에서 멀리 떨어진 사용자 지연 줄임. (2) **최종 일관성 모델로 제한 장치 간 동기화** ← 이게 내 토론거리 1번이랑 정면으로 연결됨(4번 참고). +- **모니터링** — 채택한 알고리즘·규칙이 효과적인지 데이터를 모아 확인. 규칙이 빡빡하면 완화, 트래픽 패턴 바뀌면 알고리즘 교체. 1차에 없던 운영 관점. +- **다양한 계층의 제한** — 나는 L7(HTTP)만 봄. 책은 L3(IP, iptables)에서도 제한 가능하다고 언급. +- **제한 회피하는 클라이언트 설계** — 클라 캐시로 호출 줄이기, 백오프 둔 재시도 등. 막는 쪽뿐 아니라 걸리는 쪽 설계도 언급. + +--- + +## 3. 책도 답 안 준, 1차의 날카로웠던 지점 + +1차 끝에 남긴 토론거리 3개를 책이 어떻게 다루나 봤더니 — **거의 다 책이 안 다룬 지점이었음.** 이게 이번 챕터의 진짜 수확. + +1. **Redis 핫 키 병목** ("같은 사용자/IP는 같은 키라 한 노드에 쏠림, 게이트웨이만 늘려선 안 풀림") → **책은 정면으로 안 다룸.** 성능 최적화에서 에지 서버(지연)와 최종 일관성(동기화 부담)만 건드리지, 키 샤딩/핫 키는 침묵. 책보다 내가 깊었음. + +2. **fail-open이 DoS 한복판에 보호를 끄는 역설** → 제일 큰 발견. **책은 요구사항에 "높은 결함 감내성"을 적어놓고, 본문에서 Redis가 죽으면 어떻게 되는지를 사실상 안 다룸.** fail-open/closed 판단도, graceful degradation도 본문엔 없음. 책이 결함 감내성을 짚는 건 마무리의 **경성/연성(hard/soft) 제한** 한 줄뿐 — 근데 이게 보완의 열쇠가 됨(아래 5번). + +3. **로컬 폴백 카운터 한도 뻥튀기**(게이트웨이 N대 → 실효 한도 N배) → 책 안 다룸. 내 고유 발견. + +--- + +## 4. 보완: Redis 핫 키 — 강한 일관성(내 선택) vs 최종 일관성(책) + +토론거리 1번에 대해 책이 던지는 해법은 **최종 일관성(eventual consistency) 모델로 제한 장치 간 동기화**. 이게 내 1차 선택과 정면으로 트레이드오프가 갈림. **내 결정이 틀린 게 아니라, 무엇을 1순위로 보느냐의 축이 다른 것.** + +| | 내 1차 (강한 일관성) | 책 (최종 일관성) | +|---|---|---| +| 방식 | 매 요청 Redis 원자 INCR, 한 곳에서 강하게 셈 | 노드가 로컬에서 세고 비동기로 느슨하게 맞춤 | +| **1순위로 보는 것** | **정확성** (요구사항 1번) | **규모 / 지연** | +| 한도 | 정확히 지켜짐 | 살짝 샐 수 있음 | +| 대가 | 모든 요청이 Redis를 때림 → **핫 키 병목** | 정확성 일부 포기 (어차피 오차 0.003%급) | + +**판단: 책의 교환을 받아들임.** 대부분 도메인에서 한도가 1~2개 새는 건 무해하고, 그 대가로 핫 키 병목이 풀림. 단 이건 알고리즘 2번에서 "정확도 vs 메모리를 도메인이 고르게"라고 한 거랑 같은 결: **정확성이 진짜 치명적인 도메인(과금·결제 한도)이면 강한 일관성을 유지하고 핫 키는 키 샤딩으로 따로 푼다.** = 일관성 강도를 도메인이 고르는 다이얼. + +(주의: 이 "느슨하게 센다"는 내 토론거리 3번 폴백 뻥튀기랑 표면이 비슷하지만 다름. 폴백은 Redis가 *죽었을 때* 어쩔 수 없이 노드별로 세는 장애 상황이고, 여기 최종 일관성은 *평상시* 의도적으로 동기화를 느슨하게 해 부하를 줄이는 정상 운영 전략임. 전자는 N배 뻥튀기가 사고, 후자는 동기화 주기를 짧게 잡아 오차를 통제함.) + +--- + +## 5. 보완: fail-open 역설 — 경성/연성(hard/soft) 제한으로 일반화 + +토론거리 2번. 책의 **경성/연성 제한**이 내 fail 정책 고민과 사실 같은 한 축이었음: + +- **경성(hard)**: 임계치를 절대 못 넘음 ≈ **fail-closed**(장치 죽으면 다 막아 임계치 사수) +- **연성(soft)**: 잠시는 넘어도 됨 ≈ **fail-open**(장치 죽으면 통과시켜 잠시 한도 초과 허용) + +그럼 "평상시 vs DoS 중" 역설이 이렇게 번역됨: + +> **fail 정책 = 경성/연성을 도메인(또는 상태)별로 고르는 다이얼.** +> - 가용성이 우선인 일반 API → 연성 = fail-open (장치 장애가 본체를 죽이면 안 됨, 요구사항 6번) +> - 정확성이 치명적인 도메인(결제 한도·인증 시도) → 경성 = fail-closed +> - "DoS 한복판" 같은 상태 변화 → 상태를 감지해 일시적으로 경성으로 전환하는 것도 가능 + +알고리즘 2번의 버킷 개수, 4번의 일관성 강도와 **완전히 같은 패턴**: 핵심 트레이드오프를 하나의 다이얼로 빼고 기본값만 안전하게 두는 것. 1차 땐 fail-open/closed를 "둘 중 뭐가 맞나"로만 봤는데, 책의 hard/soft 어휘를 얻고 나니 **둘은 한 스펙트럼의 양 끝이고 도메인이 고르는 것**으로 정리됨. + +--- + +## 최종 구조 + +책 그림 4-13(상세 설계)을 기반으로, 내 2차 보완(최종 일관성 동기화 · Redis 클러스터 · fail 정책 다이얼 · 로컬 폴백)까지 얹은 구조. **굵은 화살표 = 핫 패스(매 요청), 점선 = 백그라운드/장애 경로.** + +```mermaid +flowchart TD + C1["클라이언트 1"] + C2["클라이언트 2"] + + subgraph GW["처리율 제한 게이트웨이 (여러 대 · 무상태)"] + M1["제한 미들웨어 1"] + M2["제한 미들웨어 2"] + end + + C1 ==> M1 + C2 ==> M2 + + subgraph RULE["제한 규칙 (읽기 폭발 · 쓰기 드묾)"] + RS[("규칙 원본
YAML 설정파일 / Redis")] + W["워커: 주기적 갱신"] + LC["게이트웨이 로컬 캐시"] + RS -. 수시로 읽음 .-> W + W -. 갱신 .-> LC + end + + M1 -. "규칙 조회 (왕복 0)" .-> LC + M2 -. "규칙 조회 (왕복 0)" .-> LC + + subgraph REDIS["Redis 클러스터 (카운터 원본 · SPOF 완화)"] + R[("카운터
INCR / EXPIRE
Lua 원자 연산")] + end + + M1 == "원자 카운트" ==> R + M2 == "원자 카운트" ==> R + M1 <-. "최종 일관성 동기화" .-> M2 + + M1 ==> D{"한도 초과?"} + D == "아니오 · 성공" ==> API["API 서버"] + D == "예 · 스로틀링" ==> RESP["429 Too Many Requests
+ X-Ratelimit-Limit/Remaining/Retry-After"] + RESP --> O1["옵션 1: 요청 버림"] + RESP --> O2["옵션 2: 메시지 큐 보관"] + + R -. "Redis 장애 시" .-> FAIL{"fail 정책 다이얼"} + FAIL -. "연성=fail-open · 일반 API" .-> FB["로컬 폴백 카운터
(대략 보호, 한도 N배 주의)"] + FAIL -. "경성=fail-closed · 결제·인증" .-> BLK["전부 차단"] +``` + +> **REDIS를 클러스터로**, **미들웨어 간 최종 일관성 동기화**, **Redis 장애 시 fail 정책 다이얼 + 로컬 폴백** + +--- + +## 6. 다음 설계 때 가져갈 교훈 + +1. **재발명은 나쁘지 않다.** 이동 윈도 카운터 공식·Lua script를 이름 모르고 도출함 — 요구사항에서 출발해 트레이드오프를 따지면 정설에 수렴함. 용어를 몰라도 추론이 맞으면 됨. +2. **내가 찜찜한 지점이 책보다 깊을 수도 있다.** 1차의 토론거리 3개가 책 미답 영역이었음. 책을 정답지로만 보지 말 것 — 책도 요구사항(결함 감내성)을 적어놓고 본문에서 안 푸는 구멍이 있음. +3. **반복되는 메타 패턴 = "핵심 트레이드오프를 다이얼로 빼고 기본값을 안전하게."** 버킷 개수(메모리↔정확도), 일관성 강도(정확성↔규모), fail 정책=hard/soft(정확성↔가용성) — 전부 같은 모양. 이걸 ch06 일관성 옵션 때부터 봤음. 앞으로 설계할 때 "이 결정도 다이얼로 뺄 수 있나?"를 먼저 물어볼 것. +4. **운영 관점(모니터링)을 1차에 빠뜨림.** 다음엔 "설치 후 효과를 어떻게 측정하나"를 처음부터 넣을 것. + +--- + +## 요구사항 → 기술 최종 매핑표 (2차) + +| 요구사항 | 1차 결정 | 2차 보완 | +|---|---|---| +| 1. 처리율 초과 정확히 제한 | 이동 윈도 카운터(2버킷) | 동일. 정확성 진짜 중요하면 버킷 잘게 / 강한 일관성 유지 | +| 2. 낮은 응답시간 | Redis 카운터 + 규칙 로컬 캐시 + 원자 연산 | 동일 + 에지 서버(지역 지연), Lua script로 원자성 | +| 3. 메모리 적게 | 버킷 카운터 + 2버킷 + TTL | 동일 (이동 윈도 로그 대신 카운터 = 책과 동일 선택) | +| 4. 분산형 | Redis 중앙집중 + 원자 연산 | + **최종 일관성으로 동기화(핫 키 병목 완화)** ← 일관성 강도는 도메인 다이얼 | +| 5. 예외 처리 | 429 + X-Ratelimit-* 헤더 + 큐 옵션 | 동일 (책과 헤더까지 일치) | +| 6. 높은 결함 감내성 | fail-open + 로컬 폴백 + Redis 클러스터 | + **fail 정책 = 경성/연성 다이얼**(도메인/상태별), 모니터링으로 효과 측정 | +| 위치 | 서버 앞단 미들웨어/게이트웨이 | 동일 (= 책의 API 게이트웨이) | +| 유연한 규칙 | 규칙 원본 + 로컬 캐시 주기 갱신 | 동일. 책은 원본을 디스크 YAML에, 나는 Redis에 (핫패스 영향 없음) | + +--- + +## 의문점 + +- **핫 키 본질은 안 풀림.** 최종 일관성으로 동기화 부하는 줄여도, "같은 키는 한 노드"라는 샤딩의 본질적 핫 키는 그대로. 강한 일관성이 필요한 도메인에선 결국 키를 더 잘게 쪼개거나(`user:123:shard:0~9` 후 합산) 별도 처리해야 할 듯. 책은 여기까지 안 감. +- **상태 기반 fail 전환의 트리거.** "DoS 감지 시 경성으로 전환"이 말은 쉬운데, 무엇을 보고 공격이라 판단하나? 그 판단 자체가 또 다른 처리율 측정이라 순환 같음. 이건 더 파볼 거리.