diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..2e12f2c --- /dev/null +++ b/.dockerignore @@ -0,0 +1,47 @@ +# Gradle +.gradle/ +build/ +gradle-app.setting +!gradle-wrapper.jar + +# IDE +.idea/ +.vscode/ +*.iws +*.iml +*.ipr + +# OS +.DS_Store +Thumbs.db + +# Git +.git/ +.gitignore + +# Documentation +README.md +*.md + +# Docker +Dockerfile* +docker-compose*.yml + +# Monitoring +monitoring/ + +# Test files +src/test/ + +# Temporary files +*.tmp +*.log +*.pid +*.seed +*.pid.lock + +# Node modules (if any) +node_modules/ + +# Maven (if any) +target/ diff --git a/.github/workflows/gemini-review.yml b/.github/workflows/gemini-review.yml deleted file mode 100644 index 541d24f..0000000 --- a/.github/workflows/gemini-review.yml +++ /dev/null @@ -1,48 +0,0 @@ -name: "Code Review by Gemini AI" - -on: - pull_request: - types: [opened, synchronize, reopened] - -jobs: - review: - runs-on: ubuntu-latest - permissions: - contents: read - pull-requests: write - steps: - - uses: actions/checkout@v3 - - - name: "Get diff of the pull request" - id: get_diff - shell: bash - env: - PULL_REQUEST_HEAD_REF: "${{ github.event.pull_request.head.ref }}" - PULL_REQUEST_BASE_REF: "${{ github.event.pull_request.base.ref }}" - run: |- - git fetch origin "${{ env.PULL_REQUEST_HEAD_REF }}" - git fetch origin "${{ env.PULL_REQUEST_BASE_REF }}" - git checkout "${{ env.PULL_REQUEST_HEAD_REF }}" - git diff "origin/${{ env.PULL_REQUEST_BASE_REF }}" > "diff.txt" - { - echo "pull_request_diff<> $GITHUB_OUTPUT - - - uses: rubensflinco/gemini-code-review-action@1.0.5 - name: "Code Review by Gemini AI" - id: review - with: - gemini_api_key: ${{ secrets.GEMINI_API_KEY }} - github_token: ${{ secrets.GITHUB_TOKEN }} - github_repository: ${{ github.repository }} - github_pull_request_number: ${{ github.event.pull_request.number }} - git_commit_hash: ${{ github.event.pull_request.head.sha }} - model: "gemini-2.5-flash" - pull_request_diff: |- - ${{ steps.get_diff.outputs.pull_request_diff }} - pull_request_chunk_size: "2000" - extra_prompt: |- - 코드 품질, 보안, 성능 관점에서 간단히 리뷰해주세요. 한국어로 답변. - log_level: "INFO" diff --git a/.gitignore b/.gitignore index d3f22eb..c1b059b 100644 --- a/.gitignore +++ b/.gitignore @@ -51,3 +51,15 @@ build/ # Backup files *.bak + +bin/ +bin/main/ +bin/main/application.yml +bin/test/ +bin/test/resources/ +bin/test/resources/application.yml +bin/test/resources/application.yml.bak +bin/test/resources/application.yml.bak.bak +bin/test/resources/application.yml.bak.bak.bak +bin/test/resources/application.yml.bak.bak.bak.bak +bin/test/resources/application.yml.bak.bak.bak.bak.bak \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 8be66c2..6fc912c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,13 +1,19 @@ # Build stage -FROM gradle:8.3-jdk17-alpine AS build -WORKDIR /app -COPY build.gradle.kts settings.gradle.kts /app/ -COPY src /app/src -RUN gradle build --no-daemon +FROM gradle:8.3-jdk17 AS builder +WORKDIR /home/gradle/project +COPY . . +RUN gradle build --no-daemon -x test + +# Runtime stage +FROM openjdk:17-slim +RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/* -# Package stage -FROM openjdk:17-jdk-slim WORKDIR /app -COPY --from=build /app/build/libs/*.jar /app/app.jar +COPY --from=builder /home/gradle/project/build/libs/*.jar app.jar + EXPOSE 8080 -CMD ["java", "-jar", "/app/app.jar"] +HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \ + CMD curl -f http://localhost:8080/actuator/health || exit 1 + +ENV JAVA_OPTS="-XX:+UseContainerSupport -XX:MaxRAMPercentage=75.0" +CMD ["sh", "-c", "java $JAVA_OPTS -jar app.jar"] \ No newline at end of file diff --git a/README.md b/README.md index 51f6dcc..2a0ef84 100644 --- a/README.md +++ b/README.md @@ -1,111 +1,123 @@ -## Design Doc: 빠른 리다이렉트가 가능한 URL 단축기 +## Design Doc: 단계별 성능 최적화 실험 - URL 단축기 -본 문서는 구글식 디자인 문서 구조를 참고하여(URL 단축기의) 리다이렉트 경로를 초저지연으로 만드는 설계를 요약합니다. +본 문서는 실제 성능 측정을 통해 데이터베이스 최적화와 캐싱 전략의 효과를 검증하는 실험을 위한 디자인 문서입니다. ### 1. Context & Scope -- 문제 배경: URL 단축기는 극단적인 읽기 중심 워크로드를 갖습니다. 수백만~수억 건의 리다이렉트 트래픽에서 p95/p99 지연을 낮추는 것이 핵심입니다. -- 현재 가정 트래픽: 1억 DAU × 5회/일 ≈ 5억/일 → 평균 ~5,787 RPS, 피크 100× 가정 시 ~60만 RPS. -- 스코프: 단축 코드 → 원본 URL 매핑 조회 경로 최적화(데이터 레이어 및 엣지) -- 비스코프: 단축 URL 생성 알고리즘의 상세(충돌 방지, 키 공간 설계 등)는 별도 문서에서 다룸. +- 문제 배경: URL 단축기의 핵심 병목은 단축 코드 → 원본 URL 조회 성능입니다. +- 실험 목표: 동일한 워크로드에서 인덱스와 캐싱의 성능 개선 효과를 k6로 측정 +- 현재 가정 트래픽: 1,000 DAU × 10회/일 ≈ 10,000/일 → 평균 ~0.12 RPS (실험용 소규모) +- 스코프: 단축 코드 조회 경로 최적화 (DB 인덱스 + Redis 캐시) +- 비스코프: 단축 URL 생성 알고리즘, CDN/엣지 최적화 ### 2. Goals / Non-Goals - Goals - - p95 리다이렉트 지연 단자리 ms(지역/엣지 히트 시)를 목표 - - 피크 시 ~60만 RPS 리다이렉트 처리 가능 - - 고가용성: 단일 장애 지점 제거, 캐시/DB/엣지 이중화 - - 캐시 히트율 90%+ (핫 코드 기준), 오리진 도달율 최소화 + - 각 최적화 단계별 응답 시간 측정 (p95, p99) + - 캐시 히트율 90%+ 달성 (캐시 단계) + - 단계별 성능 개선율 정량적 측정 - Non-Goals - - 단축 URL 생성 파이프라인 최적화 - - 광고/과금 로직 최적화 - - 정교한 AB 테스트/퍼스널라이제이션 - -### 3. System Context Diagram (고수준) -- 사용자는 `https://sho.rt/{code}` 로 접속 -- CDN(전세계 PoP) → 엣지 함수(코드 조회) → - - 엣지 캐시 hit 시: 301/302 즉시 응답 - - miss 시: 오리진 API → Redis →(miss)→ DB 조회 후 응답 및 상위 캐시 적재 - - 관측(로그/메트릭/트레이싱)은 엣지/오리진 모두에서 수집 - -### 4. APIs (스케치) -- GET `/api/v1/urls/{shortUrl}`: 코드로 리다이렉트 수행 - - 301/302 Location: `` - - 캐시 제어 헤더: 엣지 캐시 가능, 단 TTL/무효화 정책 고려 -- POST `/api/v1/urls` (참고): 원본 URL → 단축 코드 생성(본 문서 비스코프) + - 프로덕션 레벨 트래픽 처리 (60만 RPS) + - CDN/엣지 컴퓨팅 구현 + - 고가용성/장애 복구 + +### 3. System Context Diagram (실험용) +- 사용자는 `http://localhost:8080/api/v1/urls/{shortUrl}` 로 접속 +- **Phase 1**: Spring Boot → PostgreSQL (인덱스 없음) +- **Phase 2**: Spring Boot → PostgreSQL (인덱스 있음) +- **Phase 3**: Spring Boot → Redis → PostgreSQL (캐시 + 인덱스) + +### 4. APIs + +#### 4.1 기본 API +- **GET** `/api/v1/urls/{shortUrl}`: 코드로 리다이렉트 수행 (302) +- **POST** `/api/v1/urls`: 원본 URL → 단축 코드 생성 + +#### 4.2 Phase별 테스트 API들 +- **GET** `/api/v1/urls/baseline/{shortUrl}`: **Phase 1** - DB 직접 조회 (인덱스 없음) +- **GET** `/api/v1/urls/indexed/{shortUrl}`: **Phase 2** - 인덱스 활용 조회 +- **GET** `/api/v1/urls/cached/{shortUrl}`: **Phase 3** - Redis 캐시 우선 조회 + ### 5. Data Storage -- 테이블: `url_mapping` - - `id` (PK, 고정 길이 문자열) — 단축 코드, 기본 키 및 인덱스 - - `original_url` (text) - - `shortUrl` - - `created_at`, `expires_at`(선택) -- 인덱싱 - - B-트리 인덱스(기본) 또는 해시 인덱스(정확 일치 최적화, PostgreSQL 등) - - 샤딩/파티셔닝: 코드 해시 기반 범용 샤딩, 리전별 리드 레플리카 - -### 6. Design -- 6.1 읽기 경로 레이어링 - - 엣지 캐시(및 엣지 키-밸류 저장) → 오리진 Redis/Memcached → RDBMS - - 캐시 키: `url:{code}` → value: `` - - TTL: 인기 코드 장기 캐시, 비인기 코드 짧은 TTL 또는 캐시 미적재 - - - 엣지(Cloudflare Workers, Lambda@Edge) - - 인기 코드 즉시 리다이렉트, 오리진 경유 차단 - - 캐시 무효화: 관리용 API 또는 tag 기반 purge - - - 오리진(애플리케이션) - - 캐시 미스 시 Redis 조회, 다시 미스면 DB 조회 후 301/302 - - 결과를 Redis 및 엣지에 업서트(upsert) - -- 6.2 캐시 정책 - - 에비션: LRU 우선, 크기 제한 엄수 - - TTL: 트래픽 기반 가변 TTL(핫 키는 길게) - - 프리워밍: 롤아웃 전 상위 N개 인기 코드 프리로드 - -- 6.3 일관성/무효화 - - 대부분 read-only. 삭제/만료/수정 이벤트 시 - - 오리진에서 Redis/엣지에 무효화 브로드캐스트 - - 지연 허용 시 TTL 자연 만료 - -### 7. Alternatives Considered -- 단일 DB만으로 버티기 - - 장점: 단순. 단일 신뢰 경로 - - 단점: 초고 RPS 피크 처리 어려움, 스케일/비용 한계 -- CDN만 사용(오리진 캐시 미활용) - - 장점: 사용자는 빠름(히트 시) - - 단점: 글로벌 무효화/미스 처리 비용 증가, 오리진 병목 발생 -- NoSQL(예: DynamoDB) 단독 - - 장점: 키-밸류 조회에 적합, 수평 확장 - - 단점: 기존 RDB 스키마/관계 활용 어려움, 마이그레이션 비용 - -### 8. Cross-Cutting Concerns -- 보안: 개방 리다이렉트 방지(허용 도메인 검증), 악용 방지 레이트 리밋 -- 프라이버시: 쿼리 파라미터/PII 로깅 최소화, 지역 규제 준수 -- 관측성: RPS, p50/95/99, 히트율, 오리진 도달율, 4xx/5xx, 엣지/오리진 트레이싱 -- 신뢰성: 멀티 리전, 다중 캐시 노드, 캐시 장애 시 페일오버 경로 확인 -- 비용: CDN/엣지/캐시/DB 별 단가 모니터링, 인기 키 집중 최적화 - -### 9. Rollout Plan -- 단계 1: DB 인덱싱/샤딩 정비, 읽기용 레플리카 확충 -- 단계 2: 오리진 Redis 도입, 캐시 키/TTL 설계, 프리워밍 -- 단계 3: CDN/엣지 함수 배포, 인기 코드 엣지 캐시 -- 단계 4: 글로벌 무효화/태그 purge 자동화, 히트율/지연 최적화 반복 - -### 10. Metrics & SLO -- SLO: p95 리다이렉트 < 50ms(리전 내), 가용성 ≥ 99.95% -- 핵심 지표: 캐시 히트율, 오리진 도달율, 엣지/오리진 p95/99, 에러율, 비용/요청 - -### 11. Risks & Mitigations -- 글로벌 캐시 불일치 → TTL/태그 purge, 변경 이벤트 브로드캐스트 -- 엣지 제한(메모리/런타임) → 경량 로직, 키 압축, 외부 의존 최소화 -- 핫 키 쏠림 → 레이트 리밋/스티키 캐시, 다중 리전 분산 - -### 12. Open Questions -- 만료 정책: per-code TTL vs 글로벌 TTL 최적 조합? -- 멀티 테넌트/도메인 지원 시 키 스키마 확장 방안? -- 퍼스널라이즈드 리다이렉트(기기/지역별) 요구가 생길 경우 엣지 로직 분기 전략? - -### 13. References -- Google 스타일 디자인 문서 개요 정리: GN 기사 요약 참고 (https://news.hada.io/topic?id=14704) + +#### 5.1 테이블 분리 구조 (Phase별 성능 측정용) +- **`url_mapping_baseline`**: Phase 1용 (인덱스 없음) + - `id` (PK, Auto Increment) + - `short_url` (varchar, unique) — 인덱스 없음 (Full Table Scan) + - `original_url` (varchar, 2000자) + +- **`url_mapping_indexed`**: Phase 2용 (인덱스 있음) + - `id` (PK, Auto Increment) + - `short_url` (varchar, unique) — B-tree 인덱스 있음 (Index Scan) + - `original_url` (varchar, 2000자) + - 인덱스: `idx_short_url_indexed` on `short_url` + +#### 5.2 데이터 동기화 전략 +- **URL 생성 시**: 2개 테이블에 동시 저장 (동일한 shortUrl, originalUrl) +- **Phase별 조회**: 각각 다른 테이블에서 조회하여 성능 차이 측정 +- **캐시 레이어**: Phase 3에서 Redis 캐시 추가 (테이블은 indexed 사용) + +### 6. Performance Optimization Phases + +#### 6.1 Phase 1: Baseline (인덱스 없음) +- **아키텍처**: Spring Boot → PostgreSQL +- **특징**: 가장 단순한 구현, DB 직접 조회 +- **예상 성능**: 50-200ms per request (Full table scan) +- **목적**: 기준 성능 측정 + +#### 6.2 Phase 2: Database Indexing (인덱스 있음) +- **아키텍처**: Spring Boot → PostgreSQL (with index) +- **최적화**: `short_url` 컬럼에 B-tree 인덱스 추가 +- **특징**: O(log n) 조회 시간 +- **예상 성능**: 10-50ms per request (5-10x 개선) +- **목적**: 인덱싱 효과 검증 + +#### 6.3 Phase 3: Redis Caching (캐시 + 인덱스) +- **아키텍처**: Spring Boot → Redis → PostgreSQL +- **캐시 전략**: + - 키 포맷: `url:{shortCode}` + - TTL: 15분 (단일 정책으로 단순화) + - 에비션: LRU (Redis 기본) +- **특징**: O(1) 캐시 조회, DB 폴백 +- **예상 성능**: 1-5ms per request (100x 개선) +- **캐시 히트율 목표**: 90%+ + +#### 6.4 캐시 정책 및 일관성 +- **에비션 정책**: Redis LRU (최근 사용 우선) +- **TTL 전략**: 15분 고정 (단순화된 정책) +- **일관성**: Read-only 워크로드로 캐시 무효화 최소화 +- **모니터링**: 히트율, 미스율 실시간 추적 + +### 7. Test Strategy & Tools + +#### 7.1 k6 Load Testing +- **테스트 시나리오**: 동일한 1,000개 단축 URL에 대한 반복 조회 +- **부하 패턴**: 10 VU × 30초 (총 300회 요청) +- **측정 지표**: + - Response time (avg, p95, p99) + - Requests per second + - Error rate + - Throughput + +#### 7.2 Performance Comparison Matrix +| Phase | Architecture | Expected p95 | Expected RPS | Cache Hit Rate | +|-------|-------------|--------------|--------------|----------------| +| 1 | DB Only | 100-200ms | ~10-20 | N/A | +| 2 | DB + Index | 20-50ms | ~50-100 | N/A | +| 3 | Redis + DB | 5-20ms | ~200-500 | 90%+ | + + +### 8. Metrics & Success Criteria + +#### 8.1 Performance Targets +- **Phase 1**: Baseline 측정 (현재 성능) +- **Phase 2**: Phase 1 대비 5x 이상 성능 개선 +- **Phase 3**: Phase 1 대비 20x 이상 성능 개선, 캐시 히트율 90%+ + +#### 8.2 Monitoring Metrics +- **응답 시간**: p95, p99, 평균 +- **처리량**: RPS, 총 요청 수 +- **캐시 효율**: 히트율, 미스율 +- **에러율**: HTTP 4xx/5xx 비율 ## Local Dev Setup (Docker Compose) @@ -121,30 +133,40 @@ - JVM(스프링) 컨테이너는 `-XX:+UseContainerSupport -XX:MaxRAMPercentage=`로 컨테이너 메모리 한도를 인지시켜야 OOM을 피할 수 있습니다. ### How to Run + +#### 전체 모니터링 스택 실행 ```bash +# 모든 서비스 실행 (Spring Boot + 모니터링) docker compose --compatibility up -d -``` -### Connection Info -- Postgres: `localhost:5432` (user: bitly, password: bitly, db: bitly) -- Redis: `localhost:6379` - -### Spring Boot (예시 설정) -- `application.yml` 예시 -```yaml -spring: - datasource: - url: jdbc:postgresql://postgres:5432/bitly - username: bitly - password: bitly - redis: - host: redis - port: 6379 -server: - port: 8080 +# 또는 모니터링만 실행 +docker compose --compatibility up -d prometheus grafana node-exporter postgres-exporter cadvisor ``` -### Notes -- Redis는 `--maxmemory 1gb --maxmemory-policy allkeys-lru`로 설정되어 LRU 에비션 동작. -- Postgres/Redis 모두 healthcheck 포함. -- 추후 엣지/CDN 적용 시, 인기 코드 Top-N 프리워밍과 태그 기반 purge 전략을 고려하세요. +### Grafana Dashboard + +#### 1. Spring Boot 애플리케이션 모니터링 +- **Dashboard ID**: `4701` (JVM Micrometer) - Spring Boot 3.x 호환 + +#### 2. 시스템 모니터링 +- **Dashboard ID**: `11074` (Node Exporter for Prometheus Dashboard) + +#### 3. PostgreSQL 모니터링 +- **Dashboard ID**: `9628` (PostgreSQL Database) +- **Dashboard ID**: `455` (PostgreSQL Overview) + +#### 4. Redis 모니터링 +- **Dashboard ID**: `763` (Redis Dashboard) +- **Dashboard ID**: `11835` (Redis Exporter) + +#### k6 성능 테스트 대시보드 +- **Dashboard ID**: `2587` (k6 Performance Testing) +- **용도**: k6 성능 테스트 결과 시각화 +- **데이터소스**: InfluxDB (k6 메트릭) + +#### 대시보드 Import 방법 +1. Grafana 접속: http://localhost:3000 +2. 좌측 메뉴 → **Dashboards** → **+ New** → **Import** +3. **Import via grafana.com** 탭 선택 +4. **Dashboard ID** 입력 (예: `4701`, `2587`) +5. **Load** → **Prometheus** 또는 **InfluxDB** 데이터소스 선택 → **Import** diff --git a/build.gradle b/build.gradle index 7bac43a..8255ea7 100644 --- a/build.gradle +++ b/build.gradle @@ -20,6 +20,8 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.0.2' implementation 'org.springframework.boot:spring-boot-starter-data-redis' + implementation 'org.springframework.boot:spring-boot-starter-actuator' + implementation 'io.micrometer:micrometer-registry-prometheus' compileOnly 'org.projectlombok:lombok' developmentOnly 'org.springframework.boot:spring-boot-devtools' runtimeOnly 'org.postgresql:postgresql' diff --git a/docker-compose.yml b/docker-compose.yml index deeb979..be78678 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -16,14 +16,20 @@ services: deploy: resources: limits: - memory: 512M + memory: 256M + cpus: '0.3' + reservations: + memory: 128M + cpus: '0.1' + networks: + - bitly redis: image: redis:7-alpine container_name: redis ports: - "6379:6379" - command: redis-server --maxmemory 1gb --maxmemory-policy allkeys-lru + command: redis-server --maxmemory 128mb --maxmemory-policy allkeys-lru healthcheck: test: ["CMD", "redis-cli", "ping"] interval: 10s @@ -32,25 +38,187 @@ services: deploy: resources: limits: - memory: 1G - -# api: -# build: . -# container_name: api -# depends_on: -# postgres: -# condition: service_healthy -# redis: -# condition: service_healthy -# ports: -# - "8080:8080" -# environment: -# - SPRING_DATASOURCE_URL=jdbc:postgresql://postgres:5432/bitly -# - SPRING_DATASOURCE_USERNAME=bitly -# - SPRING_DATASOURCE_PASSWORD=bitly -# - SPRING_REDIS_HOST=redis -# - SPRING_REDIS_PORT=6379 -# deploy: -# resources: -# limits: -# memory: 1G + memory: 128M + cpus: '0.2' + reservations: + memory: 64M + cpus: '0.1' + networks: + - bitly + + # Spring Boot 애플리케이션 + api: + build: + context: . + dockerfile: Dockerfile + container_name: api + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + ports: + - "8080:8080" + environment: + - SPRING_DATASOURCE_URL=jdbc:postgresql://postgres:5432/bitly + - SPRING_DATASOURCE_USERNAME=bitly + - SPRING_DATASOURCE_PASSWORD=bitly + - SPRING_REDIS_HOST=redis + - SPRING_REDIS_PORT=6379 + - SPRING_PROFILES_ACTIVE=docker + # # JVM 최적화 설정 + # - JAVA_OPTS=-XX:+UseContainerSupport -XX:MaxRAMPercentage=75.0 -Djava.security.egd=file:/dev/./urandom + deploy: + resources: + limits: + memory: 512M + cpus: '0.5' + reservations: + memory: 256M + cpus: '0.25' + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8080/actuator/health"] + interval: 30s + timeout: 10s + retries: 5 + start_period: 60s + restart: unless-stopped + networks: + - bitly + + # 모니터링 스택 + prometheus: + image: prom/prometheus:latest + container_name: prometheus + ports: + - "9090:9090" + volumes: + - ./monitoring/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro + - prometheus_data:/prometheus + command: + - '--config.file=/etc/prometheus/prometheus.yml' + - '--storage.tsdb.path=/prometheus' + - '--web.console.libraries=/etc/prometheus/console_libraries' + - '--web.console.templates=/etc/prometheus/consoles' + - '--storage.tsdb.retention.time=200h' + - '--web.enable-lifecycle' + depends_on: + - cadvisor + - node-exporter + - postgres-exporter + - redis-exporter + networks: + - bitly + + grafana: + image: grafana/grafana:latest + container_name: grafana + ports: + - "3000:3000" + volumes: + - grafana_data:/var/lib/grafana + - ./monitoring/grafana/provisioning:/etc/grafana/provisioning:ro + environment: + - GF_SECURITY_ADMIN_PASSWORD=admin + - GF_USERS_ALLOW_SIGN_UP=false + depends_on: + - prometheus + networks: + - bitly + + node-exporter: + image: prom/node-exporter:latest + container_name: node-exporter + ports: + - "9100:9100" + volumes: + - /proc:/host/proc:ro + - /sys:/host/sys:ro + - /:/rootfs:ro + command: + - '--path.procfs=/host/proc' + - '--path.rootfs=/rootfs' + - '--path.sysfs=/host/sys' + - '--collector.filesystem.mount-points-exclude=^/(sys|proc|dev|host|etc)($$|/)' + networks: + - bitly + + postgres-exporter: + image: prometheuscommunity/postgres-exporter:latest + container_name: postgres-exporter + ports: + - "9187:9187" + environment: + - DATA_SOURCE_NAME=postgresql://bitly:bitly@postgres:5432/bitly?sslmode=disable + depends_on: + postgres: + condition: service_healthy + networks: + - bitly + + redis-exporter: + image: oliver006/redis_exporter:latest + container_name: redis-exporter + ports: + - "9121:9121" + environment: + - REDIS_ADDR=redis://redis:6379 + - REDIS_PASSWORD= + depends_on: + - redis + networks: + - bitly + + influxdb: + image: influxdb:1.8 + container_name: influxdb + ports: + - "8086:8086" + environment: + - INFLUXDB_DB=k6 + restart: on-failure + networks: + - bitly + volumes: + - influxdb_data:/var/lib/influxdb + + k6: + image: grafana/k6:latest + container_name: k6 + networks: + - bitly + ports: + - "6565:6565" + environment: + - K6_OUT=influxdb=http://influxdb:8086/k6 + volumes: + - ./monitoring/k6:/scripts + extra_hosts: + - "host.docker.internal:host-gateway" + command: ["run", "/scripts/performance-test-with-phases.js"] + + cadvisor: + image: gcr.io/cadvisor/cadvisor:latest + container_name: cadvisor + ports: + - "8081:8080" + volumes: + - /:/rootfs:ro + - /var/run:/var/run:ro + - /sys:/sys:ro + - /var/lib/docker/:/var/lib/docker:ro + - /dev/disk/:/dev/disk:ro + devices: + - /dev/kmsg + privileged: true + networks: + - bitly + +volumes: + prometheus_data: + grafana_data: + influxdb_data: + +networks: + bitly: + driver: bridge diff --git a/monitoring/grafana/provisioning/datasources/influxdb.yml b/monitoring/grafana/provisioning/datasources/influxdb.yml new file mode 100644 index 0000000..ece0147 --- /dev/null +++ b/monitoring/grafana/provisioning/datasources/influxdb.yml @@ -0,0 +1,17 @@ +apiVersion: 1 + +datasources: + - name: InfluxDB + type: influxdb + access: proxy + url: http://influxdb:8086 + database: k6 + user: admin + secureJsonData: + password: admin + jsonData: + httpMode: POST + version: InfluxQL + tlsSkipVerify: true + isDefault: false + editable: true diff --git a/monitoring/grafana/provisioning/datasources/prometheus.yml b/monitoring/grafana/provisioning/datasources/prometheus.yml new file mode 100644 index 0000000..1a57b69 --- /dev/null +++ b/monitoring/grafana/provisioning/datasources/prometheus.yml @@ -0,0 +1,9 @@ +apiVersion: 1 + +datasources: + - name: Prometheus + type: prometheus + access: proxy + url: http://prometheus:9090 + isDefault: true + editable: true diff --git a/monitoring/k6/performance-test-with-phases.js b/monitoring/k6/performance-test-with-phases.js new file mode 100644 index 0000000..30a140e --- /dev/null +++ b/monitoring/k6/performance-test-with-phases.js @@ -0,0 +1,226 @@ +import http from 'k6/http'; +import { check, sleep } from 'k6'; +import { Rate } from 'k6/metrics'; + +// Custom metrics +const errorRate = new Rate('errors'); + +// 테스트 데이터 생성 함수 (2개 테이블에 동시 저장) +function generateTestData(baseUrl, count = 10000) { + console.log(`📊 테스트 데이터 생성 시작: ${count}개 (Baseline + Indexed 테이블에 동시 저장)`); + + const createdShortUrls = []; // 실제 생성된 shortUrl 저장 + + let createdCount = 0; + for (let i = 1; i <= count; i++) { + const originalUrl = `http://test-${i}.local`; + const payload = JSON.stringify({ + originalUrl: originalUrl + }); + + const params = { + headers: { + 'Content-Type': 'application/json', + }, + }; + + const response = http.post(`${baseUrl}/api/v1/urls/`, payload, params); + + // 첫 번째 요청의 응답 로그 출력 + if (i === 1) { + console.log(`📊 POST 응답 상태: ${response.status}`); + console.log(`📊 POST 응답 Body: "${response.body}"`); + console.log(`📊 POST 응답 Body 길이: ${response.body ? response.body.length : 0}`); + } + + if (response.status === 201 && response.body) { + // 생성된 shortUrl을 저장 (응답 body에서 추출) + let shortUrl; + try { + // JSON 파싱 시도 + shortUrl = JSON.parse(response.body); + } catch (e) { + // JSON이 아니면 따옴표 제거 + shortUrl = response.body.replace(/"/g, ''); + } + if (shortUrl && shortUrl.length > 0) { + createdShortUrls.push(shortUrl); + createdCount++; + if (i <= 5) { // 처음 5개만 로그 출력 + console.log(`📊 생성된 shortUrl: ${shortUrl}`); + } + } + } else { + console.log(`❌ POST 요청 실패: ${response.status} - ${response.body}`); + } + + // 1000개마다 진행상황 출력 + if (i % 1000 === 0) { + console.log(`📊 테스트 데이터 생성 진행: ${i}/${count} (${createdCount}개 성공)`); + } + } + + console.log(`✅ 테스트 데이터 생성 완료: ${createdCount}/${count}개 성공`); + console.log(`📊 각 테이블에 저장됨: url_mapping_baseline, url_mapping_indexed`); + console.log(`📊 생성된 shortUrl 샘플: ${createdShortUrls.slice(0, 5).join(', ')}`); + return { createdShortUrls, createdCount }; +} + +export const options = { + stages: [ + // Phase 1: Baseline (60초) - 인덱스 없이 + { duration: '60s', target: 50 }, // VU 수 감소 + { duration: '10s', target: 0 }, // Cool down + // Phase 2: Indexed (60초) - 인덱스 있음 + { duration: '60s', target: 50 }, // VU 수 감소 + { duration: '10s', target: 0 }, // Cool down + // Phase 3: Cached (60초) - Redis 캐시 + { duration: '60s', target: 50 }, // VU 수 감소 + ], + thresholds: { + http_req_duration: ['p(95)<10000'], // 타임아웃 증가 + errors: ['rate<0.1'], // 에러율 허용치 증가 + }, +}; + +// Phase 상태 관리 +let currentPhase = 'baseline'; +let phaseStartTime = Date.now(); + +export function setup() { + console.log('🚀 성능 테스트 시작 - Phase별 테이블 분리'); + console.log('📊 네트워크 진단 시작...'); + + // 1. 테스트 데이터 생성 (2개 테이블에 동시 저장됨) + const baseUrl = 'http://api:8080'; + console.log(`📊 대상 URL: ${baseUrl}`); + + // API 연결 확인 (재시도 로직) + console.log('📊 API 연결 확인 중...'); + let healthResponse; + let retryCount = 0; + const maxRetries = 10; + + while (retryCount < maxRetries) { + healthResponse = http.get(`${baseUrl}/actuator/health`); + if (healthResponse.status === 200) { + console.log('✅ API 연결 성공'); + break; + } else { + console.log(`❌ API 연결 실패 (${retryCount + 1}/${maxRetries}): ${healthResponse.status}`); + console.log(`📊 응답 Body: ${healthResponse.body}`); + console.log(`📊 에러: ${healthResponse.error}`); + retryCount++; + if (retryCount < maxRetries) { + console.log('⏳ 5초 후 재시도...'); + sleep(5); + } + } + } + + if (healthResponse.status !== 200) { + console.log('❌ API 연결 최종 실패'); + return { createdShortUrls: [], createdCount: 0 }; + } + + const { createdShortUrls, createdCount } = generateTestData(baseUrl, 10); // 더 적게 시작 + + // 2. 실제 존재하는 모든 shortUrl 조회 + console.log('📊 실제 존재하는 shortUrl 조회 중...'); + const allUrlsResponse = http.get(`${baseUrl}/api/v1/urls/test/all-short-urls`); + let actualShortUrls = []; + + if (allUrlsResponse.status === 200) { + actualShortUrls = JSON.parse(allUrlsResponse.body); + console.log(`✅ 실제 존재하는 shortUrl: ${actualShortUrls.length}개`); + console.log(`📊 샘플: ${actualShortUrls.slice(0, 5).join(', ')}`); + } else { + console.log('❌ shortUrl 조회 실패, 생성된 URL 사용'); + actualShortUrls = createdShortUrls; + } + + // 3. Phase 준비 완료 확인 (이제 단순히 로그만 출력) + console.log('📊 Phase 1 준비: Baseline 테이블 사용 (Full Table Scan)'); + const baselineResponse = http.post(`${baseUrl}/api/v1/urls/prepare/baseline`); + check(baselineResponse, { + 'Phase 1 준비 성공': (r) => r.status === 200, + }); + + console.log('📊 Phase 2 준비: Indexed 테이블 사용 (Index Scan)'); + const indexedResponse = http.post(`${baseUrl}/api/v1/urls/prepare/indexed`); + check(indexedResponse, { + 'Phase 2 준비 성공': (r) => r.status === 200, + }); + + console.log('✅ 모든 Phase 준비 완료 - 테스트 시작'); + console.log(`📊 최종 사용할 shortUrl 개수: ${actualShortUrls.length}`); + console.log(`📊 샘플 shortUrl: ${actualShortUrls.slice(0, 3).join(', ')}`); + + if (actualShortUrls.length === 0) { + console.log('❌ 사용할 수 있는 shortUrl이 없습니다!'); + } + + return { createdShortUrls: actualShortUrls, createdCount: actualShortUrls.length }; +} + +export default function (data) { + const now = Date.now(); + const elapsed = now - phaseStartTime; + + // Phase 결정 로직 (시간 기반) - JSON 응답 엔드포인트 사용 + let phase; + if (elapsed < 60000) { + // Phase 1: Baseline (0-60초) + phase = { name: 'baseline', endpoint: '/baseline' }; + } else if (elapsed < 130000) { + // Phase 2: Indexed (60-130초, 10초 cool down 포함) + if (currentPhase !== 'indexed') { + console.log('📊 Phase 2 시작: Indexed 테이블 사용 (Index Scan)'); + currentPhase = 'indexed'; + } + phase = { name: 'indexed', endpoint: '/indexed' }; + } else { + // Phase 3: Cached (130초 이후) + if (currentPhase !== 'cached') { + console.log('📊 Phase 3 시작: Redis 캐시 사용'); + currentPhase = 'cached'; + } + phase = { name: 'cached', endpoint: '/cached' }; + } + + // 순차적으로 조회 (랜덤 대신) + if (!data.createdShortUrls || data.createdShortUrls.length === 0) { + console.log('❌ 생성된 shortUrl이 없습니다. 테스트를 건너뜁니다.'); + return; + } + + const urlIndex = __VU % data.createdShortUrls.length; + const selectedUrl = data.createdShortUrls[urlIndex]; + + if (!selectedUrl || selectedUrl === 'undefined') { + console.log(`❌ 잘못된 shortUrl: ${selectedUrl}`); + return; + } + + const url = `http://api:8080/api/v1/urls${phase.endpoint}/${selectedUrl}`; + + // redirects: 0 옵션을 추가하여 리디렉션을 방지합니다. + const response = http.get(url, { + tags: { phase: phase.name }, + redirects: 0, + }); + + const success = check(response, { + // 이제 302 상태 코드만 성공으로 간주합니다. + 'status is 302': (r) => r.status === 302, + }); + + // 에러 시 상세 로그 출력 + if (!success) { + console.log(`❌ 요청 실패: ${url} - Status: ${response.status}, Body: ${response.body}`); + } + + errorRate.add(!success); + + sleep(0.1); // 100ms 대기 +} \ No newline at end of file diff --git a/monitoring/prometheus/prometheus.yml b/monitoring/prometheus/prometheus.yml new file mode 100644 index 0000000..9fbad3f --- /dev/null +++ b/monitoring/prometheus/prometheus.yml @@ -0,0 +1,47 @@ +global: + scrape_interval: 15s + evaluation_interval: 15s + +rule_files: + # - "first_rules.yml" + # - "second_rules.yml" + +scrape_configs: + # The job name is added as a label `job=` to any timeseries scraped from this config. + - job_name: 'prometheus' + static_configs: + - targets: ['localhost:9090'] + + - job_name: 'node-exporter' + static_configs: + - targets: ['node-exporter:9100'] + + - job_name: 'cadvisor' + static_configs: + - targets: ['cadvisor:8080'] + + - job_name: 'postgres-exporter' + static_configs: + - targets: ['postgres-exporter:9187'] + + - job_name: 'spring-boot-app' + metrics_path: '/actuator/prometheus' + scrape_interval: 5s + static_configs: + - targets: ['host.docker.internal:8080'] + # Spring Boot 애플리케이션이 준비될 때까지 기다림 + relabel_configs: + - source_labels: [__address__] + target_label: instance + replacement: 'bitly-api' + + # Docker 컨테이너 메트릭 + - job_name: 'docker-containers' + static_configs: + - targets: ['cadvisor:8080'] + metrics_path: '/metrics' + + # Redis 메트릭 + - job_name: 'redis' + static_configs: + - targets: ['redis-exporter:9121'] diff --git a/src/main/java/com/example/bitly/config/RedisConfig.java b/src/main/java/com/example/bitly/config/RedisConfig.java new file mode 100644 index 0000000..004b1dc --- /dev/null +++ b/src/main/java/com/example/bitly/config/RedisConfig.java @@ -0,0 +1,26 @@ +package com.example.bitly.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +@Configuration +public class RedisConfig { + + @Bean + public RedisTemplate redisTemplate(RedisConnectionFactory connectionFactory) { + RedisTemplate template = new RedisTemplate<>(); + template.setConnectionFactory(connectionFactory); + + // String 직렬화 설정 + template.setKeySerializer(new StringRedisSerializer()); + template.setValueSerializer(new StringRedisSerializer()); + template.setHashKeySerializer(new StringRedisSerializer()); + template.setHashValueSerializer(new StringRedisSerializer()); + + template.afterPropertiesSet(); + return template; + } +} diff --git a/src/main/java/com/example/bitly/controller/UrlController.java b/src/main/java/com/example/bitly/controller/UrlController.java index 42f3407..b584b8f 100644 --- a/src/main/java/com/example/bitly/controller/UrlController.java +++ b/src/main/java/com/example/bitly/controller/UrlController.java @@ -1,7 +1,7 @@ package com.example.bitly.controller; - import com.example.bitly.controller.dto.UrlCreateRequest; +import com.example.bitly.service.CacheService; import com.example.bitly.service.UrlService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; @@ -27,32 +27,97 @@ public class UrlController { private final UrlService urlService; + private final CacheService cacheService; @Operation(summary = "URL 단축 생성", description = "원본 URL을 받아 단축된 URL을 생성합니다.") @ApiResponses({ - @ApiResponse(responseCode = "201", description = "단축 URL 생성 성공"), - @ApiResponse(responseCode = "400", description = "잘못된 URL 형식") + @ApiResponse(responseCode = "201", description = "단축 URL 생성 성공"), + @ApiResponse(responseCode = "400", description = "잘못된 URL 형식") }) @PostMapping() public ResponseEntity createShortUrl(@RequestBody UrlCreateRequest request) { String shortUrl = urlService.createShortUrl(request.getOriginalUrl()); - return ResponseEntity.status(HttpStatus.CREATED).body(shortUrl); + // JSON 문자열로 명확하게 반환 + return ResponseEntity.status(HttpStatus.CREATED) + .header("Content-Type", "application/json") + .body("\"" + shortUrl + "\""); } - @Operation(summary = "원본 URL로 리디렉션", description = "단축 URL을 통해 원본 URL로 리디렉션합니다.") + @Operation(summary = "원본 URL로 리디렉션", description = "단축 URL을 통해 원본 URL로 리디렉션합니다. (Phase 3: 캐시 우선)") @ApiResponses({ - @ApiResponse(responseCode = "302", description = "리디렉션 성공"), - @ApiResponse(responseCode = "404", description = "존재하지 않는 단축 URL") + @ApiResponse(responseCode = "302", description = "리디렉션 성공"), + @ApiResponse(responseCode = "404", description = "존재하지 않는 단축 URL") }) @GetMapping("/{shortUrl}") public void redirect( - @Parameter(description = "단축 URL", required = true) @PathVariable String shortUrl, - HttpServletResponse response) { - String originalUrl = urlService.getOriginalUrl(shortUrl); + @Parameter(description = "단축 URL", required = true) @PathVariable String shortUrl, + HttpServletResponse response) { + // 기본 API는 Phase 3 (캐시 우선) 사용 + String originalUrl = urlService.getOriginalUrlCached(shortUrl); try { response.sendRedirect(originalUrl); } catch (IOException e) { throw new RuntimeException(e); // Checked → Unchecked 변환 } } + + // Phase별 성능 테스트용 API들 + @GetMapping("/baseline/{shortUrl}") + public void redirectBaseline( + @Parameter(description = "단축 URL", required = true) @PathVariable String shortUrl, + HttpServletResponse response) { + // Phase 1: DB 직접 조회 (인덱스 없이) + String originalUrl = urlService.getOriginalUrlBaseline(shortUrl); + try { + response.sendRedirect(originalUrl); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + @GetMapping("/indexed/{shortUrl}") + public void redirectIndexed( + @Parameter(description = "단축 URL", required = true) @PathVariable String shortUrl, + HttpServletResponse response) { + // Phase 2: 인덱스 활용 조회 + String originalUrl = urlService.getOriginalUrlIndexed(shortUrl); + try { + response.sendRedirect(originalUrl); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + @GetMapping("/cached/{shortUrl}") + public void redirectCached( + @Parameter(description = "단축 URL", required = true) @PathVariable String shortUrl, + HttpServletResponse response) { + // Phase 3: 캐시 우선 조회 + String originalUrl = urlService.getOriginalUrlCached(shortUrl); + try { + response.sendRedirect(originalUrl); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + // 캐시 통계 조회 API + @GetMapping("/stats") + public ResponseEntity getCacheStats() { + CacheService.CacheStats stats = cacheService.getStats(); + return ResponseEntity.ok(stats); + } + + // Phase 준비 API들 + @PostMapping("/prepare/baseline") + public ResponseEntity prepareBaselinePhase() { + urlService.prepareBaselinePhase(); + return ResponseEntity.ok("Phase 1 준비 완료: 인덱스 삭제 (Full Table Scan)"); + } + + @PostMapping("/prepare/indexed") + public ResponseEntity prepareIndexedPhase() { + urlService.prepareIndexedPhase(); + return ResponseEntity.ok("Phase 2 준비 완료: 인덱스 생성 (Index Scan)"); + } } diff --git a/src/main/java/com/example/bitly/entity/Url.java b/src/main/java/com/example/bitly/entity/Url.java deleted file mode 100644 index 4ef2ef5..0000000 --- a/src/main/java/com/example/bitly/entity/Url.java +++ /dev/null @@ -1,33 +0,0 @@ -package com.example.bitly.entity; - -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; -import lombok.AccessLevel; -import lombok.Getter; -import lombok.NoArgsConstructor; - -@Entity -@Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) -public class Url { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @Column(nullable = false, columnDefinition = "TEXT") - private String originalUrl; - - private String shortUrl; - - public Url(String originalUrl) { - this.originalUrl = originalUrl; - } - - public void setShortUrl(String shortUrl) { - this.shortUrl = shortUrl; - } -} \ No newline at end of file diff --git a/src/main/java/com/example/bitly/entity/UrlBaseline.java b/src/main/java/com/example/bitly/entity/UrlBaseline.java new file mode 100644 index 0000000..f55503d --- /dev/null +++ b/src/main/java/com/example/bitly/entity/UrlBaseline.java @@ -0,0 +1,27 @@ +package com.example.bitly.entity; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "url_mapping_baseline") +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class UrlBaseline { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "short_url", nullable = false, unique = true) + // 인덱스 없음 - Full Table Scan용 + private String shortUrl; + + @Column(name = "original_url", nullable = false, length = 2000) + private String originalUrl; +} diff --git a/src/main/java/com/example/bitly/entity/UrlIndexed.java b/src/main/java/com/example/bitly/entity/UrlIndexed.java new file mode 100644 index 0000000..31a5c40 --- /dev/null +++ b/src/main/java/com/example/bitly/entity/UrlIndexed.java @@ -0,0 +1,26 @@ +package com.example.bitly.entity; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "url_mapping_indexed", indexes = @Index(name = "idx_short_url_indexed", columnList = "short_url")) +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class UrlIndexed { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "short_url", nullable = false, unique = true) + private String shortUrl; + + @Column(name = "original_url", nullable = false, length = 2000) + private String originalUrl; +} diff --git a/src/main/java/com/example/bitly/repository/UrlBaselineRepository.java b/src/main/java/com/example/bitly/repository/UrlBaselineRepository.java new file mode 100644 index 0000000..c4ff96b --- /dev/null +++ b/src/main/java/com/example/bitly/repository/UrlBaselineRepository.java @@ -0,0 +1,13 @@ +package com.example.bitly.repository; + +import com.example.bitly.entity.UrlBaseline; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface UrlBaselineRepository extends JpaRepository { + + Optional findByShortUrl(String shortUrl); + + Optional findByOriginalUrl(String originalUrl); +} diff --git a/src/main/java/com/example/bitly/repository/UrlIndexedRepository.java b/src/main/java/com/example/bitly/repository/UrlIndexedRepository.java new file mode 100644 index 0000000..24433f5 --- /dev/null +++ b/src/main/java/com/example/bitly/repository/UrlIndexedRepository.java @@ -0,0 +1,11 @@ +package com.example.bitly.repository; + +import com.example.bitly.entity.UrlIndexed; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface UrlIndexedRepository extends JpaRepository { + + Optional findByShortUrl(String shortUrl); +} diff --git a/src/main/java/com/example/bitly/repository/UrlRepository.java b/src/main/java/com/example/bitly/repository/UrlRepository.java deleted file mode 100644 index 3b7f3b5..0000000 --- a/src/main/java/com/example/bitly/repository/UrlRepository.java +++ /dev/null @@ -1,12 +0,0 @@ -package com.example.bitly.repository; - -import com.example.bitly.entity.Url; -import java.util.Optional; -import org.springframework.data.jpa.repository.JpaRepository; - -public interface UrlRepository extends JpaRepository { - - Optional findByOriginalUrl(String originalUrl); - - Optional findByShortUrl(String shortUrl); -} \ No newline at end of file diff --git a/src/main/java/com/example/bitly/service/CacheService.java b/src/main/java/com/example/bitly/service/CacheService.java new file mode 100644 index 0000000..3c2d299 --- /dev/null +++ b/src/main/java/com/example/bitly/service/CacheService.java @@ -0,0 +1,92 @@ +package com.example.bitly.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; + +import java.time.Duration; +import java.util.Optional; + +@Service +@RequiredArgsConstructor +@Slf4j +public class CacheService { + + private final RedisTemplate redisTemplate; + + private static final String CACHE_PREFIX = "url:"; + private static final Duration DEFAULT_TTL = Duration.ofMinutes(15); // 15분 + + /** + * 캐시에서 URL 조회 + */ + public Optional get(String shortUrl) { + try { + String key = CACHE_PREFIX + shortUrl; + String originalUrl = redisTemplate.opsForValue().get(key); + + if (originalUrl != null) { + log.debug("Cache HIT for key: {}", key); + return Optional.of(originalUrl); + } else { + log.debug("Cache MISS for key: {}", key); + return Optional.empty(); + } + } catch (Exception e) { + log.error("Redis cache error for key: {}", shortUrl, e); + return Optional.empty(); + } + } + + /** + * 캐시에 URL 저장 + */ + public void put(String shortUrl, String originalUrl) { + try { + String key = CACHE_PREFIX + shortUrl; + + redisTemplate.opsForValue().set(key, originalUrl, DEFAULT_TTL); + log.debug("Cached URL: {} -> {} (TTL: {})", shortUrl, originalUrl, DEFAULT_TTL); + } catch (Exception e) { + log.error("Redis cache put error for key: {}", shortUrl, e); + } + } + + /** + * 캐시에서 URL 삭제 + */ + public void evict(String shortUrl) { + try { + String key = CACHE_PREFIX + shortUrl; + redisTemplate.delete(key); + log.debug("Evicted cache for key: {}", key); + } catch (Exception e) { + log.error("Redis cache evict error for key: {}", shortUrl, e); + } + } + + /** + * 캐시 통계 조회 + */ + public CacheStats getStats() { + try { + // Redis INFO 명령으로 통계 조회 (간단한 구현) + // 실제 운영에서는 Redis INFO 명령을 직접 사용하거나 RedisTemplate의 info() 메서드 사용 + return new CacheStats(0, 0, 0.0); // 임시 구현 + } catch (Exception e) { + log.error("Error getting cache stats", e); + return new CacheStats(0, 0, 0.0); + } + } + + /** + * 캐시 통계 DTO + */ + public record CacheStats(long hits, long misses, double hitRate) { + @Override + public String toString() { + return String.format("CacheStats{hits=%d, misses=%d, hitRate=%.2f%%}", hits, misses, hitRate); + } + } +} diff --git a/src/main/java/com/example/bitly/service/UrlService.java b/src/main/java/com/example/bitly/service/UrlService.java index 9363dd3..39e6a82 100644 --- a/src/main/java/com/example/bitly/service/UrlService.java +++ b/src/main/java/com/example/bitly/service/UrlService.java @@ -1,39 +1,113 @@ package com.example.bitly.service; -import com.example.bitly.entity.Url; -import com.example.bitly.repository.UrlRepository; +import com.example.bitly.entity.UrlBaseline; +import com.example.bitly.entity.UrlIndexed; +import com.example.bitly.repository.UrlBaselineRepository; +import com.example.bitly.repository.UrlIndexedRepository; import jakarta.persistence.EntityNotFoundException; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.concurrent.atomic.AtomicLong; +import java.util.Optional; @Service @RequiredArgsConstructor +@Slf4j public class UrlService { - private final UrlRepository urlRepository; + private final UrlBaselineRepository urlBaselineRepository; + private final UrlIndexedRepository urlIndexedRepository; + private final CacheService cacheService; private static final String BASE62_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; private static final long ID_OFFSET = 1000000000L; + private static final AtomicLong COUNTER = new AtomicLong(System.currentTimeMillis()); @Transactional public String createShortUrl(String originalUrl) { - // 이미 등록된 URL인지 확인 - return urlRepository.findByOriginalUrl(originalUrl) - .map(Url::getShortUrl) - .orElseGet(() -> { - Url newUrl = new Url(originalUrl); - urlRepository.save(newUrl); - String shortUrl = encode(newUrl.getId()); - newUrl.setShortUrl(shortUrl); - return shortUrl; - }); + // 이미 등록된 URL인지 확인 (Baseline 테이블에서 체크) + return urlBaselineRepository.findByOriginalUrl(originalUrl) + .map(UrlBaseline::getShortUrl) + .orElseGet(() -> { + // 새로운 shortUrl 생성 + String shortUrl = generateUniqueShortUrl(); + + // 1. Baseline 테이블에 저장 (인덱스 없음) + UrlBaseline baselineUrl = UrlBaseline.builder() + .shortUrl(shortUrl) + .originalUrl(originalUrl) + .build(); + urlBaselineRepository.save(baselineUrl); + + // 2. Indexed 테이블에 저장 (인덱스 있음) + UrlIndexed indexedUrl = UrlIndexed.builder() + .shortUrl(shortUrl) + .originalUrl(originalUrl) + .build(); + urlIndexedRepository.save(indexedUrl); + + log.info("URL 생성 완료: {} -> {} (2개 테이블에 저장)", shortUrl, originalUrl); + return shortUrl; + }); + } + + // 고유한 shortUrl 생성 (메인 테이블 없이) + private String generateUniqueShortUrl() { + long id = COUNTER.incrementAndGet(); + return encode(id); + } + + // Phase별 준비 메서드들 (이제 단순히 로그만 출력) + @Transactional + public void prepareBaselinePhase() { + // Phase 1: Baseline 테이블 준비 완료 (이미 데이터 있음) + log.info("Phase 1 준비 완료: Baseline 테이블 사용 (Full Table Scan) - {} 개 데이터", urlBaselineRepository.count()); + } + + @Transactional + public void prepareIndexedPhase() { + // Phase 2: Indexed 테이블 준비 완료 (이미 데이터 있음) + log.info("Phase 2 준비 완료: Indexed 테이블 사용 (Index Scan) - {} 개 데이터", urlIndexedRepository.count()); } + // Phase 1: Baseline - 인덱스 없는 테이블에서 Full Table Scan @Transactional(readOnly = true) - public String getOriginalUrl(String shortUrl) { - return urlRepository.findByShortUrl(shortUrl) - .map(Url::getOriginalUrl) - .orElseThrow(() -> new EntityNotFoundException("URL not found for short URL: " + shortUrl)); + public String getOriginalUrlBaseline(String shortUrl) { + return urlBaselineRepository.findByShortUrl(shortUrl) + .map(UrlBaseline::getOriginalUrl) + .orElseThrow(() -> new EntityNotFoundException("URL not found for short URL: " + shortUrl)); + } + + // Phase 2: Indexed - 인덱스 있는 테이블에서 Index Scan + @Transactional(readOnly = true) + public String getOriginalUrlIndexed(String shortUrl) { + return urlIndexedRepository.findByShortUrl(shortUrl) + .map(UrlIndexed::getOriginalUrl) + .orElseThrow(() -> new EntityNotFoundException("URL not found for short URL: " + shortUrl)); + } + + // Phase 3: Cached - 캐시 우선 조회 (Redis 캐시 구현) + @Transactional(readOnly = true) + public String getOriginalUrlCached(String shortUrl) { + // 1. 캐시에서 먼저 조회 + Optional cachedUrl = cacheService.get(shortUrl); + if (cachedUrl.isPresent()) { + log.debug("Cache HIT for shortUrl: {}", shortUrl); + return cachedUrl.get(); + } + + // 2. 캐시 미스 시 DB에서 조회 + log.debug("Cache MISS for shortUrl: {}, querying database", shortUrl); + String originalUrl = urlBaselineRepository.findByShortUrl(shortUrl) + .map(UrlBaseline::getOriginalUrl) + .orElseThrow(() -> new EntityNotFoundException("URL not found for short URL: " + shortUrl)); + + // 3. DB에서 조회한 결과를 캐시에 저장 + cacheService.put(shortUrl, originalUrl); + log.debug("Cached result for shortUrl: {}", shortUrl); + + return originalUrl; } private String encode(long id) { diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index a7ed0c8..54984db 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -2,16 +2,62 @@ spring: application: name: bitly datasource: - url: jdbc:postgresql://localhost:5432/bitly - username: bitly - password: bitly + url: ${DATABASE_URL:jdbc:postgresql://localhost:5432/bitly} + username: ${DATABASE_USERNAME:bitly} + password: ${DATABASE_PASSWORD:bitly} driver: org.postgresql.Driver - redis: - host: localhost - port: 6379 + data: + redis: + host: ${REDIS_HOST:localhost} + port: ${REDIS_PORT:6379} + timeout: 2000ms + connect-timeout: 2000ms jpa: hibernate: ddl-auto: update show-sql: true + +# 프로파일별 설정 +--- +spring: + config: + activate: + on-profile: docker + datasource: + url: jdbc:postgresql://postgres:5432/bitly + username: bitly + password: bitly + data: + redis: + host: redis + port: 6379 + timeout: 2000ms + connect-timeout: 2000ms + +# Actuator 및 메트릭 설정 +management: + endpoints: + web: + exposure: + include: health,info,metrics,prometheus + endpoint: + metrics: + enabled: true + health: + show-details: when_authorized + health: + redis: + enabled: true + prometheus: + metrics: + export: + enabled: true + observations: + key-values: + application: bitly + info: + env: + enabled: true + server: port: 8080 \ No newline at end of file