
URL 단축 서비스 설계: 쉬워 보이지만 함정투성이
URL 줄이는 거 뭐가 어렵겠어? 했는데, 해시 충돌, 만료 정책, 리다이렉트 성능까지 고려하니 시스템 디자인의 축소판이었다.

URL 줄이는 거 뭐가 어렵겠어? 했는데, 해시 충돌, 만료 정책, 리다이렉트 성능까지 고려하니 시스템 디자인의 축소판이었다.
DB 설계의 기초. 데이터를 쪼개고 쪼개서 이상 현상(Anomaly)을 방지하는 과정. 제1, 2, 3 정규형을 쉽게 설명합니다.

왜 CPU는 빠른데 컴퓨터는 느릴까? 80년 전 고안된 폰 노이만 구조의 혁명적인 아이디어와, 그것이 남긴 치명적인 병목현상에 대해 정리했습니다.

ChatGPT는 질문에 답하지만, AI Agent는 스스로 계획하고 도구를 사용해 작업을 완료한다. 이 차이가 왜 중요한지 정리했다.

결제 API 연동이 끝이 아니었다. 중복 결제 방지, 환불 처리, 멱등성까지 고려하니 결제 시스템이 왜 어려운지 뼈저리게 느꼈다.

bit.ly 클론 만들기 프로젝트를 시작했을 때 내 생각은 이랬다. 긴 URL 받아서 → 해시 함수 돌려서 짧은 문자열 만들고 → DB에 저장 → 리다이렉트. 끝. 하루면 되겠다 싶었다.
근데 막상 설계 문서 작성하면서 질문이 꼬리를 물었다. 해시 충돌 나면? 같은 URL 두 번 넣으면? 10억 개 URL 저장하면 DB 어떻게 하지? 클릭 분석은? 만료는? 301 리다이렉트? 302?
결국 이틀 동안 화이트보드만 붙잡고 있었다. URL 단축 서비스는 간단해 보이지만, 실제로는 시스템 디자인의 모든 핵심 개념이 농축된 문제였다. 마치 카푸치노처럼 - 커피, 우유, 거품만으로 보이지만 비율과 타이밍이 전부를 좌우한다.
처음엔 "URL 짧게 만들기"가 전부인 줄 알았다. 하지만 실제 서비스로 만들려면 이것저것 고려할 게 많았다.
기능적 요구사항:이 시점에서 깨달았다. 이거 "간단한 CRUD 앱"이 아니라 제대로 된 분산 시스템 설계 문제였다.
짧은 URL을 만드는 방법은 크게 두 가지다.
내 첫 번째 직관이었다. MD5나 SHA-256으로 URL 해시 → Base62 인코딩으로 변환.
import hashlib
import string
BASE62 = string.digits + string.ascii_lowercase + string.ascii_uppercase
def base62_encode(num):
if num == 0:
return BASE62[0]
result = []
while num:
result.append(BASE62[num % 62])
num //= 62
return ''.join(reversed(result))
def generate_short_url(long_url):
# MD5 해시 생성
hash_bytes = hashlib.md5(long_url.encode()).digest()
# 첫 8바이트를 정수로 변환
hash_int = int.from_bytes(hash_bytes[:8], 'big')
# Base62로 인코딩해서 7자로 자르기
short_code = base62_encode(hash_int)[:7]
return f"https://short.link/{short_code}"
# 테스트
print(generate_short_url("https://www.example.com/very/long/url"))
# https://short.link/aB3xK9m
이 방법의 장점:
함정:
데이터베이스 auto-increment ID를 Base62로 변환하는 방식.
class URLShortener {
private counter: number = 1000000; // 6자 시작점
private base62Encode(num: number): string {
const chars = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
let result = '';
while (num > 0) {
result = chars[num % 62] + result;
num = Math.floor(num / 62);
}
return result || '0';
}
async createShortURL(longURL: string): Promise<string> {
// DB에서 다음 ID 가져오기 (트랜잭션)
const id = await this.getNextID();
const shortCode = this.base62Encode(id);
await this.db.insert({
id,
short_code: shortCode,
long_url: longURL,
created_at: new Date(),
clicks: 0
});
return `https://short.link/${shortCode}`;
}
private async getNextID(): Promise<number> {
// PostgreSQL SEQUENCE 사용
const result = await this.db.query("SELECT nextval('url_id_seq')");
return result.rows[0].nextval;
}
}
이 방법의 장점:
함정:
나는 결국 카운터 방식을 선택했다. 충돌 처리 로직 짜느니 ID 생성기 스케일링이 더 익숙한 문제였기 때문이다. 게다가 PostgreSQL SEQUENCE는 초당 수만 건 발급 가능하고, 나중에 여러 범위로 나눠서 샤딩도 가능하다.
초기 스키마는 단순했다.
CREATE TABLE urls (
id BIGSERIAL PRIMARY KEY,
short_code VARCHAR(10) UNIQUE NOT NULL,
long_url TEXT NOT NULL,
user_id BIGINT,
created_at TIMESTAMP DEFAULT NOW(),
expires_at TIMESTAMP,
clicks BIGINT DEFAULT 0
);
CREATE INDEX idx_short_code ON urls(short_code);
CREATE INDEX idx_expires_at ON urls(expires_at) WHERE expires_at IS NOT NULL;
하지만 분석 기능 추가하면서 딜레마에 빠졌다. 클릭할 때마다 IP, 위치, Referer, User-Agent를 저장해야 하는데, 이걸 같은 테이블에?
옵션 1: 비정규화 (clicks를 JSON으로)ALTER TABLE urls ADD COLUMN click_details JSONB;
장점: 쿼리 1번, 조인 없음 단점: 10억 클릭 시 JSON 거대해짐, 분석 쿼리 느림
옵션 2: 정규화 (별도 테이블)CREATE TABLE clicks (
id BIGSERIAL PRIMARY KEY,
url_id BIGINT REFERENCES urls(id),
clicked_at TIMESTAMP DEFAULT NOW(),
ip_address INET,
country VARCHAR(2),
referer TEXT,
user_agent TEXT
);
CREATE INDEX idx_url_id_clicked_at ON clicks(url_id, clicked_at DESC);
장점: 확장 가능, 시계열 분석 용이 단점: 조인 오버헤드, 스토리지 폭발
나는 하이브리드 접근을 택했다:
urls.clicks 카운터는 실시간 증가 (캐시에서)clicks 테이블은 비동기로 배치 삽입마치 카페 계산대처럼 - 현금은 서랍에 빠르게 넣고, 장부 정리는 나중에.
URL 단축 서비스는 극단적으로 읽기 헤비다. 같은 단축 URL이 수천 번 클릭될 수 있다. DB에 매번 쿼리하면 죽는다.
class URLService {
private redis: RedisClient;
private db: PostgresClient;
async redirect(shortCode: string): Promise<string> {
// 1단계: Redis 캐시 확인
const cached = await this.redis.get(`url:${shortCode}`);
if (cached) {
// 비동기로 클릭 카운트 증가
this.incrementClickCount(shortCode);
return cached;
}
// 2단계: DB 조회
const url = await this.db.query(
'SELECT long_url, expires_at FROM urls WHERE short_code = $1',
[shortCode]
);
if (!url) {
throw new Error('URL not found');
}
// 만료 체크
if (url.expires_at && new Date() > url.expires_at) {
throw new Error('URL expired');
}
// 3단계: 캐시에 저장 (TTL 1시간)
await this.redis.setex(`url:${shortCode}`, 3600, url.long_url);
this.incrementClickCount(shortCode);
return url.long_url;
}
private async incrementClickCount(shortCode: string) {
// Redis에서 카운트 증가
await this.redis.incr(`clicks:${shortCode}`);
// 100클릭마다 DB에 플러시
const count = await this.redis.get(`clicks:${shortCode}`);
if (parseInt(count) % 100 === 0) {
await this.flushClicksToDB(shortCode);
}
}
}
캐싱 계층:
이 구조로 DB 부하를 90% 줄였다. Redis는 초당 10만 요청 처리 가능하니까.
리다이렉트 상태 코드 선택이 비즈니스를 좌우한다는 걸 몰랐다.
처음엔 성능을 위해 301을 쓰려 했다. 그런데 문제가 있었다:
결론: 302 사용. 분석 데이터가 레이턴시 10ms보다 훨씬 중요했다.
단, 스폰서 링크나 영구 리다이렉트는 옵션으로 301 제공.
10억 개 URL 저장하면 무슨 일이 일어날까?
스토리지 계산:괜찮아 보인다. 하지만 클릭 데이터가 문제다.
URL 단축 서비스 설계하면서 배운 것들:
bit.ly 클론 만들기는 하루 프로젝트가 아니라, 시스템 디자인 입문 과정 그 자체였다. 간단해 보이는 문제일수록 숨어있는 복잡도를 존중해야 한다. 마치 백준 브론즈 문제가 실제론 골드인 경우처럼.
다음에 누가 "그거 쉽잖아"라고 하면, 이 화이트보드 사진을 보여줄 생각이다. 온통 화살표와 물음표로 가득한.