콘텐츠로 이동

시스템 디자인 케이스 (URL Shortener · Rate Limiter)

분류: Layer 9 - 아키텍처 & 설계 패턴

시스템 디자인 케이스 스터디는 흔히 면접·실무에서 등장하는 대표 문제(URL Shortener, Rate Limiter, 채팅, 피드 등)를 요구사항 → 용량 추정 → 데이터 모델 → API → 확장 전략 → 운영 함정 의 일관된 프레임워크로 풀어내며 시니어 백엔드/플랫폼 엔지니어의 문제 분해 능력 을 훈련하는 학습 단위다.

시스템 디자인은 정답 이 있는 문제가 아니라 제약과 트레이드오프 의 문제다. “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, 캐시 워킹 셋 크기
3API와 데이터 모델을 그린다 — 무엇을 입력받고 무엇을 저장하나OpenAPI 한 페이지, ER 다이어그램, 키-값 스키마
4고수준 아키텍처를 그린다 — write path, read path, 캐시, 큐, DB컴포넌트 다이어그램 + 데이터 흐름
5확장 전략을 결정한다 — 어디서 sharding/replication, 어디서 캐싱정량 임계 + 다음 단계 트리거
6운영 함정을 식별한다 — hot key, replication lag, 보안, 비용시나리오별 감지·복구 절차

이 표는 다음 §4·§5 두 케이스 모두에 같은 순서로 적용된다. 핵심은 순서 자체가 기억의 비계 라는 점이다. 새 문제(피드, 채팅, 알림, 결제 등)를 만나도 이 6단계로 들어가면 구조적으로 빠뜨림이 없다.


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 알림 (스트리밍 분석 별도)

가정: 신규 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 부담 없음)
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 + Base627 (Base62 변환)없음 (DB가 unique 보장)△ (단일 DB sequence 병목)✗ (순차 노출)소규모, 보안 요구 낮을 때
Snowflake + Base6211 (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와 자동 생성 코드 사이의 충돌 검증 범위가 줄어든다.

┌───── 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로 발행.
임계다음 단계
QPS > 100,000PG read replica 추가 → 90% read 트래픽을 replica로
단일 PG row 수 > 10Bshort_code 첫 문자로 sharding (a-z, A-Z, 0-9 → 62 shard)
Redis working set > 1TBRedis Cluster + consistent hashing
글로벌 latency > 100msmulti-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)으로 안전 보장
  • 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 집계.

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 Window1분 슬롯에 카운터. 슬롯 경계에서 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 BucketFIFO 큐. fixed rate로 drain△ (소진하면 buffer)큐 크기중간downstream 보호 (outbound throttle)

현실적 디폴트:

  • API 호출 제한 일반: Sliding Window Counter (token bucket과 거의 동등, 메모리 적음)
  • burst 허용 명시적 : Token Bucket (예: “분당 60, 단기 100 burst 허용”)
  • outbound throttling (자기 서비스가 외부 API 호출 시): Leaky Bucket

핵심 도전: 여러 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 시간만큼 refill
local elapsed = tonumber(ARGV[3]) - last_refill
local 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가 생긴다.

  • 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 LOADEVALSHA를 재시도한다. 이 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 차단.

글로벌 API의 경우 한 사용자가 region A·B 둘 다 호출 가능. region-local 카운터로는 합산 한도 초과 가능.

현실적 타협: 한도를 region-local로 두고 글로벌 한도 는 별도 비동기 집계 (Kafka → ClickHouse) → 사후 차단. 또는 Aurora Global Database / Spanner 같은 strongly consistent 글로벌 카운터 (비용 큼).


6. 두 케이스 공통 패턴 + 차이점

섹션 제목: “6. 두 케이스 공통 패턴 + 차이점”
차원URL ShortenerRate Limiter
read/write 비율read 압도 (100:1)read=write (모든 요청이 둘 다)
데이터 영속성영구 (DB primary)휘발 (Redis만)
분산 ID 필요✓ (Snowflake)✗ (counter만)
원자성 도구DB transactionRedis Lua script
캐시 역할hot URL fast lookup (Redis = 부속물)primary storage (Redis 없으면 fail)
HTTP status301/302 (redirect)429 (Too Many Requests)
fail modeRedis 죽으면 DB로 fallbackRedis 죽으면 fail-open or fail-closed 결정
확장 임계 패턴row 수 > 10B → shardinghot key → 키 partition

두 케이스 모두 §3 6단계 프레임워크의 같은 순서로 풀린다. 새 케이스를 만나도 이 순서를 그대로 적용한다.


  • 새 시스템 디자인 문제를 만났을 때 §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.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. 차이를 알고 들어가야 한다.


  • 다른 시스템 디자인 케이스: 트위터 피드, 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

  • 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 LOADEVALSHA → 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 프레임워크로 풀어 본인 학습 도구 정립

  1. 시스템 디자인은 문제 분해 절차 가 본질: §3 6단계(요구사항 → 용량 → API/모델 → 아키텍처 → 확장 → 운영 함정)를 순서대로 적용 하는 습관. 정답이 아니라 트레이드오프.
  2. URL Shortener의 핵심 결정은 단축 코드 생성: Snowflake + Base62가 default(분산 친화 + 충돌 없음), public code는 HMAC 등으로 난독화해야 열거 공격을 막는다. hash-truncate는 충돌 부담, auto-increment는 보안 부담.
  3. URL Shortener의 핵심 트레이드오프는 read:write 100:1: Redis가 부속 역할, PG가 primary. hot URL이 hot key가 되는 함정을 CDN+클라이언트 캐시로 분산.
  4. Rate Limiter의 핵심 결정은 알고리즘: Sliding Window Counter가 API 디폴트, Token Bucket은 burst 허용 시. Redis Lua script로 분산 원자성. fail-open vs fail-closed 의식적 결정.
  5. 두 케이스의 차이가 패턴 을 보여준다: 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).