Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
187 changes: 187 additions & 0 deletions ch04-처리율-제한/윤유탁.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
# [Ch.04] 처리율 제한 장치의 설계 — 윤유탁

> 1차 설계안 (책 해설을 읽기 전, 문제와 요구사항만 보고 설계)

## 문제

네트워크 시스템에서 **처리율 제한 장치(rate limiter)** 를 설계한다.
클라이언트/서비스가 보내는 트래픽의 처리율(rate)을 제어해서, 특정 기간 내 요청 횟수가
임계치를 넘으면 추가 요청을 막는다.

예) "초당 2회 이상 새 글 금지", "같은 IP로 하루 10계정 이상 생성 금지" 등.

두는 이유: DoS 방어 / 비용 절감 / 서버 과부하 방지.

## 설계 범위 (면접관과의 문답으로 확정한 가정)

- **서버 측** 처리율 제한 장치를 설계한다 (클라이언트 측 아님)
- 제어 기준은 **유연**해야 한다 (IP, 사용자 ID 등 다양한 throttling rule 정의 가능)
- **대규모 + 분산 환경**에서 동작해야 한다
- 요청이 제한되면 **사용자에게 그 사실을 알려야** 한다

## 요구사항

1. 설정된 처리율을 초과하는 요청은 **정확하게** 제한한다
2. **낮은 응답시간** — HTTP 응답시간에 나쁜 영향을 주면 안 된다
3. **메모리를 적게** 쓴다
4. **분산형 처리율 제한** — 하나의 제한 장치를 여러 서버/프로세스가 공유 가능해야 한다
5. **예외 처리** — 요청이 제한됐을 때 사용자에게 분명히 보여준다
6. **높은 결함 감내성** — 제한 장치에 장애가 생겨도 전체 시스템에 영향을 주면 안 된다

---

## 의사결정 및 고민할 것들

### 1. 처리율 제한 장치를 어디에 둘 것인가? (위치)
선택지
1. 클라이언트 측
2. API 서버 코드 안에 인라인
3. 서버 앞단의 독립된 컴포넌트 (미들웨어 / 게이트웨이)

1번은 안 됨. 클라이언트는 요청 위변조가 쉬워서 제한을 안정적으로 걸 장소가 못 됨. 악의적 클라이언트가 무시하면 끝.

2번도 안 됨. 거부할 요청조차 일단 서버까지 도달해야 검사가 됨. 막아야 할 부하가 이미 서버에 닿음. 제한 로직이 서버마다 박혀서 공유(4번)·장애 격리(6번)도 어려워짐.

3번이 최적. 서버 앞단에 처리율을 처리해주는 독립 컴포넌트(미들웨어/게이트웨이)를 둠. 부하가 서버에 닿기 전에 거름.

트레이드오프: 분리하면 홉이 하나 늘고 운영할 컴포넌트가 하나 더 생김. 대신 부하 격리 + 공유 + 장애 격리를 얻음. 요구사항상 남는 장사라 분리가 맞음.

---

### 2. 요청 수를 어떻게 세고 허용/거부를 판단? (제한 알고리즘)
구체 규칙: "한 사용자 분당 최대 5회"를 어떻게 구현할 것인가.

제일 단순한 건 사용자별 카운터 하나 두고 매 분 정각에 리셋(고정 윈도우). 근데 이건 윈도우 경계 버스트 문제가 있음. 1:00:30~2:00:30처럼 윈도우를 살짝 옮겨 1분을 잘라보면 한도의 2배가 통과될 수 있음.

→ 그래서 **슬라이딩 윈도우**. 고정 크기(1분) 윈도우가 매 순간 "지금 기준 직전 60초"로 움직이면서 셈. 한 번에 쏘든 걸쳐 쏘든 1분 총량만 지켜지므로 경계 문제 없음.

근데 "정확히" 슬라이딩하려면 각 요청의 타임스탬프를 다 들고 있어야 함(카운터 숫자 하나로는 오래된 요청 만료를 못 함). 공격자가 1분에 수천 번 쏘면 그 타임스탬프를 다 저장 → 메모리 폭발. 요구사항 3번(메모리 적게)이랑 충돌.

**해결: 시간을 버킷으로 쪼개고 버킷마다 카운트만 센다.** 저장하는 게 "요청 수천 개 시각"이 아니라 "고정 개수 버킷 카운터"라 메모리가 확 줄어듦.

버킷을 몇 개로 쪼갤지가 다이얼:
- 잘게(1초 60버킷): 슬라이딩 거의 정확. 대신 사용자 1명당 카운터 60개 × 수백만 명 → 메모리 60배
- 굵게(2버킷, "직전 1분" + "현재 1분"): 메모리 최소. 대신 직전 버킷을 겹침 비율로만 반영 → "직전 1분 안에서 요청이 균등 분포했다"는 가정이 깔림. 트래픽이 한쪽에 쏠리면 과대/과소평가 오차 발생

**결정: 기본은 굵게(2버킷). 추정식 = `현재 윈도우 수 + 직전 윈도우 수 × 겹치는 비율`.**

근거: 대부분 도메인에서 완전 정확한 제한은 필요 없음. 1~2개 더/덜 통과돼도 사용자도 체감 못 함. 잘게의 유일한 장점이 정확도인데 그게 안 중요하니, 60배 메모리를 낼 이유가 없음. 균등분포 가정 오차는 감수.

단, **버킷 개수는 튜닝 다이얼로 남김.** 정확도가 진짜 중요한 도메인(과금/결제 한도 등)이면 버킷을 잘게 쪼개 정밀도↑. = 메모리 vs 정확도를 도메인이 고르게. (6장에서 일관성을 "요청 단위 옵션 + 기본값 안전하게"로 뺀 거랑 같은 패턴.)

버스트 스무딩(고정 처리율로 고르게 흘려보내기) 같은 다른 요구는 현재 요구사항에 없어서 슬라이딩 윈도우로 충분. 그런 요구가 생기면 다른 알고리즘 계열 검토.

---

### 3. 카운터(버킷)를 어디에 저장하나?
선택지
1. 관계형 DB(디스크)
2. 제한 장치 프로세스 자신의 로컬 메모리
3. 별도의 빠른 외부 저장소 (Redis)

1번은 안 됨. 검사는 모든 요청마다 일어나는데 매번 디스크 read/write면 느림. 요구사항 2번(낮은 응답시간) 위반.

2번은 빠른데, 제한 장치가 여러 대로 늘어나면(요구사항 4번) A서버가 센 카운트를 B서버가 모름. 상태가 안 공유됨.

3번이 답. **Redis** 같은 인메모리 외부 저장소. 필요한 성질이랑 정확히 맞음:
- 인메모리라 빠름 → 요구사항 2번. 매 요청 검사해도 디스크 안 탐
- 외부 공유 저장소 → 요구사항 4번. 제한 장치 여러 대여도 다 같은 Redis를 봐서 카운트가 한 군데로 모임 (2번의 "안 공유됨" 해결)
- TTL/만료 지원 → 윈도우 지나면 버킷이 알아서 사라짐. 직접 청소 불필요 → 요구사항 3번에도 도움

---

### 4. 분산 환경 동기화 / 경쟁 조건
요구사항 4번(여러 제한 장치가 공유)은 두 조각의 문제임.

**(1) 동기화** — 제한 장치가 여러 대면 A가 센 카운트를 B가 모름. 웹 계층이 무상태라 같은 클라이언트의 다음 요청이 다른 제한 장치로 갈 수 있음. sticky session(같은 클라는 항상 같은 제한 장치로)으로 풀 수도 있지만 확장성·유연성이 나빠 비추.
→ 결정 3에서 이미 해결됨. 상태를 Redis 한 곳에 모았으니 모든 제한 장치가 같은 카운트를 봄.

**(2) 경쟁 조건** — 검사는 사실 3단계: ① 카운터 읽기 → ② 한도 검사 → ③ 증가시켜 쓰기. 같은 사용자 요청 2개가 거의 동시에 오면 둘 다 ①에서 같은 옛 값(4)을 읽고 둘 다 통과시켜 5로 씀. 실제론 6개 통과(한도 5인데!), 카운터는 5, 진짜는 6이어야 함. 카운트가 적게 잡혀 한도를 넘김.
- 버그의 핵심 = ①(읽기)과 ③(쓰기) **사이의 틈**. 그 틈에 다른 요청이 끼어들어 옛 값을 읽음. → 이 틈을 없애야(원자성).

선택지
- A. 락(lock): 그 카운터 건드리는 동안 한 요청만 통과, 나머지는 대기. 확실하지만 요청이 직렬화돼 느려짐(요구사항 2번 위반). 분산 락은 구현도 복잡.
- B. 연산을 저장소 안으로: 읽기-검사-증가를 Redis가 한 단위로 통째 실행. Redis는 명령을 한 번에 하나씩 처리(단일 스레드)하므로 그 사이 끼어들 틈이 없음. 증가 명령이 증가된 새 값을 원자적으로 돌려주면 그 값으로 한도 검사(동시 요청도 각각 5, 6 받아 안 헷갈림). 또는 읽기+검사+증가를 한 덩어리 스크립트로 보내 통째 실행.

**결정: B.** 락처럼 남을 막아 세우는 게 아니라 저장소의 "한 번에 하나씩" 성질에 올라타는 거라 블로킹 없음 → 맞음 + 빠름(요구사항 2번) 둘 다 잡음.

---

### 5. 제한에 걸린 요청은 어떻게 응답/처리하나?
요구사항 5번(제한됐을 때 사용자에게 분명히 보여주기).

**상태 코드: `429 Too Many Requests`.** "요청을 너무 많이 보냈음"의 표준 코드.

**정보는 HTTP 응답 헤더에 실어 보냄.** 막기만 하면 클라가 답답하니, 협조할 수단을 줌:
- `X-Ratelimit-Limit` — 윈도우당 보낼 수 있는 총 요청 수(한도)
- `X-Ratelimit-Remaining` — 현재 윈도우에 남은 요청 수
- `X-Ratelimit-Retry-After` — 차단 안 당하려면 몇 초 뒤 재시도해야 하는지
→ 똑똑한 클라는 remaining 보고 속도 줄이고, 걸리면 retry-after만큼 기다렸다 재시도.

**무조건 버리느냐 — 아님.** 두 옵션:
- 옵션 1: 그냥 버림 (429 던지고 끝)
- 옵션 2: 메시지 큐에 보관했다가 나중에 처리 (유실되면 안 되는 요청, 예: 과부하로 한도에 걸린 주문)
요청 성격에 따라 고름.

---

### 6. 제한 규칙은 어떻게 정의·저장·갱신하나?
설계 범위의 "유연한 throttling rule" 요구. 예: 로그인 분당 5회, 마케팅 메시지 하루 5개, 친구 추가 하루 150회.

규칙과 카운터는 성격이 정반대임:
- 카운터: 매 요청마다 값이 바뀜. 항상 최신·공유 필수 → Redis 직접.
- 규칙: 매 요청마다 읽히지만 값은 거의 안 바뀜(관리자가 가끔 수정). 읽기 폭발, 쓰기 드묾, 조금 옛날이어도 무해.

**결정: 원본은 Redis(또는 설정 저장소)에 두고, 게이트웨이는 로컬 캐시 + 주기적 갱신으로 읽는다.**
1. 원본(source of truth)은 Redis. 관리자는 여기서 규칙 수정 → 게이트웨이 재시작 없이 변경.
2. 각 게이트웨이가 규칙을 자기 로컬 메모리에 복사본으로 보유.
3. 백그라운드 워커가 몇 초마다 Redis에서 규칙을 다시 읽어 로컬 복사본 갱신.
4. 매 요청은 로컬 메모리에서 규칙 조회 → 네트워크 왕복 0.

근거:
- 요구사항 2번 — 핫 패스에서 Redis 왕복 제거. 로컬 조회는 사실상 공짜.
- 유연한 변경 유지 — 규칙 바꾸면 다음 갱신 주기(몇 초) 내 전 게이트웨이로 전파. 재시작 불필요.
- 대가 — 변경이 갱신 주기만큼 늦게 반영. 근데 규칙은 거의 안 바뀌고 몇 초 옛날이어도 무해라 OK. (카운터였으면 절대 안 됨.)

핵심: "읽기 폭발 + 쓰기 드묾 + 약간 stale 허용" = 로컬 캐싱의 교과서적 조건. 카운터와 정반대 성격이라 저장 전략도 정반대.

---

### 7. 제한 장치나 Redis가 죽으면? (결함 감내성)
요구사항 6번(제한 장치 장애가 전체 시스템에 영향 X). 설계하다 보니 Redis가 모든 것의 중심(카운터+규칙 원본)이 돼서 새 약점이 됨.

**(1) Redis가 죽으면? — fail-open vs fail-closed**
- fail-closed(다 막음): Redis 노드 하나 죽음 → 모든 게이트웨이가 카운터 못 읽음 → 모든 요청 429 거부 → 정상 사용자까지 차단 → API 전체 먹통. = Redis 하나 죽은 게 전체 장애로 번짐. 요구사항 6번이 금지하는 바로 그 상황.
- fail-open(다 통과): 제한 기능은 잠깐 꺼지지만 API는 살아있음.

**결정: 기본 fail-open.** 근거 = 요구사항 6번. 처리율 제한 장치는 API를 보호하는 보조 장치인데, 보조 장치가 죽었다고 본체까지 죽이면 안 됨("안전벨트 고장 났다고 차 폭파").
- 단, 이건 "가용성 잃는 게 나쁘냐 vs 보호 잃는 게 나쁘냐"의 판단 문제. 제한 못 지키는 게 치명적인 도메인(결제 한도, 보안 인증 시도)이면 fail-closed가 옳을 수 있음. 이 장의 요구사항은 가용성에 무게.

**중간 지대 채택: graceful degradation.** Redis 죽으면 "다 통과" 대신 게이트웨이가 자기 로컬 메모리 카운터로 임시 대체(부정확하지만 대략적 보호 유지). 하드 의존을 소프트 의존으로 바꿈.

**(2) Redis SPOF 줄이기:** Redis를 클러스터로 구성/복제. 한 노드 죽어도 다른 노드가 받게. (6장 "SPOF 만들지 마라" 원칙 그대로.)

---

## 요구사항 → 기술 매핑

| 요구사항 | 기술/결정 |
|---|---|
| 1. 처리율 초과 정확히 제한 | 슬라이딩 윈도우 (버킷 카운터) |
| 2. 낮은 응답시간 | 인메모리 Redis 카운터 + 규칙 로컬 캐시(핫패스 왕복 0) + 원자 연산(락 회피) |
| 3. 메모리 적게 | 타임스탬프 낱개 저장 대신 버킷 카운터 + 버킷 굵게(2버킷) + TTL 자동 만료 |
| 4. 분산형(여러 제한 장치 공유) | 상태를 Redis 한 곳에 모음(동기화) + 저장소 원자 연산(경쟁 조건) |
| 5. 예외 처리(사용자 통지) | 429 + X-Ratelimit-* 헤더(Limit/Remaining/Retry-After), 필요시 메시지 큐 |
| 6. 높은 결함 감내성 | fail-open + 로컬 폴백 카운터(graceful degradation) + Redis 클러스터 |
| 위치(설계 범위) | 서버 앞단 독립 미들웨어/게이트웨이 |
| 유연한 규칙(설계 범위) | 규칙 원본 Redis + 게이트웨이 로컬 캐시 주기 갱신 |

---

## 병목 / 장애 지점 / 토론하고 싶은 것

1. **모든 요청이 Redis를 때린다.** 검사를 매 요청마다 하는데 그게 전부 Redis로 감. 게이트웨이는 여러 대로 늘려도 Redis는 한 곳에 몰림. 트래픽이 커지면 여기가 병목 아닐까? Redis 클러스터로 분산한다 쳐도, 같은 사용자/IP 카운터는 같은 키라 한 노드에 쏠림(핫 키). 게이트웨이만 늘려선 안 풀리는 문제 같음.

2. **fail-open의 타이밍이 하필.** Redis가 DoS 공격 한복판에서 죽으면? fail-open이라 다 통과시키는데 — 보호가 가장 필요한 순간에 보호가 꺼짐. 로컬 폴백 카운터가 있긴 한데 그게 완벽히 메워주나? 평상시엔 가용성 위해 fail-open이 맞는데, 공격 중에는 그 선택이 정확히 독이 되는 역설. 상태에 따라 fail 정책을 바꿔야 하나?

3. **로컬 폴백 카운터의 함정.** Redis 죽어서 게이트웨이마다 자기 로컬 카운터로 제한하면, 게이트웨이가 10대일 때 "분당 5회" 제한이 사용자한텐 실제로 분당 50회로 보임(각 게이트웨이가 자기 것만 세니까). 즉 폴백이 "대략적 보호 유지"라지만 실효 한도가 게이트웨이 수만큼 뻥튀기됨. 부정확한 정도가 생각보다 큼.