콘텐츠로 이동

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

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

시스템 디자인은 컴포넌트 이름을 맞히는 문제가 아니라, 요구사항 → 불변식 → 부하 모델 → 병목 → 트레이드오프 → 검증 순서로 제약을 좁혀 가는 사고법이다. 케이스 스터디는 URL Shortener, Rate Limiter, 피드, 알림처럼 서로 다른 문제에 이 순서를 반복 적용해 왜 이 구조여야 하는지 설명하는 훈련이다.

시스템 디자인은 정답 이 있는 문제가 아니라 제약과 트레이드오프 의 문제다. “Redis로 캐시하면 되지 않나요?”는 답이 아니다. 캐시 미스율, hot key(트래픽이 특정 키 하나에 집중되는 현상), 데이터 영속성, 비용 제약 안에서 왜 Redis인지 를 정량으로 정당화해야 한다. Base62는 숫자·영문 대소문자 62개를 자릿값으로 사용해 정수를 짧은 문자열로 표현하는 인코딩이다. URL Shortener·Rate Limiter처럼 작아 보이는 문제도 Base62 vs Snowflake, token bucket vs sliding window 결정에 규모, 처리량, 일관성, 비용이 다르게 얽힌다. 케이스 스터디의 목적은 답안을 외우는 것이 아니라, 새 문제에서도 같은 질문 순서로 설계를 도출하는 것이다.

선수지식: L8 transaction-basics.mdx, L8 db-index-query-optimization.mdx, L8 redis-internals.mdx, api-design-contract.mdx, distributed-systems-basics.mdx. 조건부 참고: DB와 캐시의 변경 전파를 실습할 때는 L8 cdc-outbox.mdx, 서비스 경계를 별도로 검토할 때는 msa-patterns.mdx를 본다. 인접 케이스: web-crawler-system-design.mdx.

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 같은 대안이 나을 수 있다고 경고하는 이유도 같다: “정답 컴포넌트”보다 부하 조건과 실패 모드가 먼저다.

QPS(Queries Per Second) 는 시스템이 초당 처리하는 요청 수이고, p99는 전체 측정값의 99%가 그 값 이하에 놓이는 tail percentile이다. 그래서 이 토픽은 특정 제품 레시피가 아니라 6단계 추론 프레임으로 전개한다. 요구사항과 절대 깨지면 안 되는 조건을 먼저 고정하고, 20,000 redirect QPS·6B URL·p99 < 5ms 같은 숫자를 센 뒤, 가장 먼저 포화할 자원을 찾는다. URL Shortener의 Snowflake는 중앙 sequence 병목을 없애는 메커니즘이고, Rate Limiter의 Redis Lua는 여러 gateway가 같은 키를 갱신할 때 원자성을 확보하는 메커니즘이다. 마지막에는 각 결정마다 “어떤 지표가 보이면 가정이 틀렸는가”를 붙인다.

3. 시스템 디자인은 제약을 좁히는 과정이다

섹션 제목: “3. 시스템 디자인은 제약을 좁히는 과정이다”

3.1 먼저 같은 언어로 요구를 고정한다

섹션 제목: “3.1 먼저 같은 언어로 요구를 고정한다”

기능 요구사항(Functional Requirement) 은 사용자가 시스템으로 무엇을 할 수 있어야 하는지 정의한다. URL 생성·redirect, 사용자별 요청 제한처럼 API가 제공할 행동이다. 비기능 요구사항(Non-Functional Requirement) 은 그 행동이 얼마나 빠르고, 오래 보존되며, 장애 중에도 어느 정도 동작해야 하는지 정의한다. “redirect한다”는 기능 요구이고, “p99 100ms 이내, 월 가용성 99.99%“는 비기능 요구다.

비기능 요구를 말로만 두면 검증할 수 없다. SLI(Service Level Indicator, 서비스 수준 지표) 는 성공률·지연 시간처럼 실제로 측정하는 값이고, SLO(Service Level Objective, 서비스 수준 목표) 는 그 지표가 만족해야 할 목표다. 예를 들어 SLI = 성공한 redirect 비율, SLO = 30일 동안 99.99% 이상이면 오류 예산은 시간부터 계산하지 않는다. 30일 동안 SLI 분모에 포함된 redirect가 N건일 때 실패 요청 예산은 N × (1 - 0.9999) = N × 0.0001건이다. 월 4.32분가용 시간 비율을 SLI로 택했을 때의 시간 예산이거나, 트래픽이 균일하고 그 시간 동안 모든 요청이 실패하는 완전 장애로 환산한 별도 예시일 뿐이다. 이 구분은 “부분 실패가 오래 지속될 때 요청 오류 예산을 얼마나 쓰는가”를 정확히 계산하게 한다.

요구사항 다음에는 불변식(invariant) 을 적는다. 불변식은 정상 경로뿐 아니라 동시성·재시도·장애 중에도 반드시 참이어야 하는 조건이다. URL Shortener에서는 하나의 short_code는 최대 하나의 long_url만 가리킨다, Rate Limiter에서는 허용된 요청 수가 정책이 정한 오차 범위를 넘지 않는다가 불변식이다. 모든 데이터가 즉시 같아야 한다는 뜻은 아니다. 일관성(consistency) 은 여러 복제본이나 요청이 어떤 값을 볼 수 있는지에 관한 약속이고, 내구성(durability) 은 성공으로 응답한 데이터가 장애 뒤에도 남는다는 약속이며, 가용성(availability) 은 요청 시점에 성공 응답을 받을 수 있는 정도다. 세 약속의 우선순위가 다르면 저장소와 장애 모드도 달라진다.

3.2 평균이 아니라 부하의 모양을 모델링한다

섹션 제목: “3.2 평균이 아니라 부하의 모양을 모델링한다”

워크로드 형태(workload shape) 는 단순 평균 QPS가 아니라 read:write 비율, peak 배수, payload 크기, 키 분포, 시간대별 burst, 데이터 보존 기간을 합친 부하의 모양이다. 평균 40 writes/s인 URL 생성도 peak가 5배면 200 writes/s로 설계해야 하고, redirect가 100:1이면 peak 20,000 reads/s가 된다. 단, 이 계산은 read:write 비율이 peak 구간에도 유지된다는 가정이다. 클릭이 캠페인 시간에 몰리면 redirect peak는 생성 peak와 독립적으로 더 커질 수 있다.

항상 단위를 식에 남긴다.

requests/month ÷ seconds/month = requests/s
rows × bytes/row = bytes
requests/s × bytes/response = bytes/s
latency budget = network + queue + application + cache/DB + serialization

단위를 지우면 MBMb, 월평균과 초당 peak, raw storage와 replica 포함 storage를 섞기 쉽다. 저장 공간은 원본 row만이 아니라 인덱스·복제본·여유 공간을 따로 적고, 처리량은 QPS와 bytes/s를 함께 적으며, 지연 시간은 각 hop에 예산을 배분한다.

3.3 병목을 찾고 트레이드오프를 검증한다

섹션 제목: “3.3 병목을 찾고 트레이드오프를 검증한다”

병목은 다이어그램에서 가장 커 보이는 박스가 아니라 부하가 늘 때 먼저 예산을 소진하는 자원이다. URL Shortener는 전체 read QPS보다 한 URL에 30,000 QPS가 몰리는 hot key가 먼저 Redis shard를 포화시킬 수 있다. 피드는 한 사용자의 게시물을 수백만 follower에게 쓰는 fan-out(한 입력이 여러 대상 작업으로 펼쳐지는 것) 이 write amplification을 만든다. 알림은 공급자가 느려질 때 큐 적체가 무한히 커지지 않도록 backpressure(하류 처리 속도에 맞춰 상류 유입을 늦추거나 거부하는 제어) 가 필요하다.

설계 선택은 장점만이 아니라 지불하는 비용과 함께 기록한다. 캐시는 지연을 줄이지만 stale data와 invalidation 비용을 만든다. 복제는 read 처리량과 가용성을 높이지만 replication lag를 만든다. 동기식 처리는 즉시 일관성을 높이지만 tail latency와 결합도를 높인다. 마지막으로 SLI와 부하 테스트로 가정을 반증한다. 평균 latency만 좋아도 p99가 목표를 넘거나, 총 QPS는 여유가 있어도 단일 shard CPU가 100%라면 설계는 실패한 것이다.

3.4 시스템 디자인 추론 프레임 비교

**1**

질문: **요구사항과 SLO를 좁힌다** — 기능, 지연, 가용성, 보존 기간, 제외 범위

산출물: "무엇을 만들고 어느 수준까지 보장하는가" 목록

**2**

질문: **불변식과 부하 모델을 세운다** — 정확성 경계, 평균/peak QPS, 키 분포, 저장 기간

산출물: 불변식, workload shape, 단위가 남은 산식

**3**

질문: **API와 데이터 모델을 그린다** — 무엇을 입력받고 무엇을 저장하나

산출물: API 계약, 키·인덱스·상태 전이, 일관성/내구성 경계

**4**

질문: **병목부터 고수준 아키텍처를 그린다** — read/write path, 캐시, 큐, DB

산출물: 데이터 흐름 + 첫 포화 지점 + 장애 전파 경로

**5**

질문: **트레이드오프와 확장 전략을 결정한다** — 어디서 복제·샤딩·비동기화할 것인가

산출물: 선택 이유, 포기한 대안, 정량 전환 트리거

**6**

질문: **가정을 검증한다** — SLI, 부하 테스트, 장애 주입, capacity 재산정

산출물: latency/throughput/storage budget과 실패 신호

이 순서는 컴포넌트를 강제하지 않는다. 같은 “대규모 읽기”라도 URL redirect는 캐시 가능한 point lookup이고, 피드는 사용자별 정렬·권한·fan-out이 섞인 결과다. 같은 6단계를 적용하되 요구와 불변식이 다르면 설계가 달라져야 정상이다.


기능 요구사항(Functional Requirement):

  • 긴 URL을 짧은 URL로 변환 (https://...https://sho.rt/AbC123Z)
  • 짧은 URL 입력 시 원본으로 redirect (HTTP 301 또는 302)
  • 만료 일자 설정 가능 (선택)
  • 사용자 지정 alias 가능 (선택, sho.rt/my-promo)
  • click 분석 (선택, 후순위)

비기능 요구사항(Non-Functional Requirement):

  • 99.99% 가용성 (redirect는 web의 critical path가 되기 쉬움)
  • 100ms 이내 redirect latency (체감 지연 없게)
  • 단축 URL은 예측 불가능 해야 함 (보안: 순차 ID 노출 시 brute force로 다른 사람 URL 발견)
  • 5년 영구 보관 (만료 없는 기본 plan)

이를 검증 가능한 계약으로 바꾸면 redirect 성공률redirect p99 latency가 SLI이고, 30일 성공률 99.99%, 정상 트래픽에서 p99 100ms 이하가 SLO다. 여기서 p99는 요청 100개 중 느린 쪽 1개가 시작되는 경계다. 평균 20ms라도 1%가 2초라면 redirect 체감과 연쇄 호출 timeout은 이미 나쁘다.

불변식:

  • 같은 short_code는 동시에 두 long_url을 가리키지 않는다.
  • 생성 API가 성공을 반환한 영구 URL은 DB 장애 복구 뒤에도 남는다. 즉 생성 경로에는 내구성이 필요하다.
  • 만료·quarantine 정책이 활성화된 URL은 허용된 전파 지연 뒤에는 redirect되지 않는다.
  • click 분석 지연은 허용해도 redirect 성공 여부에는 영향을 주지 않는다.

제외:

  • 콘텐츠 검열 (별도 시스템 위임)
  • 로그인·사용자 관리 (별도)
  • 실시간 click 알림 (스트리밍 분석 별도)

가정: 신규 URL 생성 100M/월 → 약 40 URL/s 평균. peak는 5배 → 200 URL/s. Read:Write 비율 100:1 (단축 URL은 한 번 만들면 여러 번 클릭) → redirect peak 20,000 QPS.

단위 검산은 다음과 같다.

100,000,000 URL/month
÷ (30 day/month × 86,400 s/day)
= 38.6 URL/s ≈ 40 URL/s average
40 URL/s × 5 = 200 URL/s peak writes
200 URL/s × 100 redirects/write = 20,000 redirect/s peak reads

마지막 줄은 생성과 클릭의 peak가 같은 시간대에 오고 100:1 비율이 peak에도 유지된다는 단순화다. 실제 workload shape가 캠페인·뉴스에 좌우되면 평균 × 고정 배수 대신 분 단위 실측 percentile과 단일 URL 최대 QPS를 별도 입력으로 둔다. 평균 QPS로 shard 수를 정하고 hot key를 누락하는 것이 대표적인 반례다.

저장 공간:

  • 5년 누적 URL 수: 100M × 12 × 5 = 6B URL
  • row 당 평균 500 bytes (long_url 평균 200B + short_code 7B + metadata 등) → 3 TB raw
6,000,000,000 row × 500 byte/row
= 3,000,000,000,000 byte
≈ 3 TB (decimal) ≈ 2.73 TiB (binary)

3TB는 테이블 원본만 센 값이다. 인덱스·MVCC(Multi-Version Concurrency Control, 다중 버전 동시성 제어) 여유 공간·백업을 제외하고도 3개 복제본이면 약 9TB raw replica storage다. 실제 capacity plan에는 raw 3TB × replication factor 3 × index/overhead factor처럼 논리 데이터와 물리 저장 예산을 분리한다.

Base62 7-char 단축 코드의 공간: 62^7 = 약 3.5 trillion. 6B URL은 **0.17%**를 사용한다.

여기서 점유율이 낮으니 충돌이 거의 없다는 결론은 틀릴 수 있다. 6B 코드를 3.5T 공간에서 독립적으로 무작위 추출하면 birthday effect로 예상 충돌 쌍은 대략 N(N-1)/(2M) ≈ 5.1M이다. 0.17% 점유율은 retry 후 공간이 충분하다는 뜻이지, unique check가 필요 없다는 뜻이 아니다. HMAC(Hash-based Message Authentication Code) 은 secret key와 메시지로 고정 길이 인증값을 만드는 keyed hash다. 입력만으로 누구나 계산할 수 있는 일반 hash와 달리 secret 없이는 같은 값을 만들기 어렵다. allocator의 fenced epoch 인코딩 조건을 충족한 Snowflake와 HMAC 기반 확률적 코드를 구분해야 한다.

캐시:

  • 80/20 법칙 적용 → 20% URL이 redirect 80% 차지
  • hot working set = 6B × 20% × 500B = 600 GB. 이건 Redis 단일 인스턴스로 무리, 샤딩된 Redis 클러스터 (10~20 노드).
  • 또는 hot tier에 LRU로 상위 N% 만 유지하여 working set 축소

이 600GB도 “모든 상위 20% row를 값 전체 500B로 캐시한다”는 상한 가정이다. Redis 객체 overhead, replication factor, allocator fragmentation을 더하면 물리 메모리는 더 크다. 반대로 실제 value가 short_code → long_url 250B이고 상위 1%만 LRU(Least Recently Used, 가장 오래 쓰지 않은 항목부터 제거)로 유지해도 hit rate가 충분하다면 필요한 메모리는 크게 줄어든다. 그래서 cache node 수는 80/20 법칙이 아니라 실측 hit-rate curve와 노드당 usable memory로 확정한다.

대역폭:

  • redirect 응답 평균 500B → 20,000 QPS × 500B = 10 MB/s (CDN/ALB 부담 없음)

20,000 response/s × 500 byte/response = 10,000,000 byte/s = 10 MB/s ≈ 80 Mb/s다. bytes와 bits를 바꿀 때 8배를 빠뜨리지 않는다. 이 평균 payload 기준 대역폭은 작지만 TLS handshake와 connection churn은 별도 CPU 비용이므로, QPS가 같다는 이유만으로 네트워크 비용이 같다고 보지 않는다.

초기 예산표:

예산목표/가정검증할 관측값
Storage5년 raw 3TB, 복제 3개만 반영하면 raw 9TB월별 row 증가량, 실제 row/index 크기, backup 배수
Throughput200 peak writes/s, 20,000 peak redirects/s분 단위 peak, 단일 key QPS, cache miss 시 DB QPS
Latencyredirect p99 < 100msCDN, LB, app, Redis/PG 각 hop의 p95/p99와 queueing

예를 들어 100ms를 edge/network 35ms + LB/app 15ms + cache 5ms + DB fallback 20ms + serialization 5ms + 여유 20ms로 나눌 수 있다. 합은 100ms다. Redis가 p99 5ms를 넘거나 miss 경로 DB가 20ms를 넘으면 전체 p99가 깨지기 전에 해당 구성요소 예산에서 먼저 경고할 수 있다. 이 숫자는 측정 전 가설이며, 운영 SLI로 보정해야 한다.

POST /v1/urls
Header: Idempotency-Key: <logical-create-request-id>
Body: {"longUrl": "https://...", "expiresAt": "..."(optional), "alias": "..."(optional)}
Response: 201 {"shortCode": "AbC123Z", "shortUrl": "https://sho.rt/AbC123Z"}
GET /{shortCode}
Response: 302 Found / Location: <longUrl>

shortCode와 custom alias는 모두 하나의 URL path segment다. API는 percent-decoding 뒤 전체 값이 [A-Za-z0-9_-]{1,64}와 일치할 때만 받으며 /, percent-encoded slash, 공백, 점(.)을 거부한다. ASCII만 허용하므로 Unicode normalization은 적용하지 않고 non-ASCII 입력을 400으로 거부한다. 코드는 case-sensitive라서 AbCabc는 서로 다른 값이며, generated Base62는 이 계약의 부분집합인 [A-Za-z0-9]{1,11}을 사용한다.

데이터 모델 (PostgreSQL 기준):

CREATE TABLE urls (
short_code VARCHAR(64) COLLATE "C" PRIMARY KEY,
code_kind TEXT NOT NULL CHECK (code_kind IN ('generated', 'alias')),
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 (별도 비동기 집계 권장)
CONSTRAINT urls_code_format CHECK (
short_code ~ '^[A-Za-z0-9_-]{1,64}$'
AND (
code_kind = 'alias'
OR short_code ~ '^[A-Za-z0-9]{1,11}$'
)
)
);
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;
CREATE TABLE url_create_idempotency (
requester_key TEXT NOT NULL,
idempotency_key TEXT NOT NULL,
request_fingerprint BYTEA NOT NULL,
short_code VARCHAR(64) NOT NULL REFERENCES urls (short_code),
response_body JSONB NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
PRIMARY KEY (requester_key, idempotency_key)
);

short_code가 PK인 점이 핵심이다. COLLATE "C"와 CHECK가 API의 ASCII·case-sensitive 계약을 DB에서도 반복 검증한다. GET /{shortCode}는 PK lookup 한 번으로 끝나고, 최대 11자인 generated Base62와 최대 64자인 custom alias는 PK가 만드는 같은 전역 unique index에서 충돌한다. 생성 API는 URL row와 url_create_idempotency row를 한 transaction에 저장한다. 같은 requester와 Idempotency-Key가 같은 request fingerprint로 재시도되면 TTL 동안 저장된 201 body를 그대로 반환하고, fingerprint가 다르면 409로 거부한다. 키가 없는 호출은 새 생성 요청으로 센다는 계약을 명시해, 네트워크 재시도를 같은 논리 요청으로 취급하려는 client가 반드시 키를 보내게 한다.

4.4 단축 코드 생성 — 핵심 의사결정

섹션 제목: “4.4 단축 코드 생성 — 핵심 의사결정”

네 가지 접근. 트레이드오프 다름.

방식짧은 코드 길이충돌 처리분산 친화보안(예측 불가능)결정 트리거
일반 hash + truncate (MD5/SHA)7 (truncate)unique 검증 + nonce 변경 retry✗ (중앙 검증 필요)✗ (입력을 알면 후보 계산 가능)무결성용 hash를 공개 코드 난독화로 오해하지 않을 때
HMAC-SHA256 + 균일 매핑고정 7unique 검증 + salt 변경 retry△ (secret 배포 필요)✓ (secret 보호 조건)고정 길이와 열거 저항성이 필요하고 충돌 retry를 감수할 때
Auto-increment + Base627 (Base62 변환)없음 (DB가 unique 보장)△ (단일 DB sequence 병목)✗ (순차 노출)소규모, 보안 요구 낮을 때
Snowflake + Base62최대 11fenced epoch 인코딩 + DB unique retry✓ (worker 노드 독립 생성)△ (raw ID는 시간·worker 추론 가능)권장 default. 공개 코드 열거 저항성이 필요하면 HMAC/permutation 추가

왜 Snowflake가 default: timestamp + node + 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). 여기서는 uniqueness를 allocator 계약으로 강제한다. worker allocator는 새 lease를 획득할 때마다 해당 slot의 persistent high-water mark를 transaction으로 증가시켜 (worker_slot, generator_epoch)를 발급하고, 이 generator_epoch를 lease의 fencing token으로 사용한다. renew·release 같은 allocator 상태 변경은 token 일치 조건으로 old owner를 거부하며, generator는 token을 node bits 일부에 그대로 인코딩한다. lease 갱신은 같은 epoch를 유지하지만 소유권이 바뀐 새 lease는 새 epoch를 소비한다. 따라서 만료된 process가 old epoch로 계속 실행되어도 new process의 ID 공간과 겹치지 않는다. 노드별로 중앙 sequence DB 없이 생성할 수 있지만 다음 운영 조건이 모두 필요하다.

  • allocator는 같은 (worker_slot, generator_epoch)를 해당 ID 형식의 timestamp horizon 안에 다시 할당하지 않는다. epoch가 소진되면 pair를 재사용하지 말고 bit layout/version을 바꾼다.
  • process restart 뒤에는 마지막 timestamp/sequence를 durable storage에서 복원한다. 복원할 수 없으면 allocator에서 이전 process와 겹치지 않는 새 generator epoch를 발급받기 전까지 ID를 만들지 않는다.
  • 현재 clock이 마지막 발급 timestamp보다 뒤로 가면 rollback 폭과 무관하게 발급을 거부한다.
  • 같은 millisecond의 sequence를 소진하면 다음 millisecond까지 기다리거나 backpressure를 건다.

구체적인 URL 생성용 profile을 [timestamp 39bit][worker_slot 6bit][generator_epoch 13bit][sequence 5bit]로 잡으면 총 63bit이며, 약 17.4년의 수명, 동시 worker 64개, slot당 8,192번의 새 lease, worker당 ms당 32개(초당 32,000개)를 얻는다. timestamp bits를 늘리면 수명은 늘지만 worker·epoch·sequence 예산이 줄고, worker bits를 늘리면 동시 generator 수는 늘지만 재시작에 쓸 epoch 수가 줄며, sequence bits를 늘리면 순간 처리량은 늘지만 나머지 셋 중 하나를 희생한다. 이 예시는 peak 200 writes/s인 현재 workload에는 충분하지만, lease churn이 17.4년 동안 slot당 8,192회를 넘거나 worker가 64개를 넘는 환경에는 맞지 않는다. 이 조건 중 하나라도 깨지면 generator-level uniqueness를 주장할 수 없다. 또한 DB의 short_code unique constraint는 allocator 버그, bit-layout 배포 불일치, alias와의 충돌까지 막는 최종 충돌 방어선이며, insert 충돌 시 새 ID를 발급해 제한된 횟수만 retry한다.

단, raw Snowflake를 그대로 short_code로 쓰면 “예측 불가능”하지 않다. 시각과 worker 범위를 아는 공격자는 근접 ID를 추측할 수 있다. 7자 단축이 꼭 필요하고 열거 방지가 보안 요구라면 HMAC digest의 Base62 prefix를 단순히 잘라 쓰지 않는다. M = 62^7, limit = floor(2^256 / M) × M으로 두고 HMAC-SHA256 결과를 256-bit 정수 x로 해석한다. x >= limit이면 domain-separated attempt counter를 넣어 HMAC을 다시 계산하고, x < limit인 첫 값을 x mod M으로 매핑한다. 이 rejection sampling은 [0, M)의 각 정수를 같은 확률로 만들며, 결과를 Base62로 인코딩한 뒤 선행 0을 채워 정확히 7자로 만든다. DB unique constraint 충돌 시 salt를 바꿔 제한된 횟수만 retry한다. 6B URL이 62^7 = 약 3.5T 공간의 0.17%를 차지해 retry할 빈 공간은 충분하지만, 누적 생성에서 충돌 자체는 무시할 수 없다. 이 경우에는 “구조적으로 0”이 아니므로 unique constraint와 retry가 불변식을 지키는 메커니즘이다.

// URL 생성용 64-bit profile:
// [sign 1bit][timestamp 39bit][worker_slot 6bit][generator_epoch 13bit][sequence 5bit]
//
// timestamp: 39bit → 약 17.4년 (epoch 기준)
// worker_slot: 6bit → 동시 worker 64개
// generator_epoch: 13bit → slot당 새 lease 8192회, horizon 내 재사용 금지
// sequence: 5bit → worker당 ms당 32 ID
//
// 조건: allocator가 lease마다 epoch/fencing token을 발급하고 epoch를 인코딩하며,
// rollback 때 발급을 거부하고 sequence 소진 시 다음 ms까지 기다린다.
// DB short_code unique constraint 충돌 시 새 ID로 제한된 횟수만 retry한다.
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와 generated code 네임스페이스: 이 API는 둘 다 GET /{shortCode}로 조회하므로 길이로 암묵적으로 나누지 않고 하나의 전역 short_code 네임스페이스를 사용한다. PK가 최종 소유권을 결정하며 first writer wins다. alias 생성이 이미 점유된 코드와 충돌하면 409를 반환하고, generated code가 기존 alias와 충돌하면 새 Snowflake ID 또는 HMAC salt로 제한된 횟수만 retry한다. code_kind는 출처와 운영 정책을 구분할 뿐 충돌 우회용 별도 공간이 아니다. 별도 네임스페이스가 필요하다면 /a/{alias}/s/{generated}처럼 URL 자체에서 명시적으로 분리해야 한다.

요구가 달라지면 default도 달라진다. 사내 도구로 하루 1만 건만 만들고 코드 열거가 보안 문제가 아니라면 auto-increment + Base62가 가장 단순하다. 반대로 광고 문구 때문에 정확히 7자가 필요하고 공개 코드 추측을 막아야 한다면 Snowflake 11자를 그대로 쓰는 설계는 요구 위반이다. HMAC 균일 매핑 + unique retry의 복잡도를 지불해야 한다. “Snowflake가 항상 정답”이 아니라 분산 생성·길이·예측 불가능성 중 무엇을 우선하는지가 선택 기준이다.

┌───── 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 수정 직후다. origin의 no-store deny 응답은 요청이 origin까지 도달한 뒤 그 응답을 저장하지 말라는 뜻일 뿐, edge에 이미 fresh한 302를 덮거나 제거하지 못한다. 보안 차단 SLO를 지키려면 cache lookup보다 먼저 실행되는 edge denylist를 배포하거나 해당 cache key를 purge/invalidate해야 한다. 유료 alias 수정 기능은 purge를 제공하지 않는다면 “최대 5분 전파 지연”을 API 계약에 명시한다.
  • Redis에서 hot URL을 24h TTL. miss 시 PG에서 채워 넣음.
  • PG 자체는 PK lookup 한 번이지만 p99 1ms를 보장값으로 가정하지 않는다. connection pool 대기, storage cache hit, replica 위치를 포함해 부하 테스트로 검증할 DB hop latency budget 후보로 둔다.
  • click 카운팅은 비동기 — redirect critical path에 영향 없게 Kafka로 발행.

Kafka 발행도 무한히 밀어 넣을 수는 없다. consumer 처리량보다 click event 생산량이 크면 queue lag와 storage가 계속 증가한다. redirect 불변식을 우선한다면 bounded local buffer, sampling, event drop 같은 backpressure 정책으로 분석 정확도를 일부 포기하고 redirect를 보호할 수 있다. 반대로 과금 정산처럼 click 누락이 허용되지 않으면 outbox와 충분한 queue capacity를 두고, queue가 예산을 넘을 때 생성 경로를 늦추거나 별도 degraded mode로 전환해야 한다. 같은 “비동기 분석”도 내구성 요구가 달라지면 장애 모드가 달라진다.

요구 변화에 따른 반례:

요구 변화기존 설계에서 깨지는 가정바뀌는 선택
alias 수정이 즉시 반영되어야 함CDN max-age=300의 stale 5분을 허용할 수 없음versioned URL, purge API, 짧은 TTL 또는 no-cache
삭제된 URL은 전 세계에서 1초 안에 차단eventual cache invalidation으로는 SLO를 못 지킴denylist를 edge에 push하고 redirect cache보다 먼저 검사
click 하나도 잃으면 안 되는 과금 시스템fire-and-forget Kafka event drop을 허용할 수 없음transactional outbox, idempotent consumer, backlog durability 예산
익명 단축만 제공하고 사용자 조회는 없음user_id 인덱스가 필요하지 않음인덱스 제거로 write/storage 비용 절약

Redis Cluster는 consistent hashing을 사용하지 않는다. key를 CRC16(key) mod 16384로 계산한 16,384개 hash slot 중 하나에 배정하고 slot을 primary shard에 할당한다. {...} hash tag가 있으면 중괄호 안의 값만 계산하므로 관련 key를 같은 slot에 둘 수 있다. 반면 consistent hashing(일관 해싱) 은 key와 node를 application-managed cache ring에 놓고 node 증감 때 일부 key만 이동시키는 별도 선택지다. replica 수와 virtual node 배치를 애플리케이션이나 client가 직접 설계해야 하며 Redis Cluster의 slot 규칙과 같은 메커니즘이 아니다.

임계다음 단계
QPS > 100,000PG read replica 추가 → 90% read 트래픽을 replica로
단일 PG row 수 > 10Bhash(short_code) 기반 sharding으로 key 분포를 균등화
Redis working set > 1TBRedis Cluster의 CRC16 기반 16,384 hash slot로 분산
글로벌 latency > 100msmulti-region 배포 + GeoDNS
analytics 부담 PG 80%+click 이벤트를 Kafka → ClickHouse로 완전 분리
hot URL 1개가 QPS 30,000+CDN edge에 hot URL을 별도 강제 캐시 (manual prewarming)

임계값은 한 번에 하나씩 적용한다. 예를 들어 전체 redirect가 20,000 QPS인데 hot URL 하나가 30,000 QPS라는 값은 서로 다른 시나리오다. 후자는 평균 기반 capacity를 넘어서는 바이럴 이벤트를 뜻한다. 이때 먼저 전체 DB를 shard하는 것은 병목과 맞지 않는다. CDN hit rate와 hot Redis shard CPU를 확인하고, key-local 부하를 edge와 replica로 분산하는 편이 직접적인 대응이다.

깨지는 조건 — 위 권고가 더 이상 안 통할 때:

  • 단축 코드의 예측 불가능성 이 보안 요구라면 raw Snowflake를 public code로 노출하지 말고, HMAC/format-preserving permutation으로 섞은 값을 노출한다. 길이를 11→12+ 로 늘리는 것은 brute force 공간만 키울 뿐 순차 추측 가능성을 없애지는 못한다.
  • 사용자 가 무엇을 단축했는지 조회해야 한다면 urls_user_idx 위에 추가 query 패턴 (페이지네이션 필요)
  • click 이벤트 발행이 Kafka 장애 로 멈추면 → outbox 패턴(L8 cdc-outbox.mdx)으로 안전 보장

4.7 실패 신호로 가정을 검증한다 (Step 6)

섹션 제목: “4.7 실패 신호로 가정을 검증한다 (Step 6)”
실패 신호깨진 가정설계 판단
바이럴 URL 30,000 QPS, 특정 Redis shard CPU 100%트래픽이 키에 고르게 분포한다클라이언트 short-TTL·CDN 캐시를 강화하고 동일 키 read를 replica로 분산한다. 전체 DB sharding보다 hot key가 먼저다.
redirect hop 수와 p99가 함께 증가long_url이 다시 같은 단축 도메인을 가리키지 않는다생성 시 같은 도메인을 거부해 redirect chain과 timeout을 막는다.
quarantine 뒤에도 최대 5분 redirect가 계속됨origin deny가 fresh cached 302를 덮을 수 있다cache lookup 전 edge denylist를 적용하거나 302 cache key를 purge한다. deny 자체에는 no-store를 붙인다.
expires_at < NOW() row와 batch 시간이 계속 증가만료 데이터 정리 비용이 일정하다daily partition + DROP PARTITION을 검토한다(L8 cdc-outbox.mdx §8.3 동일 패턴).
hot URL에서 row lock wait와 redirect latency가 증가동기 click_count UPDATE가 충분히 싸다raw event를 ClickHouse에 저장하고 click_count는 eventually consistent하게 집계한다.
악성 URL 신고율과 차단 전 노출 시간이 증가비동기 검사가 충분히 빠르다생성 직후 Google Safe Browsing/VirusTotal 검사, 의심 URL quarantine, 검사 lag SLI를 둔다.

검증 순서는 “문제가 생기면 명령어를 실행한다”가 아니다. 먼저 정상 peak의 1배·2배·5배에서 throughput과 p99를 측정하고, 한 키에 부하를 집중해 hot key를 재현하며, Redis를 차단해 DB fallback headroom을 확인한다. 그 결과가 위 storage/throughput/latency budget을 넘는 지점이 다음 확장 단계의 근거다.


기능 요구사항(Functional Requirement):

  • API gateway 앞단에서 client 별 / API 별 요청 제한
  • 한도 초과 시 HTTP 429 + Retry-After, version을 고정한 RateLimit/RateLimit-Policy 또는 자체 헤더 계약 반환
  • 한도는 동적으로 변경 가능 (config reload)

비기능 요구사항(Non-Functional Requirement):

  • p99 한 요청당 < 5ms 결정 시간 (critical path)
  • 분산 환경 일관성 (모든 노드가 같은 카운터 봄)
  • 가용성: rate limiter 자체가 장애 단일 지점 이 되면 안 됨 (fail-open vs fail-closed 결정)

여기서 결정 시간 p99정책상 허용량을 초과해 통과한 요청 비율이 SLI다. SLO를 p99 < 5ms, 정상 상태에서 초과 허용률 < 0.1%처럼 두면 latency와 정확성을 동시에 본다. “분산 환경 일관성”도 강도부터 정해야 한다. 결제·인증 공격 방어는 거의 정확한 global limit가 필요할 수 있지만, 일반 검색 API는 region별로 소량 초과를 허용해 가용성과 지연을 얻을 수 있다.

불변식과 허용 오차:

  • 하나의 정책 판단은 카운터 읽기와 차감을 원자적으로 처리한다.
  • 외부 요청은 재시도를 포함해 도착할 때마다 독립 admission attempt로 평가하며, 토큰이 있으면 매번 quota 1개를 차감한다. client supplied Idempotency-Key로 이전 allowed 판단을 재사용하지 않는다.
  • bucket scope는 tenant/client + API route + policy version이다. client가 같아도 route나 정책 버전이 다르면 상태를 공유하지 않는다.
  • fail-open을 택했다면 limiter 장애가 원래 API 장애로 전파되지 않는다.
  • fail-closed를 택했다면 보호 대상의 안전성을 위해 정상 요청 거부를 감수한다.

Rate Limiter의 admission counting과 upstream business operation의 idempotency는 다른 계약이다. limiter를 통과한 두 재시도는 quota를 각각 소비하지만, upstream 결제·URL 생성 서비스는 같은 Idempotency-Key로 실제 비즈니스 변경을 한 번만 수행할 수 있다. Rate Limiter의 상태는 URL mapping과 달리 짧은 시간이 지나면 가치가 사라진다. 그래서 Redis를 primary로 써도 되는 것은 “Redis는 언제나 내구적”이어서가 아니라 이 카운터의 durability 요구가 낮기 때문이다.

API peak가 100,000 requests/s라면 limiter도 최소 100,000 decisions/s를 처리한다. 요청 하나가 Redis Lua 1회 round-trip을 만든다면 Redis operation도 약 100,000 ops/s이고, multi-region 이중 검사를 하면 네트워크 hop과 operation 수가 배가된다. 사용자 10M명, 활성 key 2M개, key당 상태 100B라는 단순 가정이면 논리 상태는 2M × 100B = 200MB지만 Redis 객체 overhead와 replica를 포함한 물리 메모리는 더 크다.

5ms latency budget의 예시는 gateway logic 0.5ms + Redis network/queue 2ms + Lua 0.5ms + serialization 0.5ms + 여유 1.5ms다. 합은 5ms다. 평균 Redis RTT가 1ms여도 connection pool 대기와 hot key queueing 때문에 p99가 5ms를 넘을 수 있으므로, 평균 RTT만으로 통과 판정을 내리지 않는다.

workload shape에는 전체 QPS 외에 고유 key 수, key별 요청 분포, burst 크기, 정책 갱신 빈도가 포함된다. 100,000 사용자가 초당 1회씩 호출하는 부하와 한 API key가 초당 100,000회 호출하는 부하는 총 QPS가 같지만 후자는 단일 Redis shard와 단일 key 직렬화가 병목이다.

5.3 알고리즘 결정 — 핵심 의사결정 (Step 3~5)

섹션 제목: “5.3 알고리즘 결정 — 핵심 의사결정 (Step 3~5)”

다섯 가지 주요 알고리즘은 같은 “분당 60회” 정책도 burst 허용과 정확도 비용을 다르게 해석한다.

알고리즘특징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 원자성):

-- scope = tenant/client + stable API route ID + policy version
-- 각 구성요소를 개별 base64url 인코딩한 뒤 ':'로 결합한다.
-- KEYS[1] = "ratelimit:{dGVuYW50LTc:Y2xpZW50LTQy:cGF5bWVudHMuY3JlYXRl:djM}:bucket"
-- ARGV[1] = max_tokens (예: 100)
-- ARGV[2] = refill_rate_per_sec (예: 10)
-- 반환: { allowed (1/0), remaining_tokens, available_after_seconds }
local server_time = redis.call("TIME")
local redis_now_ms = tonumber(server_time[1]) * 1000 + math.floor(tonumber(server_time[2]) / 1000)
local bucket = redis.call("HMGET", KEYS[1], "tokens", "last_refill")
local tokens = tonumber(bucket[1]) or tonumber(ARGV[1])
local last_ms = tonumber(bucket[2]) or redis_now_ms
-- 저장 timestamp를 high-water mark로 유지해 시계 후퇴 구간의 중복 refill을 막는다.
local effective_now_ms = math.max(redis_now_ms, last_ms)
local elapsed_ms = effective_now_ms - last_ms
local refilled = math.min(
tonumber(ARGV[1]),
tokens + (elapsed_ms / 1000) * tonumber(ARGV[2])
)
if refilled < 1 then
redis.call("HMSET", KEYS[1], "tokens", refilled, "last_refill", effective_now_ms)
redis.call("EXPIRE", KEYS[1], 3600)
local available_after = math.ceil((1 - refilled) / tonumber(ARGV[2]))
return { 0, math.floor(refilled), available_after }
end
redis.call("HMSET", KEYS[1], "tokens", refilled - 1, "last_refill", effective_now_ms)
redis.call("EXPIRE", KEYS[1], 3600)
return { 1, math.floor(refilled - 1), 0 }

Lua script는 Redis event loop 안에서 원자적 으로 실행되므로 같은 bucket의 refill·판정·차감 사이에 다른 명령이 끼어들지 않는다. gateway가 전달한 시간이 아니라 Redis TIME의 초·마이크로초를 millisecond로 합쳐 모든 gateway가 같은 서버 시계를 사용한다. effective_now_ms = max(redis_now_ms, last_ms)는 저장된 timestamp를 high-water mark(지금까지 관측한 최대 시각) 로 유지한다. Redis 시계가 last_ms보다 뒤로 가면 elapsed_ms = effective_now_ms - last_ms = 0이고 저장 timestamp도 effective_now_ms, 즉 기존 last_ms로 남는다. 따라서 후퇴 구간에서는 refill이 멈추고, 실제 Redis TIME이 high-water mark를 넘어선 뒤의 경과분만 refill된다.

단순히 elapsed_ms = max(0, redis_now_ms - last_ms)만 적용하면서 저장 timestamp를 redis_now_ms로 쓰면 이 불변식이 깨진다. 예를 들어 저장 시각이 10,000ms일 때 Redis 시계가 8,000ms로 후퇴하면 첫 호출은 refill하지 않지만 timestamp를 8,000ms로 되감는다. 다음 호출의 Redis 시각이 9,000ms이면 이전 최대 시각 10,000ms에 아직 못 미쳤는데도 1,000ms를 다시 경과한 것으로 계산해 중복 refill한다. effective_now_ms를 계산과 저장에 함께 쓰면 두 호출 모두 elapsed가 0이다.

시계 변화 테스트 벡터:

상황저장 last_msRedis TIMEeffective_now_mselapsed_ms저장 timestamp기대 refill
정상 진행10,00010,50010,50050010,500500ms분
2초 후퇴10,0008,00010,000010,000없음
후퇴 구간에서 전진10,0009,00010,000010,000없음
high-water mark 도달10,00010,00010,000010,000없음
high-water mark 통과10,00010,50010,50050010,500통과한 500ms분만
큰 forward jump10,000100,000100,00090,000100,000계산 후 max_tokens에서 capacity clamp

긴 정지나 큰 forward jump 뒤에는 계산상 많은 토큰이 생겨도 min(max_tokens, ...)가 capacity에서 clamp하므로 bucket은 최대 burst 용량까지만 복구된다. 이 clamp는 monotonic timestamp 보정과 독립적으로 그대로 유지된다.

EVALSHA(Redis에 캐시된 스크립트를 SHA1 digest로 실행하는 명령) 로 script를 호출하면 일반 경로는 네트워크 1 round-trip이지만, 이는 한 Redis primary의 한 key에서 실행되는 원자성이다. 서로 다른 region의 독립 Redis가 즉시 같은 값을 본다는 global consistency까지 보장하지는 않는다. 호출마다 script가 실행되므로 retry도 새 토큰을 소비하며, client의 Idempotency-Key는 limiter key나 판단 cache에 포함하지 않는다.

scope 구성요소를 먼저 :로 합친 뒤 전체를 인코딩하면 tenant="a:b", client="c"tenant="a", client="b:c"가 같은 원문을 만들어 경계가 모호하다. 아래 caller는 tenantId, clientId, routePolicyId, policyVersion각각 base64url로 인코딩한 뒤 :로 결합한다. base64url alphabet에는 :가 없고 구성요소 수도 네 개로 고정되므로 delimiter가 데이터와 섞이지 않는다.

TypeScript caller:

import Redis from "ioredis";
const redis = new Redis();
const script = `...위 Lua 스크립트...`;
let rateLimitScriptSha: string | undefined;
async function loadRateLimitScript(): Promise<string> {
rateLimitScriptSha = String(await redis.script("LOAD", script));
return rateLimitScriptSha;
}
function isNoScript(error: unknown): boolean {
return error instanceof Error && error.message.includes("NOSCRIPT");
}
async function runRateLimitScript(
key: string,
maxTokens: number,
refillRatePerSecond: number,
): Promise<[number, number, number]> {
let sha = rateLimitScriptSha ?? (await loadRateLimitScript());
const args = [sha, 1, key, maxTokens, refillRatePerSecond] as const;
try {
return (await redis.evalsha(...args)) as [number, number, number];
} catch (error) {
if (!isNoScript(error)) throw error;
sha = await loadRateLimitScript();
return (await redis.evalsha(
sha,
1,
key,
maxTokens,
refillRatePerSecond,
)) as [number, number, number];
}
}
function encodeScopePart(value: string): string {
return Buffer.from(value, "utf8").toString("base64url");
}
async function checkRateLimit(
tenantId: string,
clientId: string,
routePolicyId: string,
policyVersion: string,
): Promise<{
allowed: boolean;
remaining: number;
availableAfterSeconds: number;
}> {
const scope = [tenantId, clientId, routePolicyId, policyVersion]
.map(encodeScopePart)
.join(":");
const [allowed, remaining, availableAfterSeconds] = await runRateLimitScript(
`ratelimit:{${scope}}:bucket`,
100, // max_tokens
10, // refill rate
);
return {
allowed: allowed === 1,
remaining,
availableAfterSeconds,
};
}
// NestJS interceptor
@Injectable()
export class RateLimitInterceptor implements NestInterceptor {
async intercept(
ctx: ExecutionContext,
next: CallHandler,
): Promise<Observable<any>> {
const req = ctx.switchToHttp().getRequest();
const tenantId = req.tenant.id;
const clientId = req.user?.id ?? req.ip;
const routePolicyId = "payments.create"; // query string 없는 stable route ID
const policyVersion = "v3";
const result = await checkRateLimit(
tenantId,
clientId,
routePolicyId,
policyVersion,
);
const res = ctx.switchToHttp().getResponse();
const advertisedEffectiveWindowSeconds = result.allowed
? 10
: result.availableAfterSeconds;
res.setHeader("RateLimit-Policy", '"default";q=100;w=10');
res.setHeader(
"RateLimit",
`"default";r=${result.remaining};t=${advertisedEffectiveWindowSeconds}`,
);
if (!result.allowed) {
res.setHeader("Retry-After", result.availableAfterSeconds);
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 필드는 아직 표준 확정 전 Internet-Draft라 버전에 따라 wire format이 바뀐다. 위 예시는 2026년 5월의 draft-ietf-httpapi-ratelimit-headers-11에 맞춰 Structured Fields 기반 RateLimit-Policy: "default";q=100;w=10RateLimit: "default";r=...;t=...를 사용한다(출처: https://datatracker.ietf.org/doc/draft-ietf-httpapi-ratelimit-headers/11/). draft-05의 RateLimit-Limit·RateLimit-Remaining·RateLimit-Reset 세 필드와 섞으면 안 된다. 운영 API는 draft version을 계약에 고정하거나, RFC 확정 전에는 Acme-RateLimit-*처럼 명시적으로 자체 필드를 정의해야 한다. draft-11의 r은 available quota이고 t는 그 quota 이하만 사용해야 하는 effective window(초) 다. t는 quota가 완전히 reset되는 epoch timestamp가 아니다. 위 코드에서 허용 응답의 t=10은 현재 bucket 상태로 계산한 reset 시간이 아니라 capacity 100 ÷ refill rate 10/s를 정책 window로 삼아 보수적으로 광고한 effective window다. 거부 응답의 t는 Lua가 현재 토큰 부족분에서 실제 계산한 availableAfterSeconds이며, 같은 값을 Retry-After에도 사용한다. Retry-After가 함께 있으면 client는 이를 우선한다.

5.5 실패 신호로 가정을 검증한다 (Step 6)

섹션 제목: “5.5 실패 신호로 가정을 검증한다 (Step 6)”
실패 신호깨진 가정판단
Redis timeout과 함께 보호 API도 5xx 또는 429 폭증limiter 장애를 원래 API와 격리했다보안이 critical하면 fail-closed, 가용성이 critical하면 fail-open을 택한다. 일반적 default는 fail-open + 별도 알람이지만 명시해야 한다.
한 user key에서 shard CPU·command latency 급증shard를 늘리면 단일 hot key도 분산된다중앙 window budget을 겹치지 않는 token tranche로 region/node bucket에 선할당한다. Redis Cluster shard 추가만으로는 한 key가 쪼개지지 않는다.
동시 요청에서 정책 한도를 반복 초과read-modify-write가 원자적이다분리된 GET → 계산 → SET을 Redis Lua로 묶는다.
failover 직후 NOSCRIPT 재시도 실패와 500 증가script cache가 데이터처럼 유지된다EVALSHANOSCRIPT에만 SCRIPT LOAD 후 한 번 재시도한다. 그래도 실패하면 Redis 장애로 처리한다.
key 수가 DAU(Daily Active Users, 일간 활성 사용자 수)보다 빠르게 계속 증가비활성 key가 자동 만료된다TTL/EXPIRE 누락을 의심한다. key cardinality와 memory 증가율을 함께 본다.
429 직후 동일 client QPS가 더 증가client가 backoff를 지킨다Retry-After를 제공하고 jitter를 포함한 backoff를 요구한다. 무시하는 client는 IP·credential 차단을 검토한다.

Redis Cluster는 서로 다른 key를 여러 shard에 분산할 뿐, 하나의 hot key는 한 hash slot과 한 primary에서 직렬화된다. 정말 나누려면 중앙 controller가 각 1분 window의 budget 60을 겹치지 않는 token tranche로 region/node bucket에 미리 할당한다. 한 window에서 발급한 tranche 합은 항상 <= 60이어야 한다. 만료되거나 사용되지 않은 tranche도 같은 window에는 재발급하지 않아 중복 소유에 따른 over-admission을 막고, 수요 편향으로 quota가 남는 underutilization은 감수한다. 가용성을 위해 5% emergency allowance가 필요하면 기본 60을 재사용하는 것이 아니라 별도 초과 예산 3으로 계상해 base 60 + emergency 3 = 최대 63으로 상한을 고정한다. 이 구조는 hot key를 여러 key로 바꾸지만 controller와 window별 tranche ledger의 복잡도를 지불한다.

SCRIPT EXISTS나 key scan 명령은 원인을 좁히는 선택 도구일 뿐 본문 결론이 아니다. 먼저 decision p99, allowed/denied 비율, Redis timeout, key cardinality, hot-key concentration을 함께 본다. p99만 나쁘면 queueing 문제일 수 있고, 초과 허용률만 나쁘면 region-local counter나 원자성 경계를 의심한다.

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

현실적 타협: 한도를 region-local로 두고 글로벌 한도 는 별도 비동기 집계 (Kafka → ClickHouse) → 사후 차단한다. 정확한 global transaction이 필요하면 Spanner 같은 strongly consistent 저장소를 검토할 수 있다. Aurora Global Database는 기본적으로 같은 범주가 아니다. write는 한 primary region에서 처리되고 secondary에는 비동기로 복제된다. secondary에서 쓰려면 global write forwarding을 활성화해 primary로 보내야 하며, secondary read는 EVENTUAL·SESSION·GLOBAL 같은 consistency mode에 따라 stale 허용 또는 복제 대기를 선택한다(출처: https://docs.aws.amazon.com/AmazonRDS/latest/AuroraUserGuide/aurora-global-database-write-forwarding.html). 정확한 global counter에서 admission은 secondary read가 아니라 primary의 조건부 atomic update가 성공했는지로 결정해야 한다. 따라서 primary routing 또는 write forwarding과 cross-region write latency를 예산에 넣어야 한다.

요구가 “사용자당 분당 60회, region별 최대 5% 초과 허용”이라면 region-local counter가 지연과 가용성 면에서 합리적이다. 반대로 “쿠폰은 전 세계에서 정확히 1,000번만 발급”이라면 Rate Limiter의 근사 카운터로 재고 불변식을 대신하면 안 된다. strongly consistent reservation이나 단일 소유 partition이 필요하다. 트래픽 제어와 재고 정확성은 겉모양이 카운터여도 같은 문제가 아니다.


6. 같은 프레임이 다른 설계를 만드는 이유

섹션 제목: “6. 같은 프레임이 다른 설계를 만드는 이유”

요구가 “팔로우한 계정의 최신 게시물 20개를 p99 200ms 안에 보여준다”라면 먼저 불변식을 좁힌다. 모든 follower가 정확히 같은 순간에 같은 순서를 봐야 하는가, 삭제 게시물이 몇 초 늦게 사라져도 되는가, 광고·권한 필터가 read 시점에 적용되어야 하는가를 결정한다. 이 답이 없으면 Redis sorted set이나 Kafka를 먼저 고를 근거가 없다.

부하 모델을 게시 10,000 writes/s, 피드 조회 500,000 reads/s, 평균 follower 200명으로 두면 fan-out on write10,000 post/s × 200 follower = 2,000,000 timeline insert/s다. 읽기는 미리 계산된 timeline을 가져와 빠르지만 write amplification을 지불한다. 평균만 보면 가능해 보여도 follower 50M인 celebrity 한 명은 게시 1개가 50M insert를 만들어 hot key와 queue backlog를 동시에 만든다.

따라서 보통 계정은 fan-out on write, 거대 계정은 read 시점에 게시물을 합치는 hybrid를 택할 수 있다. 이것은 특정 제품 레시피가 아니라 fan-out 임계가 write throughput budget을 넘는가라는 선택 기준에서 나온다. 피드 freshness lag, timeline queue lag, celebrity post의 fan-out 완료 시간, 조회 p99가 검증 SLI다. queue lag가 계속 증가하면 consumer를 무작정 늘리기 전에 partition key와 celebrity 분기 기준이 맞는지 확인한다.

반례: “read-heavy니까 URL Shortener처럼 결과를 CDN에 오래 캐시하자”는 접근은 사용자별 권한·팔로우 관계·삭제 반영 때문에 그대로 적용되지 않는다. URL redirect는 동일 key가 대체로 같은 값을 반환하지만, 피드는 사용자와 시간에 따라 결과가 바뀌고 한 게시물이 여러 timeline으로 펼쳐진다.

요구가 “이벤트를 push·email로 보내고 사용자 설정과 quiet hours를 지킨다”라면 성공의 뜻부터 정해야 한다. provider가 요청을 받으면 성공인지, 단말에 도착해야 성공인지, 같은 이벤트의 중복 알림을 허용하는지가 불변식과 API 상태를 바꾼다. provider delivery receipt가 없다면 end-to-end exactly-once를 약속할 수 없고, 보통은 idempotency key로 중복을 줄이는 at-least-once 전달을 택한다.

평균 5,000 notification/s, 캠페인 peak 20배면 100,000/s다. provider가 30,000/s만 받는 동안 생산 속도를 유지하면 backlog는 초당 70,000개씩 늘어난다. 10분이면 70,000/s × 600s = 42M pending notification이다. 여기서는 DB read보다 queue storage, provider quota, retry storm이 먼저 병목이 된다.

backpressure 정책은 요구에 따라 갈린다. 보안 OTP는 오래된 메시지의 가치가 급격히 떨어지므로 짧은 TTL 뒤 drop하고 최신 요청을 우선할 수 있다. 결제 영수증은 늦더라도 잃지 않아야 하므로 durable queue와 재시도 예산이 필요하다. 마케팅 알림은 queue가 임계를 넘으면 sampling·campaign pause로 상류를 늦출 수 있다. 같은 알림 시스템에서도 가치의 시간 민감도와 durability가 다르면 우선순위 큐, TTL, retry가 달라진다.

검증 SLI는 enqueue 성공률만으로 부족하다. event → provider accepted, provider accepted → delivered 지연을 분리하고, queue age, retry 비율, provider별 error rate, dedup hit를 본다. 전체 처리량이 정상이어도 가장 오래된 메시지 age가 계속 증가하면 시스템은 backlog를 갚지 못하고 있다.

차원URL ShortenerRate Limiter홈 피드알림
read/write 비율read 압도 (100:1)read=write (모든 요청이 둘 다)read-heavy지만 write fan-out 큼campaign burst가 큼
데이터 영속성영구 (DB primary)짧은 수명의 counter (Redis primary 가능)post는 영구, timeline은 재생성 가능채널·이벤트별로 TTL/durability 다름
핵심 불변식code가 한 URL만 가리킴허용 오차 안에서 quota 차감권한·삭제·정렬 정책사용자 설정·dedup·전달 상태
주요 증폭hot key readhot key serializationfollower fan-outretry와 multi-channel fan-out
캐시 역할hot URL fast lookup (Redis = 부속물)counter의 primary 역할계산된 timeline 가속preference/template lookup 가속
fail modeRedis 죽으면 DB fallbackfail-open 또는 fail-closed 결정stale feed 또는 partial result지연, drop, channel fallback을 등급별 결정
확장 임계 패턴row > 10B, hot URL > 30K QPShot key와 decision p99fan-out inserts/s, queue lagoldest message age, provider quota

같은 “Redis + Kafka + DB” 그림을 그릴 수는 있지만, 각 컴포넌트가 지키는 불변식과 실패 시 포기하는 속성이 다르다. 이 차이를 설명하지 못하면 아키텍처는 요구에서 도출된 것이 아니라 암기한 구성이다.


7. 공통 실패 신호와 잘못된 직관

섹션 제목: “7. 공통 실패 신호와 잘못된 직관”

7.1 평균값만으로 capacity를 확정한다

섹션 제목: “7.1 평균값만으로 capacity를 확정한다”

용량 추정이 감으로 가면 후속 결정이 모두 어긋난다. “월 100M URL”은 DAU × 사용자당 URL/day × 30 같은 기반 수치와 peak 분포로 검증해야 한다. 운영 실측이 추정에서 30% 이상 벗어나면 capacity plan을 재산정한다. 평균은 맞는데 p99와 hot-key concentration이 나쁘다면 평균 배수만 고치는 것으로는 부족하다.

7.2 병목을 확인하기 전에 sharding한다

섹션 제목: “7.2 병목을 확인하기 전에 sharding한다”

단일 PostgreSQL row 수가 10B에 이르지 않았는데 sharding부터 도입하면 rebalancing과 cross-shard query 비용을 먼저 지불한다. §4.6의 정량 트리거가 보일 때까지 수직 확장과 replication으로 미룰 수 있다. 반대로 hot URL 하나 때문에 Redis shard가 포화됐는데 PostgreSQL을 shard하는 것도 병목을 잘못 찾은 설계다.

7.3 저장소 제품명으로 durability를 판단한다

섹션 제목: “7.3 저장소 제품명으로 durability를 판단한다”

URL Shortener에서 Redis만 두고 PostgreSQL 없이 영구 URL을 저장하면 cache eviction과 장애 복구가 사용자 데이터 손실이 된다. AOF(Append Only File, Redis 명령 로그)가 있어도 복구 시점과 설정에 따른 손실 경계가 요구와 맞는지 별도로 증명해야 한다. 메모리 가격도 디스크보다 크게 비싸다.

기준본의 거친 비용 감각은 “메모리가 디스크보다 100배 비싸다”였지만, 이는 인스턴스 유형·복제 수·managed service 가격에 따라 크게 달라지는 가정이다. 설계 결론은 100배를 상수로 외워서가 아니라, 동일 보존 기간과 복제 조건에서 GB-month 비용을 실제 견적으로 비교해 내려야 한다.

Rate Limiter에서는 반대 선택이 가능하다. counter는 짧은 시간이 지나면 효용이 사라지고 재구성 가능하므로 Redis가 primary 역할을 해도 된다. 핵심은 “Redis는 cache”라는 규칙이 아니라 데이터를 잃었을 때 어떤 불변식이 깨지는가다.

7.4 비동기화하면 부하가 사라진다고 생각한다

섹션 제목: “7.4 비동기화하면 부하가 사라진다고 생각한다”

queue는 일을 없애지 않고 시간축으로 옮긴다. producer 100,000/s, consumer 30,000/s면 backlog는 70,000/s씩 증가한다. queue lag와 oldest message age가 계속 오르면 처리량 적자는 누적되고 있다. backpressure, drop/TTL, producer 감속, consumer 증설 중 어떤 선택을 할지는 데이터의 시간 가치와 durability 요구로 정한다.


8. 설계 답안을 검증하는 최소 질문

섹션 제목: “8. 설계 답안을 검증하는 최소 질문”

설계 추론 확인

  • 기능 요구와 SLO를 분리하고, 반드시 지킬 불변식을 한 문장으로 썼는가?
  • average와 peak, QPS와 bytes/s, raw와 replica 포함 storage를 단위와 함께 계산했는가?
  • 전체 부하뿐 아니라 hot key, fan-out, burst처럼 분포가 만드는 병목을 찾았는가?
  • 선택한 구조의 이점과 함께 포기한 consistency·durability·availability를 설명했는가?
  • 각 가정을 반증할 SLI, 부하 테스트, 실패 신호와 다음 단계 임계를 정했는가?

이 다섯 질문은 면접 답안 형식이 아니라 설계의 인과관계를 확인하는 압축 도구다. 다이어그램의 각 컴포넌트에 “어떤 요구와 병목 때문에 존재하는가”를 답할 수 없다면 제거하거나 근거를 다시 찾아야 한다.


9. 선택 부록: 손으로 검증하는 실습

섹션 제목: “9. 선택 부록: 손으로 검증하는 실습”
기존 케이스를 직접 실행해 보는 항목
  • 6단계 프레임 손에 익히기: 피드·채팅·알림 중 하나를 §3 순서로 한 페이지 작성한다.
  • Snowflake → Base62 구현: §4.4 코드를 실행해 1,000개 unique를 확인한다. 이어 무작위 7자 코드는 DB unique constraint와 retry가 필요함을 설명한다.
  • PG + Redis URL Shortener: cdc-outbox.mdx §11.1의 compose 패턴을 참고해 POST → short code → GET redirect를 관찰한다.
  • Token Bucket Lua: SCRIPT LOADEVALSHA로 100개 요청을 보내 토큰 refill과 burst carry-over를 관찰한다.
  • Sliding Window vs Fixed Window: slot 경계 직전에 burst를 보내 fixed window의 경계 효과를 비교한다.
  • 429 계약: §5.4 caller로 Retry-After, draft-11 RateLimit, RateLimit-Policy가 실제 응답에 도달하고 Structured Fields parser로 해석되는지 확인한다.
  • hot key 재현: k6 또는 vegeta로 한 사용자 ID에 10,000 RPS를 보내 전체 QPS와 shard CPU가 다른 신호임을 확인한다.
  • 장애 모드 재현: Redis를 중단해 fail-open과 fail-closed가 각각 원래 API의 가용성과 보호 수준을 어떻게 바꾸는지 기록한다.

추가 탐색 키워드는 Twitter/WhatsApp 피드·채팅, Dropbox/S3, Uber matching, Netflix streaming, Yelp search, Twitter Snowflake·Sonyflake·Discord Snowflake·ULID·KSUID, consistent hashing, Bloom Filter, CRDT·Aurora Global·Spanner, Istio·Envoy·API Gateway다. 키워드 자체보다 어떤 요구와 임계에서 필요한지 연결해 학습한다.


  1. 시스템 디자인은 제약을 좁히는 사고법이다. 요구사항 → 불변식 → 부하 모델 → 병목 → 트레이드오프 → 검증 순서로 답을 도출한다.
  2. 숫자는 단위와 분포까지 적는다. average/peak, QPS/bytes/s, raw/replica storage, 평균/p99를 섞지 않는다.
  3. URL Shortener는 영구·read-heavy point lookup이다. PostgreSQL이 primary이고 Redis/CDN은 hot read를 흡수하며, 코드 길이·분산 생성·열거 방지에 따라 ID 전략이 달라진다.
  4. Rate Limiter는 짧은 수명의 원자적 counter다. 알고리즘은 burst 허용으로 고르고, Redis Lua의 단일-primary 원자성과 multi-region consistency를 구분한다.
  5. 피드와 알림은 증폭과 적체가 중심이다. fan-out과 backpressure를 workload shape에 포함하고, SLI와 실패 신호가 설계 가정을 반증하게 만든다.

참고 출처: 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-11 (https://datatracker.ietf.org/doc/draft-ietf-httpapi-ratelimit-headers/11/), 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), Aurora Global Database write forwarding (https://docs.aws.amazon.com/AmazonRDS/latest/AuroraUserGuide/aurora-global-database-write-forwarding.html).