https://honeybarrel.co.kr/products/detail?id=1234&ref=newsletter&utm_source=email→hny.kr/aB3x9z— 어떻게 만드는가? 이 질문은 시스템 설계 인터뷰의 단골이지만, 실제로 구현해보면 배울 것이 훨씬 많다.
요구사항 정의
Functional Requirements:
- 긴 URL → 짧은 URL 생성 (
hny.kr/aB3x9z) - 짧은 URL → 원본 URL 리다이렉트 (301/302)
- URL 만료 시간 설정 가능 (선택)
- 클릭 통계 수집 (선택)
Non-Functional Requirements:
- 쓰기: 1,000 RPS, 읽기(리다이렉트): 100,000 RPS (읽기 집중형)
- 단축 URL은 7자 이하
- 가용성 99.9% (연간 8.7시간 이하 다운타임)
- 데이터 보존: 5년
용량 계산:
하루 쓰기: 100만 건 → 5년 = 18억 건
하루 읽기: 1억 건 → 평균 100:1 읽기/쓰기 비율
저장 공간:
레코드당: 짧은 URL(7B) + 긴 URL(200B) + 메타(100B) = ~300B
18억 × 300B = ~540GB → SSD DB로 충분
QPS:
쓰기: 100만 / 86,400 ≈ 11 RPS
읽기: 1억 / 86,400 ≈ 1,157 RPS (피크: 10,000 RPS)
전체 아키텍처
graph TD
Client["클라이언트"]
Client -->|"POST /shorten\n{url: 'https://...'}"| LB["로드 밸런서\n(Nginx)"]
Client -->|"GET /aB3x9z"| LB
LB --> ShortSvc["URL 단축 서비스\n(Spring Boot)"]
LB --> RedirectSvc["리다이렉트 서비스\n(Spring Boot)"]
ShortSvc --> IDGen["분산 ID 생성\n(Snowflake)"]
IDGen --> Base62["Base62 인코딩\n62^6 = 56억 코드"]
Base62 --> DB_Write["PostgreSQL\n(쓰기)"]
Base62 --> Cache_Write["Redis 캐시\n(write-through)"]
RedirectSvc --> Cache_Read["Redis 캐시\nCache-Aside"]
Cache_Read -->|"Cache Hit\n(~95%)"| Client
Cache_Read -->|"Cache Miss\n(~5%)"| DB_Read["PostgreSQL\n(읽기 복제본)"]
DB_Read --> Cache_Write2["Redis에 저장\n(TTL 24h)"]
DB_Read --> Client
DB_Write -.->|"복제"| DB_Read
Analytics["Analytics\n(Kafka + ClickHouse)\n클릭 통계 비동기"]
RedirectSvc --> Analytics
style Cache_Read fill:#c8e6c9,stroke:#388e3c
style Base62 fill:#bbdefb,stroke:#1565c0
style Analytics fill:#fff9c4,stroke:#f9a825
Base62 인코딩 — 왜 6자면 충분한가
10진수: 0-9 = 10가지
16진수: 0-9, a-f = 16가지
Base62: 0-9, a-z, A-Z = 62가지
62^6 = 56,800,235,584 (약 568억)
62^7 = 3,521,614,606,208 (약 3.5조)
→ 6자리로 568억 URL 처리 가능
→ 5년간 18억 건이라면 62^6으로 충분
// src/main/java/co/kr/honey/url/util/Base62Encoder.java
public final class Base62Encoder {
private static final String CHARS =
"0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
private static final int BASE = 62;
private static final int MIN_LENGTH = 6;
private Base62Encoder() {} // 유틸리티 클래스 — 인스턴스화 방지
/** 숫자 → Base62 문자열 */
public static String encode(long num) {
if (num < 0) throw new IllegalArgumentException("음수 인코딩 불가: " + num);
if (num == 0) return pad("0");
StringBuilder sb = new StringBuilder();
while (num > 0) {
sb.append(CHARS.charAt((int)(num % BASE)));
num /= BASE;
}
return pad(sb.reverse().toString());
}
/** Base62 문자열 → 숫자 */
public static long decode(String encoded) {
long result = 0;
for (char c : encoded.toCharArray()) {
int idx = CHARS.indexOf(c);
if (idx == -1) throw new IllegalArgumentException("잘못된 Base62 문자: " + c);
result = result * BASE + idx;
}
return result;
}
// 최소 길이 패딩 (앞에 '0' 채우기)
private static String pad(String s) {
if (s.length() >= MIN_LENGTH) return s;
return "0".repeat(MIN_LENGTH - s.length()) + s;
}
// 단위 테스트
public static void main(String[] args) {
System.out.println(encode(1)); // "000001"
System.out.println(encode(62)); // "000010"
System.out.println(encode(3844)); // "000100"
System.out.println(encode(1_000_000L)); // "4c92"
System.out.println(decode("4c92")); // 1000000
}
}
Snowflake ID — 분산 환경 고유 ID 생성
왜 Auto Increment가 아닌가?
- Auto Increment: 단일 DB에서만 동작, 샤딩 불가
- UUID v4: 128비트, Base62 인코딩 시 너무 김, 정렬 불가
- Snowflake: 64비트, 시간 순 정렬, 수평 확장 가능
Snowflake ID 64비트 구성:
┌──────────────────────────────────────────┬────────────────┬──────────┬─────────────┐
│ 41비트: 타임스탬프 (밀리초) │ 10비트: 머신 ID │ 12비트: │ 시퀀스 번호 │
│ (현재 - epoch 기준) │ (5비트 DC + │ │ (밀리초당 │
│ 최대 69년 표현 │ 5비트 워커) │ │ 4096개) │
└──────────────────────────────────────────┴────────────────┴──────────┴─────────────┘
초당 최대 4096 × 1000 = 4,096,000 개 ID 생성
// src/main/java/co/kr/honey/url/id/SnowflakeIdGenerator.java
@Component
public class SnowflakeIdGenerator {
// 커스텀 epoch (2020-01-01 00:00:00 UTC)
private static final long EPOCH = 1577836800000L;
private static final int WORKER_ID_BITS = 5;
private static final int DATACENTER_ID_BITS = 5;
private static final int SEQUENCE_BITS = 12;
private static final long MAX_WORKER_ID = ~(-1L << WORKER_ID_BITS); // 31
private static final long MAX_DATACENTER_ID = ~(-1L << DATACENTER_ID_BITS); // 31
private static final long MAX_SEQUENCE = ~(-1L << SEQUENCE_BITS); // 4095
private static final int WORKER_ID_SHIFT = SEQUENCE_BITS; // 12
private static final int DATACENTER_ID_SHIFT = SEQUENCE_BITS + WORKER_ID_BITS; // 17
private static final int TIMESTAMP_SHIFT = SEQUENCE_BITS + WORKER_ID_BITS + DATACENTER_ID_BITS; // 22
private final long workerId;
private final long datacenterId;
private long sequence = 0L;
private long lastTimestamp = -1L;
public SnowflakeIdGenerator(
@Value("${snowflake.worker-id:1}") long workerId,
@Value("${snowflake.datacenter-id:1}") long datacenterId) {
if (workerId > MAX_WORKER_ID || workerId < 0)
throw new IllegalArgumentException("Worker ID 범위 초과: " + workerId);
if (datacenterId > MAX_DATACENTER_ID || datacenterId < 0)
throw new IllegalArgumentException("Datacenter ID 범위 초과: " + datacenterId);
this.workerId = workerId;
this.datacenterId = datacenterId;
}
/** 스레드 안전한 ID 생성 */
public synchronized long nextId() {
long timestamp = currentTimestamp();
if (timestamp < lastTimestamp) {
// 시계 역행 — 허용 범위(5ms) 내면 대기
long offset = lastTimestamp - timestamp;
if (offset <= 5) {
try { Thread.sleep(offset); } catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException("Snowflake ID 생성 중단", e);
}
timestamp = currentTimestamp();
} else {
throw new RuntimeException("시계 역행 " + offset + "ms — ID 생성 불가");
}
}
if (timestamp == lastTimestamp) {
sequence = (sequence + 1) & MAX_SEQUENCE;
if (sequence == 0) {
// 같은 밀리초에 4095개 모두 사용 → 다음 밀리초까지 대기
timestamp = waitNextMillis(lastTimestamp);
}
} else {
sequence = 0L;
}
lastTimestamp = timestamp;
return ((timestamp - EPOCH) << TIMESTAMP_SHIFT)
| (datacenterId << DATACENTER_ID_SHIFT)
| (workerId << WORKER_ID_SHIFT)
| sequence;
}
private long waitNextMillis(long lastTs) {
long ts = currentTimestamp();
while (ts <= lastTs) ts = currentTimestamp();
return ts;
}
private long currentTimestamp() {
return System.currentTimeMillis();
}
}
Spring Boot 전체 구현
도메인 모델 + Repository
// src/main/java/co/kr/honey/url/domain/ShortUrl.java
@Entity
@Table(name = "short_urls")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class ShortUrl {
@Id
private String shortCode; // Base62 인코딩된 6자리 코드 (PK)
@Column(nullable = false, length = 2048)
private String originalUrl; // 원본 URL
@Column(nullable = false)
private String createdBy; // 생성자 (IP or user ID)
@Column(nullable = false)
private LocalDateTime createdAt;
@Column
private LocalDateTime expiresAt; // null = 만료 없음
@Column(nullable = false)
private boolean active = true;
@Builder
private ShortUrl(String shortCode, String originalUrl, String createdBy, LocalDateTime expiresAt) {
this.shortCode = shortCode;
this.originalUrl = originalUrl;
this.createdBy = createdBy;
this.createdAt = LocalDateTime.now();
this.expiresAt = expiresAt;
}
public boolean isExpired() {
return expiresAt != null && LocalDateTime.now().isAfter(expiresAt);
}
}
// Repository
public interface ShortUrlRepository extends JpaRepository<ShortUrl, String> {
@Query("SELECT s FROM ShortUrl s WHERE s.shortCode = :code AND s.active = true")
Optional<ShortUrl> findActiveByCode(String code);
}
서비스 계층
// src/main/java/co/kr/honey/url/service/UrlShortenerService.java
@Service
@RequiredArgsConstructor
@Slf4j
public class UrlShortenerService {
private final SnowflakeIdGenerator idGenerator;
private final ShortUrlRepository repository;
private final RedisTemplate<String, String> redisTemplate;
private final KafkaTemplate<String, ClickEvent> kafkaTemplate;
private static final String CACHE_PREFIX = "url:";
private static final Duration CACHE_TTL = Duration.ofHours(24);
private static final String BASE_URL = "https://hny.kr/";
/** URL 단축 */
@Transactional
public ShortenResponse shorten(ShortenRequest request, String clientIp) {
// 1. URL 유효성 검증
validateUrl(request.originalUrl());
// 2. 중복 체크 (같은 URL + 요청자에게 기존 코드 재사용)
// 실제 서비스에서는 hash index로 빠른 조회
// 여기서는 생략 (새 코드 항상 생성)
// 3. Snowflake ID → Base62 인코딩
long id = idGenerator.nextId();
String shortCode = Base62Encoder.encode(id);
// 4. DB 저장
ShortUrl shortUrl = ShortUrl.builder()
.shortCode(shortCode)
.originalUrl(request.originalUrl())
.createdBy(clientIp)
.expiresAt(request.expiresAt())
.build();
repository.save(shortUrl);
// 5. Redis 캐시 저장 (Write-Through)
String cacheKey = CACHE_PREFIX + shortCode;
redisTemplate.opsForValue().set(cacheKey, request.originalUrl(), CACHE_TTL);
String shortUrl1 = BASE_URL + shortCode;
log.info("단축 URL 생성: {} → {}", request.originalUrl(), shortUrl1);
return new ShortenResponse(shortUrl1, shortCode, request.expiresAt());
}
/** 원본 URL 조회 (리다이렉트용) */
public String getOriginalUrl(String shortCode) {
String cacheKey = CACHE_PREFIX + shortCode;
// 1. Redis 캐시 확인 (Cache-Aside)
String cached = redisTemplate.opsForValue().get(cacheKey);
if (cached != null) {
log.debug("Cache Hit: {}", shortCode);
return cached;
}
// 2. DB 조회 (Cache Miss)
log.debug("Cache Miss: {}", shortCode);
ShortUrl shortUrl = repository.findActiveByCode(shortCode)
.orElseThrow(() -> new UrlNotFoundException("단축 URL 없음: " + shortCode));
if (shortUrl.isExpired()) {
throw new UrlExpiredException("만료된 URL: " + shortCode);
}
// 3. 캐시 저장
Duration ttl = shortUrl.getExpiresAt() != null
? Duration.between(LocalDateTime.now(), shortUrl.getExpiresAt())
: CACHE_TTL;
redisTemplate.opsForValue().set(cacheKey, shortUrl.getOriginalUrl(), ttl);
return shortUrl.getOriginalUrl();
}
private void validateUrl(String url) {
try {
URI uri = new URI(url);
if (!uri.isAbsolute() || (!url.startsWith("http://") && !url.startsWith("https://"))) {
throw new InvalidUrlException("유효하지 않은 URL: " + url);
}
} catch (URISyntaxException e) {
throw new InvalidUrlException("URL 파싱 오류: " + url);
}
}
}
컨트롤러
@RestController
@RequiredArgsConstructor
@Slf4j
public class UrlController {
private final UrlShortenerService service;
/** URL 단축 API */
@PostMapping("/api/shorten")
public ResponseEntity<ShortenResponse> shorten(
@Valid @RequestBody ShortenRequest request,
HttpServletRequest httpRequest) {
String clientIp = getClientIp(httpRequest);
ShortenResponse response = service.shorten(request, clientIp);
return ResponseEntity.status(HttpStatus.CREATED).body(response);
}
/** 리다이렉트 API */
@GetMapping("/{shortCode}")
public ResponseEntity<Void> redirect(
@PathVariable @Pattern(regexp = "[0-9a-zA-Z]{6,7}") String shortCode,
HttpServletRequest request) {
String originalUrl = service.getOriginalUrl(shortCode);
// 302 Temporary Redirect (캐싱 안 함, 통계 수집 가능)
// 301 Permanent Redirect (브라우저 캐싱, 통계 부정확)
return ResponseEntity.status(HttpStatus.FOUND)
.location(URI.create(originalUrl))
.build();
}
private String getClientIp(HttpServletRequest request) {
String forwarded = request.getHeader("X-Forwarded-For");
return forwarded != null ? forwarded.split(",")[0].trim()
: request.getRemoteAddr();
}
}
// DTO
public record ShortenRequest(
@NotBlank @Size(max = 2048) String originalUrl,
@Future LocalDateTime expiresAt // 선택
) {}
public record ShortenResponse(
String shortUrl,
String shortCode,
LocalDateTime expiresAt
) {}
301 vs 302 리다이렉트
301 Permanent (영구):
- 브라우저가 캐싱 → 다음 방문 시 서버 요청 없이 직접 이동
- 장점: 서버 부하 ↓
- 단점: 클릭 통계 수집 불가, URL 변경 불가
302 Found (임시):
- 브라우저가 매번 서버에 요청
- 장점: 클릭 통계 수집 가능, URL 변경 가능
- 단점: 서버 부하 ↑
URL 단축 서비스 권장: 302
→ 통계 수집 + 만료/변경 유연성이 더 중요
→ Redis 캐시로 서버 부하 보완
트레이드오프 — 설계 결정
Hash (MD5/SHA-256) vs Snowflake + Base62:
Hash:
장점: 구현 단순
단점: 충돌 가능, 충돌 시 재해싱 필요, 글로벌 순서 없음
충돌률: MD5 6자리 → 56억 분의 1 (그래도 1억 URL에서 문제 발생 가능)
Snowflake + Base62 (선택):
장점: 충돌 없음 (분산 환경), 시간 순 정렬, 고성능 (메모리 내 생성)
단점: 워커/데이터센터 ID 관리 필요, 시계 역행 처리 필요
Redis Counter:
장점: 구현 매우 단순 (INCR 명령 하나)
단점: Redis가 단일 장애점, Redis 재시작 시 카운터 리셋 위험
적합: 소규모 서비스
보안 고려사항
1. 악성 URL 차단:
- Google Safe Browsing API로 피싱/악성코드 URL 필터링
- 블랙리스트 도메인 DB 유지
2. 속도 제한 (Rate Limiting):
- IP당 분당 10개 단축 URL 생성 제한
- Redis의 sliding window counter로 구현
3. 예측 불가능한 URL:
- 순차 Snowflake ID를 그대로 노출하면 크롤링 가능
- (ID × 소수 + SALT) % 2^64 로 순서 뒤섞기
4. 개인 URL:
- 비공개 단축 URL: 8~10자리 랜덤 코드
- 생성 시 비밀번호/토큰 요구
실행
# docker-compose.yml (개발 환경)
services:
app:
build: .
environment:
- SPRING_DATASOURCE_URL=jdbc:postgresql://db:5432/urldb
- SPRING_REDIS_HOST=cache
- SNOWFLAKE_WORKER_ID=1
- SNOWFLAKE_DATACENTER_ID=1
ports: ["8080:8080"]
depends_on: [db, cache]
db:
image: postgres:16-alpine
environment:
POSTGRES_DB: urldb
POSTGRES_USER: honey
POSTGRES_PASSWORD: honey1234
cache:
image: redis:7-alpine
# 실행
docker compose up -d
# 테스트
curl -X POST http://localhost:8080/api/shorten \
-H "Content-Type: application/json" \
-d '{"originalUrl": "https://blog.honeybarrel.co.kr/posts/spring-boot-deep-dive"}'
# {"shortUrl": "https://hny.kr/000aB3", "shortCode": "000aB3"}
curl -I http://localhost:8080/000aB3
# HTTP/1.1 302 Found
# Location: https://blog.honeybarrel.co.kr/posts/spring-boot-deep-dive
레퍼런스
문서 & 기사
- Designing a Scalable URL Shortener: Snowflake IDs, Base62 — nileshblog.tech — Snowflake + Base62 + 마이크로서비스 설계 (2025.10)
- How to Design a URL Shortener Using Redis — OneUptime — Redis 기반 URL 단축 서비스 (2026.03)
- High-throughput Spring URL Shortener — GitHub — Write-Behind + 락프리 ID 생성 오픈소스
- Java Snowflake ID Generator — GitHub — Java Snowflake 구현 레퍼런스
이 포스트는 HoneyByte 개발 블로그의 일부입니다.