"그냥 해시 돌리면 되는 거 아니야?"
bit.ly 클론 만들기 프로젝트를 시작했을 때 내 생각은 이랬다. 긴 URL 받아서 → 해시 함수 돌려서 짧은 문자열 만들고 → DB에 저장 → 리다이렉트. 끝. 하루면 되겠다 싶었다.
근데 막상 설계 문서 작성하면서 질문이 꼬리를 물었다. 해시 충돌 나면? 같은 URL 두 번 넣으면? 10억 개 URL 저장하면 DB 어떻게 하지? 클릭 분석은? 만료는? 301 리다이렉트? 302?
결국 이틀 동안 화이트보드만 붙잡고 있었다. URL 단축 서비스는 간단해 보이지만, 실제로는 시스템 디자인의 모든 핵심 개념이 농축된 문제였다. 마치 카푸치노처럼 - 커피, 우유, 거품만으로 보이지만 비율과 타이밍이 전부를 좌우한다.
요구사항 뜯어보기 - 생각보다 복잡했다
처음엔 "URL 짧게 만들기"가 전부인 줄 알았다. 하지만 실제 서비스로 만들려면 이것저것 고려할 게 많았다.
기능적 요구사항:
- 긴 URL → 짧은 URL 생성 (당연)
- 짧은 URL → 원본 URL 리다이렉트 (당연)
- 커스텀 단축 URL 지원 (bit.ly/my-awesome-link 같은 거)
- URL 만료 기능 (7일 뒤 자동 삭제 같은)
- 클릭 분석 (몇 명이 언제 어디서 클릭했는지)
비기능적 요구사항:
- 읽기 헤비: 쓰기 대비 읽기가 100:1 정도 (만들기보단 클릭이 훨씬 많음)
- 낮은 레이턴시: 리다이렉트 100ms 이내
- 고가용성: 99.9% 업타임
- 짧아야 함: 최대 7자 (트위터 280자 제한 시대의 유산)
이 시점에서 깨달았다. 이거 "간단한 CRUD 앱"이 아니라 제대로 된 분산 시스템 설계 문제였다.
해시 vs 카운터 - 두 가지 길
짧은 URL을 만드는 방법은 크게 두 가지다.
방법 1 - 해시 기반 접근
내 첫 번째 직관이었다. 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
이 방법의 장점:
- 구현 간단
- 같은 URL은 항상 같은 단축 코드 생성 (멱등성)
- 분산 환경에서 조율 불필요
함정:
- 충돌 가능성: MD5의 128비트를 7자(약 42비트)로 줄이니 생일 문제(birthday paradox) 발생
- 7자 Base62 = 62^7 = 약 3.5조 조합. 10억 개 URL 저장 시 충돌 확률 약 0.01%
- 충돌 처리 로직 필요 (재해시? 카운터 추가?)
방법 2 - 카운터 기반 접근
데이터베이스 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는 유니크하니까
- 순차적이라 DB 인덱스 효율적
- 예측 가능한 용량 계획
함정:
- 단일 장애점: ID 생성기가 병목
- 순차적 패턴 노출 (보안 이슈? 뭐 큰 문제는 아님)
- 분산 환경에서 ID 조율 필요 (Snowflake 같은 분산 ID 생성기)
나는 결국 카운터 방식을 선택했다. 충돌 처리 로직 짜느니 ID 생성기 스케일링이 더 익숙한 문제였기 때문이다. 게다가 PostgreSQL SEQUENCE는 초당 수만 건 발급 가능하고, 나중에 여러 범위로 나눠서 샤딩도 가능하다.
DB 스키마 - 정규화 vs 비정규화
초기 스키마는 단순했다.
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테이블은 비동기로 배치 삽입- 오래된 클릭 데이터는 ClickHouse 같은 OLAP DB로 이동
마치 카페 계산대처럼 - 현금은 서랍에 빠르게 넣고, 장부 정리는 나중에.
캐싱 전략 - Redis가 생명줄
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);
}
}
}
캐싱 계층:
- L1 (Redis): 핫 URL (상위 1%) → 히트율 99%
- L2 (DB): 나머지 URL
- TTL 전략: 자주 클릭되는 URL은 TTL 자동 연장
이 구조로 DB 부하를 90% 줄였다. Redis는 초당 10만 요청 처리 가능하니까.
301 vs 302: 의외로 중요한 선택
리다이렉트 상태 코드 선택이 비즈니스를 좌우한다는 걸 몰랐다.
- 301 Permanent: 브라우저가 캐시 → 두 번째 클릭부터 서버 안 거침
- 302 Temporary: 매번 서버 거침
처음엔 성능을 위해 301을 쓰려 했다. 그런데 문제가 있었다:
- 클릭 분석 불가능 (브라우저 캐시 때문에)
- URL 대상 변경 불가능 (예: A/B 테스트)
결론: 302 사용. 분석 데이터가 레이턴시 10ms보다 훨씬 중요했다.
단, 스폰서 링크나 영구 리다이렉트는 옵션으로 301 제공.
스케일 고려사항 - 10억 URL의 무게
10억 개 URL 저장하면 무슨 일이 일어날까?
스토리지 계산:
- 평균 URL 길이: 100바이트
- 메타데이터(ID, 타임스탬프 등): 50바이트
- 총 150바이트 × 10억 = 150GB
괜찮아 보인다. 하지만 클릭 데이터가 문제다.
- 클릭당 200바이트 × 100억 클릭 = 2TB
샤딩 전략:
- ID 범위로 샤딩: 0-99M은 Shard 0, 100-199M은 Shard 1...
- 지리적 샤딩: 미국/유럽/아시아 별도 DB
핫스팟 문제:
- 바이럴 URL이 특정 샤드에 몰림
- 해결: Consistent Hashing으로 부하 분산
- 또는 핫 URL은 별도 캐시 클러스터
정리 - 간단해 보이는 게 제일 어렵다
URL 단축 서비스 설계하면서 배운 것들:
- 해시 충돌은 생각보다 빨리 온다 - 생일 문제를 무시하지 말 것
- 읽기/쓰기 비율이 모든 걸 결정한다 - 99% 읽기면 캐싱이 핵심
- 작은 결정이 스케일에서 터진다 - 301 vs 302, 정규화 vs 비정규화
- 분석은 나중 생각 아니다 - 처음부터 설계에 포함해야 함
- 만료 정책은 복잡하다 - Lazy deletion vs Active cleanup
bit.ly 클론 만들기는 하루 프로젝트가 아니라, 시스템 디자인 입문 과정 그 자체였다. 간단해 보이는 문제일수록 숨어있는 복잡도를 존중해야 한다. 마치 백준 브론즈 문제가 실제론 골드인 경우처럼.
다음에 누가 "그거 쉽잖아"라고 하면, 이 화이트보드 사진을 보여줄 생각이다. 온통 화살표와 물음표로 가득한.