대용량 트래픽을 견디는 URL 단축 서비스 시스템 설계
1. 개요
URL 단축은 익숙한 시스템 설계 문제입니다. 로직은 단순하게 짧은 코드를 긴 URL에 매핑하는 것뿐입니다. 어려운 부분은 규모에 있습니다. 초당 수만 개의 리다이렉트, 갑자기 “바이럴”되는 링크, 그리고 코드 중복을 절대 생성하면 안 된다는 요구사항이 있기 때문입니다.
이 글은 전체 과정을 다룹니다. 요구사항 정의, 트래픽 추정, 데이터 모델, API, 코드 생성 방식, 캐시, 그리고 어려운 부분들까지요. 이 글을 읽고 나면 Spring Boot 3 코드와 함께 설계 가이드를 갖게 되어, 직접 만들거나 면접에 가져갈 수 있습니다.
2. 문제 요구사항
2.1. 기능 요구사항
- 긴 URL로부터 단축 링크를 만들 수 있습니다. 사용자는
/sale-2026같은 별칭을 직접 설정할 수 있습니다. - 단축 링크에 접근하면 원본 URL로 리다이렉트됩니다.
- 링크는 만료 시간(
expires_at)을 가질 수 있고, 소유자가 비활성화할 수 있습니다. - 링크별 클릭 통계가 필요합니다. 몇 초의 지연은 허용됩니다.
2.2. 규모
월 1억 개의 새로운 링크가 생성되고 읽기/쓰기 비율이 100:1이라고 가정합니다.
쓰기: 100M / (30 * 86400s) ≈ 40 링크/초 (피크 시 ×10 ≈ 400/초)
읽기: 40 × 100 ≈ 4,000 rps (피크 시 ×10 ≈ 40,000 rps)
5년 보관: 60억 개 링크 × ~500 바이트 ≈ 3 TB
7자 base62 코드 공간: 62^7 ≈ 3.5조 (60억 개에 충분함)
이 수치로부터 두 가지 결론을 얻을 수 있습니다. 첫째, 시스템은 읽기 위주이므로 캐시가 설계의 중심입니다. 둘째, 쓰기 부하가 낮으므로 PostgreSQL 프라이머리 하나로 충분하고, 아직 샤딩이 필요하지 않습니다.
2.3. 제약사항
- 리다이렉트의 p99 지연시간이 20ms 미만이어야 합니다.
- 단축 코드는 중복되지 않으며 순서대로 추측할 수 없어야 하므로, 다른 사람이 비공개 링크를 찾기 어렵습니다.
- 분석이나 Kafka에 장애가 발생해도 시스템은 계속 리다이렉트할 수 있어야 합니다.
3. 데이터 모델 및 API
필요한 의존성 (Spring Boot가 버전 관리):
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.kafka</groupId>
<artifactId>spring-kafka</artifactId>
</dependency>
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
</dependency>
</dependencies>
PostgreSQL 스키마:
CREATE TABLE links (
code VARCHAR(16) PRIMARY KEY,
long_url TEXT NOT NULL,
owner_id BIGINT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ,
disabled BOOLEAN NOT NULL DEFAULT false
);
CREATE INDEX idx_links_owner ON links (owner_id, created_at DESC);
CREATE SEQUENCE link_id_seq START 1 INCREMENT BY 1000;
주요 설계 결정:
code는 기본 키입니다. 가장 많이 실행되는 쿼리는WHERE code = ?이므로 primary key 인덱스에 바로 들어갑니다.idx_links_owner는 “내 링크” 화면에 필요합니다.link_id_seq는 매번 1000씩 증가합니다.nextval호출 시 애플리케이션은 한 번에 전체 블록을 받습니다 (4.2절 참고).
두 가지 주요 API:
POST /api/links {"url": "https://shop.vn/...", "alias": null}
-> 201 {"code": "0bX3kQz", "shortUrl": "https://nonla.ly/0bX3kQz"}
-> 409 별칭이 이미 있는 경우
GET /{code}
-> 302 Location: https://shop.vn/...
-> 404 존재하지 않음 / 만료됨 / 비활성화됨
참고:
301이 아닌302를 사용하세요. 브라우저는301을 영구적으로 캐시합니다. 그러면 클릭 통계를 잃게 되고 이미 퍼진 링크를 비활성화할 수 없습니다.
4. 단축 코드 생성
4.1. 전략 선택
| 방식 | 장점 | 단점 |
|---|---|---|
| URL 해시 (MD5 후 7자 자르기) | 상태 관리 불필요 | 충돌 가능, 재시도 필요 |
| 랜덤 7자 | 간단함 | 링크가 많아질수록 충돌 확률 증가, 매번 DB 확인 필요 |
| 카운터 + base62 | 절대 중복되지 않음 | 순서 노출 위험, 중앙화된 ID 소스 필요 |
이 글은 세 번째 방식을 선택하고 두 가지 단점을 해결합니다. 순서를 숨기기 위해 전단사(bijection)로 ID를 섞습니다. 중앙화된 ID 소스의 의존성을 줄이기 위해 블록 단위로 ID를 할당합니다.
import java.math.BigInteger;
public final class ShortCode {
private static final String ALPHABET =
"0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
private static final int LENGTH = 7;
private static final BigInteger SPACE = BigInteger.valueOf(62).pow(LENGTH);
// 홀수이고 62^7로 나누어떨어지지 않음 => 62^7과 서로소
private static final BigInteger MULTIPLIER = BigInteger.valueOf(1_580_030_173L);
private ShortCode() {}
public static String of(long id) {
if (id < 0 || id >= SPACE.longValue()) {
throw new IllegalArgumentException("ID가 코드 공간을 벗어남: " + id);
}
long mixed = BigInteger.valueOf(id).multiply(MULTIPLIER).mod(SPACE).longValue();
char[] out = new char[LENGTH];
for (int i = LENGTH - 1; i >= 0; i--) {
out[i] = ALPHABET.charAt((int) (mixed % 62));
mixed /= 62;
}
return new String(out);
}
}
동작 방식:
- 모듈로 곱셈
id × MULTIPLIER mod 62^7은 전단사입니다.MULTIPLIER가62^7과 서로소이기 때문입니다. 각 ID는 정확히 하나의 코드를 생성하고, 서로 다른 두 ID는 절대 같은 코드를 만들지 않습니다. - 반복문은 수를 base62로 변환하고 왼쪽에 0을 채우므로 코드는 항상 정확히 7자입니다.
- ID 1과 ID 2는 두 번째 문자부터 다른 코드를 만들기 때문에 외부인이 다음 링크를 추측하기 어렵습니다.
BigInteger는 곱할 때long오버플로우를 방지합니다. 400 쓰기/초 부하에서 이 비용은 무시할 수 있습니다.
이 방식은 코드를 추측하기 어렵게 할 뿐, 암호화는 아닙니다. MULTIPLIER를 알면 역산할 수 있습니다. 진정한 프라이빗 링크는 추가 인증 메커니즘이 필요합니다.
4.2. 블록 단위 ID 할당
매번 DB에서 ID를 가져오면 primary가 병목이 됩니다. 대신 각 인스턴스가 1000개의 ID 블록을 가져온 후 메모리에서 천천히 할당합니다:
import java.util.concurrent.locks.ReentrantLock;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
@Component
public class IdBlockAllocator {
private static final int BLOCK_SIZE = 1000; // INCREMENT BY와 일치해야 함
private final JdbcTemplate jdbc;
private final ReentrantLock lock = new ReentrantLock();
private long next;
private long end;
public IdBlockAllocator(JdbcTemplate jdbc) {
this.jdbc = jdbc;
}
public long nextId() {
lock.lock();
try {
if (next >= end) {
Long start = jdbc.queryForObject("SELECT nextval('link_id_seq')", Long.class);
next = start;
end = start + BLOCK_SIZE;
}
return next++;
} finally {
lock.unlock();
}
}
}
주의할 점:
nextval은 atomic 연산입니다. 두 인스턴스가 같은 블록을 받을 수 없으므로 분산 잠금이 필요 없습니다.- 400 쓰기/초 부하에서 각 인스턴스는 몇 초마다 한 번씩만 DB에 접근합니다.
- 코드는
synchronized대신ReentrantLock을 사용합니다. Java 21에서 virtual thread는synchronized블록 내 I/O를 기다릴 때 carrier thread에 “핸”됩니다. - 인스턴스 재시작 시 블록의 나머지는 낭비됩니다. 코드 공간이 3.5조 값을 가지므로 이는 허용됩니다.
5. 링크 생성 플로우
import java.net.URI;
import java.time.Duration;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
@Service
public class LinkWriteService {
static final String KEY = "link:";
private final JdbcTemplate jdbc;
private final StringRedisTemplate redis;
private final IdBlockAllocator allocator;
public LinkWriteService(JdbcTemplate jdbc, StringRedisTemplate redis, IdBlockAllocator allocator) {
this.jdbc = jdbc;
this.redis = redis;
this.allocator = allocator;
}
public String create(String longUrl, String alias, Long ownerId) {
String scheme = URI.create(longUrl).getScheme();
if (!"http".equals(scheme) && !"https".equals(scheme)) {
throw new IllegalArgumentException("http/https만 허용됩니다");
}
String code = (alias != null) ? alias : ShortCode.of(allocator.nextId());
try {
jdbc.update("INSERT INTO links(code, long_url, owner_id) VALUES (?, ?, ?)",
code, longUrl, ownerId);
} catch (DuplicateKeyException e) {
throw new AliasTakenException(code);
}
redis.opsForValue().set(KEY + code, longUrl, Duration.ofHours(24));
return code;
}
}
class AliasTakenException extends RuntimeException {
AliasTakenException(String code) { super("별칭이 이미 있음: " + code); }
}
처리 흐름은 네 단계:
- Scheme을 확인하여
javascript:또는file:URL을 차단합니다. 이 단계를 건너뛰면 서비스가 악성 링크 배포 도구가 됩니다. - 코드를 얻습니다. 사용자가 설정한 별칭을 사용하거나 ID에서 코드를 생성합니다.
INSERT합니다. Primary key 제약이 중복 코드에 대한 최후의 방어 계층입니다.- Redis에도 바로 씁니다 (write-through). 첫 번째 리다이렉트가 DB에 갈 필요가 없습니다.
프로덕션에서는 사용자가 설정한 별칭을 생성된 코드와 다른 문자 또는 길이로 제한해야 합니다. 예를 들어 -를 포함하거나 최소 8자 이상이어야 합니다. 그렇지 않으면 사용자의 별칭이 생성기가 나중에 만들 코드를 미리 “선점”할 수 있습니다. 그러면 생성기가 DuplicateKeyException을 만나게 되는데, 그 목적은 절대 중복을 만들지 않는 것입니다.
6. 리다이렉트 플로우 및 다층 캐시
40,000 rps의 고파에서 대부분의 요청은 DB에 접근하지 않고 처리되어야 합니다. 세 가지 레이어를 사용합니다: JVM 내 Caffeine, 공유 Redis, 그리고 PostgreSQL입니다.
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import java.time.Duration;
import java.util.List;
import java.util.Optional;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
@Service
public class LinkReadService {
private static final String NOT_FOUND = ""; // 음수 캐시용 센티넬
private final Cache<String, String> local = Caffeine.newBuilder()
.maximumSize(100_000)
.expireAfterWrite(Duration.ofSeconds(30))
.build();
private final JdbcTemplate jdbc;
private final StringRedisTemplate redis;
public LinkReadService(JdbcTemplate jdbc, StringRedisTemplate redis) {
this.jdbc = jdbc;
this.redis = redis;
}
public Optional<String> resolve(String code) {
String url = local.get(code, this::loadFromRedisOrDb);
return url.isEmpty() ? Optional.empty() : Optional.of(url);
}
private String loadFromRedisOrDb(String code) {
String key = LinkWriteService.KEY + code;
String cached = redis.opsForValue().get(key);
if (cached != null) return cached;
List<String> rows = jdbc.queryForList("""
SELECT long_url FROM links
WHERE code = ? AND NOT disabled
AND (expires_at IS NULL OR expires_at > now())""", String.class, code);
String url = rows.isEmpty() ? NOT_FOUND : rows.getFirst();
redis.opsForValue().set(key, url, url.isEmpty() ? Duration.ofMinutes(5) : Duration.ofHours(24));
return url;
}
}
중요한 점:
- Caffeine의
local.get(code, loader)는 같은 키에 대한 동시 로드를 병합합니다. 5,000개의 요청이 캐시에 없는 같은 코드를 물어보면 한 스레드만 Redis/DB에 갑니다. 다른 스레드는 그 결과를 기다립니다. 이는 인스턴스 범위에서 cache stampede를 막는 방식입니다. - 음수 캐싱: 존재하지 않는 코드도 5분 동안 빈 문자열로 캐시됩니다. 덕분에 코드를 무작위로 탐사하는 봇이 DB를 꺾을 수 없습니다.
- Caffeine은
null값을 캐시하지 않으므로 코드는null대신 센티넬""을 사용합니다.
Controller는 302를 반환하고 클릭 이벤트를 Kafka로 보냅니다:
import java.net.URI;
import java.util.Map;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.kafka.core.KafkaTemplate;
import org.springframework.web.bind.annotation.*;
@RestController
public class LinkController {
private final LinkWriteService writer;
private final LinkReadService reader;
private final KafkaTemplate<String, String> kafka;
public LinkController(LinkWriteService writer, LinkReadService reader,
KafkaTemplate<String, String> kafka) {
this.writer = writer;
this.reader = reader;
this.kafka = kafka;
}
public record CreateLinkRequest(String url, String alias) {}
@PostMapping("/api/links")
public ResponseEntity<Map<String, String>> create(@RequestBody CreateLinkRequest req) {
String code = writer.create(req.url(), req.alias(), null);
return ResponseEntity.status(HttpStatus.CREATED)
.body(Map.of("code", code, "shortUrl", "https://nonla.ly/" + code));
}
@GetMapping("/{code:[0-9a-zA-Z-]{1,16}}")
public ResponseEntity<Void> redirect(@PathVariable String code) {
return reader.resolve(code)
.map(url -> {
kafka.send("link-clicks", code, String.valueOf(System.currentTimeMillis()));
return ResponseEntity.status(HttpStatus.FOUND).location(URI.create(url)).<Void>build();
})
.orElse(ResponseEntity.notFound().build());
}
@ExceptionHandler(AliasTakenException.class)
ResponseEntity<String> conflict(AliasTakenException e) {
return ResponseEntity.status(HttpStatus.CONFLICT).body(e.getMessage());
}
}
kafka.send는 비동기이므로 리다이렉트가 broker를 기다릴 필요가 없습니다. 별도의 consumer가 몇 초마다 클릭 이벤트를 code로 그룹화한 후 batch로 통계 테이블에 씁니다. Producer 설정은 낮은 지연을 우선합니다:
spring.kafka.producer.acks=1
spring.kafka.producer.properties.linger.ms=20
spring.kafka.producer.properties.max.block.ms=50
max.block.ms=50은 broker가 다운되었을 때 리다이렉트 스레드가 오래 멈추는 것을 방지합니다. “분석이 실패해도 리다이렉트는 계속 작동”이라는 제약과 일치합니다. 그 경우 일부 클릭 이벤트를 잃을 수 있습니다.
7. 어려운 부분과 해결책
7.1. 경합(Race Condition)
- 두 사람이 같은 별칭을 설정:
SELECT를 한 후INSERT하지 마세요. 두 요청이 “아직 없음”을 동시에 볼 수 있고 둘 다 쓸 수 있습니다. DB의 unique 제약에 맡기고 5절처럼DuplicateKeyException을 잡으세요. - 여러 인스턴스가 동시에 코드 생성: sequence가 각 인스턴스에 별도의 블록을 할당하므로 경합이 없습니다.
7.2. 일관성
- 생성 직후 읽기: read replica에서 읽으면 replication 지연으로 새 링크가 404를 반환할 수 있습니다. 생성할 때 바로 Redis에 쓰기 (write-through)는 이 지연을 숨깁니다.
- 링크 비활성화: DB를 업데이트한 후 Redis 키를 삭제합니다. Caffeine은 각 인스턴스에서 최대 30초 동안 오래된 값을 보관합니다. 예를 들어 사기 링크처럼 즉시 차단해야 하면 Redis Pub/Sub 또는 Kafka를 통해 invalidate 이벤트를 발행하여 각 인스턴스에서 로컬 캐시를 삭제하세요.
- 만료: 캐시의 TTL은
min(24h, expires_at - now)여야 합니다. 그렇지 않으면 만료된 링크가 각 인스턴스에서 추가로 최대 24시간 리다이렉트될 수 있습니다.
7.3. 트래픽 스파이크
- Hot key: 바이럴 링크는 모든 트래픽을 Redis의 한 샤드로 집중시킵니다. 로컬 Caffeine이 이 부하의 대부분을 흡수합니다. 각 인스턴스는 각 키에 대해 평균 30초마다 한 번씩만 Redis에 질문합니다.
- Cold start: 새 인스턴스는 Caffeine에 아무것도 없지만, 6절의 로드 병합 기능이 Redis를 request 폭주로부터 보호합니다.
- 링크 생성 스팸: 생성 API를 속도 제한하세요. API 키나 IP당, 예를 들어 Redis의 token bucket을 사용하여
LinkWriteService를 호출하기 전에 제한하세요.
8. 코드 생성 동작 테스트
시스템의 가장 중요한 속성은 코드가 중복되지 않고, 길이가 고정되며, 순서를 노출하지 않는다는 것입니다:
import static java.util.stream.Collectors.toSet;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import java.util.Set;
import java.util.stream.LongStream;
import org.junit.jupiter.api.Test;
class ShortCodeTest {
@Test
void 올바른_길이와_문자를_가진_코드_생성() {
assertThat(ShortCode.of(1)).hasSize(7).matches("[0-9a-zA-Z]{7}");
}
@Test
void 연속된_10만_ID에서_중복_없음() {
Set<String> codes = LongStream.range(0, 100_000).mapToObj(ShortCode::of).collect(toSet());
assertThat(codes).hasSize(100_000);
}
@Test
void 연속된_ID는_코드도_연속적이지_않음() {
assertThat(ShortCode.of(1).substring(0, 5)).isNotEqualTo(ShortCode.of(2).substring(0, 5));
}
@Test
void 범위_밖의_ID는_거부() {
assertThatThrownBy(() -> ShortCode.of(-1)).isInstanceOf(IllegalArgumentException.class);
}
}
두 번째 테스트는 샘플에서만 확인합니다. 전단사 성질을 증명하지는 못합니다. 전단사는 수학에서 나옵니다 (gcd = 1). 테스트는 누군가 실수로 MULTIPLIER를 짝수로 바꾸는 것만 막습니다.
9. 흔한 실수와 복잡하게 할 필요가 없을 때
주의: 출시 후
MULTIPLIER또는ALPHABET을 바꾸면 새로 생성된 ID가 기존 코드와 충돌합니다. 이들을 영구 상수로 여기세요.
- 각 클릭을
links테이블에 직접 씀 (UPDATE ... SET clicks = clicks + 1)은 바이럴 링크에서 row lock 경합을 만듭니다. Kafka로 모으세요. - Caffeine 크기 제한을 하지 않으면 무작위 코드 탐사 대량으로 인해
OutOfMemoryError가 발생합니다. - 매일 몇백 개의 링크만 있는 내부 제품이면 PostgreSQL 테이블과 controller 하나면 충분합니다. Redis, Kafka, 블록 단위 ID 할당은 실제 부하가 요구할 때만 필요합니다.
10. 결론
대용량 트래픽을 견디는 URL 단축 서비스 설계는 세 가지 결정을 중심으로 합니다. 블록 단위 할당되는 카운터에서 전단사로 섞인 코드를 생성하여 중복 없이 순서를 숨깁니다. 음수 캐싱과 로드 병합이 있는 다층 캐시를 사용하여 hot key와 봇 탐사를 견딥니다. 클릭 통계처럼 필수 아닌 모든 작업을 Kafka를 통해 리다이렉트 경로 밖으로 보냅니다.
규모 추정부터 시작하세요. 2절의 수치가 각 레이어를 언제 추가해야 할지 알려주므로, 처음부터 모든 것을 구축할 필요가 없습니다.