시스템 디자인 케이스 (URL Shortener · Rate Limiter)
분류: Layer 9 - 아키텍처 & 설계 패턴
1. 한 줄 정의
섹션 제목: “1. 한 줄 정의”시스템 디자인 케이스 스터디는 흔히 면접·실무에서 등장하는 대표 문제(URL Shortener, Rate Limiter, 채팅, 피드 등)를 요구사항 → 용량 추정 → 데이터 모델 → API → 확장 전략 → 운영 함정 의 일관된 프레임워크로 풀어내며 시니어 백엔드/플랫폼 엔지니어의 문제 분해 능력 을 훈련하는 학습 단위다.
2. 왜 중요한가
섹션 제목: “2. 왜 중요한가”시스템 디자인은 정답 이 있는 문제가 아니라 제약과 트레이드오프 의 문제다. “Redis로 캐시하면 되지 않나요?”는 답이 아니다 — 캐시 미스율, hot key, 데이터 영속성, 비용 제약 안에서 왜 Redis인지 를 정량으로 정당화할 수 있어야 시니어다. URL Shortener·Rate Limiter처럼 작아 보이는 문제는 이 능력의 시금석이다. 진짜로 풀어보면 Base62 vs Snowflake, token bucket vs sliding window 같은 결정에 scale, throughput, 일관성, 비용 이 다르게 얽혀 있고, 잘못 고르면 production에서 비싸게 배운다. 이 케이스 스터디들은 분해 절차를 손에 익혀 다른 새 문제를 만났을 때 같은 절차로 풀게 하는 훈련이다.
선수지식: api-design-contract.md, msa-patterns.md, distributed-systems-basics.md, L8 db-index-query-optimization.md, L8 redis-internals.md. 인접 케이스: web-crawler-system-design.md.
2.1 선행 방식의 한계 — 케이스 스터디가 필요한 이유
섹션 제목: “2.1 선행 방식의 한계 — 케이스 스터디가 필요한 이유”시스템 디자인을 컴포넌트 암기(“캐시를 둔다”, “큐를 붙인다”, “Redis로 제한한다”)로만 풀면, 작은 문제에서도 결정이 바로 깨진다. URL Shortener에서 auto-increment id → Base62는 충돌이 없지만 공개 코드가 순차적으로 노출되어 다른 사용자의 URL을 열거하기 쉽고, 단일 DB sequence가 write path의 중앙 병목이 된다. Rate Limiter에서 API 서버별 in-memory counter는 노드가 10개가 되는 순간 사용자당 한도가 최대 10배까지 새어 나갈 수 있고, Redis를 붙여도 GET → 계산 → SET을 분리하면 동시 요청 사이 race가 생긴다. RFC 6585가 429를 정의하면서도 대량 공격 상황에서는 429를 하나하나 반환하는 것 자체가 자원을 소모하므로 연결 drop 같은 대안이 나을 수 있다고 경고하는 이유도 같다: “정답 컴포넌트”보다 부하 조건과 실패 모드가 먼저다.
그래서 이 토픽은 특정 제품 레시피가 아니라 6단계 프레임워크로 등장한다. 요구사항을 좁히고, 20,000 redirect QPS·6B URL·p99 < 5ms 같은 숫자를 세운 뒤, 각 결정마다 “언제 깨지는가”를 붙인다. URL Shortener의 Snowflake는 중앙 sequence 병목을 없애는 메커니즘이고, Rate Limiter의 Redis Lua는 여러 gateway가 같은 키를 갱신할 때 원자성을 확보하는 메커니즘이다. 이 토픽이 빠지면 엔지니어는 개별 기술은 알아도, 새 케이스에서 어떤 제약이 어떤 아키텍처 결정을 강제하는지 연결하지 못한다.
3. 시스템 디자인 접근 프레임워크 (일반 공식)
섹션 제목: “3. 시스템 디자인 접근 프레임워크 (일반 공식)”새 케이스를 만났을 때 적용하는 6단계.
| 단계 | 질문 | 산출물 |
|---|---|---|
| 1 | 요구사항을 좁힌다 — Functional + Non-Functional | ”무엇을 만들고, 무엇은 안 만드는가” 명시적 리스트 |
| 2 | 용량을 추정한다 — QPS, 저장 공간, 대역폭, 시간 경과 시 성장률 | 5년 후 데이터 크기, peak/평균 QPS, 캐시 워킹 셋 크기 |
| 3 | API와 데이터 모델을 그린다 — 무엇을 입력받고 무엇을 저장하나 | OpenAPI 한 페이지, ER 다이어그램, 키-값 스키마 |
| 4 | 고수준 아키텍처를 그린다 — write path, read path, 캐시, 큐, DB | 컴포넌트 다이어그램 + 데이터 흐름 |
| 5 | 확장 전략을 결정한다 — 어디서 sharding/replication, 어디서 캐싱 | 정량 임계 + 다음 단계 트리거 |
| 6 | 운영 함정을 식별한다 — hot key, replication lag, 보안, 비용 | 시나리오별 감지·복구 절차 |
이 표는 다음 §4·§5 두 케이스 모두에 같은 순서로 적용된다. 핵심은 순서 자체가 기억의 비계 라는 점이다. 새 문제(피드, 채팅, 알림, 결제 등)를 만나도 이 6단계로 들어가면 구조적으로 빠뜨림이 없다.
4. 케이스 1: URL Shortener
섹션 제목: “4. 케이스 1: URL Shortener”4.1 요구사항 (Step 1)
섹션 제목: “4.1 요구사항 (Step 1)”Functional:
- 긴 URL을 짧은 URL로 변환 (
https://...→https://sho.rt/AbC123Z) - 짧은 URL 입력 시 원본으로 redirect (HTTP 301 또는 302)
- 만료 일자 설정 가능 (선택)
- 사용자 지정 alias 가능 (선택,
sho.rt/my-promo) - click 분석 (선택, 후순위)
Non-Functional:
- 99.99% 가용성 (redirect는 web의 critical path가 되기 쉬움)
- 100ms 이내 redirect latency (체감 지연 없게)
- 단축 URL은 예측 불가능 해야 함 (보안: 순차 ID 노출 시 brute force로 다른 사람 URL 발견)
- 5년 영구 보관 (만료 없는 기본 plan)
제외:
- 콘텐츠 검열 (별도 시스템 위임)
- 로그인·사용자 관리 (별도)
- 실시간 click 알림 (스트리밍 분석 별도)
4.2 용량 추정 (Step 2)
섹션 제목: “4.2 용량 추정 (Step 2)”가정: 신규 URL 생성 100M/월 → 약 40 URL/s 평균. peak는 5배 → 200 URL/s. Read:Write 비율 100:1 (단축 URL은 한 번 만들면 여러 번 클릭) → redirect peak 20,000 QPS.
저장 공간:
- 5년 누적 URL 수: 100M × 12 × 5 = 6B URL
- row 당 평균 500 bytes (long_url 평균 200B + short_code 7B + metadata 등) → 3 TB
Base62 7-char 단축 코드의 공간: 62^7 = 약 3.5 trillion. 6B URL은 0.17% 사용 → 충돌 거의 없음.
캐시:
- 80/20 법칙 적용 → 20% URL이 redirect 80% 차지
- hot working set = 6B × 20% × 500B = 600 GB. 이건 Redis 단일 인스턴스로 무리, 샤딩된 Redis 클러스터 (10~20 노드).
- 또는 hot tier에 LRU로 상위 N% 만 유지하여 working set 축소
대역폭:
- redirect 응답 평균 500B → 20,000 QPS × 500B = 10 MB/s (CDN/ALB 부담 없음)
4.3 API + 데이터 모델 (Step 3)
섹션 제목: “4.3 API + 데이터 모델 (Step 3)”POST /v1/urls Body: {"longUrl": "https://...", "expiresAt": "..."(optional), "alias": "..."(optional)} Response: 201 {"shortCode": "AbC123Z", "shortUrl": "https://sho.rt/AbC123Z"}
GET /{shortCode} Response: 302 Found / Location: <longUrl>데이터 모델 (PostgreSQL 기준):
CREATE TABLE urls ( short_code CHAR(7) PRIMARY KEY, -- Base62 7-char long_url TEXT NOT NULL, user_id UUID, -- nullable (익명 단축 허용) expires_at TIMESTAMPTZ, -- nullable (영구) created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), click_count BIGINT NOT NULL DEFAULT 0 -- counter (별도 비동기 집계 권장));
CREATE INDEX urls_user_idx ON urls (user_id) WHERE user_id IS NOT NULL;CREATE INDEX urls_expires_idx ON urls (expires_at) WHERE expires_at IS NOT NULL;short_code가 PK인 점이 핵심: GET /{shortCode} 가 PK lookup → 인덱스 1회 hit.
4.4 단축 코드 생성 — 핵심 의사결정
섹션 제목: “4.4 단축 코드 생성 — 핵심 의사결정”세 가지 접근. 트레이드오프 다름.
| 방식 | 짧은 코드 길이 | 충돌 처리 | 분산 친화 | 보안(예측 불가능) | 결정 트리거 |
|---|---|---|---|---|---|
| Hash + truncate (MD5/SHA) | 7 (truncate) | 충돌 시 수동 retry (값 변형) | ✗ (중앙 검증 필요) | ✓ | 사용 비추 (충돌·중앙 점검 부담) |
| Auto-increment + Base62 | 7 (Base62 변환) | 없음 (DB가 unique 보장) | △ (단일 DB sequence 병목) | ✗ (순차 노출) | 소규모, 보안 요구 낮을 때 |
| Snowflake + Base62 | 11 (Base62) | 없음 (분산 ID 자체가 unique) | ✓ (worker 노드 독립 생성) | △ (raw ID는 시간·worker 추론 가능) | 권장 default. 분산 친화 + 충돌 없음. 공개 코드는 별도 난독화 |
왜 Snowflake가 default: timestamp + datacenter_id + worker_id + sequence를 64-bit으로 인코딩한다. Twitter Snowflake 원본 구현도 datacenter 5bit, worker 5bit, sequence 12bit를 사용하고, 같은 millisecond 안에서는 sequence를 증가시키며, 시계가 뒤로 가면 ID 생성을 거부한다(출처: https://github.com/twitter-archive/snowflake/blob/snowflake-2010/src/main/scala/com/twitter/service/snowflake/IdWorker.scala). 노드별 독립 생성, 중앙 sequence DB 없음. Base62 인코딩 결과는 11자로 7자보다 길지만 충돌이 구조적으로 불가능한 이점이 크다.
단, raw Snowflake를 그대로 public_short_code로 쓰면 “예측 불가능”하지 않다. 시각과 worker 범위를 아는 공격자는 근접 ID를 추측할 수 있다. 7자 단축이 꼭 필요하고 열거 방지가 보안 요구라면 base62(HMAC-SHA256(secret, snowflake_id)).slice(0, 7)처럼 공개 코드는 secret 기반으로 섞고, DB unique constraint 충돌 시 salt를 바꿔 retry한다. 6B URL이 62^7 = 약 3.5T 공간의 0.17%만 차지하므로 충돌은 낮지만, 이 경우에는 “구조적으로 0”이 아니므로 unique check가 결정 기준이다.
// Snowflake 64-bit 구조 (Twitter 원본):// [sign 1bit][timestamp 41bit][worker_id 10bit][sequence 12bit]//// timestamp: 41bit → 약 69년 (epoch 기준)// worker_id: 10bit → 1024 worker 노드// sequence: 12bit → 노드당 ms 당 4096 ID//// 결과: 노드당 ms 당 4096 unique ID, 1024 노드 × 4096 = 약 4M ID/ms// → 200 URL/s 가뜬히 처리 (4M/ms × 1000 = 4B/s)
function snowflakeToBase62(id: bigint): string { const base62 = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"; let result = ""; let n = id; while (n > 0n) { result = base62[Number(n % 62n)] + result; n /= 62n; } return result || "0";}사용자 지정 alias 분리: auto-generated public code는 시스템 생성 코드 길이(예: 7자 HMAC-truncate 또는 11자 Snowflake Base62)로 고정한다. 사용자 alias는 8자 이상이거나 URL-safe 특수문자(-, _) 포함으로 강제한다. 두 공간이 구조적으로 겹치지 않게 분리하면 alias와 자동 생성 코드 사이의 충돌 검증 범위가 줄어든다.
4.5 고수준 아키텍처 (Step 4)
섹션 제목: “4.5 고수준 아키텍처 (Step 4)” ┌───── Write Path ─────┐ Client → ALB → API Server → Snowflake Worker → PostgreSQL (urls table) │ └─→ Redis (set short_code → long_url, TTL 24h)
┌───── Read Path ─────┐ Client → CDN ──────────→ Redis Cache ─→ PostgreSQL ─→ HTTP 302 Redirect (hit 90%+) (miss fallback)
┌───── Analytics ──────┐ Read path → Kafka click event → ClickHouse (분석) ↑ 비동기, redirect critical path와 분리Read 경로의 핵심 결정:
- CDN edge에서
Cache-Control: public, max-age=300으로 1차 캐시 (5분). RFC 9111은 캐시 freshness의 주된 메커니즘을Expires또는Cache-Control: max-age같은 명시적 만료 시간으로 정의하므로, redirect 응답도 origin이max-age=300을 주면 cache가 5분 동안 origin 재접속 없이 재사용할 수 있다(출처: https://www.rfc-editor.org/rfc/rfc9111.html). 깨지는 조건은 악성 URL quarantine 또는 alias 수정 직후다. 5분 동안 stale redirect가 남을 수 있으므로 보안 차단은Cache-Control: no-store를 붙인 deny 응답으로 덮고, 유료 alias 수정 기능은 “최대 5분 전파 지연”을 API 계약에 명시한다. - Redis에서 hot URL을 24h TTL. miss 시 PG에서 채워 넣음.
- PG 자체도 PK lookup이라 p99 1ms 이내.
- click 카운팅은 비동기 — redirect critical path에 영향 없게 Kafka로 발행.
4.6 확장 전략 (Step 5)
섹션 제목: “4.6 확장 전략 (Step 5)”| 임계 | 다음 단계 |
|---|---|
| QPS > 100,000 | PG read replica 추가 → 90% read 트래픽을 replica로 |
| 단일 PG row 수 > 10B | short_code 첫 문자로 sharding (a-z, A-Z, 0-9 → 62 shard) |
| Redis working set > 1TB | Redis Cluster + consistent hashing |
| 글로벌 latency > 100ms | multi-region 배포 + GeoDNS |
| analytics 부담 PG 80%+ | click 이벤트를 Kafka → ClickHouse로 완전 분리 |
| hot URL 1개가 QPS 30,000+ | CDN edge에 hot URL을 별도 강제 캐시 (manual prewarming) |
깨지는 조건 — 위 권고가 더 이상 안 통할 때:
- 단축 코드의 예측 불가능성 이 보안 요구라면 raw Snowflake를 public code로 노출하지 말고, HMAC/format-preserving permutation으로 섞은 값을 노출한다. 길이를 11→12+ 로 늘리는 것은 brute force 공간만 키울 뿐 순차 추측 가능성을 없애지는 못한다.
- 사용자 가 무엇을 단축했는지 조회해야 한다면
urls_user_idx위에 추가 query 패턴 (페이지네이션 필요) - click 이벤트 발행이 Kafka 장애 로 멈추면 → outbox 패턴(L8 cdc-outbox.md)으로 안전 보장
4.7 운영 함정 (Step 6)
섹션 제목: “4.7 운영 함정 (Step 6)”- Hot key 폭주: 바이럴 트윗 한 줄에서 URL이 30,000 QPS 받음 → Redis 단일 노드 CPU 100% → 클러스터 전체 영향. 해결: 클라이언트 측 short-TTL 캐시 (CDN, browser cache) 강화 + Redis 의 동일 키를 replication 으로 read replica 분산.
- redirect chain 폭탄: 사용자가 단축 URL을 다시 단축. 무한 chain → 클라이언트 timeout. 해결: 생성 시 long_url이 이미 같은 도메인이면 거부.
- Phishing / 악성 URL: 단축 URL이 phishing 사이트로 redirect. 해결: 생성 직후 비동기 검사(Google Safe Browsing, VirusTotal). 의심 시 quarantine.
- 만료 처리 비대화:
expires_at < NOW()row 누적 → click*count 등 batch 작업 부담. *해결_: daily partition + DROP PARTITION (L8 cdc-outbox.md §8.3 동일 패턴). - click_count UPDATE 경합: hot URL의
UPDATE urls SET click_count = click_count + 1이 row lock 경쟁 → write 병목. 해결: ClickHouse에 raw event 저장, clickcount 칼럼은 _eventually 집계.
5. 케이스 2: Rate Limiter
섹션 제목: “5. 케이스 2: Rate Limiter”5.1 요구사항 (Step 1)
섹션 제목: “5.1 요구사항 (Step 1)”Functional:
- API gateway 앞단에서 client 별 / API 별 요청 제한
- 한도 초과 시 HTTP 429 +
Retry-After,RateLimit-*헤더 반환 - 한도는 동적으로 변경 가능 (config reload)
Non-Functional:
- p99 한 요청당 < 5ms 결정 시간 (critical path)
- 분산 환경 일관성 (모든 노드가 같은 카운터 봄)
- 가용성: rate limiter 자체가 장애 단일 지점 이 되면 안 됨 (fail-open vs fail-closed 결정)
5.2 알고리즘 결정 — 핵심 의사결정
섹션 제목: “5.2 알고리즘 결정 — 핵심 의사결정”네 가지 주요 알고리즘.
| 알고리즘 | 특징 | burst 처리 | 메모리 | 정확도 | 권장 시나리오 |
|---|---|---|---|---|---|
| Fixed Window | 1분 슬롯에 카운터. 슬롯 경계에서 reset | ✗ (경계 spike) | 키당 1 int | 낮음 (경계 효과) | 단순. 정확도 중요치 않을 때 |
| Sliding Window Log | 모든 요청 timestamp 저장. 윈도우 안 count | ✓ | 키당 N entries (큼) | 매우 높음 | 메모리 충분 + 정확도 critical |
| Sliding Window Counter | 현재·이전 window 카운트로 가중 평균 | ✓ (부드러움) | 키당 2 int | 높음 | API에 가장 일반적 default |
| Token Bucket | 토큰 N개. 요청 시 1개 소비. fixed rate로 refill | ✓ (큰 burst) | 키당 2 int (tokens + ts) | 높음 | burst 허용 이 본질일 때 (예: 결제 API) |
| Leaky Bucket | FIFO 큐. fixed rate로 drain | △ (소진하면 buffer) | 큐 크기 | 중간 | downstream 보호 (outbound throttle) |
현실적 디폴트:
- API 호출 제한 일반: Sliding Window Counter (token bucket과 거의 동등, 메모리 적음)
- burst 허용 명시적 : Token Bucket (예: “분당 60, 단기 100 burst 허용”)
- outbound throttling (자기 서비스가 외부 API 호출 시): Leaky Bucket
5.3 Redis 분산 구현
섹션 제목: “5.3 Redis 분산 구현”핵심 도전: 여러 API 서버 노드가 같은 client 의 요청을 본다. 카운터가 중앙 집중되어야 일관성.
Token Bucket on Redis (Lua script 원자성):
-- KEYS[1] = "ratelimit:user:123"-- ARGV[1] = max_tokens (예: 100)-- ARGV[2] = refill_rate_per_sec (예: 10)-- ARGV[3] = now_unix_seconds-- 반환: { allowed (1/0), remaining_tokens, reset_at }
local bucket = redis.call("HMGET", KEYS[1], "tokens", "last_refill")local tokens = tonumber(bucket[1]) or tonumber(ARGV[1])local last_refill = tonumber(bucket[2]) or tonumber(ARGV[3])
-- elapsed 시간만큼 refilllocal elapsed = tonumber(ARGV[3]) - last_refilllocal refilled = math.min(tonumber(ARGV[1]), tokens + elapsed * tonumber(ARGV[2]))
if refilled < 1 then redis.call("HMSET", KEYS[1], "tokens", refilled, "last_refill", ARGV[3]) redis.call("EXPIRE", KEYS[1], 3600) return { 0, math.floor(refilled), tonumber(ARGV[3]) + math.ceil((1 - refilled) / tonumber(ARGV[2])) }end
redis.call("HMSET", KEYS[1], "tokens", refilled - 1, "last_refill", ARGV[3])redis.call("EXPIRE", KEYS[1], 3600)return { 1, math.floor(refilled - 1), tonumber(ARGV[3]) + 1 }Lua script는 Redis에서 원자적 으로 실행. race condition 없음. EVALSHA로 캐시된 script 호출하면 네트워크 1 round-trip.
TypeScript caller:
import Redis from "ioredis";const redis = new Redis();const script = `...위 Lua 스크립트...`;
async function checkRateLimit( userId: string,): Promise<{ allowed: boolean; remaining: number; resetAt: number }> { const now = Math.floor(Date.now() / 1000); const [allowed, remaining, resetAt] = (await redis.eval( script, 1, // KEYS 개수 `ratelimit:user:${userId}`, 100, // max_tokens 10, // refill rate now, )) as [number, number, number]; return { allowed: allowed === 1, remaining, resetAt };}
// NestJS interceptor@Injectable()export class RateLimitInterceptor implements NestInterceptor { async intercept( ctx: ExecutionContext, next: CallHandler, ): Promise<Observable<any>> { const req = ctx.switchToHttp().getRequest(); const userId = req.user?.id ?? req.ip; const result = await checkRateLimit(userId);
const res = ctx.switchToHttp().getResponse(); const now = Math.floor(Date.now() / 1000); const resetAfter = Math.max(0, result.resetAt - now); res.setHeader("RateLimit-Limit", 100); res.setHeader("RateLimit-Remaining", result.remaining); res.setHeader("RateLimit-Reset", resetAfter);
if (!result.allowed) { res.setHeader("Retry-After", resetAfter); throw new HttpException( { type: "/errors/rate-limit", title: "Rate limit exceeded", status: 429, }, 429, ); } return next.handle(); }}HTTP 429는 RFC 6585가 정의한 “Too Many Requests” 응답이며, 응답 본문에는 제한 조건 설명을, Retry-After에는 다시 시도할 시간을 담을 수 있다(출처: https://www.rfc-editor.org/rfc/rfc6585.html). RateLimit-Limit, RateLimit-Remaining, RateLimit-Reset은 IETF HTTPAPI draft에서 서비스 quota를 클라이언트가 스스로 pacing할 수 있게 하기 위해 정의한 필드이고, 특히 RateLimit-Reset은 Unix timestamp가 아니라 “quota reset까지 남은 초”다(출처: https://www.ietf.org/archive/id/draft-ietf-httpapi-ratelimit-headers-05.html). 그래서 위 caller는 Lua script가 돌려준 epoch resetAt을 그대로 헤더에 쓰지 않고 resetAfter로 변환한다. 이 변환을 빼먹으면 서버·클라이언트 clock skew나 epoch/delta 해석 차이 때문에 정상 클라이언트가 수십 년 뒤까지 대기하는 silent failure가 생긴다.
5.4 운영 함정 (Step 6)
섹션 제목: “5.4 운영 함정 (Step 6)”- Redis 다운 — fail-open vs fail-closed: rate limiter Redis가 죽으면 모든 요청 통과 (fail-open) 또는 모든 요청 거부 (fail-closed). 보안이 critical하면 closed, 가용성이 critical하면 open. fail-open + 별도 알람이 일반적.
- Hot key (한 user가 모든 요청 차지): 한 사용자 ID 키가 Redis 단일 노드 hot. 해결: 사용자 ID + 노드 ID로 키 partition (정확도 약간 손해), 또는 클러스터 shard.
- 분산 카운터 race: API gateway 노드가 여럿일 때 Redis 한 번 다녀오는 동안 다른 노드가 카운트 증가 → over-limit으로 일시 통과. Lua script 원자성으로 해결 (위 코드).
- Lua script cache 유실: Redis 공식 문서는 Lua script가 원자적으로 실행되지만, script cache는 DB의 일부가 아니어서 restart·failover·
SCRIPT FLUSH뒤 사라질 수 있다고 설명한다(출처: https://redis.io/docs/latest/develop/programmability/eval-intro/). 감지:redis-cli -h "$REDIS_HOST" SCRIPT EXISTS "$SHA1"→0이거나 애플리케이션 로그에NOSCRIPT가 보이면SCRIPT LOAD후EVALSHA를 재시도한다. 이 fallback이 없으면 Redis 장애가 아니라도 모든 요청이 500으로 떨어진다. - TTL 잊기: 활성 사용자 key는 EXPIRE로 자동 정리. 안 하면 Redis 메모리 영구 증가. 감지:
redis-cli -h "$REDIS_HOST" --scan --pattern 'ratelimit:*' | wc -l결과가 DAU보다 계속 빠르게 증가하면 TTL 누락을 의심한다. - 429 응답 자체에 rate limit 적용: 429를 받은 client가 즉시 retry 하면 부하 폭증.
Retry-After를 강제. 이걸 무시하는 client는 IP 차단.
5.5 확장 — Multi-Region
섹션 제목: “5.5 확장 — Multi-Region”글로벌 API의 경우 한 사용자가 region A·B 둘 다 호출 가능. region-local 카운터로는 합산 한도 초과 가능.
현실적 타협: 한도를 region-local로 두고 글로벌 한도 는 별도 비동기 집계 (Kafka → ClickHouse) → 사후 차단. 또는 Aurora Global Database / Spanner 같은 strongly consistent 글로벌 카운터 (비용 큼).
6. 두 케이스 공통 패턴 + 차이점
섹션 제목: “6. 두 케이스 공통 패턴 + 차이점”| 차원 | URL Shortener | Rate Limiter |
|---|---|---|
| read/write 비율 | read 압도 (100:1) | read=write (모든 요청이 둘 다) |
| 데이터 영속성 | 영구 (DB primary) | 휘발 (Redis만) |
| 분산 ID 필요 | ✓ (Snowflake) | ✗ (counter만) |
| 원자성 도구 | DB transaction | Redis Lua script |
| 캐시 역할 | hot URL fast lookup (Redis = 부속물) | primary storage (Redis 없으면 fail) |
| HTTP status | 301/302 (redirect) | 429 (Too Many Requests) |
| fail mode | Redis 죽으면 DB로 fallback | Redis 죽으면 fail-open or fail-closed 결정 |
| 확장 임계 패턴 | row 수 > 10B → sharding | hot key → 키 partition |
두 케이스 모두 §3 6단계 프레임워크의 같은 순서로 풀린다. 새 케이스를 만나도 이 순서를 그대로 적용한다.
7. 체크리스트
섹션 제목: “7. 체크리스트”- 새 시스템 디자인 문제를 만났을 때 §3의 6단계(요구사항 → 용량 → API/모델 → 아키텍처 → 확장 → 운영 함정)를 순서대로 적용할 수 있는가?
- URL Shortener에서 Snowflake가 hash-truncate / auto-increment보다 default인 이유 3가지를 들 수 있는가?
- Base62 7자가 3.5T 공간을 만들고, 6B URL이 그 0.17%만 차지한다는 충돌 산정을 직접 계산할 수 있는가?
- hot URL 30,000 QPS 발생 시 어떤 layer(CDN / Redis / DB)에서 어떤 대응을 하는지 설명할 수 있는가?
- Rate Limiter 4 알고리즘(Fixed Window / Sliding Log / Sliding Counter / Token Bucket / Leaky Bucket)을 언제 어느 것 인지 판단할 수 있는가?
- Token Bucket Lua script가 원자성 을 보장하는 이유를 설명할 수 있는가?
- Rate Limiter Redis가 죽었을 때 fail-open vs fail-closed 결정 기준을 말할 수 있는가?
- 429 응답에 포함되어야 할 헤더 3가지(
Retry-After,RateLimit-Limit,RateLimit-Remaining)를 알고 있는가? - 두 케이스의 read/write 비율 차이(URL은 100:1, RL은 1:1)가 아키텍처 결정 에 어떻게 다르게 반영되는지 설명할 수 있는가?
- 새 케이스(예: 채팅, 피드, 알림)에 §3 프레임워크를 적용한 간략 디자인 1쪽을 직접 그릴 수 있는가?
8. 운영 함정 종합
섹션 제목: “8. 운영 함정 종합”각 케이스의 함정 외에 공통 함정 셋.
8.1 용량 추정을 감 으로 — 실제 측정 없이 architecture 결정
섹션 제목: “8.1 용량 추정을 감 으로 — 실제 측정 없이 architecture 결정”용량 추정 (Step 2)이 감 으로 가면 후속 모든 결정이 어긋난다. “월 100M URL”이 월간 액티브 유저 추정 으로 검증되지 않으면 그 다음 sharding 결정이 무의미하다.
해결: 추정의 기반 수치 를 명시 (DAU × 사용자당 URL/day × 30). 운영 시작 후 실측 vs 추정 을 매월 리뷰. 실측이 추정의 30%+ 벗어나면 즉시 capacity plan 재산정.
8.2 Premature sharding — 필요 없는 분산화
섹션 제목: “8.2 Premature sharding — 필요 없는 분산화”10B row 안 되는데 sharding부터 도입하는 패턴. 운영 복잡도(re-balancing, cross-shard query 어려움)가 당장의 확장 이득보다 크다.
해결: §4.6, §5.5의 임계 트리거 가 명확하다면 그 임계가 보일 때까지 수직 확장 + replication 으로 미룬다.
8.3 Cache as primary 의 함정 — Redis 데이터를 진짜로 영속
섹션 제목: “8.3 Cache as primary 의 함정 — Redis 데이터를 진짜로 영속”URL Shortener에서 Redis만 두고 PG 없이 가는 anti-pattern. Redis는 캐시 지 primary storage가 아니다. AOF 복구 가능해도 불완전 하고, 메모리 가격이 디스크의 100배.
Rate Limiter는 반대 — Redis가 primary 역할이 의도된 거라 OK. 차이를 알고 들어가야 한다.
9. 추가 학습 키워드
섹션 제목: “9. 추가 학습 키워드”- 다른 시스템 디자인 케이스: 트위터 피드, WhatsApp 채팅, Dropbox/S3, Uber 매칭, Netflix 스트리밍, Yelp 검색
- 분산 ID 생성기: Twitter Snowflake, Sonyflake, Discord Snowflake, ULID, KSUID
- Consistent Hashing: 키 분산의 표준. Redis Cluster, Cassandra 모두 채택
- Bloom Filter: short_code 존재 여부 빠른 negative lookup (URL Shortener에 hit 가능)
- Geo-distributed counter: CRDT, Aurora Global, Spanner
- Service Mesh rate limit: Istio EnvoyFilter, AWS API Gateway, Cloudflare
- API Gateway 패턴: Kong, AWS API Gateway, Azure API Management
10. 내가 직접 확인해볼 것
섹션 제목: “10. 내가 직접 확인해볼 것”- 6단계 프레임워크 손에 익히기: 새 케이스(“Twitter 타임라인” 같은) 1개를 §3 순서대로 한 페이지 작성
- Snowflake → Base62 직접 구현: 위 §4.4 코드를 ts-node로 실행. 1000개 생성 → unique 검증
- PG + Redis URL Shortener docker-compose: cdc-outbox.md §11.1의 docker-compose 패턴 재활용. POST → 단축 코드 → GET redirect까지
- Token Bucket Lua script 로컬 Redis로 실행: redis-cli
SCRIPT LOAD→EVALSHA→ 100 요청 보내며 carry-over 관찰 - Sliding Window vs Fixed Window 비교 실험: 둘 다 구현 후 slot 경계 직전 에 burst 보내 정확도 차이 관찰
- 429 응답 헤더 강제: NestJS interceptor로 위 §5.3 코드 적용 후
Retry-After,RateLimit-*헤더가 실제로 클라이언트에 도달하는지 검증 - 부하 테스트로 hot key 재현: k6 또는 vegeta로 한 사용자 ID에 10,000 RPS 보내 Redis 단일 노드 CPU 보기
- fail-open vs fail-closed 의도적 재현: Redis 죽이고 rate limiter 동작 확인. 두 모드 둘 다 테스트
- 다음 케이스 1개 더: 채팅 / 피드 / 알림 중 하나를 §3 프레임워크로 풀어 본인 학습 도구 정립
11. 5줄 요약
섹션 제목: “11. 5줄 요약”- 시스템 디자인은 문제 분해 절차 가 본질: §3 6단계(요구사항 → 용량 → API/모델 → 아키텍처 → 확장 → 운영 함정)를 순서대로 적용 하는 습관. 정답이 아니라 트레이드오프.
- URL Shortener의 핵심 결정은 단축 코드 생성: Snowflake + Base62가 default(분산 친화 + 충돌 없음), public code는 HMAC 등으로 난독화해야 열거 공격을 막는다. hash-truncate는 충돌 부담, auto-increment는 보안 부담.
- URL Shortener의 핵심 트레이드오프는 read:write 100:1: Redis가 부속 역할, PG가 primary. hot URL이 hot key가 되는 함정을 CDN+클라이언트 캐시로 분산.
- Rate Limiter의 핵심 결정은 알고리즘: Sliding Window Counter가 API 디폴트, Token Bucket은 burst 허용 시. Redis Lua script로 분산 원자성. fail-open vs fail-closed 의식적 결정.
- 두 케이스의 차이가 패턴 을 보여준다: URL은 영속·read-heavy·DB primary, RL은 휘발·균형·Redis primary. 새 케이스 만났을 때 같은 6단계가 다르게 풀린다.
참고 출처: RFC 6585 429 Too Many Requests (https://www.rfc-editor.org/rfc/rfc6585.html), RFC 9111 HTTP Caching (https://www.rfc-editor.org/rfc/rfc9111.html), IETF HTTPAPI RateLimit Fields draft-05 (https://www.ietf.org/archive/id/draft-ietf-httpapi-ratelimit-headers-05.html), Redis 공식 Lua scripting 문서 (https://redis.io/docs/latest/develop/programmability/eval-intro/), Redis 공식 Token Bucket Rate Limiter 문서 (https://redis.io/docs/latest/develop/use-cases/rate-limiter/), Twitter Snowflake 원본 구현 (https://github.com/twitter-archive/snowflake/blob/snowflake-2010/src/main/scala/com/twitter/service/snowflake/IdWorker.scala).