https://honeybarrel.co.kr/products/detail?id=1234&ref=newsletter&utm_source=emailhny.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

레퍼런스

문서 & 기사


이 포스트는 HoneyByte 개발 블로그의 일부입니다.