트위터가 140자 제한이었던 시절, https://www.example.com/very/long/path?campaign=summer&source=newsletter&medium=email 같은 URL은 그 자체로 트윗 대부분을 차지했다. bit.ly는 이 문제를 7글자로 해결했다. 단순해 보이지만, 초당 10만 건의 리다이렉트를 100ms 이내에 처리하고 수십 TB의 데이터를 수년간 관리하는 시스템이다. “짧게 만든다”는 단순한 기능 뒤에 어떤 설계가 숨어있는가.


1. 요구사항 분석 — 무엇을 만들 것인가

요구사항을 나열하는 것이 아니라, 각 요구사항이 어떤 설계 결정을 강제하는가부터 파악해야 한다. 면접에서 요구사항을 읽는 목적은 기능 목록 확인이 아니라 설계 제약 조건 도출이다.

기능 요구사항

기능 설계에 미치는 영향
긴 URL → 짧은 URL 생성 전역 유일한 코드 생성 전략 필요 (단일 서버? 분산?)
짧은 URL → 원본으로 리다이렉트 읽기 경로 최적화가 핵심. 301 vs 302 결정 필요
클릭 분석 (횟수, 국가, 기기) 리다이렉트 경로에서 이벤트 수집 → 동기 불가, 비동기 파이프라인 필요
커스텀 별칭 (bit.ly/mybrand) 코드 생성 공간과 커스텀 공간의 충돌 방지 전략 필요
만료 기간 설정 만료 URL 처리 — Lazy 검사 vs Eager 배치 정리 선택

비기능 요구사항 — 숫자의 의미

읽기:쓰기 = 100:1   → 리다이렉트가 전체 부하의 99%. 읽기 경로가 병목
리다이렉트 100ms 이내 → 사용자가 링크 클릭 후 체감 지연. 캐시 없이 불가능
99.99% 가용성       → 연간 다운타임 52분. 이 서비스가 죽으면 전 세계 bit.ly 링크가 404

비기능 요구사항 중 읽기:쓰기 = 100:1 이 가장 중요하다. 설계의 모든 선택이 “읽기를 어떻게 빠르게 만드는가”로 수렴한다.


2. 규모 추정 — 숫자로 설계 방향을 결정한다

추정의 목적은 정확한 숫자가 아니다. 어떤 컴포넌트가 병목인가를 수치로 보여주는 것이다.

일일 새 URL 생성:    1억 건
읽기:쓰기 비율:      100:1
일일 리다이렉트:     100억 건

쓰기 QPS  = 1억 / 86,400 ≈ 1,160 QPS
읽기 QPS  = 1,160 × 100  = 116,000 QPS
피크 QPS  = 116,000 × 3  ≈ 350,000 QPS   ← 이게 설계 기준

URL 하나 크기:
  short_code: 7B
  long_url:   100B (평균)
  메타데이터: 30B (user_id, created_at, expires_at)
  합계:       ≈ 137B

5년 저장량:
  1억 × 365 × 5 × 137B ≈ 25TB
10년 저장량:
  1억 × 365 × 10 × 137B ≈ 50TB

이 숫자에서 나오는 결론:

  • 읽기 116,000 QPS → MySQL 단일 인스턴스로 불가능(한계 ~10,000 QPS). Redis 캐시 필수
  • 쓰기 1,160 QPS → MySQL 마스터 1대로 충분. DB 샤딩은 저장량 증가 시점에 필요
  • 10년 50TB → 단일 인스턴스 한계. 수평 샤딩 전략 필요

3. DB 선택 — WHY MySQL + Redis + Kafka인가

DB 선택은 “어떤 DB가 좋은가”가 아니라 “이 워크로드에 어떤 DB가 맞는가”의 문제다.

3-1. MySQL — URL 매핑 영구 저장소

graph LR
    API["API 서버"] --> MySQL["MySQL Master"]
    MySQL --> Replica["Read Replica × N"]
    API --> Replica

왜 MySQL인가?

URL 단축기의 핵심 데이터는 short_code → long_url 매핑이다. 얼핏 단순한 Key-Value 처럼 보이지만 다음 쿼리가 필요하다:

  • 사용자별 생성한 URL 목록 (WHERE user_id = ?)
  • 만료 URL 배치 삭제 (WHERE expires_at < NOW())
  • 동일 긴 URL 재단축 요청 시 기존 코드 반환 (WHERE long_url = ?)
  • 클릭 통계와 URL 정보의 JOIN

이 패턴은 관계형 쿼리가 필요한 구조다. DynamoDB 같은 NoSQL은 단순 KV 조회에는 강하지만 복잡한 필터·JOIN에서 약하다. MySQL은 이 쿼리들을 인덱스 기반으로 효율적으로 처리한다.

DynamoDB를 선택하지 않는 이유: 클릭 분석, 사용자 링크 목록, 만료 관리에서 복잡한 쿼리가 필요하다. DynamoDB는 파티션 키 기반 단순 조회에 최적화되어 있고, 관계형 쿼리를 지원하지 않는다. 운영 규모가 글로벌(Phase 4)로 확장될 때 DynamoDB Global Tables로 전환을 고려한다.

3-2. Redis — 리다이렉트 캐시 (sub-ms 지연)

왜 Redis인가?

읽기 QPS 116,000을 MySQL이 전부 처리하면 즉시 다운된다. Redis는 메모리 기반으로 단일 인스턴스에서 초당 100만 건 이상의 GET/SET을 처리한다. 리다이렉트 경로(short_code → long_url 조회)는 순수한 Key-Value 패턴이라 Redis에 완벽하게 맞는다.

캐시 히트율 80% 목표 시 DB에 도달하는 QPS: 116,000 × 0.2 = 23,200 QPS — MySQL 한계 이내.

Memcached를 선택하지 않는 이유: Redis는 Sorted Set(클릭 수 기반 인기 URL 추적), TTL 자동 만료(URL 만료 동기화), Pub/Sub(이벤트 알림)을 지원한다. Memcached는 단순 KV 캐시만 지원하여 이 추가 기능을 쓸 수 없다.

3-3. Kafka — 클릭 이벤트 비동기 수집

왜 Kafka인가?

클릭 이벤트(IP, User-Agent, Referer, 타임스탬프)를 리다이렉트 응답 경로에서 동기로 DB에 저장하면 리다이렉트 지연이 증가한다. 목표 응답 시간 100ms 중 DB 쓰기가 50ms를 차지하면 UX가 망가진다.

Kafka는 이벤트를 먼저 디스크에 순서대로 적재하고 Consumer가 비동기로 처리한다. 리다이렉트 요청은 Kafka Produce(~1ms) 후 즉시 302 응답을 반환한다. 분석 저장은 Consumer가 별도로 처리한다.

SQS/RabbitMQ를 선택하지 않는 이유: 클릭 이벤트는 초당 수십만 건이 발생한다. Kafka는 이 고처리량에 최적화되어 있고, 이벤트를 7일간 보관하여 재처리(클릭 집계 버그 발생 시 재계산)가 가능하다. SQS/RabbitMQ는 소비된 메시지를 삭제하여 재처리가 불가능하다.


4. 핵심 설계 결정 — 왜 이 선택인가

결정 1: 코드 생성 — Base62 인코딩 + Snowflake ID

문제: 서버 20대가 동시에 7자리 코드를 만들 때 중복이 발생하면 안 된다.

먼저 문자 집합 선택이다. 7자리로 몇 개의 URL을 표현할 수 있는가:

방식 문자 수 7자리 공간
숫자만 (0-9) 10 1,000만
Base62 (0-9, a-z, A-Z) 62 3.5조
Base64 (+ +, /) 64 4.4조

Base62를 선택하는 이유: URL에 특수문자(+, /) 없이 3.5조 공간 확보. 10년치 1억 건/일 = 3,650억 개 필요. 3.5조는 이를 10배 수용한다. Base64의 +, /는 URL 인코딩 시 %2B, %2F로 변환되어 단축 URL이 깨질 수 있다.

// Base62 인코딩 — 고유 숫자를 7자리 문자열로 변환
@Component
public class Base62Encoder {

    private static final String CHARS =
        "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
    private static final int BASE = 62;

    public String encode(long id) {
        if (id == 0) return String.valueOf(CHARS.charAt(0));
        StringBuilder sb = new StringBuilder();
        while (id > 0) {
            sb.append(CHARS.charAt((int)(id % BASE)));
            id /= BASE;
        }
        return sb.reverse().toString();
    }

    public long decode(String code) {
        long result = 0;
        for (char c : code.toCharArray()) {
            result = result * BASE + CHARS.indexOf(c);
        }
        return result;
    }
}

이제 “고유한 숫자”를 어떻게 만드는가 — Snowflake ID

여러 후보를 비교하면:

후보 장점 단점 결론
랜덤 문자열 구현 단순 충돌 확률 존재, 재시도 필요, 매번 DB 조회 소규모만 가능
MD5 해시 앞 7자리 같은 URL → 같은 코드(중복 방지) 해시 충돌 가능, 7자리 잘라내면 충돌 증가 위험
MySQL AUTO_INCREMENT + Base62 충돌 없음 서버 수평 확장 시 DB가 단일 병목 단일 서버만
Snowflake ID + Base62 전역 유일, 충돌 없음, 타임스탬프 포함 워커 ID 관리 필요 채택

Snowflake ID 구조:

64비트 구성:
  [1비트: 부호] [41비트: 타임스탬프] [10비트: 워커ID] [12비트: 시퀀스]

41비트 타임스탬프: 2^41ms ≈ 69년
10비트 워커ID:    최대 1,024대 서버 동시 운영
12비트 시퀀스:    동일 ms에 서버 1대당 최대 4,096개

서버 20대가 동일한 밀리초에 동시에 ID를 생성해도, 각 서버의 워커 ID 비트가 다르므로 수학적으로 충돌이 불가능하다.

@Component
public class SnowflakeIdGenerator {

    private static final long EPOCH = 1700000000000L; // 2023-11-15 기준
    private static final long WORKER_ID_BITS = 10L;
    private static final long SEQUENCE_BITS = 12L;
    private static final long MAX_SEQUENCE = ~(-1L << SEQUENCE_BITS);  // 4095
    private static final long WORKER_ID_SHIFT = SEQUENCE_BITS;
    private static final long TIMESTAMP_SHIFT = SEQUENCE_BITS + WORKER_ID_BITS;

    private final long workerId;
    private long lastTimestamp = -1L;
    private long sequence = 0L;

    public SnowflakeIdGenerator(@Value("${snowflake.worker-id}") long workerId) {
        this.workerId = workerId;
    }

    public synchronized long nextId() {
        long now = System.currentTimeMillis();

        if (now == lastTimestamp) {
            sequence = (sequence + 1) & MAX_SEQUENCE;
            if (sequence == 0) {
                // 동일 ms에 4096개 초과 — 다음 ms까지 대기
                now = waitNextMillis(lastTimestamp);
            }
        } else {
            sequence = 0L;
        }

        lastTimestamp = now;
        return ((now - EPOCH) << TIMESTAMP_SHIFT)
             | (workerId << WORKER_ID_SHIFT)
             | sequence;
    }

    private long waitNextMillis(long lastTs) {
        long ts = System.currentTimeMillis();
        while (ts <= lastTs) ts = System.currentTimeMillis();
        return ts;
    }
}

Base62 7자리 ← Snowflake ID 흐름:

graph LR
    REQ["단축 요청"] --> SF["Snowflake ID 생성"]
    SF --> B62["Base62 인코딩"]
    B62 --> CODE["7자리 코드"]
    CODE --> DB["MySQL 저장"]

결정 2: 301 vs 302 리다이렉트 — WHY 302인가

문제: 짧은 URL 접속 시 브라우저에 어떤 상태코드를 반환하는가. 이 결정이 클릭 분석 데이터 수집 가능 여부를 결정한다.

구분 301 (영구) 302 (임시)
브라우저 캐싱 캐시함 — 이후 서버 미경유 캐시 안 함 — 매번 서버 경유
서버 부하 낮음 높음
클릭 추적 불가 — 브라우저가 서버 없이 직접 이동 가능 — 매번 서버를 거쳐 이벤트 수집
long_url 변경 시 반영 안 됨 (캐시된 URL로 이동) 즉시 반영

302를 선택하는 이유: bit.ly의 수익 모델은 클릭 분석 데이터다. 광고주가 구매하는 것은 “몇 명이, 어느 나라에서, 어떤 기기로, 몇 시에 클릭했는가”다. 301이면 브라우저가 캐시하여 서버를 거치지 않으므로 클릭 이벤트 자체를 수집할 수 없다. 서버 부하 문제는 Redis 캐시로 해결한다.

301을 선택하면 어떻게 되는가: 사용자의 브라우저가 W7e3p2K → https://example.com/target 매핑을 캐시한다. 이후 클릭은 서버를 거치지 않아 클릭 카운트가 0으로 기록된다. 분석 리포트가 완전히 무의미해지고, longUrl을 변경해도 캐시된 브라우저는 구 URL로 이동한다.

@RestController
@RequestMapping("/")
public class RedirectController {

    private final UrlService urlService;
    private final KafkaTemplate<String, ClickEvent> kafkaTemplate;

    @GetMapping("/{shortCode}")
    public ResponseEntity<Void> redirect(
            @PathVariable String shortCode,
            HttpServletRequest request) {

        String longUrl = urlService.getLongUrl(shortCode)
            .orElseThrow(() -> new UrlNotFoundException(shortCode));

        // 클릭 이벤트 비동기 발행 — 응답 지연 없음
        ClickEvent event = ClickEvent.builder()
            .shortCode(shortCode)
            .ipMasked(maskIp(request.getRemoteAddr()))
            .userAgent(request.getHeader("User-Agent"))
            .referer(request.getHeader("Referer"))
            .clickedAt(Instant.now())
            .build();
        kafkaTemplate.send("click-events", shortCode, event);

        // 302 임시 리다이렉트 — 브라우저 캐시 안 함
        return ResponseEntity.status(HttpStatus.FOUND)
            .location(URI.create(longUrl))
            .build();
    }

    private String maskIp(String ip) {
        // GDPR: 마지막 옥텟 마스킹 (1.2.3.4 → 1.2.3.x)
        int lastDot = ip.lastIndexOf('.');
        return lastDot > 0 ? ip.substring(0, lastDot) + ".x" : ip;
    }
}

결정 3: 읽기 캐시 전략 — 파레토 기반 선별 캐시

문제: Redis에 모든 URL을 캐시할 수 없다. 어떤 URL을 캐시하고 어떤 URL을 DB에서 가져오는가.

전략 장점 단점
고정 TTL (모두 캐시) 구현 단순 인기 URL도 TTL 만료 후 미스, 비인기 URL이 공간 점유
LRU (최근 사용) 자동으로 비인기 URL 제거 최근 단 한 번 조회된 URL이 오래된 인기 URL 밀어냄
파레토 기반 트래픽 80%를 2GB로 처리 인기도 추적 로직 필요

URL 클릭 분포는 파레토 법칙을 따른다. 상위 20%의 URL이 전체 트래픽의 80%를 처리한다.

전체 URL = 1억 건 × 0.2 = 상위 2,000만 건
URL 하나 캐시 크기 = 7B(코드) + 100B(longUrl) = 107B
필요 Redis 메모리 = 2,000만 × 107B ≈ 2.14GB

Redis 서버 16GB → 충분

구현 — Redis Sorted Set으로 클릭 수 추적:

@Service
public class CacheWarmingService {

    private static final String HOT_URLS_KEY = "hot_urls";
    private static final int TOP_N = 1_000_000; // 상위 100만 개 추적

    private final RedisTemplate<String, String> redis;
    private final UrlRepository urlRepository;

    // 클릭마다 점수 증가
    public void recordClick(String shortCode) {
        redis.opsForZSet().incrementScore(HOT_URLS_KEY, shortCode, 1.0);
    }

    // 매 5분 상위 URL을 캐시에 유지
    @Scheduled(fixedDelay = 300_000)
    public void warmTopUrls() {
        Set<String> topCodes = redis.opsForZSet()
            .reverseRange(HOT_URLS_KEY, 0, TOP_N - 1);

        if (topCodes == null) return;

        topCodes.forEach(code -> {
            String cacheKey = "url:" + code;
            if (!Boolean.TRUE.equals(redis.hasKey(cacheKey))) {
                urlRepository.findByShortCode(code)
                    .ifPresent(url -> redis.opsForValue()
                        .set(cacheKey, url.getLongUrl(), 1, TimeUnit.HOURS));
            }
        });
    }
}

결정 4: 분산 ID 생성 — 왜 각 서버가 독립 생성인가

문제: 중앙 ID 생성 서버(Redis INCR)를 두는 방법과 각 서버가 독립 생성하는 방법 중 어느 것인가.

방식 장점 단점
Redis INCR (중앙 카운터) 구현 단순, 완벽한 순서 보장 Redis 단일 장애점. Redis 다운 = 전체 URL 생성 불가
DB AUTO_INCREMENT ACID 보장 쓰기 병목. DB가 모든 생성 요청의 병목
Snowflake (각 서버 독립) SPOF 없음, 선형 확장, 충돌 불가 워커 ID 설정·관리 필요

워커 ID는 Kubernetes 환경에서는 Pod 순번(StatefulSet 인덱스)으로 자동 할당하거나, ZooKeeper/Consul에서 시작 시 발급받는다.


5. 전체 아키텍처

graph LR
    Client["Browser"] --> LB["Load Balancer"]
    LB --> API["API 서버"]
    API --> Redis["Redis Cluster"]
    API --> MySQL["MySQL Master"]
    API --> Kafka["Kafka"]
    Kafka --> CH["ClickHouse"]

각 컴포넌트의 역할:

  • API 서버: 단축 URL 생성, 리다이렉트, 클릭 이벤트 Kafka 발행
  • Redis Cluster: 리다이렉트 캐시 (sub-ms 응답), 클릭 수 Sorted Set 관리
  • MySQL Master: URL 매핑 영구 저장, 쓰기 전용
  • Read Replica: 캐시 미스 시 조회, 사용자 링크 목록 쿼리
  • Kafka: 클릭 이벤트 버퍼 (리다이렉트 응답 경로 분리)
  • Flink: 실시간 클릭 집계 (1분 윈도우)
  • ClickHouse: 클릭 분석 쿼리 저장소 (컬럼형 OLAP)

6. URL 생성 흐름 — 쓰기 경로

graph LR
    REQ["POST /shorten"] --> CHECK["동일 URL 캐시 확인"]
    CHECK -->|"캐시 히트"| EXIST["기존 코드 반환"]
    CHECK -->|"미스"| SNOW["Snowflake ID 생성"]
    SNOW --> B62["Base62 인코딩"]
    B62 --> SAVE["MySQL INSERT"]
    SAVE --> RET["short URL 반환"]
@Service
@RequiredArgsConstructor
public class UrlShortenService {

    private final SnowflakeIdGenerator idGenerator;
    private final Base62Encoder encoder;
    private final UrlRepository urlRepository;
    private final RedisTemplate<String, String> redis;
    private final MaliciousUrlChecker maliciousUrlChecker;

    public ShortenResponse shorten(ShortenRequest request) {
        // 1. 악성 URL 동기 검사 (Google Safe Browsing)
        if (!maliciousUrlChecker.isSafe(request.getLongUrl())) {
            throw new MaliciousUrlException("악성 URL로 분류된 주소입니다");
        }

        // 2. 커스텀 코드 요청 처리
        if (StringUtils.hasText(request.getCustomAlias())) {
            return handleCustomAlias(request);
        }

        // 3. 동일 longUrl 중복 확인 (캐시 → DB 순서)
        String dedupeKey = "dedup:" + DigestUtils.sha256Hex(request.getLongUrl());
        String existingCode = redis.opsForValue().get(dedupeKey);
        if (existingCode != null) {
            return ShortenResponse.of(existingCode);
        }

        Optional<UrlMapping> existing = urlRepository.findByLongUrl(request.getLongUrl());
        if (existing.isPresent()) {
            redis.opsForValue().set(dedupeKey, existing.get().getShortCode(), 24, TimeUnit.HOURS);
            return ShortenResponse.of(existing.get().getShortCode());
        }

        // 4. 신규 코드 생성
        long id = idGenerator.nextId();
        String shortCode = encoder.encode(id);

        UrlMapping mapping = UrlMapping.builder()
            .shortCode(shortCode)
            .longUrl(request.getLongUrl())
            .userId(request.getUserId())
            .expiresAt(request.getExpiresAt())
            .createdAt(Instant.now())
            .build();

        urlRepository.save(mapping);

        // 5. 즉시 캐시 적재
        Duration ttl = request.getExpiresAt() != null
            ? Duration.between(Instant.now(), request.getExpiresAt())
            : Duration.ofDays(30);

        if (!ttl.isNegative()) {
            redis.opsForValue().set("url:" + shortCode, request.getLongUrl(), ttl);
        }

        return ShortenResponse.of(shortCode);
    }

    private ShortenResponse handleCustomAlias(ShortenRequest request) {
        String alias = request.getCustomAlias();
        validateCustomAlias(alias);  // 예약어·욕설·형식 검사

        UrlMapping mapping = UrlMapping.builder()
            .shortCode(alias)
            .longUrl(request.getLongUrl())
            .userId(request.getUserId())
            .expiresAt(request.getExpiresAt())
            .isCustom(true)
            .build();

        try {
            urlRepository.save(mapping);
        } catch (DataIntegrityViolationException e) {
            throw new AliasAlreadyTakenException("이미 사용 중인 별칭입니다: " + alias);
        }

        return ShortenResponse.of(alias);
    }
}

7. URL 리다이렉트 흐름 — 읽기 경로

graph LR
    HIT["Redis 캐시 히트 (80%)"] --> R302["302 응답"]
    MISS["캐시 미스 (20%)"] --> DB["Read Replica 조회"]
    DB --> WARM["Redis 캐시 적재"]
    WARM --> R302

캐시 미스 — Cache-Aside 패턴:

@Service
@RequiredArgsConstructor
public class UrlService {

    private final RedisTemplate<String, String> redis;
    private final UrlRepository urlRepository;
    private final CacheWarmingService cacheWarmingService;

    public Optional<String> getLongUrl(String shortCode) {
        // 1. L1: Redis 캐시 조회 (sub-ms)
        String cached = redis.opsForValue().get("url:" + shortCode);
        if (cached != null) {
            cacheWarmingService.recordClick(shortCode);  // 클릭 수 증가
            return Optional.of(cached);
        }

        // 2. L2: DB 조회 (캐시 미스)
        Optional<UrlMapping> mapping = urlRepository.findByShortCode(shortCode);

        mapping.ifPresent(m -> {
            // 만료 확인
            if (m.getExpiresAt() != null && m.getExpiresAt().isBefore(Instant.now())) {
                return; // 만료 URL은 캐시하지 않음
            }

            // Redis에 적재 (TTL = URL 만료까지 남은 시간 or 기본 1시간)
            Duration ttl = m.getExpiresAt() != null
                ? Duration.between(Instant.now(), m.getExpiresAt())
                : Duration.ofHours(1);

            redis.opsForValue().set("url:" + shortCode, m.getLongUrl(), ttl);
            cacheWarmingService.recordClick(shortCode);
        });

        return mapping.map(UrlMapping::getLongUrl);
    }
}

8. 데이터베이스 설계

-- URL 매핑 테이블
CREATE TABLE url_mappings (
    id          BIGINT          NOT NULL,           -- Snowflake ID
    short_code  VARCHAR(30)     NOT NULL,           -- Base62 코드 또는 커스텀 별칭
    long_url    VARCHAR(2048)   NOT NULL,
    user_id     BIGINT,
    is_custom   BOOLEAN         NOT NULL DEFAULT FALSE,
    created_at  DATETIME(3)     NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
    expires_at  DATETIME(3),
    PRIMARY KEY (id),
    UNIQUE  KEY uk_short_code (short_code),          -- 코드 중복 방지
    INDEX       idx_long_url   (long_url(255)),       -- 동일 URL 재단축 요청 조회
    INDEX       idx_user_id    (user_id),             -- 사용자별 링크 목록
    INDEX       idx_expires_at (expires_at)           -- 만료 배치 작업
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- 클릭 통계 집계 테이블 (Flink가 적재)
CREATE TABLE click_stats (
    short_code  VARCHAR(30)     NOT NULL,
    period      DATETIME        NOT NULL,            -- 1시간 버킷
    granularity ENUM('hour','day') NOT NULL,
    clicks      BIGINT          NOT NULL DEFAULT 0,
    unique_ips  BIGINT          NOT NULL DEFAULT 0,
    PRIMARY KEY (short_code, period, granularity),
    INDEX       idx_period      (period)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

long_url에 인덱스가 필요한가: 같은 긴 URL을 두 번 단축 요청할 때 기존 코드를 반환해야 한다. long_url은 최대 2048자이므로 전체 컬럼에 B-Tree 인덱스를 걸면 페이지가 커진다. long_url(255) 프리픽스 인덱스로 타협 — 255자 이내로 구분되는 URL은 인덱스로 처리하고, 충돌 시 full scan으로 확인한다.

왜 Snowflake ID를 PK로 쓰는가: AUTO_INCREMENT PK는 MySQL 마스터에서만 생성할 수 있어 서버 수평 확장 시 병목이 된다. Snowflake ID는 각 애플리케이션 서버에서 독립 생성하므로 DB 의존 없이 PK가 결정된다. 시간순 단조 증가라 B-Tree 인덱스 재균형도 최소화된다.


9. Analytics 파이프라인 — 클릭 이후의 여정

graph LR
    API["API 서버"] --> Kafka["Kafka 토픽"]
    Kafka --> Flink["Flink 집계"]
    Kafka --> CH["ClickHouse 원시"]
    Flink --> Redis["Redis 실시간 카운터"]
    CH --> Dash["분석 대시보드"]

Kafka Consumer → Flink 집계 흐름:

// 클릭 이벤트 도메인 모델
@Data
@Builder
public class ClickEvent {
    private String shortCode;
    private String ipMasked;      // 마지막 옥텟 마스킹
    private String userAgent;
    private String referer;
    private String country;       // GeoIP 변환 후 채움
    private Instant clickedAt;
}
// Kafka Producer — 리다이렉트 후 비동기 발행
@Component
@RequiredArgsConstructor
public class ClickEventPublisher {

    private final KafkaTemplate<String, ClickEvent> kafka;
    private static final String TOPIC = "click-events";

    public void publish(ClickEvent event) {
        // shortCode를 파티션 키로 사용 → 동일 코드의 이벤트는 같은 파티션
        kafka.send(TOPIC, event.getShortCode(), event)
            .addCallback(
                result -> {},
                ex -> log.warn("클릭 이벤트 발행 실패: {}", event.getShortCode(), ex)
            );
    }
}

ClickHouse 원시 이벤트 저장 (OLAP):

-- ClickHouse: 수백억 행 집계를 초 단위로 처리
CREATE TABLE click_events (
    short_code  String,
    ip_masked   String,
    user_agent  String,
    country     String,
    clicked_at  DateTime
) ENGINE = MergeTree()
PARTITION BY toYYYYMM(clicked_at)
ORDER BY (short_code, clicked_at);

-- 시간별 클릭 집계 쿼리 (수억 행도 수초 처리)
SELECT
    toStartOfHour(clicked_at)   AS hour,
    count()                     AS clicks,
    uniqExact(ip_masked)        AS unique_visitors
FROM click_events
WHERE short_code = 'W7e3p2K'
  AND clicked_at >= now() - INTERVAL 7 DAY
GROUP BY hour
ORDER BY hour DESC;

왜 ClickHouse인가: MySQL로 수억 건 클릭 로그를 집계하면 수십 분이 걸린다. ClickHouse는 컬럼형 스토리지로 집계 쿼리에서 MySQL 대비 100~1000배 빠르다. MergeTree 엔진은 초당 수백만 행 삽입을 지원한다.


10. URL 만료 처리 — Lazy + Eager 조합

만료 처리에는 두 가지 접근이 있다. 실무에서는 두 가지를 조합한다.

Lazy 검사 — 요청 시 만료 확인:

@GetMapping("/{shortCode}")
public ResponseEntity<Void> redirect(@PathVariable String shortCode) {
    UrlMapping mapping = urlRepository.findByShortCode(shortCode)
        .orElseThrow(() -> new UrlNotFoundException(shortCode));

    // 만료 확인
    if (mapping.getExpiresAt() != null
            && mapping.getExpiresAt().isBefore(Instant.now())) {
        // 410 Gone: 의도적으로 제거된 리소스 (404와 구분)
        // 검색 엔진이 410을 받으면 색인에서 즉시 제거
        return ResponseEntity.status(HttpStatus.GONE).build();
    }

    kafkaTemplate.send("click-events", buildClickEvent(shortCode));
    return ResponseEntity.status(HttpStatus.FOUND)
        .location(URI.create(mapping.getLongUrl()))
        .build();
}

Eager 정리 — 새벽 배치 삭제:

@Component
@RequiredArgsConstructor
public class ExpiredUrlCleaner {

    private final JdbcTemplate jdbc;
    private final RedisTemplate<String, String> redis;

    // 매일 새벽 3시 실행
    @Scheduled(cron = "0 0 3 * * *")
    public void cleanExpiredUrls() {
        // 만료 후 30일 유예 기간 (분쟁 대응)
        // LIMIT으로 배치 분할 → 대량 잠금 방지
        int deleted;
        do {
            deleted = jdbc.update("""
                DELETE FROM url_mappings
                WHERE expires_at < NOW() - INTERVAL 30 DAY
                LIMIT 5000
                """);
            log.info("만료 URL 삭제: {}건", deleted);

            if (deleted > 0) {
                try { Thread.sleep(100); } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                    break;
                }
            }
        } while (deleted == 5000); // 5000건 미만이면 완료
    }
}

왜 410 Gone인가: 404는 “없거나 임시 오류”를 의미한다. 검색 엔진은 404를 받으면 나중에 재시도할 수 있다고 판단한다. 410은 “의도적으로 영구 삭제”를 의미하여 검색 엔진이 해당 URL을 색인에서 즉시 제거한다.


11. 수평 확장 — 언제 샤딩이 필요한가

10년 저장량이 50TB다. MySQL 단일 인스턴스로는 한계가 있다. 그러나 처음부터 샤딩을 설계하면 오버엔지니어링이다. 어느 시점에 샤딩이 필요한가:

  • 단일 MySQL 인스턴스 저장량 > 5TB
  • 캐시 미스 후 DB 읽기 QPS > 8,000 QPS
  • 쓰기 QPS > 5,000 QPS

Consistent Hashing 샤딩 전략:

단순 hash(short_code) % N 방식은 샤드 수를 4 → 5로 늘리는 순간 대부분의 키가 다른 샤드로 이동한다 (약 80% 재배치). Consistent Hashing은 샤드 추가 시 전체의 1/N만 재배치한다.

@Component
public class ConsistentHashRouter {

    private final TreeMap<Long, DataSource> ring = new TreeMap<>();
    private static final int VIRTUAL_NODES = 150; // 샤드당 가상 노드 수

    public void addShard(String shardId, DataSource ds) {
        for (int i = 0; i < VIRTUAL_NODES; i++) {
            long hash = hash(shardId + "#" + i);
            ring.put(hash, ds);
        }
    }

    public DataSource getShardFor(String shortCode) {
        long hash = hash(shortCode);
        Map.Entry<Long, DataSource> entry = ring.ceilingEntry(hash);
        // 링의 끝을 넘어가면 첫 번째 샤드로 wrap-around
        return entry != null ? entry.getValue() : ring.firstEntry().getValue();
    }

    private long hash(String key) {
        // MurmurHash3 — CRC32보다 분산 균일
        return Hashing.murmur3_128().hashString(key, StandardCharsets.UTF_8)
            .asLong() & Long.MAX_VALUE;
    }
}

가상 노드 (Virtual Nodes): 샤드 1대를 링 위에 150개의 가상 노드로 분산 배치한다. 샤드 간 데이터 불균형(핫스팟)을 방지하고, 샤드 추가·제거 시 부하가 여러 샤드에 고르게 분산된다.

modulo 해싱:         샤드 4→5개 시 ~80% 키가 다른 샤드로 이동
Consistent Hashing:  샤드 4→5개 시 ~20%(1/N)의 키만 이동

12. 보안 — 악성 URL 차단 다중 레이어

@Service
@RequiredArgsConstructor
public class MaliciousUrlChecker {

    private final SafeBrowsingClient safeBrowsing;  // Google Safe Browsing API
    private final RedisTemplate<String, String> redis;

    // 동기 검사 — URL 생성 시 (SLA: 200ms 이내)
    public boolean isSafe(String url) {
        // Redis 블랙리스트 먼저 확인 (이미 악성으로 판정된 URL)
        String hash = DigestUtils.sha256Hex(url);
        if (Boolean.TRUE.equals(redis.hasKey("blocked:url:" + hash))) {
            return false;
        }

        return safeBrowsing.check(url);
    }

    // 비동기 재검사 — 등록 후 24시간 뒤 (나중에 악성으로 등록된 URL 대응)
    @KafkaListener(topics = "url-recheck", groupId = "malicious-checker")
    public void recheckAsync(String shortCode) {
        urlRepository.findByShortCode(shortCode).ifPresent(mapping -> {
            if (!isSafe(mapping.getLongUrl())) {
                // Redis에 차단 플래그 설정
                redis.opsForValue().set(
                    "blocked:" + shortCode, "1", 30, TimeUnit.DAYS);
                log.warn("악성 URL 사후 탐지: {}", shortCode);
            }
        });
    }
}

커스텀 슬러그 보호:

@Component
public class CustomAliasValidator {

    private static final Set<String> RESERVED = Set.of(
        "api", "admin", "health", "login", "signup", "help",
        "support", "about", "terms", "privacy", "docs", "status"
    );
    private static final Pattern VALID_PATTERN = Pattern.compile("^[a-zA-Z0-9_-]{4,30}$");

    public void validate(String alias) {
        if (!VALID_PATTERN.matcher(alias).matches()) {
            throw new InvalidAliasException("영문, 숫자, _, - 만 사용 가능 (4~30자)");
        }
        if (RESERVED.contains(alias.toLowerCase())) {
            throw new InvalidAliasException("예약된 별칭: " + alias);
        }
    }
}

클릭 데이터 프라이버시 (GDPR):

  • IP는 저장 전 마지막 옥텟 마스킹 (1.2.3.4 → 1.2.3.x)
  • User-Agent는 기기/OS 파싱 후 원본 삭제
  • GDPR 삭제 요청 시 해당 user_id의 click_events를 90일 이내 삭제

13. Day 1 → Scale 진화

URL 단축기를 처음부터 Consistent Hashing 샤딩과 Redis Cluster로 구축하면 과잉 설계다. 읽기 QPS와 저장량 규모에 맞게 단계적으로 진화해야 한다.

Phase 1 — 일 생성 1만 건, 일 리다이렉트 100만 건

아키텍처: API 서버 1대 + MySQL + AUTO_INCREMENT + Redis 단일 노드

항목 선택 이유
코드 생성 MySQL AUTO_INCREMENT + Base62 단일 서버라 충돌 없음
리다이렉트 Redis → MySQL 순서 읽기 QPS ~12, MySQL도 충분
분석 MySQL clicks 테이블 직접 INSERT Kafka 불필요
월 비용 EC2 t3.medium + RDS db.t3.medium ~$95/월

Phase 2 — 일 생성 100만 건, 일 리다이렉트 1억 건

아키텍처: Snowflake ID 도입 + Redis 캐시 + Kafka 비동기 분석

항목 변경 이유  
Snowflake ID 서버 수평 확장 준비. AUTO_INCREMENT는 DB 병목  
Kafka 도입 클릭 분석을 리다이렉트 경로에서 분리. 응답 시간 보호  
파레토 캐시 클릭 수 상위 20% URL 자동 캐시. 히트율 80% 목표  
월 비용 EC2 × 3 + RDS Multi-AZ + ElastiCache + Kafka MSK ~$1,300/월

Phase 3 — 일 생성 1000만 건, 일 리다이렉트 10억 건

아키텍처: MySQL 샤딩 + Redis Cluster + ClickHouse 분석

항목 변경 이유
MySQL 4샤드 저장량 10TB 초과, 단일 인스턴스 한계
Redis Cluster 캐시 용량 및 고가용성
ClickHouse 도입 수억 건 클릭 집계. MySQL로는 수십 분 → ClickHouse 수초
월 비용 ~$6,800/월

Phase 4 — 일 생성 1억 건, 일 리다이렉트 100억 건 (글로벌)

아키텍처: 멀티리전 + DynamoDB 전환 + CDN 엣지 리다이렉트

항목 변경 이유
MySQL → DynamoDB Global Tables 자동 샤딩, 멀티리전 복제
CloudFront Functions 엣지에서 직접 리다이렉트 (지연 20ms 이하)
리전별 Snowflake 워커 리전 비트로 전역 유일성 보장
월 비용 ~$43,000/월

14. 면접 포인트 5가지

Q. 301 vs 302 — 어느 것을 선택하고 그 이유는?

답변 구조: 302를 선택한다. 이유는 비즈니스 요구사항에서 시작한다.

301은 브라우저가 캐시하여 이후 요청을 서버에 보내지 않는다. 클릭 추적이 불가능해진다. bit.ly의 수익 모델은 클릭 분석 데이터 판매다. 몇 명이 어느 나라에서, 어떤 기기로, 몇 시에 클릭했는가를 광고주에게 제공한다. 301로 배포하면 브라우저가 캐시하여 클릭 카운트가 0으로 기록된다. 분석 리포트가 완전히 무의미해진다.

부하 문제는 Redis 캐시로 해결한다. 302라도 Redis에서 응답하면 캐시 히트율 80%에서 DB 부하는 원래의 20%로 줄어든다.

추가 포인트: 만약 순수하게 URL 단축만 필요하고 분석이 불필요하다면 301이 맞다. 결정은 비즈니스 모델에서 나온다.

Q. 코드 생성 전략 — Snowflake vs 랜덤 vs 해시의 트레이드오프는?

랜덤 Base62: 구현이 단순하다. 그러나 코드 생성 시마다 DB에서 중복 확인이 필요하다. URL이 쌓일수록 충돌 확률이 증가한다 (생일 역설). 3.5조 공간에서 10억 개가 존재하면 충돌 확률은 1 - e^(-n²/2m) ≈ 14%다.

MD5 해시 앞 7자리: 같은 URL은 항상 같은 코드가 나와 중복 저장을 자동으로 방지한다. 그러나 128비트 해시를 7자리(42비트)로 잘라내면 충돌이 증가한다. 충돌 발생 시 재생성 로직이 필요하다.

Snowflake ID + Base62: 구조적으로 충돌이 불가능하다. 타임스탬프가 포함되어 최근 생성 URL을 날짜 범위로 조회할 수 있다. 워커 ID 관리 인프라가 필요하지만, 이는 Kubernetes StatefulSet 인덱스로 자동화할 수 있다. 대규모 분산 환경의 표준이다.

Q. 초당 10만 건 단축 요청을 처리하려면?

병목은 DB 쓰기다. Aurora MySQL 단일 마스터는 약 1~2만 TPS가 한계다.

해결 방법:

  1. Snowflake ID를 각 서버가 독립 생성 → DB 시퀀스 의존 제거
  2. 코드 사전 생성 풀 (Pre-generation Pool) — 백그라운드 스레드가 코드를 미리 생성해 메모리 큐에 적재, 요청 시 큐에서 꺼냄
@Component
public class ShortCodePool {

    private final BlockingQueue<String> pool = new LinkedBlockingQueue<>(10_000);
    private final SnowflakeIdGenerator idGenerator;
    private final Base62Encoder encoder;

    @PostConstruct
    public void init() { refillPool(); }

    public String acquire() throws InterruptedException {
        return pool.poll(100, TimeUnit.MILLISECONDS);
    }

    @Scheduled(fixedDelay = 1000)
    public void refillPool() {
        int deficit = 10_000 - pool.size();
        for (int i = 0; i < deficit; i++) {
            pool.offer(encoder.encode(idGenerator.nextId()));
        }
    }
}
  1. 단축 요청을 Kafka에 먼저 쌓고 Consumer가 배치 INSERT → DB 쓰기 I/O 최소화

Q. 만료된 코드 재사용 정책은?

만료된 코드를 즉시 재사용하면 문제가 생긴다. 브라우저 캐시에 구 URL이 남아있을 수 있고, 검색 엔진 색인에 구 URL이 있을 수 있다. 재사용하면 새 URL과 구 URL이 혼재한다.

안전한 정책: 만료 후 최소 30일 Grace Period 유지 후 재사용 허용. 또는 만료 코드를 재사용하지 않고 새 코드 생성 (공간 낭비이지만 단순). Base62 7자리 = 3.5조이므로 10년치 URL 3,650억 개를 수용하고도 공간이 남는다. 재사용 없이 운영해도 문제없다.

Redis TTL 동기화: URL 만료 시 Redis 캐시도 함께 만료되어야 한다. EXPIREAT 명령으로 DB의 expires_at과 Redis TTL을 동기화한다. URL을 수동 삭제할 때는 DEL url:{shortCode}를 즉시 호출한다.

Q. 극한 트래픽 — 방송에서 bit.ly 링크가 노출되면?

유명 방송에서 단축 URL이 노출되면 순간 트래픽이 평소의 100배가 된다. 초당 50만 요청이 단일 URL에 집중된다. 이를 Hot URL 문제라 한다.

방어 계층:

1단계: 각 API 서버 로컬 캐시 (Caffeine)
   → 상위 1,000개 URL을 프로세스 메모리에 보관
   → 네트워크 없이 마이크로초 응답

2단계: Redis 캐시
   → 로컬 캐시 미스 시 Redis 조회
   → 수십만 QPS 처리 가능

3단계: DB Read Replica
   → Redis 미스 시 DB 조회 (전체 요청의 ~1%)
// 로컬 캐시 (Caffeine) — L1 캐시
@Bean
public Cache<String, String> localUrlCache() {
    return Caffeine.newBuilder()
        .maximumSize(1_000)          // 상위 1,000개 URL
        .expireAfterWrite(5, TimeUnit.MINUTES)
        .recordStats()
        .build();
}

// 읽기 순서: L1(로컬) → L2(Redis) → L3(DB)
public Optional<String> getLongUrl(String shortCode) {
    // L1
    String local = localUrlCache.getIfPresent(shortCode);
    if (local != null) return Optional.of(local);

    // L2
    String cached = redis.opsForValue().get("url:" + shortCode);
    if (cached != null) {
        localUrlCache.put(shortCode, cached);
        return Optional.of(cached);
    }

    // L3
    return urlRepository.findByShortCode(shortCode)
        .map(m -> {
            redis.opsForValue().set("url:" + shortCode, m.getLongUrl(),
                1, TimeUnit.HOURS);
            localUrlCache.put(shortCode, m.getLongUrl());
            return m.getLongUrl();
        });
}

15. 극한 시나리오 분석

시나리오 1: Redis 클러스터 전체 다운

상황: Redis Cluster 6노드가 네트워크 파티션으로 전부 응답 불가. 캐시 히트율 0%로 떨어진다.

영향: 읽기 QPS 116,000이 전부 MySQL DB로 직접 도달한다. MySQL 한계(~10,000 QPS)를 10배 초과. DB 커넥션 풀 고갈 → 리다이렉트 응답 실패.

방어:

  1. 각 API 서버 로컬 캐시 (Caffeine) — Redis 없이도 상위 1,000개 URL 응답 가능
  2. Circuit Breaker (Resilience4j) — DB 과부하 시 일부 요청을 즉시 503으로 차단, DB 보호
  3. Read Replica 추가 투입 — 평소에는 캐시 미스만 처리, 비상 시 전체 트래픽 수용
@Service
public class UrlService {

    @CircuitBreaker(name = "db-fallback", fallbackMethod = "fallbackUrl")
    public Optional<String> getLongUrlFromDb(String shortCode) {
        return urlRepository.findByShortCode(shortCode)
            .map(UrlMapping::getLongUrl);
    }

    // DB도 다운 시 fallback — 503 응답
    public Optional<String> fallbackUrl(String shortCode, Exception ex) {
        log.error("DB 조회 실패 — circuit open: {}", shortCode);
        return Optional.empty();  // Controller에서 503 반환
    }
}

시나리오 2: Snowflake 워커 ID 중복 설정

상황: 배포 자동화 버그로 서버 A와 서버 B가 동일한 워커 ID(42번)로 시작한다. 동일 밀리초에 두 서버가 ID를 생성하면 (타임스탬프 동일) + (워커 ID 동일) + (시퀀스 동일) 조건이 겹칠 수 있다.

영향: 동일한 short_code가 두 URL에 부여된다. MySQL UNIQUE KEY가 두 번째 INSERT를 거부. 한 요청은 HTTP 500으로 실패한다. 더 심각한 경우, 레이스 컨디션으로 두 URL이 모두 INSERT 성공하면 하나의 코드가 두 개의 원본 URL을 가리키는 데이터 오염이 발생한다.

방어:

  1. 워커 ID를 Kubernetes Pod 인덱스에서 자동 할당 (K8S_POD_INDEX 환경변수)
  2. 시작 시 ZooKeeper/etcd에서 워커 ID를 원자적으로 발급받고, 종료 시 반납
  3. 모니터링: 코드 충돌 건수 메트릭. 하루 1건이라도 알람 → 즉각 워커 ID 설정 점검

시나리오 3: 봇에 의한 URL 대량 생성 공격

상황: 악의적인 봇이 초당 5,000건의 URL 생성 요청을 보낸다. DB 쓰기 QPS가 한계를 초과하고, 코드 공간이 빠르게 소진된다.

방어:

// Rate Limiter — 사용자별 초당 10건, 미인증 IP별 초당 1건
@Component
public class RateLimitFilter implements HandlerInterceptor {

    private final RateLimiter<String> limiter;

    @Override
    public boolean preHandle(HttpServletRequest req, HttpServletResponse res,
                              Object handler) throws Exception {
        String key = extractRateLimitKey(req);  // userId or IP
        if (!limiter.tryAcquire(key)) {
            res.setStatus(HttpStatus.TOO_MANY_REQUESTS.value());
            res.getWriter().write("{\"error\": \"Rate limit exceeded\"}");
            return false;
        }
        return true;
    }
}

추가로 Google reCAPTCHA를 URL 생성 API에 적용하여 봇 요청을 프론트엔드에서 1차 차단한다.

시나리오 4: 유명 링크 삭제 요청

상황: 기업 법무팀이 특정 단축 URL에 상표권 침해 콘텐츠가 연결된다고 삭제를 요청한다. 해당 URL은 하루 수백만 클릭의 인기 링크다.

처리 흐름:

  1. 관리자 API로 short_code 비활성화 (is_active = false 업데이트)
  2. Redis에서 url:{shortCode} 즉시 삭제 (DEL)
  3. Redis에 차단 플래그 설정 (SET blocked:{shortCode} 1)
  4. 이후 리다이렉트 요청은 차단 안내 페이지로 응답 (HTTP 451 — 법적 이유로 차단)
  5. 각 API 서버 로컬 캐시(Caffeine)도 무효화 → 캐시 무효화 이벤트를 Redis Pub/Sub으로 전파

16. 실무에서 놓치기 쉬운 케이스

케이스 1: 충돌 재시도 없는 랜덤 코드 생성

Snowflake ID 대신 랜덤 Base62를 쓰는 경우, 충돌 시 재시도 로직이 없으면 HTTP 500이 반환된다.

@Service
public class RandomCodeShortenService {

    private static final int MAX_RETRIES = 3;

    public String createShortUrl(String longUrl) {
        for (int attempt = 0; attempt < MAX_RETRIES; attempt++) {
            String code = generateRandomBase62(7);
            try {
                urlRepository.save(new UrlMapping(code, longUrl));
                return "https://short.ly/" + code;
            } catch (DataIntegrityViolationException e) {
                // UNIQUE 충돌 — 재시도
                log.warn("코드 충돌 {}회차: {}", attempt + 1, code);
            }
        }
        // 3회 재시도 후 Snowflake ID 폴백 (충돌 불가능)
        return createWithSnowflakeId(longUrl);
    }
}

케이스 2: 캐시 TTL 설정 누락

// 나쁜 예: TTL 없이 캐시 — 만료된 URL도 영구 캐시
redis.opsForValue().set("url:" + code, longUrl);

// 좋은 예: URL 만료 시각과 동기화
Instant expiresAt = mapping.getExpiresAt();
if (expiresAt != null) {
    Duration ttl = Duration.between(Instant.now(), expiresAt);
    if (ttl.isPositive()) {
        redis.opsForValue().set("url:" + code, longUrl, ttl);
    }
    // ttl이 음수 = 이미 만료 — 캐시하지 않음
} else {
    // 만료 없는 URL은 1시간 TTL (메모리 절약)
    redis.opsForValue().set("url:" + code, longUrl, 1, TimeUnit.HOURS);
}

케이스 3: 분석을 동기 처리해 리다이렉트 지연

// 나쁜 예: DB 쓰기가 리다이렉트 응답 경로에 포함
@GetMapping("/{code}")
public ResponseEntity<Void> redirect(@PathVariable String code) {
    String longUrl = getLongUrl(code);
    clickRepository.save(new ClickEvent(code, request.getRemoteAddr())); // 동기 DB 쓰기
    return ResponseEntity.status(302).location(URI.create(longUrl)).build();
}

// 좋은 예: Kafka 비동기 발행 (~1ms) 후 즉시 응답
@GetMapping("/{code}")
public ResponseEntity<Void> redirect(@PathVariable String code) {
    String longUrl = getLongUrl(code);
    kafka.send("click-events", buildEvent(code, request)); // 비동기, ~1ms
    return ResponseEntity.status(302).location(URI.create(longUrl)).build();
}

17. 핵심 메트릭

URL 단축기에서 이 다섯 숫자가 동시에 정상이면 서비스는 건강하다.

메트릭 정상 기준 이상 신호 원인 가설
리다이렉트 P99 100ms 이내 500ms 초과 Redis 캐시 미스 급증, DB 응답 지연, 핫 URL Redis 노드 집중
코드 생성 P99 50ms 이내 200ms 초과 Snowflake 서버 과부하, DB INSERT 경합
캐시 히트율 80% 이상 60% 미만 신규 URL 폭증으로 파레토 분포 깨짐, Redis 메모리 부족
코드 충돌 건수 0건/일 1건 이상 Snowflake 워커 ID 중복 설정
일 생성 URL 수 설계 용량 70% 이하 90% 초과 봇 공격, 바이럴 이벤트

핵심 알람:

리다이렉트 P99 > 200ms   → PagerDuty P1 (Redis 상태 즉시 확인)
캐시 히트율 < 70%        → Slack 알림 (Redis 메모리·캐시 워밍 확인)
코드 충돌 1건            → PagerDuty P0 (워커 ID 설정 즉시 점검)
일 생성 URL > 용량 85%   → Auto Scaling 트리거 + Slack 알림
악성 URL > 100건/시      → Slack 알림 (봇 공격 의심, Rate Limit 강화)

18. 설계 결정 요약

결정 선택 왜 이 선택인가
코드 생성 Snowflake ID + Base62 분산 환경 충돌 불가, 7자리 3.5조 공간
리다이렉트 302 임시 클릭 추적 가능 — 비즈니스 수익 모델
URL 영구 저장 MySQL 관계형 쿼리(사용자 목록, 만료 관리, JOIN) 필요
리다이렉트 캐시 Redis Cluster 읽기 116K QPS를 DB에서 분리, sub-ms 응답
분석 저장 ClickHouse 수억 건 집계를 초 단위 처리
클릭 이벤트 수집 Kafka 비동기 리다이렉트 응답 경로에서 분리, 재처리 가능
수평 확장 Consistent Hashing 샤드 추가 시 재배치 최소화 (1/N)
만료 처리 Lazy + Eager 배치 조합 즉시 응답(Lazy) + DB 용량 관리(Eager)
악성 URL Safe Browsing + 비동기 재검사 생성 시 동기 차단 + 사후 악성 등록 대응

URL 단축기는 “짧은 코드를 만든다”는 단순한 기능처럼 보이지만, 읽기:쓰기 100:1 비율이 캐시 전략을, 클릭 분석 수익 모델이 302 선택을, 분산 환경이 Snowflake ID를 강제한다. 모든 설계 결정은 요구사항에서 시작해야 한다. 면접에서 “왜 이 선택인가”를 항상 요구사항으로 거슬러 올라가 설명하는 것이 고점 답변의 핵심이다.


함께 읽으면 좋은 글

댓글

이 글이 도움이 됐다면?

같은 카테고리의 다른 글도 확인해보세요

더 많은 글 보기 →