[System Design Lab] 1. URL Shortener, 병목을 찾아 Redis로 개선하기

5 분 소요

Repository : [Github] URL-Shortener

가장 단순한 구조부터 시작하기

Backend System Design Lab의 첫 번째 시스템으로 URL Shortener를 구현했다.

URL Shortener의 기능 자체는 단순하다.

긴 URL 입력
→ 단축 코드 생성
→ 저장

단축 URL 요청
→ 원본 URL 조회
→ Redirect

하지만 요청량이 많아지면 이야기가 달라진다.

이번 프로젝트에서는 하루 URL 생성 1억 건, 조회 10억 건을 가정 했었다.(책 기준) 읽기와 쓰기의 비율은 약 10:1이고, 피크 트래픽은 약 38,000 RPS로 계산됐다.

하지만 운영 규모를 로컬에서 그대로 재현하기보다, 같은 조건에서 부하를 높이며 구조별 병목과 개선 효과를 비교하는 것을 목표로 했다.


우선은 처음부터 Redis나 분산 구조를 넣기보다 가장 단순한 구조에서 시작했다.

Client
  ↓
Spring Boot
  ↓
MySQL

이유는 단순했다.

먼저 가장 단순한 구조의 한계를 확인한 뒤, 실제로 문제가 발생한 지점을 기준으로 다음 구조를 결정한다.


단축 코드는 MySQL의 Auto Increment ID를 Base62로 변환하는 Sequence 방식을 사용했다.

INSERT
→ ID 발급
→ Base62 변환
→ shortCode 저장

충돌 확인이 필요 없고 구현이 단순하다는 장점이 있었다.

Redirect는 301 이 아닌 302 Found 를 사용했다. 클라이언트 캐시의 영향을 줄이고 부하테스트 요청이 계속 서버를 통과하도록 하기 위해서였다.


부하를 올리면 어디서 느려질까

먼저 Redis 없이 모든 Redirect 요청이 MySQL을 조회하도록 두고 k6로 부하를 발생시켰다.

k6
 ↓
Spring Boot
 ↓
MySQL

20, 50, 100 VU로 부하를 올린 결과는 다음과 같았다.

VU RPS p95 p99
20 7,950 4.35ms 6.28ms
50 7,379 16.17ms 27.73ms
100 8,531 32.96ms 49.88ms

요청 실패는 없었지만 VU가 증가할수록 응답 지연이 빠르게 증가했다.

처음에는 단순히 “MySQL 조회가 많아서 느리다” 정도로 생각했지만, Grafana에서 서버 지표를 같이 확인하닌 더 명확한 원이 보였다.

100 VU에서:

HikariCP Active  → 10
HikariCP Pending → 최대 약 85

기본으로 설정한 DB Connection 10개를 모두 사용하고 있었고, 그 뒤에서 약 85개의 요청이 커넥션을 기다리고 있었다.

즉 문제는 단순히 Thread 수가 부족해서가 아니라,

모든 Redirect 요청이 MySQL을 조회하면서 DB Connection을 기다리고 있다는 것

이었다.


Redis Cache Aside 적용하기

URL Shortener는 같은 단축 URL이 반복해서 조회될 가능성이 높다.

그런데 원본 URL은 생성 이후 거의 변경되지 않는다.

매 요청마다 MySQL을 조회하는 대신 Redis에 원본 URL을 저장해두면 반복 조회를 DB까지 보낼 필요가 없다.

그래서 Redirect 경로에 Redis Cache Aside를 적용했다.

Redis 조회
   ↓
Cache Hit
→ 원본 URL 반환

Cache Miss
→ MySQL 조회
→ Redis 저장
→ 원본 URL 반환

MySQL은 계속 Source of Truth로 유지하고 Redis는 조회 성능을 위한 보조 저장소로만 사용했다.

같은 100 VU 조건으로 다시 측정했다.

지표 DB Only Redis
RPS 8,531.95 17,371.33
p95 32.96ms 11.93ms
p99 49.88ms 21.52ms
실패율 0% 0%

RPS는 약 2배 증가했고 p95는 약 64% 감소했다.

더 눈에 띈 것은 DB 조회 수 였다.

DB Only
DB Lookup 366,248회

Redis
DB Lookup 1회

최초 요청에서만 MySQL을 조회하고 이후 요청은 Redis에서 처리됐다.

HikariCP Pending도 더 이상 관찰되지 않았다.

처음 예상했던 “DB 조회가 많다”는 문제를 실제로는 Connection Pool 대기까지 확인한 뒤 Redis를 적용했고, 변경 이후 같은 지표가 사라지는 것도 확인할 수 있었다.


Virtual Thread를 쓰면 더 빨라질까

Redis 실험과 별개로 한 가지 더 궁금한 점이 있었다.

Java 25와 Spring Boot를 사용하고 있었기 때문에 Platform Thread를 Virtual Thread로 변경하면 처리량이 늘어날까?

DB Only, 100 VU, HikariCP 10이라는 동일한 조건에서 Thread 방식만 변경했다.

결과는 예상과 달랐다.

지표 Platform Thread Virtual Thread
RPS 5,576 3,942
p95 50.24ms 56.39ms
HikariCP Active 10 10
HikariCP Pending 약 85 약 87

Virtual Thread는 Platform Thread 수와 CPU 사용량을 줄였지만 처리량은 오히려 감소했다.

두 구조의 HikariCP지표를 보니 이유가 보였다.

Platform Thread
        ↓
DB Connection 10개
        ↓
대기

Virtual Thread
        ↓
DB Connection 10개
        ↓
대기

Thread를 더 가볍게 만들어도 실제로 DB와 통신할 수 있는 Connection이 10개뿐이라면 병목은 그대로 남는다.

동시성을 높이는 기술을 추가한다고 항상 처리량이 증가하는 것은 아니다. 그 아래에 있는 제한 자원이 무엇인지 먼저 확인해야 한다.

이번 실험에서 가장 기억에 남은 결과 중 하나였다.


Connection Pool은 많을수록 좋을까

그렇다면 HikariCP 크기를 늘리면 계속 빨라질까?

최대 Connection을 5, 10, 20으로 변경해 다시 비교했다.

Pool 5
→ Connection 부족

Pool 10
→ 처리량 증가

Pool 20
→ 더 이상 성능 증가 없음

Pool 5에서 10으로 늘렸을 때는 처리량과 응답 시간이 모두 개선됐다.

하지만 20으로 늘렸다고 10보다 더 좋아지지는 않았다.

Connection 수가 늘어나면 애플리케이션에서 기다리는 요청은 줄일 수 있지만, 결국 DB가 동시에 처리해야 하는 쿼리도 늘어난다.

따라서 Connection Pool도 단순히 크게 잡는 것이 아니라 애플리케이션 부하와 DB 처리 능력을 함께 보고 결정해야 하는 값이라는 것을 확인했다.


Redis는 어느 정도까지 차이를 만들까

Load Test보다 부하를 더 올려보기 위해 VU를 100에서 500까지 단계적으로 증가시켰다.

DB Only 구조에서는 약 200 VU 이후부터 처리량이 8.5K~9K RPS 부근에서 더 이상 증가하지 않았다.

반면 HikariCP Pending은 약 180~190까지 계속 증가했다.

VU 증가
→ 요청 증가
→ Connection 부족
→ Pending 증가
→ RPS는 정체

Redis 구조에서는 약 22K RPS 부근까지 처리량이 증가했고 500 VU까지 요청 실패가 발생하지 않았다.

전체 Stress Test 결과에서도 Redis 구조는 DB Only보다 약 2.16배 높은 평균 처리량을 기록했다.

다만 약 22K RPS 이후 처리량 증가가 둔화된 원인이 Redis인지, 애플리케이션인지, 로컬 Docker 환경인지는 이번 실험에서 분리하지 않았다.

이 지점에서 무조건 Redis Cluster 같은 다음 기술을 추가하지 않고, 확인하지 않은 병목은 그대로 후속 실험으로 남겼다.


단축 코드는 어떻게 만드는 게 좋을까

조회 성능과 별개로 URL Shortener에서 고민했던 또 하나의 문제는 단축 코드를 어떻게 생성할 것인가 였다.

이번에는 책에 나와있는 세 방식을 직접 구현해서 비교했다.

전략 저장 방식 RPS p95
Sequence + Base62 INSERT → ID → UPDATE 1,624.60 22.23ms
Hash + Base62 충돌 확인 → Hash → INSERT 1,298.60 30.14ms
Snowflake + Base62 ID 생성 → INSERT 1,660.20 21.52ms

Hash 방식은 충돌 확인과 재시도가 필요해 가장 낮은 처리량을 기록했다.

Sequence는 구현이 가장 단순하지만 MySQL Auto Increment ID에 의존하고 생성 과정에서 INSERT 이후 UPDATE가 한 번 더 발생한다.

Snowflake는 DB에서 ID를 발급받지 않고 한 번의 INSERT로 저장할 수 있었다.

단일 실행에서는 Snowflake가 가장 높은 RPS를 기록했지만 Sequence와 차이는 크지 않았다.


그래서 이번 결과를 두고 Snowflake가 무조건 더 빠르다고 판단하지 않았다.

현재처럼 단일 MySQL을 사용하는 구조에서는 단순한 Sequence가 충분하고, 이후 여러 App에서 독립적으로 ID를 생성해야 할 때 Snowflake가 더 의미가 있다고 판단했다.


이번 단계에서 얻은 것

URL Shortener를 시작할 때는 가장 먼저 단축 코드 생성 방법을 고민했다.

하지만 실제 부하를 발생시키고 지표를 확인해보니 더 큰 문제는 다른 곳에 있었다.

모든 Redirect가 MySQL 조회
        ↓
DB Connection Pool 포화
        ↓
Pending 증가
        ↓
응답 시간 증가

그리고 이를 Redis Cache Aside로 변경한 뒤:

반복 DB 조회 제거
        ↓
Connection 대기 제거
        ↓
RPS 약 2배 증가
        ↓
p95 약 64% 감소

라는 변화를 확인했다.


Virtual Thread와 Connection Pool 실험에서도 비슷했다.

특정 기술을 먼저 정해두고 적용하기보다 현재 시스템에서 실제로 무엇이 기다리고 있는지를 확인하는 것이 먼저였다.

이번 프로젝트에서 가장 크게 가져갈 부분은 Redis 자체가 아니라 다음 과정이었다.

가설
→ 부하 발생
→ 지표 확인
→ 병목 판단
→ 구조 변경
→ 같은 조건으로 다시 측정


하지만 Redis를 적용하면서 새로운 문제가 하나 생겼다.

기존에는 MySQL만 의존했지만 이제 Redirect 요청이 Redis에 의존하기 시작했다.

그렇다면 Redis가 죽으면 어떻게 되나요?

다음 글에서는 Redis를 직접 중단해보고, MySQL Fallback → Circuit Breaker → Redis Sentinel로 장애 대응 구조를 확장한 과정과 단일 App 장애를 대비한 다중 인스턴스 구성을 정리한다.

2. URL Shortener, 장애를 내보고 복구하기

카테고리:

업데이트: