Spring Boot 기반 URL 단축 서비스
| 분류 | 기술 |
|---|---|
| Language | Java 21 (Virtual Threads) |
| Framework | Spring Boot 3.4 |
| Database | PostgreSQL 16 + Flyway |
| Cache | Valkey 8 (Redis fork) + Caffeine (L1 로컬 캐시) |
| Build | Gradle 8 (멀티모듈) |
| Test | JUnit 5 + Testcontainers |
| Performance | k6 |
| Test API Server | httpbin (kennethreitz/httpbin) |
url/
├── common/ # 공유 코드 (예외 클래스 등)
└── shortener/ # URL 단축 서비스
└── src/main/java/com/urlshortener/
├── interfaces/api/ # Controller, DTO
├── application/ # Service (UrlService)
├── domain/ # Entity, Repository 인터페이스
└── infrastructure/ # JPA, Cache, ShortCodeGenerator
| Method | Path | 설명 |
|---|---|---|
POST |
/api/v1/data/shorten |
URL 단축 |
GET |
/api/v1/{shortCode} |
원래 URL로 302 리다이렉트 |
# 단축 URL 생성
curl -X POST http://localhost:8080/api/v1/data/shorten \
-H "Content-Type: application/json" \
-d '{"originalUrl": "https://example.com/very/long/url"}'
# 응답
{"shortCode": "abc123", "shortUrl": "/api/v1/abc123"}
# 리다이렉트
curl -v http://localhost:8080/api/v1/abc123
# → 302 Location: https://example.com/very/long/url[k6] → [shortener:8080] → 302 redirect → [api-server:9090]
↕ ↕
[Valkey] [PostgreSQL]
| 서비스 | 포트 | 설명 |
|---|---|---|
| shortener | 8080 | URL 단축 서비스 |
| postgres | 5432 | 데이터베이스 |
| valkey | 6379 | 캐시 (Redis fork) |
| api-server | 9090 | 리다이렉트 검증용 httpbin |
- Docker & Docker Compose
- Java 21
- Gradle 8
# 인프라 기동 (postgres + valkey)
docker compose up postgres valkey -d
# 앱 실행
./gradlew :shortener:bootRun또는 전체 docker compose로 실행:
docker compose up --build# 전체 테스트
./gradlew :shortener:test
# 특정 테스트 클래스
./gradlew :shortener:test --tests "com.urlshortener.interfaces.api.UrlApiE2ETest"| 레이어 | 방식 | 예시 |
|---|---|---|
| Unit | Mockito (Spring 컨텍스트 없음) | UrlServiceTest, ShortCodeGeneratorTest, AccessCountFlusherTest |
| Integration | @DataJpaTest / @DataRedisTest + Testcontainers |
UrlJpaRepositoryTest, UrlCacheServiceTest, HitCounterServiceTest |
| E2E | @SpringBootTest(RANDOM_PORT) + Testcontainers |
UrlApiE2ETest |
리다이렉트 요청(GET /{shortCode})은 읽기 비중이 매우 높아 다층 캐시 구조로 DB 부하를 최소화합니다.
요청
↓
[L1: Caffeine — JVM 로컬, 최대 1000건, TTL 60s]
↓ miss
[L2: Valkey — 인스턴스 공유, TTL 24h]
↓ miss
[Population Lock: SETNX lock:url:{shortCode}, TTL 3s]
├─ 락 획득 성공 → DB 조회 → L1+L2 동시 적재 → unlock
└─ 락 획득 실패 → 50ms 대기 × 3회 재시도 → L2 hit
↓ 여전히 miss
DB 직접 조회 (폴백)
| 문제 | 해결책 |
|---|---|
| 서버 재시작 시 캐시 공백 (Cache Stampede) | Cache Warming — 시작 시 Top N URL 선적재 |
| 존재하지 않는 키 대량 조회 (Cache Penetration) | Negative Cache — __NULL__ sentinel 저장 (TTL 5분) |
| Top N 밖 URL 갑작스러운 트래픽 집중 | Population Lock — 인스턴스당 DB 조회 1회로 제한 |
redirect 요청마다 DB UPDATE를 치면 쓰기 병목이 발생합니다. Valkey Sorted Set을 버퍼로 사용해 DB 쓰기를 배치로 모읍니다.
GET /{shortCode}
↓
ZINCRBY url:hits {shortCode} 1 ← 메모리 누적 (DB 접근 없음)
[1분 주기 AccessCountFlusher]
RENAME url:hits → url:hits:flushing ← 원자적 스왑 (유실 없음)
ZRANGE url:hits:flushing WITHSCORES ← 전체 읽기
bulk UPDATE urls SET access_count + delta ← 단건이 아닌 배치
DEL url:hits:flushing
RENAME으로 원자적 교체를 하기 때문에 flush 도중 들어오는 increment는 새 url:hits에 안전하게 누적됩니다.
ExpiredUrlCleaner가 매일 자정(0 0 0 * * *)에 expired_at < NOW()인 레코드를 DB에서 일괄 삭제합니다.
[매일 자정]
DELETE FROM urls WHERE expired_at IS NOT NULL AND expired_at < NOW()
docker compose up --build로 앱을 먼저 기동한 뒤 아래 명령어로 실행합니다.
이미 실행 중인 컨테이너가 있을 경우 --no-deps 플래그로 k6 컨테이너만 단독 실행합니다.
# Smoke — 기본 동작 + 예외/Rate Limiting 검증 (1 VU, 30s)
docker compose --profile smoke up --no-deps k6-smoke
# Load — 일반 부하 (최대 50 VU, 5m)
docker compose --profile load up --no-deps k6-load
# Stress — 최대 부하 (최대 300 VU, 10m)
docker compose --profile stress up --no-deps k6-stressk6 스크립트는 api-server(httpbin)를 대상 URL로 사용하여 아래 전체 체인을 검증합니다:
k6 → POST /shorten (originalUrl: api-server/anything/...) → shortCode 발급
k6 → GET /{shortCode} → 302 redirect → api-server → 200 OK
| 시나리오 | 검증 내용 |
|---|---|
| URL 생성 | POST /shorten → 200, shortCode 존재 |
| 리다이렉트 | GET /{code} → 302, Location 헤더 존재 |
| 최종 응답 | 리다이렉트 따라가기 → 200 |
| 존재하지 않는 코드 | GET /nonexistent000 → 404 |
| Rate Limiting | 동일 IP로 shorten 연속 요청 → 429 발생 확인 |
참고:
shortener서비스에 healthcheck가 설정되어 있습니다.--no-deps없이 전체 compose로 실행할 경우 Spring Boot가 완전히 기동된 후 k6가 시작됩니다.