diff --git a/README.md b/README.md index e0c927c..72638a0 100644 --- a/README.md +++ b/README.md @@ -2,55 +2,75 @@ > Backend System Design Lab — Week 1 -URL 단축기의 코드 생성, 조회, 캐시 및 확장 전략을 직접 구현하고 -동일한 부하 조건에서 개선 전후 성능을 비교한다. +URL 단축기를 구현한 뒤 부하와 장애를 직접 만들어 보면서 구조를 단계적으로 개선한 프로젝트입니다. ## 프로젝트 소개 -URL 생성과 리다이렉트 기능을 구현하고, -읽기 중심 서비스에서 발생하는 데이터베이스 조회 및 커넥션 풀 병목을 -부하 테스트와 서버 지표를 통해 분석한 프로젝트입니다. +처음에는 Spring Boot와 MySQL만 사용했습니다. -MySQL 단독 구조를 Baseline으로 구성한 뒤 Redis Cache Aside를 적용했으며, -Platform·Virtual Thread, HikariCP 크기, -Sequence·Hash·Snowflake 단축 코드 생성 전략을 비교했습니다. +100 VU에서 모든 리다이렉트 요청이 MySQL을 조회했고 HikariCP 10개가 모두 사용되면서 최대 약 85개의 요청이 커넥션을 기다렸습니다. + +이후 Redis Cache Aside, Circuit Breaker, 다중 App, Redis Sentinel을 순서대로 적용하고 같은 방식으로 측정했습니다. + +```text +DB Only +→ Redis Cache Aside +→ Redis Fallback +→ Circuit Breaker +→ Redis Sentinel + +Single App +→ Nginx + App1/App2 +→ Snowflake nodeId 분리 +→ App Failover +``` ## 핵심 결과 -| 실험 | 결과 | -|---|---| +| 실험 | 결과 | +|-------------------|---| | Redis Cache Aside | 100 VU에서 RPS 8,531.95 → 17,371.33 | -| 응답 지연 | p95 32.96ms → 11.93ms | -| DB 조회 | 100 VU 서버 지표 측정에서 366,248회 → 1회 | -| Stress Test | 500 VU까지 요청 실패율 0% | -| 생성 전략 | Snowflake 1,660.20 RPS, Sequence 1,624.60 RPS | +| 응답 지연 | p95 32.96ms → 11.93ms | +| DB 조회 | 366,248회 → 1회 | +| 생성 전략 | Snowflake 1,660.20 RPS, Sequence 1,624.60 RPS | +| App Failover | 446,850건, 실패율 0% | +| Circuit Breaker | Redis 장애 구간 p95 약 200ms → 20~30ms | +| Redis Sentienl | 938,870건, 실패율 0%, Failover 8.567초 | ## 기술 스택 -| 구분 | 기술 | -|---|---| -| Application | Java 25, Spring Boot 4.1.0, Spring Data JPA | -| Database | MySQL 8.4 | -| Cache | Redis 7.4 | -| Monitoring | Spring Boot Actuator, Micrometer, Prometheus, Grafana | -| Performance Test | k6 | -| Infrastructure | Docker, Docker Compose | +| 구분 | 기술 | +|------------------|-------------------------------------------------------| +| Application | Java 25, Spring Boot 4.1.0, Spring Data JPA | +| Database | MySQL 8.4 | +| Cache | Redis 7.4, Redis Sentinel | +| Resilience | Resilience4j Circuit Breaker, Nginx Failover | +| Monitoring | Spring Boot Actuator, Micrometer, Prometheus, Grafana | +| Performance Test | k6 | +| Infrastructure | Docker, Docker Compose, Nginx | ## 아키텍처 ```text -Client - │ - ▼ -Spring Boot - │ - ├── Redis Cache - │ └── Cache Hit: 원본 URL 반환 - │ - └── MySQL - └── Cache Miss 및 원본 데이터 저장 + Client + │ + Nginx + ┌────┴────┐ + App1 App2 + │ │ + ┌──────┴──────────┴──────┐ + │ │ + MySQL Sentinel x3 + Source of Truth │ + Current Redis Master + ┌────┴────┐ + Replica Replica + +Prometheus → App Metrics → Grafana ``` +App Failover와 Redis Sentinel Failover는 각각 별도 Docker 환경에서 검증했습니다. + ## 주요 설계 ### Redis Cache Aside @@ -60,134 +80,100 @@ Redis 조회 → Cache Hit: 원본 URL 반환 → Cache Miss: MySQL 조회 → Redis 저장 -→ 원본 URL 반환 ``` -### 단축 코드 생성 전략 - -| 전략 | 저장 흐름 | 특징 | -| ------------------ | ----------------------- | ------------------------ | -| Sequence + Base62 | INSERT → ID 발급 → UPDATE | 단순하고 충돌 없음 | -| Hash + Base62 | 중복 조회 → Hash → INSERT | 고정 길이지만 충돌 처리 필요 | -| Snowflake + Base62 | 분산 ID 생성 → INSERT | DB ID 비의존, 다중 인스턴스 확장 가능 | - -현재 단일 인스턴스에서는 운영이 단순한 Sequence 방식을 기본값으로 유지하고, -다중 인스턴스 환경에서는 Snowflake 방식을 적용할 수 있도록 전략을 분리했습니다. +MySQL은 Source of Truth로 유지합니다. Redis 요청이 실패하면 MySQL로 Fallback하고, 장애가 반복되면 Circuit Breaker가 Redis 호출을 차단합니다. +### 단축 코드 생성 전략 -## 성능 실험 +| 전략 | 저장 흐름 | 특징 | +| ------------------ |-----------------------|-------------------------| +| Sequence + Base62 | INSERT → ID → UPDATE | 단순하고 충돌 없음 | +| Hash + Base62 | 충돌 확인 → Hash → INSERT | 고정 길이, 재시도 필요 | +| Snowflake + Base62 | 분산 ID 생성 → INSERT | DB ID 비의존, 다중 App 확장 가능 | -### Redis 적용 전후 +단일 MySQL에서는 Sequence를 기본으로 두고, 다중 App에서는 서로 다른 nodeId를 사용하는 Snowflake를 검증했습니다. -| VU | 지표 | DB Only | Redis | -|---:|---|---:|---:| -| 100 | RPS | 8,531.95 | 17,371.33 | -| 100 | p95 | 32.96ms | 11.93ms | -| 100 | p99 | 49.88ms | 21.52ms | -| 100 | 실패율 | 0% | 0% | +### 장애 대응 -### 생성 전략 비교 +```text +Redis 요청 실패 +→ MySQL Fallback +→ 반복 실패 시 Circuit Breaker OPEN -| 전략 | RPS | 평균 | p95 | 실패율 | -|---|---:|---:|---:|---:| -| Sequence | 1,624.60 | 12.12ms | 22.23ms | 0% | -| Hash | 1,298.60 | 15.18ms | 30.14ms | 0% | -| Snowflake | 1,660.20 | 11.85ms | 21.52ms | 0% | +Redis Master 장애 +→ Sentinel이 Replica 승격 +→ Lettuce가 새 Master 탐색 +→ Cache 경로 복구 -상세한 실험 조건과 서버 지표는 -[`docs/04-experiment.md`](docs/04-experiment.md)에 기록했습니다. +App 장애 +→ Nginx가 다른 App으로 GET 요청 전환 +``` ## 실행 방법 ### 사전 요구사항 -- Java 25 -- Docker -- Docker Compose -- k6 +* Java 25 +* Docker / Docker Compose +* k6 -### 전체 환경 실행 +### 환경 실행 -```bash +```text +# 기본 docker compose up -d --build -``` -애플리케이션 상태 확인: +# App 2개 + Nginx +docker compose -f docker-compose.multi.yml up -d --build -```bash -curl http://localhost:8080/actuator/health +# Redis Sentinel +docker compose -f docker-compose.sentinel.yml up -d --build ``` -### 단축 URL 생성 +상태 확인: +```curl http://localhost:8080/actuator/health``` + +### API -```bash +```text curl -X POST \ http://localhost:8080/api/v1/data/shorten \ -H 'Content-Type: application/json' \ -d '{"longUrl":"https://example.com"}' -``` -### 리다이렉트 확인 - -```bash -curl -i \ - http://localhost:8080/api/v1/{shortCode} +curl -i http://localhost:8080/api/v1/{shortCode} ``` ### 생성 전략 변경 - -```bash +```text SHORT_CODE_STRATEGY=sequence docker compose up -d --build app SHORT_CODE_STRATEGY=hash docker compose up -d --build app SHORT_CODE_STRATEGY=distributed SHORT_CODE_NODE_ID=1 \ docker compose up -d --build app ``` -지원 전략: - -- `sequence`: Auto Increment ID + Base62 -- `hash`: SHA-256 Hash + Base62 -- `distributed`: Snowflake ID + Base62 - -### 테스트 실행 - -```bash +### 테스트 +```text ./gradlew clean test -``` - -### 부하 테스트 - -리다이렉트 API 테스트: - -```bash -VUS=20 \ -DURATION=1m \ -BASE_URL=http://localhost:8080 \ -k6 run k6/load-test.js -``` - -생성 API 테스트: -```bash -RUN_ID="create-$(date +%s)" \ -VUS=20 \ -DURATION=1m \ -BASE_URL=http://localhost:8080 \ -k6 run k6/create-load.js +VUS=20 DURATION=1m BASE_URL=http://localhost:8080 \ + k6 run k6/load-test.js ``` +상세 실험 스크립트와 결과는 `k6/`, `scripts/`, `docs/04-experiment.md`에서 확인할 수 있습니다. ## 문서 | 문서 | 내용 | |---|---| -| [Requirements](docs/01-requirements.md) | 기능·비기능 요구사항 | -| [Capacity](docs/02-capacity.md) | 트래픽 및 저장 용량 추정 | -| [Architecture](docs/03-architecture.md) | 구조와 주요 설계 판단 | -| [Experiment](docs/04-experiment.md) | 부하 테스트와 병목 분석 | -| [Retrospective](docs/05-retrospective.md) | 최종 결과와 회고 | +| [Requirements](docs/01-requirements.md) | 범위와 성공 기준 | +| [Capacity](docs/02-capacity.md) | 트래픽, 저장량, 예상 병목 | +| [Architecture](docs/03-architecture.md) | 최종 구조와 설계 판단 | +| [Experiment](docs/04-experiment.md) | 부하, 병목, 장애 실험 결과 | +| [Retrospective](docs/05-retrospective.md) | 문제 해결 과정과 면접용 정리 | ## 후속 과제 -- 다중 애플리케이션 인스턴스와 Load Balancer 구성 -- Snowflake nodeId 분리 검증 -- Redis 고가용성 및 장애 시 MySQL Fallback 실험 -- 조건별 반복 측정 후 중앙값 비교 -- 조회 통계의 메시지 큐 기반 비동기 처리 \ No newline at end of file +- 조건별 3회 이상 반복 측정 후 중앙값 비교 +- Nginx와 MySQL 고가용성 구성 +- 독립 Failure Domain에서 Redis Sentinel 재검증 +- Redis 용량 또는 처리량 병목 발생 시 Cluster 기반 Sharding 검토 +- 클릭 통계가 필요해질 경우 메시지 큐 기반 비동기 처리 검토 \ No newline at end of file diff --git a/docs/01-requirements.md b/docs/01-requirements.md index b796497..47ad111 100644 --- a/docs/01-requirements.md +++ b/docs/01-requirements.md @@ -2,16 +2,14 @@ ## 1. 문제 정의 -긴 URL은 공유하거나 직접 입력하기 불편하다. +긴 URL을 짧은 코드로 바꾸고, 단축 URL 요청을 원본 URL로 리다이렉트한다. -URL Shortener는 긴 URL을 짧은 코드로 변환하고, 단축 URL 요청이 들어오면 원본 URL로 리다이렉트하는 시스템이다. +이번 프로젝트에서는 기능 구현보다 다음 질문에 답하는 데 초점을 맞췄다. -이 프로젝트에서는 단순 기능 구현을 넘어 다음 문제를 확인한다. - -* 중복되지 않는 단축 코드를 어떻게 생성할 것인가? -* 읽기 요청 증가 시 데이터베이스 병목은 어디에서 발생하는가? -* 캐시를 적용하면 성능과 DB 부하가 얼마나 개선되는가? -* 개선 과정에서 어떤 복잡성과 트레이드오프가 발생하는가? +* 단축 코드를 충돌 없이 어떻게 생성할 것인가? +* 읽기 요청이 늘면 어디에서 병목이 생기는가? +* Redis를 적용하면 DB 부하와 응답 시간이 얼마나 줄어드는가? +* Redis와 App 장애가 발생해도 요청을 계속 처리할 수 있는가? --- @@ -19,7 +17,7 @@ URL Shortener는 긴 URL을 짧은 코드로 변환하고, 단축 URL 요청이 ### 주요 사용자 -* 사용자 유형: URL을 단축하는 사용자, 단축 URL에 접근하는 사용자 +* 사용자 유형: URL을 생성하는 사용자, 단축 URL에 접근하는 사용자 * 예상 사용 규모: 하루 URL 생성 1억 건, 읽기와 쓰기 비율 10:1로 가정 * 주요 사용 환경: 웹 브라우저 및 모바일 애플리케이션 @@ -27,26 +25,26 @@ URL Shortener는 긴 URL을 짧은 코드로 변환하고, 단축 URL 요청이 1. 사용자가 긴 URL을 전달한다. 2. 시스템이 고유한 단축 코드를 생성하고 저장한다. -3. 시스템이 단축 URL을 반환한다. -4. 다른 사용자가 단축 URL에 접근한다. -5. 시스템이 원본 URL을 조회해 리다이렉트한다. +3. 단축 URL을 반환한다. +4. 사용자가 단축 URL에 접근한다. +5. 원본 URL을 조회해 `302 Found`로 리다이렉트한다. --- ## 3. 기능 요구사항 -| ID | 요구사항 | 우선순위 | -| ------ | --------------------------------------------------- | ------ | -| FR-001 | 긴 URL을 입력받아 단축 URL을 생성한다. | Must | -| FR-002 | 단축 URL 요청을 원본 URL로 리다이렉트한다. | Must | -| FR-003 | 단축 코드는 숫자와 영문 대소문자로 구성한다. | Must | -| FR-004 | 서로 다른 URL에 동일한 단축 코드를 할당하지 않는다. | Must | -| FR-005 | 유효하지 않은 URL 입력에는 400 응답을 반환한다. | Must | -| FR-006 | 존재하지 않는 단축 코드에는 404 응답을 반환한다. | Must | -| FR-007 | Sequence, Hash, Snowflake 기반 Base62 생성 방식을 비교한다. | Should | -| FR-008 | Redis를 적용해 반복적인 원본 URL 조회를 캐시한다. | Should | +| ID | 요구사항 | 우선순위 | +| ------ |----------------------------------------------------| ------ | +| FR-001 | 긴 URL을 입력받아 단축 URL을 생성한다. | Must | +| FR-002 | 단축 URL 요청을 원본 URL로 리다이렉트한다. | Must | +| FR-003 | 단축 코드는 숫자와 영문 대소문자로 구성한다. | Must | +| FR-004 | 서로 다른 URL에 동일한 단축 코드를 할당하지 않는다. | Must | +| FR-005 | 유효하지 않은 URL 입력에는 400 응답을 반환한다. | Must | +| FR-006 | 존재하지 않는 단축 코드에는 404 응답을 반환한다. | Must | +| FR-007 | Sequence, Hash, Snowflake 기반 Base62 생성 방식을 비교한다. | Should | +| FR-008 | Redis로 반복적인 원본 URL 조회를 캐시한다. | Should | | FR-009 | Bloom Filter 또는 Negative Cache로 잘못된 코드의 반복 조회를 줄인다. | Could | -| FR-010 | 사용자가 생성된 URL을 수정하거나 삭제하는 기능은 제공하지 않는다. | Won't | +| FR-010 | URL 수정과 삭제 기능은 제공하지 않는다. | Won't | 우선순위 기준: @@ -61,39 +59,37 @@ URL Shortener는 긴 URL을 짧은 코드로 변환하고, 단축 URL 요청이 ### 성능 -* 설계상 목표 처리량: 쓰기 약 1,160 RPS, 읽기 약 11,600 RPS -* 로컬 실험 목표: 동일한 조건에서 개선 전후 상대 성능 비교 -* Redis Warm Cache 목표 p95: 100ms 이하 -* Redis Warm Cache 목표 p99: 200ms 이하 +* 평균 예상 처리량: 쓰기 약 1,160 RPS, 읽기 약 11,600 RPS +* 로컬 실험에서는 절대 성능보다 동일 조건의 개선 전후 차이를 비교한다. +* Redis Warm Cache 목표: p95 100ms 이하, p99 200ms 이하 * 최대 허용 오류율: 1% 미만 * Redis 적용 후 DB 조회 수가 초기 구조보다 감소해야 한다. ### 가용성 -- Redis 조회 실패 시 MySQL 원본 저장소로 Fallback한다. -- Redis 장애 중에도 리다이렉트 요청의 오류율을 1% 미만으로 유지한다. -- Redis 복구 후 Cache Aside 조회 경로로 자동 복귀한다. -- 캐시 데이터가 유실돼도 MySQL을 통해 원본 URL을 복구할 수 있어야 한다. -- 단일 App 장애 시 Nginx가 다른 App 인스턴스로 GET 요청을 전환한다. -- 현재 서비스 경로의 단일 장애 지점은 Nginx와 MySQL이며, Redis 장애는 MySQL Fallback으로 기능을 유지한다. -- Redis 장애가 반복되면 Circuit Breaker가 Redis 호출을 차단해 반복적인 Timeout을 줄인다. -- Redis 요청 실패 시 MySQL Fallback을 통해 리다이렉트 기능을 유지한다. -- Redis 장애가 반복되면 Circuit Breaker가 Redis 호출을 차단해 반복적인 Timeout을 줄인다. +- Redis 조회 실패 시 MySQL로 Fallback한다. +- Redis 장애 중에도 리다이렉트 오류율을 1% 미만으로 유지한다. +- Redis 장애가 반복되면 Circuit Breaker로 반복 Timeout을 줄인다. +- Redis 복구 후 Cache Aside 경로로 자동 복귀한다. - Redis Master 장애 시 Sentinel이 Replica를 새로운 Master로 승격한다. -- Failover가 진행되는 동안 Circuit Breaker와 MySQL Fallback으로 사용자 요청을 처리한다. +- Failover가 진행되는 동안 Circuit Breaker와 MySQL Fallback으로 요청을 처리한다. +- 단일 App 장애 시 Nginx가 다른 App 인스턴스로 GET 요청을 전환한다. + +현재 남아 있는 주요 SPOF는 Nginx와 MySQL이다. ### 확장성 * 애플리케이션 서버는 무상태로 구성한다. -* 애플리케이션 서버는 수평 확장할 수 있어야 한다. -* 데이터 증가 시 DB 복제와 샤딩을 적용할 수 있는 구조를 고려한다. +* App 인스턴스를 수평 확장할 수 있어야 한다. +* 다중 인스턴스에서는 Snowflake nodeId를 분리할 수 있어야 한다. +* 데이터 증가 시 DB Replica, Partitioning, Sharding을 검토할 수 있어야 한다. ### 정합성 * 단축 코드의 유일성은 강하게 보장한다. -* 데이터베이스를 원본 데이터의 기준으로 사용한다. -* 캐시 데이터는 최종적 정합성을 허용한다. -* 중복 코드 저장은 DB Unique Constraint로 방지한다. +* MySQL을 Source of Truth로 사용한다. +* Redis는 조회 캐시로 사용하며 최종적 정합성을 허용한다. +* 중복 코드는 DB Unique Constraint로 방지한다. ### 보안 @@ -101,7 +97,7 @@ URL Shortener는 긴 URL을 짧은 코드로 변환하고, 단축 URL 요청이 * `http`, `https` URL만 허용한다. * `javascript:`, `file:`, `data:` 프로토콜은 차단한다. * 원본 URL 전체를 애플리케이션 로그에 남기지 않는다. -* 순차 ID 기반 코드의 예측 가능성을 트레이드오프로 분석한다. +* Sequence 기반 코드의 예측 가능성을 트레이드오프로 분석한다. --- @@ -112,45 +108,44 @@ URL Shortener는 긴 URL을 짧은 코드로 변환하고, 단축 URL 요청이 * URL 생성 API * URL 리다이렉트 API * URL 형식 검증 -* MySQL 기반 초기 구조 -* 단축 코드 생성 전략 비교 +* MySQL 기반 Baseline +* Sequence, Hash, Snowflake 생성 전략 비교 * Redis Cache-Aside -* k6 부하 테스트 -* 개선 전후 동일 조건 재측정 -* Redis 장애 시 MySQL Fallback 실험 -* 다중 애플리케이션 인스턴스 및 Nginx Failover 실험 +* k6 부하 테스트와 Prometheus, Grafana 관측 +* Redis 장애 시 MySQL Fallback +* Redis Circuit Breaker +* App 2개와 Nginx 기반 Failover * 다중 인스턴스 Snowflake nodeId 검증 -* Redis Circuit Breaker 적용 및 장애 구간 응답 지연 비교 -* Redis Sentinel 기반 Master·Replica 구성 -* Redis Master 장애와 자동 Failover 검증 +* Redis Master, Replica, Sentinel 구성 +* Redis Master 자동 Failover 검증 ### 제외 -* 사용자 인증 및 사용자별 URL 관리 -* URL 수정 및 삭제 +* 사용자 인증과 사용자별 URL 관리 +* URL 수정과 삭제 * 클릭 통계와 데이터 분석 * Kafka 기반 이벤트 처리 * 실제 DB 샤딩과 Redis Cluster * Kubernetes 및 다중 리전 배포 -제외 기능은 핵심 실험과 직접적인 관련이 없거나 로컬 환경에서 구현 비용이 크기 때문에 후속 과제로 남긴다. +제외 항목은 현재 실험에서 직접 확인한 병복과 장애에 집중하기 위해 후속 과제로 남겼다. --- ## 6. 성공 기준 -* [ ] 긴 URL을 단축 URL로 변환할 수 있다. -* [ ] 단축 URL을 통해 원본 URL로 리다이렉트할 수 있다. -* [ ] 동시에 요청해도 중복 단축 코드가 저장되지 않는다. -* [ ] MySQL 기반 초기 구조의 성능을 측정했다. -* [ ] 병목을 지표를 통해 설명할 수 있다. -* [ ] 구조를 개선한 뒤 동일한 조건으로 재측정했다. -* [ ] 개선 전후의 p95, p99, RPS, DB 조회 수를 비교했다. -* [ ] Redis 장애 시 대응 방법을 확인했다. -* [ ] 코드 생성 전략별 장단점과 트레이드오프를 설명할 수 있다. -* [ ] 다중 인스턴스에서 서로 다른 nodeId로 단축 코드 유일성을 검증했다. -* [ ] 단일 App 장애 시 다른 인스턴스로 요청이 전환되는 것을 확인했다. -* [ ] Redis 장애 시 Circuit Breaker를 통해 반복적인 Redis Timeout을 줄였다. -* [ ] Redis Master 장애 시 Sentinel이 Replica를 자동 승격한다. -* [ ] Failover 과정에서도 리다이렉트 요청 실패율 0%를 유지한다. -* [ ] Failover 이후 Redis Cache 조회 경로가 자동 복구된다. \ No newline at end of file +* [x] 긴 URL을 단축 URL로 변환할 수 있다. +* [x] 단축 URL을 통해 원본 URL로 리다이렉트할 수 있다. +* [x] 동시에 요청해도 중복 단축 코드가 저장되지 않는다. +* [x] MySQL 기반 Baseline 성능을 측정했다. +* [x] 병목을 서버와 지표를 통해 설명할 수 있다. +* [x] 개선 뒤 동일한 조건으로 재측정했다. +* [x] p95, p99, RPS, DB 조회 수를 비교했다. +* [x] Sequence, Hash, Snowflake 전략의 차이를 비교했다. +* [x] 다중 인스턴스에서 서로 다른 nodeId로 단축 코드 유일성을 검증했다. +* [x] 단일 App 장애 시 다른 인스턴스로 요청이 전환되는 것을 확인했다. +* [x] Redis 장애 시 MySQL Fallback으로 요청을 유지했다. +* [x] Circuit Breaker로 반복적인 Redis Timeout을 줄였다. +* [x] Redis Master 장애 시 Sentinel이 Replica를 자동 승격했다. +* [x] Sentinel Failover 과정에서도 리다이렉트 실패율 0%를 유지했다. +* [x] Failover 이후 Redis Cache 경로가 자동 복구되는 것을 확인했다. \ No newline at end of file diff --git a/docs/02-capacity-estimation.md b/docs/02-capacity-estimation.md index 57adde2..7203ece 100644 --- a/docs/02-capacity-estimation.md +++ b/docs/02-capacity-estimation.md @@ -2,7 +2,7 @@ ## 1. 기본 가정 -URL Shortener는 사용자 수보다 요청량을 기준으로 산정한다. +URL Shortener는 로그인 사용자 수보다 요청량을 기준으로 규모를 잡았다. | 항목 | 값 | 근거 | | ----------- | ---------: | ------------------------ | @@ -14,7 +14,7 @@ URL Shortener는 사용자 수보다 요청량을 기준으로 산정한다. | 평균 요청 크기 | 약 200Byte | URL과 요청 데이터 기준 | | 평균 응답 크기 | 약 300Byte | 단축 URL 또는 Redirect 헤더 기준 | -요청과 응답 크기는 애플리케이션 데이터 기준이며, 실제 네트워크에서는 HTTP/TLS 오버헤드가 추가된다. +요청과 응답 크기는 애플리케이션 데이터 기준이다. 실제 네트워크에서는 HTTP와 TLS 오버헤드가 추가된다. --- @@ -69,6 +69,8 @@ URL Shortener는 사용자 수보다 요청량을 기준으로 산정한다. ≈ 3,472 RPS ``` +읽기 요청이 대부분이므로 조회 경로의 DB 부하와 캐시 전략을 우선 확인한다. + --- ## 4. 저장 용량 @@ -119,7 +121,7 @@ Primary 1대, Replica 2대, 전체 백업 1개를 가정한다. 피크 RPS를 기준으로 계산한다. -### Inbound +### Inbound (요청 데이터) ```text 38,194 RPS × 200Byte @@ -127,7 +129,7 @@ Primary 1대, Replica 2대, 전체 백업 1개를 가정한다. ≈ 61Mbps ``` -### Outbound +### Outbound (응답 데이터) ```text 38,194 RPS × 300Byte @@ -135,7 +137,7 @@ Primary 1대, Replica 2대, 전체 백업 1개를 가정한다. ≈ 92Mbps ``` -실제 네트워크 사용량은 HTTP Header와 TLS 오버헤드로 인해 더 커질 수 있다. +실제 사용량은 HTTP Header와 TLS 오버헤드로 인해 더 커질 수 있다. --- @@ -143,13 +145,13 @@ Primary 1대, Replica 2대, 전체 백업 1개를 가정한다. * 캐시 대상: 자주 조회되는 단축 코드와 원본 URL * 캐시할 데이터 개수: 약 2,000만 건 -* 데이터 한 건의 크기: Redis 오버헤드를 포함해 약 300Byte +* 데이터 한 건의 크기: Redis 오버헤드 포함 약 300Byte * 예상 총 캐시 크기: 약 6GB * 여유 공간 반영: 약 8GB * TTL: 24시간 + 임의 만료 시간 * 예상 Cache Hit Ratio: 80% 이상 -캐시 크기와 Hit Ratio는 실제 접근 패턴을 측정한 뒤 조정한다. +실제 TTL과 캐시 크기는 접근 패턴과 Hit Ratio를 측정한 뒤 조정한다. --- @@ -157,19 +159,19 @@ Primary 1대, Replica 2대, 전체 백업 1개를 가정한다. 현재 범위에서는 메시지 브로커를 사용하지 않는다. -클릭 통계와 데이터 분석 기능을 구현할 경우 Kafka 등의 메시지 브로커 도입을 후속 과제로 검토한다. +클릭 통계나 분석 기능을 추가할 경우 요청 경로와 분리하기 위해 Kafka 같은 메시지 브로커를 검토한다. --- ## 8. 예상 병목 -| 컴포넌트 | 예상 병목 | 확인 지표 | 대응 방법 | -| -------------- | ---------------------- | ------------------------------- | ------------------------ | -| Application | 요청 증가에 따른 Thread 대기 | CPU, Thread, p95 | 무상태 서버 수평 확장 | -| Database | 반복 조회와 Connection 고갈 | Connection, Query 수, Slow Query | 인덱스, Redis, Read Replica | -| Cache | Hot Key와 Cache Miss 집중 | Hit Ratio, Memory, Latency | TTL Jitter, 장애 Fallback | -| Message Broker | 현재 사용하지 않음 | - | 클릭 분석 도입 시 검토 | -| External API | 현재 사용하지 않음 | - | 악성 URL 검사 도입 시 검토 | +| 컴포넌트 | 예상 병목 | 확인 지표 | 대응 방법 | +| -------------- |--------------------------------|---------------------------|--------------------------------| +| Application | 요청 증가에 따른 Thread 대기 | CPU, Thread, p95 | 무상태 서버 수평 확장 | +| Database | 반복 조회와 Connection 대기 | Active, Pending, Query 수 | 인덱스, Redis, Read Replica | +| Cache | Hot Key, Cache Miss, Master 장애 | Hit Ratio, Memory, Latency | TTL Jitter, Fallback, Sentinel | +| Message Broker | 현재 사용하지 않음 | - | 클릭 분석 도입 시 검토 | +| External API | 현재 사용하지 않음 | - | 악성 URL 검사 도입 시 검토 | --- @@ -191,14 +193,14 @@ Primary 1대, Replica 2대, 전체 백업 1개를 가정한다. ## 10. 로컬 실험 규모 -운영 규모를 로컬 환경에 그대로 재현하지 않고, 동일한 조건에서 구조별 상대 성능을 비교한다. +운영 규모를 그대로 재현하지 않고, 같은 장비에서 구조별 상대 차이를 확인한다. -| 항목 | 실험 규모 | -| ------------ | ------------------------------ | -| 데이터 수 | 10만 → 50만 → 100만 건 | -| Virtual User | 20 → 50 → 100 → 300 | -| 요청 유형 | URL 생성, 일반 조회, Hot Key, 잘못된 코드 | -| 비교 지표 | RPS, p95, p99, 오류율, DB 조회 수 | -| 비교 구조 | DB Only → Redis 적용 → 추가 개선 | +| 항목 | 실험 규모 | +| ------------ |-----------------------------------| +| 데이터 수 | 10만 → 50만 → 100만 건 | +| Virtual User | 20 → 50 → 100 → 300 이상 | +| 요청 유형 | URL 생성, 반복 조회, 장애 주입 | +| 비교 지표 | RPS, p95, p99, 오류율, DB 조회 수 | +| 비교 구조 | DB Only → Redis → 장애 대응과 Failover | -모든 개선 전후 실험에서는 데이터 수, VU, 실행 시간, JVM 옵션과 Docker 자원 조건을 동일하게 유지한다. +개선 전후 비교에서는 VU, 실행 시간, JVM 옵션, HikariCP 설정과 Docker 실행 조건을 가능한 한 동일하게 유지한다. \ No newline at end of file diff --git a/docs/03-architecture.md b/docs/03-architecture.md index bcb23b9..4e749e3 100644 --- a/docs/03-architecture.md +++ b/docs/03-architecture.md @@ -2,13 +2,12 @@ ## 1. 설계 목표 -* 해결하려는 핵심 문제: 긴 URL을 고유한 단축 URL로 변환하고, 단축 URL 요청을 원본 URL로 리다이렉트한다. -* 가장 중요한 비기능 요구사항: 단축 코드 유일성, 읽기 성능, 확장 가능성 -* 설계에서 우선한 요소: 단순한 초기 구조와 측정 가능한 베이스라인 -* 감수한 트레이드오프: 초기에는 모든 조회 요청이 MySQL에 집중된다. -* 초기 코드 생성 전략: Sequence ID + Base62 -* 최종 코드 생성 전략: 단일 인스턴스에서는 Sequence + Base62를 기본으로 사용하고, 다중 인스턴스에서는 서로 다른 nodeId를 할당한 Snowflake + Base62의 유일성을 검증했다. -* 장애 대응 전략: 애플리케이션 계층은 Nginx 기반 Failover, Redis 계층은 Circuit Breaker·MySQL Fallback과 Sentinel 기반 자동 Failover를 적용한다. +* 핵심 기능: 긴 URL을 고유한 단축 코드로 변환하고 원본 URL로 리다이렉트한다. +* 우선순위: 단축 코드 유일성, 읽기 성능, 장애 대응, 수평 확장 +* 접근 방식: 단순한 Baseline부터 시작해 부하와 장애를 직접 주입하며 구조를 확장한다. +* 초기 생성 전략: Sequence ID + Base62 +* 다중 인스턴스 생성 전략: 서로 다른 nodeId를 사용한 Snowflake + Base62 +* 장애 대응: Nginx app Failover, Redis Fallback, Circuit Breaker, Sentinel 자동 Failover ## 2. 전체 아키텍처 @@ -17,18 +16,14 @@ flowchart LR Client[Client] Nginx[Nginx] - App1[Spring Boot App1
nodeId=1] - App2[Spring Boot App2
nodeId=2] + App1[Spring Boot App1\nnodeId=1] + App2[Spring Boot App2\nnodeId=2] DB[(MySQL)] - - Sentinel1[Sentinel 1] - Sentinel2[Sentinel 2] - Sentinel3[Sentinel 3] - - RedisMaster[(Current Redis Master)] - RedisReplica1[(Redis Replica)] - RedisReplica2[(Redis Replica)] + Sentinel[Redis Sentinel x3] + Master[(Current Redis Master)] + Replica1[(Redis Replica)] + Replica2[(Redis Replica)] Prometheus[Prometheus] Grafana[Grafana] @@ -36,101 +31,77 @@ flowchart LR Client --> Nginx k6 --> Nginx - Nginx --> App1 Nginx --> App2 App1 --> DB App2 --> DB - App1 -. Master Discovery .-> Sentinel1 - App1 -. Master Discovery .-> Sentinel2 - App1 -. Master Discovery .-> Sentinel3 - - App2 -. Master Discovery .-> Sentinel1 - App2 -. Master Discovery .-> Sentinel2 - App2 -. Master Discovery .-> Sentinel3 + App1 -. Master Discovery .-> Sentinel + App2 -. Master Discovery .-> Sentinel + Sentinel -. Monitor .-> Master - App1 --> RedisMaster - App2 --> RedisMaster - - Sentinel1 -. Monitor .-> RedisMaster - Sentinel2 -. Monitor .-> RedisMaster - Sentinel3 -. Monitor .-> RedisMaster - - RedisMaster --> RedisReplica1 - RedisMaster --> RedisReplica2 + App1 --> Master + App2 --> Master + Master --> Replica1 + Master --> Replica2 Prometheus --> App1 Prometheus --> App2 Grafana --> Prometheus ``` -Nginx가 클라이언트 요청을 두 개의 Spring Boot 인스턴스로 분산한다. - -두 애플리케이션은 상태를 저장하지 않으며 동일한 MySQL을 사용한다. -MySQL은 원본 데이터를 보관하는 Source of Truth이다. +MySQL은 원본 데이터를 보관하는 Source of Truth이고 Redis는 조회 캐시다. -Redis는 리다이렉트 조회 성능을 위한 보조 저장소이며, -Master 1개와 Replica 2개를 3개의 Sentinel이 감시한다. +App은 무상태로 구성하고 Nginx가 두 인스턴스에 요청을 분산한다. Redis는 Master 1개, Replica 2개를 Sentinel 3개가 감시한다. -애플리케이션은 Sentinel을 통해 현재 Redis Master를 탐색하고, -실제 캐시 요청은 현재 Master에 전달한다. +Redis Master 장애 시 Sentinel이 Replica를 승격한다. 그 사이 Redis 요청 실패는 Circuit Breaker와 MySQL Fallback으로 처리한다. -Redis Master 장애 시 Sentinel이 Replica 하나를 새로운 Master로 승격한다. -Failover가 진행되는 동안 발생하는 Redis 요청 실패는 -Circuit Breaker와 MySQL Fallback을 통해 처리한다. +GET 요청은 App 연결 실패 시 다른 인스턴스로 재시도한다. POST 생성 요청은 처리 성공 여부가 애매한 상태에서 재시도하면 중복 생성 가능성이 있어 자동 재시도하지 않는다. -리다이렉트 GET 요청에서 특정 애플리케이션 연결에 실패하면 -Nginx가 다른 인스턴스로 요청을 재시도한다. - -POST 생성 요청은 처리 성공 여부가 불분명한 상태에서 재시도할 경우 -중복 생성 가능성이 있으므로 자동 재시도 대상으로 두지 않는다. - -다중 애플리케이션 Failover와 Redis Sentinel Failover는 -각각 별도의 로컬 Docker 환경에서 검증했다. +다중 App Failover와 Redis Sentinel Failover는 각각 별도 Docker 환경에서 검증했다. ## 3. 주요 컴포넌트 -| 컴포넌트 | 역할 | 확장 방법 | 장애 영향 | -| ---------- | ----------------- | ------------------------------- | ----------- | -| Nginx | 요청 분산 및 App 장애 시 Failover | Load Balancer 이중화 | 장애 시 외부 요청 진입 불가 | -| API Server | URL 생성, 검증, 리다이렉트 | App1·App2 무상태 수평 확장 | 단일 App 장애 시 다른 인스턴스가 처리 | -| MySQL | 원본 URL 영구 저장 | Replica, Partitioning, Sharding | 생성 및 조회 불가 | -| Prometheus | 애플리케이션 지표 수집 | 현재는 단일 인스턴스 | 성능 지표 수집 불가 | -| Grafana | 성능 지표 시각화 | 현재는 단일 인스턴스 | 대시보드 조회 불가 | -| k6 | 부하 테스트 실행 | VU 단계적 증가 | 서비스에는 영향 없음 | -| Redis | 원본 URL 조회 캐시 | Sentinel 기반 Master·Replica Failover, 필요 시 Cluster 검토 | Master 장애 시 Sentinel 자동 Failover, 전환 중 MySQL Fallback | -| Redis Sentinel | Redis Master 감시 및 자동 Failover | Sentinel 3개, quorum 2 | Sentinel 과반수 상실 시 자동 Failover 제한 | +| 컴포넌트 | 역할 | 확장 방법 | 장애 영향 | +| ---------- |------------------------|---------------------------------|-------------------------| +| Nginx | 요청 분산, App Failover | Load Balancer 이중화 | 장애 시 외부 요청 진입 불가 | +| API Server | URL 생성, 검증, 리다이렉트 | 무상태 수평 확장 | 단일 App 장애 시 다른 App이 처리 | +| MySQL | 원본 URL 영구 저장 | Replica, Partitioning, Sharding | 장애 시 생성과 조회 불가 | +| Redis | 원본 URL 조회 캐시 | Sentinel, 필요 시 Cluster | 장애 전환 중 MySQL Fallback | +| Redis Sentinel | Master 감시와 자동 Failover | Sentinel 3개, quorum 2 | 과반수 상실 시 자동 Failover 제한 | +| Prometheus | 애플리케이션 지표 수집 | 현재 단일 인스턴스 | 지표 수집 불가 | +| Grafana | 지표 시각화 | 현재 단일 인스턴스 | 대시보드 조회 불가 | +| k6 | 부하와 장애 실험 | VU 조절 | 서비스에는 직접 영향 없음 | + ## 4. 요청 흐름 ### URL 생성 -1. 클라이언트가 긴 URL을 전달한다. -2. API 서버가 URL 형식과 프로토콜을 검증한다. -3. MySQL에 원본 URL을 저장한다. -4. MySQL에서 생성된 ID를 Base62로 변환한다. +1. 긴 URL을 전달받는다. +2. URL 형식과 프로토콜을 검증한다. +3. 선택된 전략으로 단축 코드를 생성한다. +4. MySQL에 원본 URL과 단축 코드를 저장한다. 5. 단축 URL을 반환한다. ### URL 리다이렉트 -1. 애플리케이션은 Sentinel을 통해 현재 Redis Master를 탐색한다. -2. Circuit Breaker가 CLOSED이면 `shortCode`로 Redis를 조회한다. -3. Cache Hit이면 원본 URL을 반환한다. -4. Cache Miss이면 MySQL의 `short_code` 인덱스로 조회하고 Redis에 저장한다. -5. Redis 조회에 실패하면 MySQL로 Fallback한다. -6. Redis 실패가 반복돼 Circuit Breaker가 OPEN되면 Redis 호출을 생략하고 바로 MySQL을 조회한다. -7. Redis Master 장애 시 Sentinel이 Replica 하나를 새로운 Master로 승격한다. -8. Lettuce가 Sentinel을 통해 변경된 Master를 탐색한다. -9. Redis 호출 성공이 확인되면 Circuit Breaker가 CLOSED로 돌아가 Cache Aside 경로를 다시 사용한다. +1. Sentinel을 통해 현재 Redis Master를 사용한다. +2. Circuit Breaker가 CLOSED이면 Redis를 조회한다. +3. Cache Hit이면 원본 URL을 바로 반환한다. +4. Cache Miss이면 MySQL을 조회하고 Redis에 저장한다. +5. Redis 조회 실패 시 MySQL로 Fallback한다. +6. 장애가 반복되면 Circuit Breaker가 OPEN되어 Redis 호출을 생략한다. +7. Redis Master 장애 시 Sentinel이 Replica를 승격한다. +8. Lettuce가 새 Master를 탐색하고 Redis가 정상화되면 Circuit Breaker가 CLOSED로 돌아간다. 10. 원본 URL을 `302 Found`로 반환한다. ### 실패 흐름 -1. URL 형식이 잘못된 경우 `400 Bad Request`를 반환한다. -2. 단축 코드 형식이 잘못됐거나 데이터를 찾을 수 없으면 `404 Not Found`를 반환한다. -3. DB 연결에 실패하면 `503 Service Unavailable`을 반환한다. -4. Redis 연결에 실패하면 MySQL 조회로 전환한다. +1. 잘못된 URL은 `400 Bad Request`를 반환한다. +2. 존재하지 않는 단축 코드는 `404 Not Found`를 반환한다. +3. DB 연결 실패는 `503 Service Unavailable`을 반환한다. +4. Redis 연결 실패는 MySQL 조회로 전환한다. ## 5. 데이터 모델 @@ -138,7 +109,7 @@ POST 생성 요청은 처리 성공 여부가 불분명한 상태에서 재시 | 엔티티 | 주요 필드 | 설명 | |---|---|---| -| ShortUrl | id, shortCode, longUrl, createdAt | 단축 코드와 원본 URL을 저장한다. | +| ShortUrl | id, shortCode, longUrl, createdAt | 단축 코드와 원본 URL 저장 | ### 관계 @@ -154,10 +125,7 @@ erDiagram } ``` -모든 생성 전략이 동일한 조회 경로를 사용하도록 `short_code`에 Unique Index를 적용했다. -Base62의 대소문자를 구분하기 위해 `ascii_bin` Collation을 사용한다. - -Hash와 난수 Base62 방식에서는 생성된 코드를 저장해야 하므로 이후 `short_code` 컬럼과 Unique Index를 사용하는 별도 구조를 적용한다. +`short_code`에는 Unique Index를 적용했다. Base62의 대소문자를 구분하기 위해 `ascii_bin` Collation을 사용한다. ## 6. API 설계 @@ -174,7 +142,7 @@ Hash와 난수 Base62 방식에서는 생성된 코드를 저장해야 하므로 #### 문제 -읽기 요청이 많은 시스템이므로 캐시가 필요할 것으로 예상된다. 하지만 캐시를 처음부터 적용하면 개선 효과를 비교할 기준이 없다. +읽기 요청이 많아 Redis가 필요할 것으로 예상됐지만, 처음부터 캐시를 넣으면 개선 효과를 비교할 Baseline이 없다. #### 선택 @@ -182,15 +150,15 @@ Hash와 난수 Base62 방식에서는 생성된 코드를 저장해야 하므로 #### 이유 -DB Only 구조의 RPS, 응답 시간, DB 조회 수와 Connection Pool 사용량을 먼저 측정하기 위해서다. +DB 조회량, HikariCP Active와 Pending, RPS와 p95를 먼저 측정하기 위해서다. #### 대안 -Redis Cache-Aside를 처음부터 적용할 수 있다. +Redis Cache Aside를 처음부터 적용할 수 있다. #### 트레이드오프 -구조는 단순하고 베이스라인을 측정할 수 있지만, 모든 리다이렉트 요청이 MySQL에 집중된다. +구조는 단순하지만 모든 리다이렉트 요청이 MySQL에 집중된다. ### 결정 2. 초기 베이스라인으로 순차 ID 기반 Base62 사용 @@ -204,19 +172,15 @@ MySQL의 Auto Increment ID를 Base62로 변환한다. #### 이유 -충돌 확인 없이 코드를 생성할 수 있고, Base62 코드를 다시 ID로 변환해 Primary Key로 조회할 수 있다. +충돌 확인 없이 코드를 생성할 수 있고 구현이 단순한다. #### 대안 -Hash 후 충돌 해소와 난수 Base62 방식을 이후 실험에서 비교한다. +Hash와 Snowflake 방식을 같은 조건에서 비교했다. #### 트레이드오프 -코드 생성은 단순하고 빠르지만 다음 단축 코드를 예측하기 쉽다. - -이 선택은 최종 코드 생성 전략을 의미하지 않는다. 다른 생성 방식과 동일한 조건으로 비교한 뒤 최종 전략을 결정한다. - -또한 여러 DB가 독립적으로 ID를 발급하는 분산 환경에서는 ID 충돌과 Shard Routing 문제가 발생하므로, 전역 ID 생성기나 다른 코드 생성 전략이 필요하다. +코드는 예측 가능하고 DB ID에 의존한다. 다중 인스턴스에서는 Snowflake처럼 서버별로 ID 공간을 분리하는 방식이 더 적합하다. ### 결정 3. 302 Redirect 사용 @@ -226,147 +190,107 @@ Hash 후 충돌 해소와 난수 Base62 방식을 이후 실험에서 비교한 #### 선택 -초기 구현에서는 `302 Found`를 사용한다. +`302 Found`를 사용한다. #### 이유 -클라이언트 캐시의 영향을 줄이고 모든 요청이 서버를 통과하도록 해 부하 테스트가 쉽다. +클라이언트 캐시 영향을 줄이고 모든 요청이 서버를 통과하게 해 부하 테스트 결과를 보기 쉽다. #### 대안 -301 Redirect를 별도 실험에서 비교한다. +운영 정책에 따라 301을 사용할 수 있다. #### 트레이드오프 -Redirect 요청이 계속 서버에 전달되므로 301보다 서버 부하가 커질 수 있다. +301보다 서버가 더 많은 리다이렉트 요청을 처리한다. ## 8. 코드 생성 전략 비교 이번 프로젝트에서는 다음 세 가지 방식을 비교한다. -| 전략 | 생성 방식 | 주요 확인 항목 | -| --------------- | ------------------------------ | --------------------- | -| Sequence + Base62 | INSERT → ID 발급 → UPDATE | 단순하고 충돌 없음 | -| Hash + Base62 | 중복 조회 → SHA-256 → INSERT | 고정 길이, 충돌 재시도 필요 | -| Snowflake + Base62 | 분산 ID 생성 → INSERT | DB ID 비의존, nodeId 관리 필요 | - -초기 구현은 Sequence Base62로 진행하고, 이후 세 방식의 RPS, p95, DB 조회 수와 충돌 횟수를 비교한다. +| 전략 | 생성 방식 | 주요 확인 항목 | +| --------------- |--------------------------------| -------------------- | +| Sequence + Base62 | INSERT → ID 발급 → UPDATE | 단순하고 충돌 없음 | +| Hash + Base62 | 충돌 확인 → Hash(SHA-256) → INSERT | 고정 길이, 재시도 필요 | +| Snowflake + Base62 | 분산 ID 생성 → INSERT | DB ID 비의존, nodeId 관리 필요 | ### 다중 인스턴스에서의 Snowflake -다중 인스턴스 환경에서는 각 애플리케이션에 서로 다른 nodeId를 할당했다. - - App1: nodeId=1 - App2: nodeId=2 +- `synchronized`로 timestamp와 sequence 갱신을 보호한다. +- Docker 환경에서 4~8ms Clock Rollback을 관찰했다. +- 작은 시간 역행은 마지막 timestamp까지 제한적으로 기다리고, 허용 범위를 넘으면 실패 처리한다. -애플리케이션 내부에서는 `synchronized`로 timestamp와 sequence 갱신을 보호하고, -서버 간 ID 충돌은 서로 다른 nodeId를 통해 방지한다. - -부하 테스트 중 Docker 환경에서 시스템 시간이 4~8ms 역행하는 -Clock Rollback을 확인했다. - -이전 timestamp로 ID를 생성하지 않고, -작은 시간 역행에서는 마지막 생성 시각까지 시계가 복구되기를 제한된 시간 동안 기다린다. -허용 범위를 초과하는 시간 역행은 ID 중복 위험을 막기 위해 실패 처리한다. ## 9. 데이터 정합성 -* 트랜잭션 범위: 원본 URL을 MySQL에 저장하는 단일 트랜잭션 -* 동시성 제어: MySQL Auto Increment로 ID 유일성을 보장한다. -* 중복 요청 처리: 동일한 원본 URL의 중복 생성을 허용한다. -* 저장 실패 처리: DB 저장에 실패하면 단축 URL을 반환하지 않는다. -* DB와 캐시 간 정합성: MySQL을 Source of Truth로 두고 Redis에는 조회 결과만 캐시한다. -* Cache Miss 시 MySQL을 조회한 뒤 Redis에 저장한다. -* Redis 저장 실패는 원본 데이터 정합성에 영향을 주지 않으며 DB 조회 결과를 그대로 반환한다. -* Redis Master 장애 시 Sentinel이 Replica를 승격하며, Failover 공백 구간에는 MySQL Fallback으로 조회 기능을 유지한다. -Hash와 난수 방식에서는 `short_code`에 Unique Constraint를 적용해 코드 중복을 방지한다. +* MySQL을 Source of Truth로 사용한다. +* 단축 코드 유일성은 `short_code` Unique Constraint로 보장한다. +* 동일한 원본 URL의 중복 생성은 허용한다. +* DB 저장에 실패하면 단축 URL을 반환하지 않는다. +* Cache Miss는 MySQL 조회 후 Redis에 저장한다. +* Redis SET 실패는 DB 원본 데이터에 영향을 주지 않는다. +* Redis Failover 공백 구간은 MySQL Fallback으로 조회 기능을 유지한다. ## 10. 장애 대응 -| 장애 상황 | 영향 | 감지 방법 | 대응 방법 | -|---|---|---|---| -| 단일 API 서버 장애 | 해당 인스턴스 처리 중단 | Health Check, Prometheus `up` | Nginx가 다른 App 인스턴스로 요청 전환 | -| DB 장애 | URL 생성 및 조회 불가 | Connection 오류, Actuator | 503 반환, DB 복구 | -| Redis 요청 실패 | 응답 지연 및 DB 부하 증가 | Cache Error, Fallback 지표 | Circuit Breaker OPEN 후 MySQL Fallback | -| Redis Master 장애 | Cache 사용 불가 및 Failover 공백 발생 | Sentinel, Redis 연결 오류 | Replica 자동 승격 후 새 Master로 재연결 | -| Prometheus 장애 | 지표 수집 불가 | Scrape 상태 | 컨테이너 재시작 | -| Grafana 장애 | 대시보드 조회 불가 | 컨테이너 상태 | 컨테이너 재시작 | - -초기 구조에서는 DB 장애 시 요청을 처리할 대체 저장소가 없다. - -Redis GET에 실패하면 MySQL로 Fallback한다. - -Redis 장애가 확인된 요청에서는 Redis SET을 생략해 -한 요청에서 Redis Timeout이 중복되지 않도록 한다. +| 장애 상황 | 영향 | 감지 방법 | 대응 방법 | +|-----------------|------------------------------|---|-----------------------------| +| 단일 App 장애 | 해당 인스턴스 처리 중단 | Health Check, Prometheus `up` | Nginx가 다른 App으로 전환 | +| DB 장애 | 생성과 조회 불가 | Connection 오류 | 503 반환 | +| Redis 요청 실패 | 지연과 DB 부하 증가 | Cache Error, Fallback | Circuit Breaker + MySQL Fallback | +| Redis Master 장애 | Cache 사용 공백 | Sentinel, 연결 오류 | Replica 승격 후 새 Master 연결 | +| Prometheus 장애 | 지표 수집 불가 | Scrape 상태 | 재시작 | +| Grafana 장애 | 대시보드 조회 불가 | 컨테이너 상태 | 재시작 | -Redis 실패가 반복돼 Circuit Breaker의 실패율 임계값을 초과하면 -Circuit이 OPEN 상태로 전환된다. +### Redis 장애 -OPEN 상태에서는 Redis GET 자체를 호출하지 않고 -즉시 MySQL로 Fallback한다. +Redis GET 실패 시 MySQL로 Fallback하고 같은 요청에서는 Redis SET을 생략한다. -Redis 계층은 Master 1개, Replica 2개와 Sentinel 3개로 구성했다. +장애가 반복되면 Circuit Breaker가 OPEN되어 Redis 호출 자체를 막는다. Fallback만 사용했을 때 장애 구간 p95는 약 200ms였고, +Circuit Breaker 적용 후 약 20~30ms 수준으로 줄었다. -Master 장애 시 Sentinel quorum을 통해 Replica 하나를 새로운 Master로 승격하고, -애플리케이션의 Lettuce 클라이언트가 변경된 Master를 다시 탐색한다. +### Redis Master Failover -100 VU 환경에서 현재 Redis Master를 강제로 중단한 결과, -Sentinel은 8.567초 후 `redis-replica-2`를 새로운 Master로 승격했다. +Master 1개, Replica 2개, Sentinel 3개를 구성했다. -Failover가 진행되는 동안 Circuit Breaker와 MySQL Fallback이 요청을 처리해 -총 938,870건의 리다이렉트 요청에서 실패율 0%를 유지했다. +100 VU 실험에서 현재 Master를 중단하자 Sentinel이 8.567초 뒤 Replica를 새 Master로 승격했다. +938,870건의 리다이렉트 요청은 실패율 0%를 유지했고 Failover 뒤 Cache Hit이 다시 증가했다. -Failover 이후 Cache Hit이 다시 증가하면서 -Redis 조회 경로가 자동으로 복구되는 것을 확인했다. +### App Failover -Circuit Breaker와 Fallback은 Failover 공백 구간에서 사용자 요청을 보호하고, -Sentinel은 Redis 계층 자체를 자동 복구하는 역할을 담당한다. - -App1 장애 시 Nginx가 App2를 통해 GET 리다이렉트 요청을 계속 처리한다. - -Failover 실험에서 App1을 강제로 중단했지만 -리다이렉트 요청 실패율 0%를 유지했고, -App1 복구 후 다시 요청 처리에 참여하는 것을 확인했다. +App1중단 시 Nginx가 App2로 GET 요청을 넘겼다. 446,850건의 요청에서 실패율 0%를 유지했고 App1 복구 뒤 다시 요청 처리에 참여했다. ## 11. 단일 장애 지점 -* Spring Boot는 App1과 App2로 구성해 단일 애플리케이션 장애 지점을 개선했다. -* Redis는 Master 1개와 Replica 2개를 구성하고 Sentinel 3개를 통해 단일 Redis 노드 장애에 대한 자동 Failover를 검증했다. -* Nginx는 현재 단일 인스턴스이므로 외부 요청 진입 지점의 SPOF로 남아 있다. -* MySQL은 단일 인스턴스로 구성되어 있어 장애 시 URL 생성 및 조회가 불가능하다. -* Prometheus와 Grafana도 단일 인스턴스지만 서비스 요청 처리에는 직접적인 영향을 주지 않는다. - -Redis 노드와 Sentinel은 모두 동일한 Docker Desktop 호스트에서 실행했기 때문에, -호스트 자체가 장애 나는 경우 전체 Redis 계층이 함께 영향을 받는다. +* App은 2개로 구성해 단일 App 장애 지점을 줄였다. +* Redis는 Sentinel로 단일 Redis 노드 장애에 대한 자동 Failover를 검증했다. +* Nginx는 단일 인스턴스로 남아 있다. +* MySQL도 단일 인스턴스로 남아 있다. +* Prometheus와 Grafana 장애는 서비스 요청 처리에는 직접 영향을 주지 않는다. -따라서 이번 실험은 Redis 프로세스 또는 컨테이너 단위 Failover를 검증한 것이며, -독립적인 Failure Domain에 배치된 실제 운영 환경 수준의 고가용성을 의미하지 않는다. - -운영 환경에서는 Load Balancer 이중화와 MySQL Replica 및 장애 조치를 추가로 고려할 수 있다. - -Redis 저장 용량 또는 처리량이 단일 노드의 한계를 초과할 경우에는 -고가용성과 별도로 Redis Cluster 기반 Sharding을 검토할 수 있다. +Redis와 Sentinel은 모두 같은 Docker Desktop 호스트에서 실행했다. 컨테이너 단위 Failover는 검증했지만 독립 Failure Domain을 구성한 운영 수준의 HA는 아니다. ## 12. 확장 전략 ### 애플리케이션 확장 -* Nginx 뒤에 두 개의 Spring Boot 인스턴스를 배치했다. -* 애플리케이션 서버에는 세션이나 URL 상태를 저장하지 않는다. -* App1과 App2는 동일한 MySQL과 Redis를 사용한다. -* Snowflake 사용 시 각 인스턴스에 서로 다른 nodeId를 할당한다. -* 단일 App 장애 시 Nginx를 통해 다른 인스턴스로 GET 요청을 전환한다. +* Nginx 뒤에 App1과 App2를 배치한다. +* App은 세션이나 URL 상태를 저장하지 않는다. +* Snowflake 사용 시 인스턴스별 nodeId를 분리한다. +* GET은 App 장애 시 다른 인스턴스로 전환한다. ### 데이터베이스 확장 -* 초기 조회는 Primary Key 또는 `short_code` Unique Index를 사용한다. +* 조회는 Primary Key 또는 `short_code` Unique Index를 사용한다. * 읽기 요청이 증가하면 Read Replica를 검토한다. * 데이터가 단일 DB 용량을 초과하면 Partitioning과 Sharding을 검토한다. ### 캐시 확장 -DB Only 부하 테스트에서 반복적인 MySQL 조회가 병목으로 확인돼 -Redis Cache-Aside를 적용했다. +DB Only 실험에서 반복 조회와 커넥션 대기가 병목으로 확인돼 +Redis Cache Aside를 적용했다. ```text Client @@ -375,68 +299,52 @@ Client → Cache Miss 시 MySQL ``` -- 전략: Redis Cache Aside - 키: ```short-url:{shortCode}``` - TTL: 1시간 - Redis 요청 실패: Circuit Breaker + MySQL Fallback -- Redis Master 장애: Sentinel 기반 Replica 자동 승격 +- Redis Master 장애: Sentinel 자동 Failover - Source of Truth: MySQL -- 한계: Failover 공백 구간의 DB 부하 증가와 동일 호스트 Failure Domain - -현재 문제는 Redis 저장 용량 부족이 아니라 단일 Master 장애였기 때문에 -Redis Cluster는 적용하지 않았다. -향후 Redis 단일 노드의 저장 용량 또는 처리량이 병목으로 확인되면 -Cluster 기반 Sharding을 검토한다. +현재 확인한 문제는 저장 용량 부족이 아니라 Master 장애였다. Redis 단일 노드의 용량이나 처리량이 병목으로 확인되면 Cluster 기반 Sharding을 검토한다. ## 13. 보안 -* 인증 및 인가: 구현하지 않는다. -* 입력값 검증: `http`, `https` 프로토콜만 허용한다. -* 제한할 프로토콜: `javascript`, `file`, `data` -* 민감정보 저장: 별도의 개인정보를 저장하지 않는다. -* 로그 마스킹: 원본 URL의 Query Parameter 전체를 로그에 남기지 않는다. -* 접근 제한 대상: Actuator와 Prometheus 엔드포인트 +* 인증 및 인가는 구현하지 않는다. +* `http`, `https` 프로토콜만 허용한다. +* `javascript`, `file`, `data` 프로토콜은 차단한다. +* 원본 URL과 Query Parameter 전체를 로그에 남기지 않는다. +* Actuator와 Prometheus 엔드포인트는 운영 환경에서 접근 제한이 필요하다. -순차 ID 기반 코드의 예측 가능성은 코드 생성 전략 비교에서 분석한다. +Sequence 기반 코드의 예측 가능성은 트레이드오프로 남는다. ## 14. 관측 가능성 ### Metrics -* RPS -* p50, p95, p99 -* HTTP 오류율 -* CPU 사용량 -* JVM Heap 사용량 -* JVM Thread 수 -* DB 조회 수 -* DB Query Latency -* HikariCP Active Connection -* HikariCP Pending Connection -* 코드 충돌 횟수 -* 코드 생성 재시도 횟수 -* Redis Cache Error 수 -* MySQL Fallback 수 -* Redis Circuit Breaker Rejected 수 +* RPS, p50, p95, p99, HTTP 오류율 +* CPU, JVM Heap, JVM Thread +* DB Lookup, Query Latency +* HikariCP Active, Pending +* 코드 충돌, 생성 재시도 +* Cache Hit, Cache Miss, Cache Error +* MySQL Fallback +* Circuit Breaker Rejected * Redis Master Failover 소요 시간 ### Logs -* 요청 식별자: 필요 시 Request ID를 생성한다. -* 주요 이벤트: URL 생성 실패, 조회 실패, DB 오류 -* 오류 로그: 예외 종류와 요청 경로 -* 민감정보 제외 기준: 원본 URL과 Query Parameter 전체를 기록하지 않는다. -* Sentinel Master 전환 이벤트와 Failover 시작·완료 시각 +* URL 생성 실패, 조회 실패, DB 오류 +* Sentinel Master 전환 이벤트와 Failover 시각 +* 원본 URL과 Query Parameter는 제외 ### Alerts 로컬 실험에서는 실제 알림 시스템을 구현하지 않는다. -알림 조건 후보는 다음과 같다. +후보 조건: * HTTP 오류율 1% 초과 * p95 목표값 초과 -* HikariCP Pending Connection 발생 +* HikariCP Pending 발생 * MySQL 연결 실패 * Prometheus Target Down diff --git a/docs/04-experiment.md b/docs/04-experiment.md index 76c9a64..be6ca5e 100644 --- a/docs/04-experiment.md +++ b/docs/04-experiment.md @@ -2,15 +2,17 @@ ## 1. 실험 목적 -MySQL만 사용하는 초기 리다이렉트 구조에서 VU 증가에 따라 처리량과 응답 시간이 어떻게 변하는지 확인한다. +MySQL만 사용하는 리다이렉트 구조를 Baseline으로 두고, 부하가 커질 때 어디에서 병목이 생기는지 확인한다. -측정 결과는 이후 Redis 캐시 적용 전후를 비교하기 위한 Baseline으로 사용한다. +이후 Redis, Thread 방식, HikariCP, 코드 생성 전략, 다중 App과 Redis 장애 대응을 같은 방식으로 비교한다. ## 2. 가설 -> 모든 리다이렉트 요청이 MySQL 조회를 수행하므로 VU가 증가하면 응답 시간이 증가할 것이다. +> 모든 리다이렉트 요청이 MySQL을 조회하면 VU가 증가할수록 커넥션 대기와 응답 지연이 커질 것이다. -> Redis 캐시를 적용하면 반복적인 DB 조회가 줄어들어 p95와 p99가 감소하고 처리량이 증가할 것이다. +> Redis로 반복 조회를 제거하면 p95와 p99가 낮아지고 처리량이 증가할 것이다. + +> 장애 시 Fallback과 Failover를 함께 사용하면 사용자 요청을 유지하면서 복구 시간을 줄일 수 있을 것이다. ## 3. 비교 대상 @@ -26,131 +28,93 @@ MySQL만 사용하는 초기 리다이렉트 구조에서 VU 증가에 따라 ### 공통 조건 -- 동일한 단축 URL 1개 반복 조회 -- 애플리케이션 인스턴스 1개 -- MySQL 인스턴스 1개 -- Java 25 +- 동일한 단축 URL 반복 조회 - Redirect 추적 비활성화 - Think Time 없음 -- 조건별 테스트 시간 1분 +- Java 25 +- 조건별 실행 시간 동일 ### Redis 비교 -- Platform Thread 사용 -- HikariCP 최대 커넥션 10개 +- Platform Thread +- HikariCP 최대 10개 - 캐시 활성화 여부만 변경 ### Thread 비교 - DB Only -- HikariCP 최대 커넥션 10개 +- HikariCP 최대 10개 - Platform Thread와 Virtual Thread만 변경 ### HikariCP 비교 - DB Only - Platform Thread -- HikariCP 최대 커넥션만 5, 10, 20으로 변경 +- 최대 커넥션만 5, 10, 20으로 변경 ## 5. 실험 환경 -| 항목 | 값 | -|---|---| -| 실행 환경 | macOS, Docker Desktop | -| Java | 25 | -| Spring Boot | 4.1.0 | -| Database | MySQL 8.4 | -| Redis | Baseline 미적용 / Experiment 적용 | -| k6 | 2.1.0 | -| Prometheus | 3.13.0 | -| Grafana | 13.1.1 | -| 서버 지표 | 100 VU 실험에서 Prometheus·Grafana로 기록 | +| 항목 | 값 | +|---|------------------------------------| +| 실행 환경 | macOS, Docker Desktop | +| Java | 25 | +| Spring Boot | 4.1.0 | +| Database | MySQL 8.4 | +| Redis | 7.4 | +| k6 | 2.1.0 | +| Prometheus | 3.13.0 | +| Grafana | 13.1.1 | ## 6. 부하 테스트 시나리오 ### Smoke Test -- 목적: URL 생성과 리다이렉트 기능 확인 - VU: 1 - 실행 시간: 10초 -- 조건: - - Check 성공률 99% 초과 - - 실패율 1% 미만 - - p95 500ms 미만 +- 목적: URL 생성과 리다이렉트 기능 확인 ### Load Test -- 목적: VU별 처리량과 응답 시간 측정 - VU: 20, 50, 100 - 실행 시간: 조건별 1분 -- 요청 대상: 동일한 단축 URL -- Redirect 추적: 비활성화 +- 요청: 동일 shortCode GET Redirect ### Stress Test -- 수행 여부: 완료 -- 목적: - - VU 증가에 따른 처리량 한계 확인 - - p95와 p99가 급격히 증가하는 구간 확인 - - DB Only와 Redis 구조의 한계 비교 -- 비교 대상: DB Only / Redis Cache Aside -- 공통 조건: - - Platform Thread - - HikariCP 최대 커넥션 10개 - - 동일한 단축 URL 반복 조회 - - Redirect 추적 비활성화 - - Think Time 없음 -- 부하 단계: 100 → 200 → 300 → 500 VU +- VU: 100 → 200 → 300 → 500 - 전체 실행 시간: 4분 20초 +- 비교: DB Only / Redis Cache Aside +- 목적: 처리량 정체와 p95 상승 구간 확인 + ## 7. 측정 지표 ### k6 -- 요청 수 -- RPS -- 평균 응답 시간 -- p95 -- p99 -- 최대 응답 시간 -- 실패율 -- Check 성공률 +- 요청 수, RPS +- 평균, p95, p99, 최대 응답 시간 +- 실패율, Check 성공률 ### 서버 관점 -- Process CPU -- JVM Heap -- Platform Thread 수 -- HikariCP Active·Pending -- MySQL 조회량 -- Virtual Thread Mounted·Queued -- Carrier Pool Size -- Virtual Thread Pinning·제출 실패 - -CPU, JVM, HikariCP, DB Lookup 지표는 100 VU 비교 실험에서 기록했다. +- Process CPU, JVM Heap, Thread +- HikariCP Active, Pending +- DB Lookup +- Cache Hit, Miss, Error +- MySQL Fallback, Circuit Breaker Rejected -### Redis 적용 후 추가 지표 - -- 캐시 Hit 수 -- 캐시 Miss 수 -- 캐시 Hit Ratio -- MySQL 조회 횟수 변화 +Virtual Thread 실험에서는 Mounted, Queued, Carrier Pool, Pinning도 확인했다. ## 8. Warm-up - DB Only: 별도 Warm-up 없음 - Redis: `setup()`에서 최초 조회로 캐시 저장 -- URL 생성과 Redis Warm-up 요청은 측정 구간에서 제외 +- 생성 요청과 Warm-up 요청은 측정 구간에서 제외 ## 9. Baseline 결과 ### DB Only Redirect -- 구조: k6 → Spring Boot → MySQL -- Redirect: 302 -- 테스트 대상: 동일한 단축 URL 반복 조회 -- 요청 수: `setup()` 요청을 제외한 `iterations` - | VU | 실행 시간 | 요청 수 | RPS | 평균 | p95 | p99 | 최대 | 실패율 | |---:|---:|---:|---:|---:|---:|---:|---:|---:| | 20 | 1분 | 477,253 | 7,950.61 | 2.38ms | 4.35ms | 6.28ms | 52.12ms | 0.00% | @@ -159,45 +123,36 @@ CPU, JVM, HikariCP, DB Lookup 지표는 100 VU 비교 실험에서 기록했다. ### 관찰 -- 모든 조건에서 실패율은 0%, Check 성공률은 100%였다. -- VU가 증가할수록 평균, p95, p99 응답 시간이 증가했다. -- 20 VU 대비 100 VU에서 p95는 약 7.6배 증가했다. -- 100 VU의 처리량은 가장 높았지만 응답 시간 증가 폭도 컸다. -- 50 VU의 RPS가 상대적으로 낮아 단일 실행 결과의 변동성이 확인됐다. -- 서버와 DB 지표를 측정하지 않았으므로 현재 결과만으로 MySQL을 병목으로 단정할 수는 없다. +- VU가 증가할수록 평균, p95, p99가 증가했다. +- 20 VU 대비 100 VU p95는 약 7.6배 증가했다. +- 이 단계의 k6 결과만으로는 MySQL을 병목으로 단정하지 않고 서버 지표를 추가로 확인했다. ## 10. 개선 내용 -리다이렉트 조회에 Redis Cache Aside 전략을 적용했다. +리다이렉트 경로에 Redis Cache Aside를 적용했다. ```text Redis 조회 → Cache Hit: 원본 URL 반환 → Cache Miss: MySQL 조회 → Redis 저장 -→ 원본 URL 반환 ``` -기대 효과: - -- 반복 DB 조회 감소 -- p95와 p99 감소 -- 처리량 증가 -- HikariCP 사용량 감소 +목표는 반복 DB 조회와 HikariCP 대기를 줄이는 것이다. ## 11. 개선 후 결과 ### Redis Redirect -| VU | 실행 시간 | 요청 수 | RPS | 평균 | p95 | p99 | 최대 | 실패율 | -|---:|---:|---:|---:|---:|---:|---:|---:|---:| -| 20 | 1분 | 789,780 | 13,155.12 | 1.39ms | 2.81ms | 5.67ms | 198.76ms | 0.00% | -| 50 | 1분 | 975,688 | 16,238.53 | 2.90ms | 6.10ms | 11.20ms | 192.33ms | 0.00% | -| 100 | 1분 | 1,043,207 | 17,371.33 | 5.49ms | 11.93ms | 21.52ms | 154.10ms | 0.00% | +| VU | 요청 수 | RPS | 평균 | p95 | p99 | 최대 | 실패율 | +|----:|---:|---:|---:|---:|---:|---:|---:| +| 20 | 789,780 | 13,155.12 | 1.39ms | 2.81ms | 5.67ms | 198.76ms | 0.00% | +| 50 | 975,688 | 16,238.53 | 2.90ms | 6.10ms | 11.20ms | 192.33ms | 0.00% | +| 100 | 1,043,207 | 17,371.33 | 5.49ms | 11.93ms | 21.52ms | 154.10ms | 0.00% | ### Baseline 비교 -| VU | 지표 | Baseline | Redis 적용 | 변화 | +| VU | 지표 | Baseline | Redis | 변화 | |---:|---|---:|---:|---:| | 20 | RPS | 7,950.61 | 13,155.12 | 65.46% 증가 | | 20 | p95 | 4.35ms | 2.81ms | 35.40% 감소 | @@ -211,8 +166,6 @@ Redis 조회 ### 100 VU 서버 지표 비교 -캐시 활성화 여부만 변경한 동일한 코드에서 100 VU로 1분간 측정했다. - | 지표 | DB Only | Redis | |---|---:|---:| | DB Lookup | 366,248 | 1 | @@ -224,18 +177,9 @@ Redis 조회 | HikariCP Pending 최대 | 약 85 | 관찰되지 않음 | | 5xx 오류율 | 0% | 0% | -DB Only에서는 k6 iterations와 DB Lookup이 모두 366,248건으로 일치했다. -모든 리다이렉트 요청이 MySQL을 조회한 것이다. +DB Only에서는 모든 요청이 MySQL을 조회했고 HikariCP 10개가 모두 사용됐다. Pending은 약 85까지 증가했다. -HikariCP Active는 기본 최대 연결 수인 10개에 도달했고, -Pending 요청도 약 85개까지 증가했다. -DB 커넥션 대기가 p95와 p99 증가의 주요 원인으로 나타났다. - -Redis 적용 후에는 최초 요청에서만 DB를 조회했고, -이후 요청은 Cache Hit로 처리됐다. -Prometheus 수집 시점에서는 HikariCP 연결 사용과 대기가 관찰되지 않았다. - -> HikariCP는 애플리케이션이 MySQL 연결을 미리 생성하고 빌려 쓰도록 관리하는 커넥션 풀이다. +Redis 적용 뒤 최초 1회를 제외한 요청이 Cache Hit로 처리됐고 DB Lookup과 커넥션 대기가 사실상 사라졌다. #### Grafana 측정 결과 @@ -253,22 +197,13 @@ Prometheus 수집 시점에서는 HikariCP 연결 사용과 대기가 관찰되 ## 12. 결과 분석 -Redis 적용 후 모든 VU 구간에서 실패율 0%를 유지하면서 처리량이 증가하고 p95와 p99가 감소했다. - -100 VU 서버 지표에서 DB Only는 모든 요청마다 MySQL을 조회했다. -HikariCP의 10개 커넥션이 모두 사용됐고 최대 약 85개의 요청이 커넥션을 기다렸다. - -Redis 적용 후에는 최초 1회를 제외한 요청이 Cache Hit로 처리됐다. -반복적인 DB 조회가 사실상 제거됐으며, -Prometheus 수집 시점에서는 HikariCP 연결 사용과 대기가 관찰되지 않았다. -이에 따라 응답 지연이 감소하고 처리량이 증가했다. - -따라서 반복 조회가 많은 리다이렉트 경로에서는 Redis Cache Aside가 DB 접근과 커넥션 풀 병목을 줄이는 데 효과적이었다. +* 반복 조회의 핵심 병목은 HikariCP 커넥션 대기였다. +* Redis 적용 후 100VU RPS는 약 2배 증가했고 p95는 63.8% 감소했다. +* 캐시는 단순히 응답 시간을 줄인 것이 아니라 DB 조회와 커넥션 풀 사용을 함께 제거했다. ## 13. Platform Thread와 Virtual Thread 비교 -DB Only, HikariCP 최대 커넥션 10개, 100 VU, 1분 조건에서 -스레드 방식만 변경해 비교했다. +DB Only, HikariCP 10, 100 VU, 1분 조건에서 Thread 방식만 변경했다. | 지표 | Platform Thread | Virtual Thread | |---|---:|---:| @@ -284,17 +219,10 @@ DB Only, HikariCP 최대 커넥션 10개, 100 VU, 1분 조건에서 | HikariCP Pending 최대 | 약 85 | 약 87 | | 오류율 | 0% | 0% | -Virtual Thread 적용 후 Platform Thread 수와 Process CPU 사용량은 감소했다. - -하지만 RPS는 감소했고 평균, p95, p99 응답 시간은 증가했다. -두 방식 모두 HikariCP 최대 커넥션 10개를 사용했으며, -약 80개 이상의 요청이 DB 커넥션을 기다렸다. - -따라서 이번 조건에서는 Virtual Thread만으로 처리량이 개선되지 않았고, -DB 커넥션 풀이 주요 제한 요소로 남았다. - -다만 조건별 한 번만 측정했으므로 -Virtual Thread가 항상 Platform Thread보다 느리다고 일반화할 수는 없다. +* Virtual Thread는 Platform Thread 수와 CPU를 줄였다. +* 처리량과 응답 시간은 오히려 나빠졌다. +* 두 방식 모두 DB 커넥션을 기다렸기 때문에 Thread 방식보다 HikariCP가 먼저 제한 요소가 됐다. +* 이번 결과만으로 Virtual Thread 자체가 느리다고 일반화하지 않는다. ### Virtual Thread 실행 지표 @@ -307,9 +235,6 @@ Virtual Thread가 항상 Platform Thread보다 느리다고 일반화할 수는 | Pinned Events | 0 | | Submit Failed | 0 | -Virtual Thread는 최대 8개의 Carrier Thread 위에서 실행됐다. -부하 테스트 중 Carrier Thread를 점유한 채 대기하는 Pinning과 -Virtual Thread 제출 실패는 발생하지 않았다. #### Grafana 측정 결과 @@ -329,12 +254,7 @@ Virtual Thread 제출 실패는 발생하지 않았다. ## 14. HikariCP Pool 크기 비교 -Thread 비교에서 HikariCP Active가 최대 커넥션 10개에 도달하고 -Pending 요청이 발생했다. - -커넥션 풀 크기가 실제 처리량과 응답 시간에 미치는 영향을 확인하기 위해 -DB Only, Platform Thread, 100 VU, 1분 조건에서 -최대 커넥션 수만 5, 10, 20으로 변경했다. +DB Only, Platform Thread, 100VU에서 최대 커넥션만 변경했다. | 지표 | Pool 5 | Pool 10 | Pool 20 | |---|---:|---:|---:| @@ -346,37 +266,17 @@ DB Only, Platform Thread, 100 VU, 1분 조건에서 | 최대 응답 시간 | 830.87ms | 598.46ms | 335.53ms | | 오류율 | 0% | 0% | 0% | -Pool 5에서 Pool 10으로 증가했을 때 처리량이 증가하고 -평균, p95, p99 응답 시간이 모두 감소했다. -Pool 5는 100개의 동시 요청을 처리하기에 커넥션 수가 부족했던 것으로 보인다. - -반면 Pool 20은 Pool 10보다 p99와 최대 응답 시간은 감소했지만, -RPS가 낮아지고 평균과 p95가 증가했다. - -이번 단일 실행에서는 Pool 10이 처리량과 일반적인 응답 지연 측면에서 -가장 균형 있는 결과를 보였다. -커넥션 수를 늘린다고 성능이 계속 향상되는 것은 아니며, -동시에 실행되는 쿼리 증가에 따른 DB 경합과 로컬 실행 환경의 변동도 -함께 고려해야 한다. - -Pool 20 실험에서는 k6 iterations와 DB Lookup이 모두 438,566건으로 일치했다. -Cache Hit과 Cache Miss는 모두 0건이므로 -모든 리다이렉트 요청이 MySQL 조회로 처리됐음을 확인했다. - -> 조건별 한 번만 실행한 결과이므로 Pool 10을 최적값으로 확정할 수는 없다. -> 정확한 최적값을 결정하려면 조건별 반복 측정과 MySQL 서버 지표가 필요하다. +* Pool 5는 커넥션 수가 부족했다. +* 단일 실행에서는 Pool 10이 RPS와 일반 응답 지연의 균형이 가장 좋았다. +* Pool 20은 p99와 최댓값은 줄었지만 RPS와 p95가 좋아지지 않았다. +* 커넥션 수를 늘린다고 성능이 계속 좋아지는 것은 아니었다. ## 15. Stress Test -DB Only와 Redis Cache Aside 구조에서 VU를 100, 200, 300, 500까지 단계적으로 증가시켰다. - -두 구조 모두 Platform Thread와 HikariCP 최대 커넥션 10개를 사용했으며, -캐시 활성화 여부만 변경했다. +DB Only와 Redis 구조에서 VU를 100, 200, 300, 500까지 올렸다. ### k6 전체 결과 -아래 결과는 4분 20초 동안 변화한 모든 VU 구간을 합산한 값이다. - | 지표 | DB Only | Redis | |---|---:|---:| | Redirect 요청 수 | 2,123,358 | 4,586,523 | @@ -387,31 +287,14 @@ DB Only와 Redis Cache Aside 구조에서 VU를 100, 200, 300, 500까지 단계 | 최대 응답 시간 | 389.70ms | 475.44ms | | 요청 실패율 | 0% | 0% | -Redis 적용 후 DB Only 대비 전체 평균 RPS는 약 115.66% 증가했다. -평균 응답 시간은 약 55.91%, p95는 약 70.60% 감소했다. - -최대 응답 시간은 Redis가 더 높았지만, -단일 이상치에 가까운 최대값보다 평균과 p95를 중심으로 결과를 해석했다. - ### DB Only 관찰 -DB Only는 VU 증가에 따라 초기에는 처리량이 증가했지만, -약 200 VU 이후 RPS가 약 8.5K~9K 수준에서 더 이상 증가하지 않았다. - -반면 p95와 p99는 VU가 증가할수록 계속 증가했다. -HikariCP Active는 최대 커넥션 10개에 도달했고, -Pending 요청은 약 180~190까지 증가했다. - -따라서 200 VU 이후에는 더 많은 요청을 받아도 처리량이 증가하지 않고, -DB 커넥션을 기다리는 요청만 증가한 것으로 나타났다. +* 약 200 VU 이후 RPS가 8.5K~9K 수준에서 정체됐다. +* HikariCP Active는 10에 도달했고 Pending은 약 180~190까지 증가했다. +* 더 많은 요청이 처리량 증가보다 대기 증가로 이어졌다. ### Redis 관찰 -Redis 구조는 VU 증가에 따라 처리량이 약 22K RPS까지 증가했다. -500 VU까지 5xx 오류와 요청 실패는 발생하지 않았다. - -전체 Stress Test에서 측정된 캐시 및 DB 지표는 다음과 같다. - | 지표 | 결과 | |---|---:| | Cache Hit | 4,586,523 | @@ -419,24 +302,13 @@ Redis 구조는 VU 증가에 따라 처리량이 약 22K RPS까지 증가했다. | DB Lookup | 1 | | Cache Hit Ratio | 약 100% | -최초 조회에서만 Cache Miss와 DB Lookup이 발생했고, -이후 모든 반복 요청은 Redis Cache Hit로 처리됐다. - -Prometheus 수집 시점에서는 HikariCP Active와 Pending이 관찰되지 않았다. -Redis 구조도 약 22K RPS 부근에서 처리량 증가가 둔화됐지만, -500 VU까지 오류 없이 DB Only보다 낮은 지연을 유지했다. +* 처리량은 약 22K RPS 부근까지 증가했다. +* 500 VU까지 요청 실패는 없었다. +* Redis 구조도 처리량 증가가 둔화됐지만 원인은 App, Redis, 네트워크, 로컬 환경 중 하나로 분리하지 않았다. ### 결과 해석 -DB Only는 약 200 VU 이후 HikariCP 커넥션 풀이 포화되면서 -처리량이 정체되고 대기 요청이 증가했다. - -Redis Cache Aside는 반복적인 DB 조회와 커넥션 대기를 제거해 -DB Only보다 약 2.16배 높은 전체 평균 처리량과 낮은 p95를 기록했다. - -다만 Redis도 약 22K RPS 부근에서 처리량 증가가 둔화됐으므로, -더 높은 부하에서는 애플리케이션, Redis, 네트워크 또는 -로컬 Docker 환경의 다른 제한 요소를 추가로 확인해야 한다. +Redis는 반복 DB 조회와 커넥션 대기를 제거해 전체 평균 처리량을 약 2.16배 높이고 p95를 낮췄다. #### Grafana 측정 결과 @@ -450,15 +322,7 @@ DB Only보다 약 2.16배 높은 전체 평균 처리량과 낮은 p95를 기록 ## 16. 단축 코드 생성 전략 비교 -단축 코드 생성 방식에 따른 생성 API 성능을 비교했다. - -- Sequence ID + Base62 -- SHA-256 Hash + Base62 -- Snowflake ID + Base62 - -모든 전략은 Platform Thread, HikariCP 최대 커넥션 10개, -20 VU, 1분 조건에서 측정했다. -실제 신규 생성 경로를 실행하기 위해 요청마다 서로 다른 URL을 사용했다. +Platform Thread, HikariCP 10, 20 VU, 1분 조건에서 신규 URL을 계속 생성했다. | 지표 | Sequence + Base62 | Hash + Base62 | Snowflake + Base62 | |---|---:|---:|---:| @@ -469,44 +333,20 @@ DB Only보다 약 2.16배 높은 전체 평균 처리량과 낮은 p95를 기록 | 최대 응답 시간 | 254.82ms | 467.43ms | 274.23ms | | 실패율 | 0% | 0% | 0% | -각 전략의 저장 흐름은 다음과 같다. - ```text -Sequence -INSERT → Auto Increment ID 발급 → Base62 → UPDATE - -Hash -중복 코드 SELECT → SHA-256 → Base62 → INSERT - -Snowflake -분산 ID 생성 → Base62 → INSERT +Sequence : INSERT → Auto Increment ID → Base62 → UPDATE +Hash : 충돌 확인 SELECT → Hash(SHA-256) → INSERT +Snowflake : 분산 ID → Base62 → INSERT ``` -Snowflake 방식은 단일 실행에서 가장 높은 RPS와 -가장 낮은 평균·p95 응답 시간을 기록했다. - -Sequence 방식도 Snowflake 방식과 비슷한 성능을 보였지만, -DB에서 ID를 발급받은 후 short_code를 갱신하기 때문에 -생성 요청마다 INSERT와 UPDATE가 발생한다. - -Hash 방식은 충돌 확인을 위한 조회와 저장 재시도 처리가 필요해 -세 전략 중 가장 낮은 처리량과 가장 높은 응답 지연을 기록했다. -Snowflake 방식은 DB ID 발급이나 사전 충돌 조회 없이 -한 번의 INSERT로 저장할 수 있다는 장점이 있다. -다만 서버별 nodeId 관리, 시스템 시간 역행 처리, -생성기 동기화와 같은 운영 고려사항이 추가된다. - -현재 단일 MySQL 환경에서는 Sequence 방식이 가장 단순하다. -다중 애플리케이션 인스턴스로 확장할 경우에는 -DB Auto Increment 의존성이 없는 Snowflake 방식을 적용할 수 있다. - -단, 전략별 한 번만 측정했으며 Sequence와 Snowflake의 차이가 작으므로 -이번 결과만으로 Snowflake의 성능 우위를 일반화할 수는 없다. +* Snowflake가 단일 실행에서 가장 높은 RPS와 가장 낮은 p95를 기록했다. +* Sequence도 성능 차이는 작고 구현이 가장 단순했다. +* Hash는 충돌 확인과 재시도 비용이 추가돼 가장 느렸다. +* 다중 App에서는 DB ID에 의존하지 않는 Snowflake를 검증했고 nodeId를 분리했다. ## 17. Redis 장애 시 MySQL Fallback -Redis 장애가 리다이렉트 전체 장애로 이어지지 않도록 -Redis GET 실패 시 MySQL로 전환하는 Fallback을 적용했다. +Redis GET 실패 시 MySQL로 전환하도록 Fallback을 적용했다. | 항목 | 조건 | |---|---| @@ -516,7 +356,6 @@ Redis GET 실패 시 MySQL로 전환하는 Fallback을 적용했다. | Redis 중지 | 30~60초 | | 복구 관찰 | 60~120초 | | Redis Timeout | 200ms | -| HikariCP | 최대 10개 | ### 결과 @@ -529,18 +368,9 @@ Redis GET 실패 시 MySQL로 전환하는 Fallback을 적용했다. | 최대 응답 시간 | 345.44ms | | 실패율 | 0% | -Redis 장애 구간에는 GET Error, MySQL Fallback과 DB Lookup이 함께 증가했다. -장애 구간의 p95는 약 200ms, p99는 약 220ms까지 증가했지만 -5xx 오류는 발생하지 않았다. - -Redis 복구 후 Error, Fallback과 DB Lookup은 다시 0으로 감소했고, -Cache Hit와 응답시간도 정상 수준으로 복귀했다. - -이를 통해 Redis 장애 시 성능 저하를 감수하면서 기능을 유지하고, -Redis 복구 후 Cache Aside 경로로 자동 전환되는 것을 확인했다. - -현재는 모든 요청이 Redis Timeout을 기다린 뒤 Fallback하므로, -향후 Circuit Breaker를 적용하면 장애 구간의 반복 대기를 줄일 수 있다. +* 장애 중 GET Error, Fallback, DB Lookup이 함께 증가했다. +* 장애 구간 p95는 약 200ms, p99는 약 220ms였다. +* Redis 복구 뒤 Cache Hit 경로로 자동 복귀했다. ![Redis 장애 성능 지표](images/redis-fallback-100vu-performance.png) @@ -548,37 +378,22 @@ Redis 복구 후 Cache Aside 경로로 자동 전환되는 것을 확인했다. ## 18. 다중 인스턴스 및 Failover -단일 애플리케이션 장애가 전체 서비스 장애로 이어지는 문제를 줄이기 위해 -App 인스턴스를 2개로 확장하고 Nginx를 통해 요청을 분산했다. +App을 2개로 확장하고 Nginx가 요청을 분산하도록 구성했다. ### Snowflake 다중 인스턴스 -각 인스턴스에 서로 다른 Snowflake nodeId를 할당했다. - | 인스턴스 | nodeId | |---|---:| | App1 | 1 | | App2 | 2 | -동시 생성 테스트에서 Nginx를 통해 두 인스턴스에 요청이 거의 동일하게 분산됐으며, -생성된 shortCode의 중복 여부를 검증했다. - -테스트 과정에서 Snowflake 내부 동시성 문제가 아닌 -시스템 Clock Rollback도 확인했다. - -```text -App1: backwardMillis=8 -App2: backwardMillis=4 -``` +동시 생성 테스트에서 shortCode 중복이 발생하지 않았다. -이에 작은 시간 역행에서는 이전 timestamp로 ID를 생성하지 않고 -시계가 마지막 생성 시각까지 복구되기를 기다리도록 처리했다. -큰 시간 역행은 ID 중복 위험을 막기 위해 실패 처리한다. +테스트 중 Docker 시스템 시간이 App1 8ms, App2 4ms 역행하는 Clock Rollback도 확인했다. +작은 역행은 마지막 timestamp까지 기다리고 큰 역행은 실패 처리하도록 했다. ### 애플리케이션 Failover -100 VU의 리다이렉트 요청을 지속하면서 App1을 강제로 중지한 뒤 다시 실행했다. - | 항목 | 조건 | |---|---| | VU | 100 | @@ -586,7 +401,6 @@ App2: backwardMillis=4 | 정상 구간 | 0~30초 | | App1 중지 | 30~60초 | | App1 재시작 | 60초 | -| 요청 | GET Redirect | ### 결과 @@ -600,33 +414,21 @@ App2: backwardMillis=4 | 실패율 | 0% | | Check 성공률 | 100% | -App1 중지 후 Prometheus의 `up` 값이 0으로 변경됐으며, -App2가 단독으로 리다이렉트 요청을 처리했다. - -App1 장애 중에도 전체 요청의 실패율은 0%를 유지했다. -App1 재기동 후 Healthy 상태로 복구됐으며 -다시 요청 처리에 참여하는 것도 확인했다. +* App1 중단 뒤 `up=0`이 확인됐다. +* App2가 단독으로 GET 요청을 처리했다. +* 전체 요청 실패율은 0%duTek. +* App1 복구 뒤 다시 요청 처리에 참여했다. #### Grafana 측정 결과 ![다중 인스턴스 Failover](images/multi-instance-failover.png) -단일 애플리케이션 구조에서는 App 장애가 전체 요청 처리 불가로 이어질 수 있지만, -두 개의 App 인스턴스와 Nginx를 구성한 뒤에는 -한 인스턴스가 중단되어도 다른 인스턴스가 요청을 계속 처리했다. - -다만 Nginx, MySQL은 여전히 단일 인스턴스이므로 -시스템 전체의 SPOF를 제거한 것은 아니다. -이번 실험은 애플리케이션 계층의 단일 장애 지점을 개선하는 데 범위를 한정한다. +Nginx와 MySQL은 단일 인스턴스로 남아 있으므로 이 실험은 App 계층 Failover만 검증했다. ## 19. Redis Circuit Breaker -Redis 장애 시 MySQL Fallback을 적용해 서비스 가용성은 유지했지만, -각 요청이 Redis Timeout을 기다린 뒤 MySQL로 전환되면서 -장애 구간 p95가 약 200ms까지 증가하는 문제가 남았다. - -Redis 장애가 지속될 때 반복적인 Timeout을 줄이기 위해 -Redis GET 경로에 Circuit Breaker를 적용했다. +Fallback만 적용했을 때 모든 요청이 Redis Timeout을 기다리는 문제가 남았다. +Redis GET 경로에 Circuit Breaker를 추가했다. ### 설정 @@ -639,83 +441,23 @@ Redis GET 경로에 Circuit Breaker를 적용했다. | HALF_OPEN 시험 호출 | 3건 | | Redis Timeout | 200ms | -```text -CLOSED -→ Redis 호출 허용 - -Redis 실패율 임계값 초과 -→ OPEN -→ Redis 호출 차단 -→ MySQL Fallback - -OPEN 5초 경과 -→ HALF_OPEN -→ Redis 시험 호출 - -Redis 복구 확인 -→ CLOSED -→ Cache Aside 경로 복귀 -``` - -### 실험 조건 - -Fallback-only 실험과 동일한 조건으로 측정했다. - -| 항목 | 조건 | -|---|---| -| VU | 100 | -| 실행 시간 | 120초 | -| 정상 구간 | 0~30초 | -| Redis 중지 | 30~60초 | -| 복구 관찰 | 60~120초 | -| Redis Timeout | 200ms | -| HikariCP | 최대 10개 | - -### 결과 - -Redis 장애 직후에는 실제 Redis 호출 실패가 발생하지만, -실패가 누적되면서 Circuit Breaker가 OPEN 상태로 전환된다. - -OPEN 이후에는 Redis 호출 자체가 차단되고 -요청은 즉시 MySQL Fallback 경로로 처리됐다. - -Grafana에서 Circuit Breaker Rejected는 최대 약 5.7K req/s, -MySQL Fallback은 약 5.8K req/s까지 증가했으며, -5xx 오류는 발생하지 않았다. - -장애 구간의 p95는 초기 약 35ms를 기록한 뒤 -대체로 20~30ms 수준으로 유지됐다. -Fallback만 적용했을 때 장애 구간 p95가 약 200ms였던 것과 비교하면, -반복적인 Redis Timeout 대기가 크게 감소했다. - -Redis 복구 후에는 Circuit Breaker Rejected, -MySQL Fallback과 DB Lookup이 다시 0으로 감소했고 -Cache Hit이 증가하면서 정상 조회 경로로 복귀했다. - ### Fallback-only 비교 | 지표 | Fallback only | Circuit Breaker | |---|---:|---:| | VU | 100 | 100 | -| Redis Timeout | 200ms | 200ms | | Redis 장애 시간 | 30초 | 30초 | | 장애 구간 p95 | 약 200ms | 약 20~30ms | | 장애 구간 p99 | 약 220ms | 약 40~80ms | | 5xx 오류율 | 0% | 0% | | Redis 호출 | 장애 중 반복 | OPEN 이후 차단 | -| MySQL Fallback | 발생 | 발생 | -| Redis 복구 후 Cache 복귀 | 성공 | 성공 | - -Fallback만 적용했을 때는 Redis 장애가 전체 서비스 장애로 -이어지는 것은 막을 수 있었지만, -각 요청이 Redis Timeout을 기다린 뒤 DB를 조회하는 문제가 남았다. -Circuit Breaker 적용 후에는 장애를 감지한 뒤 Redis 호출을 차단해 -MySQL을 바로 조회하도록 변경했다. +* 장애 직후 실제 Redis 실패가 발생한 뒤 Circuit이 OPEN됐다. +* OPEN 상태에서는 Redis를 호출하지 않고 MySQL로 바로 전환됐다. +* Rejected와 Fallback이 증가했지만 5xx는 발생하지 않았다. +* Redis 복구 뒤 Cache Hit이 다시 증가했다. -따라서 Fallback은 Redis 장애 시 기능을 유지하고, -Circuit Breaker는 장애가 지속되는 동안 반복적인 Redis 호출과 -Timeout 비용을 줄이는 역할을 한다. +Fallback은 기능을 유지하고, Circuit Breaker는 반복 Timeout을 줄이는 역할로 나눴따. #### Grafana 측정 결과 @@ -723,12 +465,7 @@ Timeout 비용을 줄이는 역할을 한다. ## 20. Redis Sentinel 자동 Failover -Circuit Breaker와 MySQL Fallback을 통해 Redis 장애 중에도 -서비스 요청을 처리할 수 있었지만, Redis 자체는 단일 인스턴스로 남아 있었다. - -Redis 계층의 자동 복구를 검증하기 위해 -1개의 Master, 2개의 Replica, 3개의 Sentinel로 구성된 -Redis Sentinel 환경을 구축했다. +Redis 장애를 우회하는 것에서 끝내지 않고 Redis 계층 자체의 자동 복구를 검증했다. ```text Spring Boot @@ -740,10 +477,6 @@ Redis Sentinel 환경을 구축했다. Redis Redis Redis ``` -Sentinel은 Master 장애를 감지하면 Replica 중 하나를 새로운 Master로 -승격하고, Spring Boot의 Lettuce 클라이언트는 Sentinel을 통해 -변경된 Master를 다시 탐색하도록 구성했다. - ### 실험 조건 | 항목 | 값 | @@ -756,15 +489,8 @@ Sentinel은 Master 장애를 감지하면 Replica 중 하나를 새로운 Master | Sentinel | 3개 | | Sentinel quorum | 2 | | down-after-milliseconds | 5초 | -| Redis Timeout | 200ms | - -### 장애 전환 결과 -실험 시작 당시 Sentinel이 관리하는 Master는 -`redis-replica-1`이었다. - -실행 30초 후 현재 Master를 강제로 중단했고, -Sentinel은 `redis-replica-2`를 새로운 Master로 승격했다. +### 결과 | 지표 | 결과 | |---|---:| @@ -779,83 +505,56 @@ Sentinel은 `redis-replica-2`를 새로운 Master로 승격했다. | 요청 실패율 | 0% | | Redirect 검증 성공률 | 100% | -Master 장애 직후 Redis 연결 실패가 발생하면서 -Circuit Breaker Rejected와 MySQL Fallback이 증가했다. - -이 구간에서는 Redis 대신 MySQL이 요청을 처리했으며 -5xx 오류는 발생하지 않았다. - -이후 Sentinel이 약 8.6초 만에 Replica를 새로운 Master로 승격했고, -애플리케이션이 새로운 Redis Master에 다시 연결되면서 -Circuit Breaker Rejected와 Fallback은 감소하고 -Cache Hit 경로가 자동으로 복구됐다. - -```text -Redis Master 장애 - | - v -Circuit Breaker OPEN - | - +----> MySQL Fallback - | | - | +----> 사용자 요청 지속 - | - v -Sentinel 장애 감지 - | - v -Replica -> Master 승격 - | - v -Lettuce 새 Master 탐색 - | - v -Redis Cache 자동 복구 -``` - -따라서 Circuit Breaker와 Fallback은 Redis Failover가 진행되는 -공백 구간에서 사용자 요청을 보호하고, -Sentinel은 Redis 계층 자체를 자동 복구하는 역할을 담당한다. +* Master 장애 직후 Circuit Breaker와 MySQL Fallback이 요청을 보호했다. +* Sentinel은 8.567초 뒤 Replica를 새 Master로 승격했다. +* Lettuce가 새 Master에 연결된 뒤 Cache Hit 경로가 복구됐다. +* 전체 938,870건의 요청에서 실패가 발생하지 않았다. #### Grafana 측정 결과 ![Redis Sentinel Failover](images/redis-sentinel-failover-100vu.png) -## 19. 실험 한계 +## 21. 실험 한계 + +### 공통 환경 +- 모든 실험은 로컬 Docker Desktop에서 실행했다. +- k6, App, MySQL, Redis가 같은 장비의 CPU와 메모리를 공유했다. +- 조건별 1회 측정이므로 실행 환경의 변동이 포함될 수 있다. +- Prometheus 수집 간격보다 짧은 변화는 그래프에서 누락될 수 있다. -- 로컬 Docker 환경에서 실행했다. -- k6, 애플리케이션, MySQL, Redis가 같은 장비의 자원을 사용했다. -- 조건별 한 번만 측정해 실행 환경의 변동이 포함될 수 있다. +### 성능 실험 - Load Test는 조건별 1분, Stress Test는 구조별 4분 20초 동안 실행했다. -- 하나의 단축 URL만 반복 조회했다. -- Prometheus 수집 간격 사이의 짧은 지표 변화는 누락될 수 있다. -- 고정 VU 및 단계적 VU 증가 방식에서는 응답 시간이 짧을수록 더 많은 요청이 발생한다. -- Stress Test의 k6 최종 결과는 모든 VU 구간을 합산한 값이므로, 특정 VU 구간의 값은 Grafana 시계열을 통해 판단했다. -- Redis Stress Test의 처리량 한계가 애플리케이션, Redis 또는 로컬 환경 중 어디에서 발생했는지는 추가로 분리하지 않았다. -- 단축 코드 생성 전략도 조건별 한 번만 측정해 Sequence와 Snowflake의 작은 차이가 실행 환경의 변동인지 확인하지 못했다. -- Redis 장애 실험은 프로세스 중지만 재현했으며 네트워크 지연과 패킷 손실은 검증하지 않았다. -- Redis 장애 중 더 높은 부하에서는 MySQL과 커넥션 풀이 포화될 수 있다. -- 다중 인스턴스 실험은 로컬 Docker 환경에서 App 2개와 Nginx 1개로 수행했으며, 실제 독립 서버 장애를 재현한 것은 아니다. -- Circuit Breaker의 상태 전환은 Rejected 지표를 통해 간접적으로 확인했으며, - CLOSED, OPEN, HALF_OPEN 상태 자체를 별도 메트릭으로 기록하지 않았다. -- Sentinel, Redis 노드가 모두 동일한 Docker Desktop 호스트에서 실행됐기 때문에 - 독립적인 Failure Domain을 구성한 실제 운영 환경의 HA와는 차이가 있다. -- p95는 23.72ms로 유지됐지만 Failover 과정에서 최대 1.98초의 - tail latency가 발생했다. -- Redis Sentinel은 자동 Failover를 제공하지만 데이터 Sharding을 제공하지 않으므로, - Redis 단일 노드의 저장 용량 또는 처리량 한계를 해결하기 위한 구조는 아니다. +- 하나의 shortCode를 반복 조회해 Hot Key에 가까운 패턴이다. +- 고정 VU 방식은 응답이 빨라질수록 같은 시간에 더 많은 요청이 발생한다. +- Stress Test의 k6 최종 값은 전체 VU 구간을 합산한 값이라 구간별 변화는 Grafana 시계열로 판단했다. +- Redis가 약 22K RPS에서 둔화된 원인을 App, Redis, 네트워크 중 하나로 분리하지 않았다. + +### 코드 생성 실험 +- Sequence, Hash, Snowflake는 조건별 1회씩 측정했다. +- Sequence와 Snowflake의 작은 차이가 환경 변동인지 성능 차이인지 반복 측정하지 않았다. +- Clock Rollback은 Docker 환경에서 관찰한 4~8ms 수준만 다뤘다. + +### 장애와 Failover 실험 +- Redis 장애는 프로세스 중지 중심으로 재현했고 네트워크 지연과 패킷 손실은 다루지 않았다. +- Redis 장애 중 부하가 더 커지면 MySQL과 HikariCP가 다시 포화될 수 있다. +- App Failover는 같은 호스트의 App 2개와 Nginx 1개로 검증했다. +- Circuit Breaker 상태는 Rejected 지표로 확인했고 CLOSED, OPEN, HALF_OPEN을 별도 메트릭으로 기록하지 않았다. +- Redis 노드와 Sentinel도 같은 Docker 호스트에 있어 독립 Failure Domain을 구성한 운영 환경과는 차이가 있다. +- Sentinel 실험의 p95는 23.72ms였지만 Failover 순간 최대 1.98초의 tail latency가 발생했다. +- Sentinel은 자동 Failover를 제공하지만 Redis Cluster처럼 데이터 Sharding을 제공하지 않는다. ## 20. 후속 실험 - [x] Redis Cache Aside 적용 - [x] Redis 적용 전후 부하 테스트 -- [x] Prometheus·Grafana 서버 지표 비교 -- [x] 캐시 ON/OFF 환경변수 적용 +- [x] Prometheus와 Grafana 서버 지표 비교 - [x] Platform Thread와 Virtual Thread 비교 - [x] HikariCP Pool 크기 비교 -- [x] 더 높은 VU로 Stress Test 수행 -- [x] Sequence ID + Base62, Hash, Snowflake ID + Base62 비교 -- [x] Redis 장애 시 MySQL Fallback 및 자동 복구 검증 -- [x] 다중 애플리케이션 인스턴스와 장애 전환 검증 -- [x] Circuit Breaker를 통한 Redis 장애 구간 Timeout 감소 -- [x] Redis Sentinel 기반 자동 Failover 검증 +- [x] 500 VU Stress Test +- [x] Sequence, Hash, Snowflake 생성 전략 비교 +- [x] Redis 장애 시 MySQL Fallback 검증 +- [x] 다중 App과 Nginx Failover 검증 +- [x] Circuit Breaker로 Redis 장애 구간 Timeout 감소 +- [x] Redis Sentinel 자동 Failover 검증 +- [ ] 조건별 반복 측정 후 중앙값 비교 +- [ ] Redis 용량 또는 처리량 병목 발생 시 Cluster 기반 Sharding 검토 diff --git a/docs/05-retrospective.md b/docs/05-retrospective.md index c928e4e..6c0f2c8 100644 --- a/docs/05-retrospective.md +++ b/docs/05-retrospective.md @@ -2,184 +2,156 @@ ## 1. 해결한 문제 -읽기 요청이 많은 URL Shortener에서 모든 리다이렉트 요청이 MySQL을 조회하면서 발생하는 DB 부하와 커넥션 대기 문제를 개선했다. +처음에는 URL Shortener에서 단축 코드를 만드는 방법이 가장 중요한 문제라고 생각했다. -MySQL 단독 구조를 Baseline으로 두고 Redis Cache Aside를 적용했으며, Platform Thread와 Virtual Thread, HikariCP 크기, 단축 코드 생성 전략을 동일한 조건에서 비교했다. +실제로 부하를 걸어보니 더 먼저 드러난 문제는 리다이렉트 조회였다. 모든 요청이 MySQL을 조회하면서 +HikariCP 10개가 모두 사용됐고 최대 약 85개의 요청이 커넥션을 기다렸따. -## 2. 최종 결과 +이후 프로젝트의 방향은 기능 추가보다 실제로 확인한 병목과 장애를 하나씩 줄이는 쪽으로 바뀌었다. -100 VU, Platform Thread, HikariCP 최대 커넥션 10개 조건의 리다이렉트 실험 결과다. +## 2. 최종 결과 | 항목 | 결과 | |---|---| -| 주요 기능 | URL 생성, 302 리다이렉트, Redis Cache Aside, 생성 전략 전환 | -| 목표 RPS | DB Only Baseline 8,531.95 초과 | -| 측정 RPS | 17,371.33 | -| 목표 p95 | DB Only Baseline 32.96ms 미만 | -| 측정 p95 | 11.93ms | -| 오류율 | 0% | - -Redis 적용 후 RPS는 약 103.6% 증가했고, p95는 약 63.8% 감소했다. +| Redis 적용 | 100 VU RPS 8,531.95 → 17,371.33 | +| 응답 지연 | p95 32.96ms → 11.93ms | +| DB 조회 | 366,248회 → 1회 | +| Circuit Breaker | Redis 장애 구간 p95 약 200ms → 20~30ms | +| App Failover | 446,850건, 실패율 0% | +| Sentinel Failover | 938,870건, 실패율 0%, 8.567초 만에 Master 전환 | -Stress Test에서는 100 VU부터 500 VU까지 오류율 0%를 유지했으며, Redis 구조는 전체 평균 약 17,598 RPS를 기록했다. +성능 개선뿐 아니라 App과 Redis 장애가 발생했을 때 요청이 어떻게 이어지는지도 실제로 검증했다. ## 3. 주요 설계 판단 ### 가장 잘한 결정 -- 결정: 리다이렉트 조회에 Redis Cache Aside를 적용했다. -- 이유: 동일한 단축 URL이 반복 조회되는 읽기 중심 구조에서 매 요청마다 MySQL을 조회할 필요가 없다고 판단했다. -- 결과: 100 VU에서 DB 조회를 최초 1회로 줄였고, HikariCP 커넥션 대기를 제거해 RPS를 약 2배로 높였다. +- 결정: 처음부터 Redis를 넣지 않고 DB Only Baseline을 먼저 측정했다. +- 이유: 어디가 느린지 확인하지 않은 상태에서 캐시를 넣으면 개선 근거를 설명하기 어렵다고 봤다. +- 결과: DB Lookup, HikariCP Active와 Pending을 통해 반복 조회와 커넥션 대기를 병목으로 확인한 뒤 Redis를 적용할 수 있었다. ### 다시 검토할 결정 -- 결정: Sequence ID + Base62를 기본 생성 전략으로 유지했다. -- 문제: DB Auto Increment ID를 발급받은 후 `short_code`를 갱신하므로 INSERT와 UPDATE가 발생하며, 다중 인스턴스에서는 중앙 DB에 의존한다. -- 개선 방향: 단일 인스턴스에서는 단순한 Sequence 방식을 유지하되, 다중 인스턴스 전환 시 Snowflake + Base62 적용을 검토한다. +- 결정: Sequence + Base62를 기본 전략으로 유지했다. +- 이유: 단일 MySQL에서는 가장 단순하고 충돌 확인이 필요 없다. +- 한계: INSERT 뒤 UPDATE가 필요하고 DB ID에 의존한다. +- 정리: 단일 인스턴스에서는 Sequence를 유지하고, 다중 App에서는 nodeId를 분리한 Snowflake를 검증했다. ## 4. 발생한 문제와 해결 과정 ### 문제 1. Base62 코드 UNIQUE 충돌 -#### 현상 - -Sequence ID를 Base62로 변환했음에도 부하 테스트 중 `Duplicate entry` 오류가 발생했다. - -#### 원인 - -Base62는 대소문자를 다른 문자로 사용하지만, MySQL의 기본 Collation은 대소문자를 구분하지 않았다. - -따라서 `a`와 `A`, `gf`와 `GF`처럼 서로 다른 Base62 코드가 UNIQUE 인덱스에서 같은 값으로 처리됐다. - -#### 해결 - -`short_code` 컬럼의 문자 집합과 Collation을 다음과 같이 변경했다. - -```sql -CHARACTER SET ascii -COLLATE ascii_bin -```` - -#### 검증 +MySQL 기본 Collation이 대소문자를 구분하지 않아 `a`와 `A`가 같은 값으로 처리됐다. -Sequence 생성 테스트에서 97,495건을 처리하는 동안 UNIQUE 충돌과 요청 실패가 발생하지 않았다. +`short_code`를 `ascii_bin`으로 변경해 Base62 문자 체계와 DB Unique 규칙을 맞췄다. ### 문제 2. Hash 충돌 재시도 트랜잭션 -#### 현상 +UNIQUE 충돌 뒤 같은 트랜잭션에서 재시도하면 rollback-only 상태가 이어질 수 있었다. -Hash 코드 저장 중 UNIQUE 충돌이 발생한 뒤 다음 재시도를 수행하더라도 트랜잭션이 정상적으로 처리되지 않을 수 있었다. +`saveAndFlush()`로 충돌을 즉시 확인하고 저장 로직을 별도 Bean으로 분리해 `REQUIRES_NEW`로 재시도했다. -#### 원인 +### 문제 3. Virtual Thread를 넣어도 빨라지지 않음 -DB 제약조건 오류가 발생한 트랜잭션은 rollback-only 상태가 될 수 있으며, 같은 트랜잭션 안에서 다음 저장을 시도하면 재시도가 독립적으로 처리되지 않는다. +Virtual Thread는 Thread 수와 CPU를 줄였지만 RPS와 p95는 좋아지지 않았다. -또한 `save()`만 사용하면 SQL 실행과 예외 발생이 트랜잭션 종료 시점까지 지연될 수 있었다. +HikariCP Active와 Pending을 같이 보니 두 방식 모두 커넥션 10개를 모두 사용하고 있었다. 병목은 Thread보다 DB Connection Pool이었다. -#### 해결 +### 문제 4. Fallback만으로는 Redis 장애 지연이 남음 -`saveAndFlush()`로 DB 오류를 저장 시점에 확인하고, 저장 로직을 `ShortUrlWriter`로 분리했다. +MySQL Fallback으로 실패율 0%는 유지했지만 모든 요청이 Redis 200ms Timeout을 기다렸다. -각 저장 시도에는 `REQUIRES_NEW`를 적용해 이전 충돌과 다음 재시도가 서로 다른 트랜잭션에서 실행되도록 했다. +Circuit Breaker를 적용해 장애가 반복되면 Redis 호출을 건너뛰도록 했고 장애 구간 p95를 약 20~30ms 수준으로 낮췄다. -#### 검증 +### 문제 5. 장애 우회와 자동 복구는 다른 문제 -Hash 전략으로 77,933건을 생성했으며 오류율 0%를 기록했다. +Circuit Breaker와 Fallback은 Redis 장애를 우회할 뿐 Redis 자체를 복구하지 않는다. -### 문제 3. Virtual Thread 적용 후 성능 감소 - -#### 현상 - -Virtual Thread를 적용했지만 Platform Thread보다 RPS가 낮아지고 p95와 p99가 증가했다. - -#### 원인 - -두 방식 모두 HikariCP 최대 커넥션 10개를 모두 사용했고, 약 80개 이상의 요청이 DB 커넥션을 기다렸다. - -병목은 스레드 생성 비용보다 DB 커넥션 풀이었다. - -#### 해결 - -Virtual Thread 적용 여부뿐 아니라 HikariCP Active와 Pending 지표를 함께 확인하고, 커넥션 풀 크기를 5, 10, 20으로 나누어 추가 실험했다. - -#### 검증 - -이번 환경에서는 Pool 10이 처리량과 응답시간 측면에서 가장 균형적인 결과를 보였다. +Master 1개, Replica 2개, Sentinel 3개를 구성했고 Master 중단 뒤 8.567초 만에 Replica가 승격되는 것을 확인했다. +Failover 동안 938,870건의 요청에서 실패는 없었다. ## 5. 트레이드오프 -| 선택 | 얻은 것 | 포기한 것 | -| ------------------ | -------------------------- | ------------------------------ | -| Redis Cache Aside | DB 조회와 커넥션 대기 감소 | 캐시 정합성과 장애 대응 복잡도 증가 | -| Sequence + Base62 | 단순한 구현과 충돌 없는 코드 | DB ID 의존, ID 추정 가능성, 추가 UPDATE | -| Hash + Base62 | 고정 길이 코드와 DB ID 비의존성 | 충돌 조회와 재시도 비용 | -| Snowflake + Base62 | 사전 조회 없는 단일 INSERT와 분산 확장성 | nodeId 관리와 시스템 시간 역행 처리 필요 | +| 선택 | 얻은 것 | 추가된 부담 | +|--------------------|----------------------|---------------------------| +| Redis Cache Aside | DB 조회와 커넥션 대기 감소 | 캐시 정합성과 장애 처리 | +| Sequence + Base62 | 단순한 구현, 충돌 없음 | DB ID 의존, 추가 UPDATE | +| Hash + Base62 | DB ID 비의존 | 충돌 확인과 재시도 | +| Snowflake + Base62 | 단일 INSERT, 다중 App 확장 | nodeId와 Clock Rollback 관리 | +| Circuit Breaker | 반복 Timeout 감소 | 상태 전환과 복구 정책 관리 | +| Redis Sentinel | Master 자동 Failover | Replica, Sentinel 운영 복잡도 | ## 6. 운영 환경에서 추가할 사항 -* [ ] 다중 인스턴스 -* [ ] Load Balancer -* [ ] Database 이중화 -* [ ] Cache 고가용성 +* [x] 다중 App 구조와 Nginx Failover 검증 +* [x] Redis Sentinel Failover 검증 +* [ ] Nginx 이중화 또는 Managed Load Balancer +* [ ] MySQL Replica와 자동 장애 조치 * [ ] 중앙 집중식 로그 * [ ] Alerting * [ ] Secret Manager -* [ ] 백업 및 복구 +* [ ] 백업과 복구 절차 * [ ] 장애 대응 Runbook +로컬 Docker에서 검증한 Failover는 독립 Failure Domain을 구성한 운영 환경과는 차이가 있다. + ## 7. 기술적으로 배운 점 -* 시스템 설계: 성능 결과만 보지 않고 애플리케이션, 커넥션 풀, DB 지표를 함께 확인해야 병목을 판단할 수 있다. -* Spring: 트랜잭션 전파와 프록시 경계를 고려해 재시도 로직을 별도 Bean으로 분리해야 한다. -* 데이터베이스: UNIQUE 제약조건은 값뿐 아니라 컬럼의 Collation 규칙에도 영향을 받는다. -* 캐시: Cache Aside는 읽기 성능에 효과적이지만 캐시 장애와 정합성 문제를 함께 고려해야 한다. -* 성능 테스트: 비교 조건, 워밍업, 데이터 초기화와 반복 측정 여부가 결과의 신뢰성에 영향을 준다. -* 모니터링: RPS와 응답시간뿐 아니라 HikariCP Active·Pending, DB Lookup, Cache Hit Ratio를 함께 확인해야 한다. +* 시스템 설계: 예상 병목보다 측정된 병목을 기준으로 다음 구조를 결정하는 편이 설명하기도 쉽고 결과도 명확했다. +* Spring: 트랜잭션 재시도는 예외 시점과 프록시 경계를 함께 봐야 했다. +* 데이터베이스: Unique Constraint는 값뿐 아니라 Collation 규칙에도 영향을 받는다. +* 캐시: Cache Aside를 넣으면 성능뿐 아니라 장애 시 Fallback과 복구 경로까지 같이 설계해야 한다. +* 동시성: Virtual Thread를 사용해도 DB Connection 같은 하위 자원이 제한되면 처리량이 바로 늘지 않는다. +* 장애 대응: Fallback과 Circuit Breaker는 요청을 보호하고 Sentinel은 Redis 계층을 복구한다. +* 모니터링: RPS 하나보다 p95, HikariCP Pending, DB Lookup, Cache Error를 같이 볼 때 원인을 찾기 쉬었다. ## 8. 면접 기반 정리 ### 30초 요약 -MySQL 기반 URL Shortener를 구현하고 리다이렉트 부하를 측정했습니다. 100 VU에서 HikariCP 커넥션 10개가 모두 사용되고 최대 약 85개의 요청이 대기하는 것을 확인해 Redis Cache Aside를 적용했습니다. 그 결과 DB 조회를 최초 1회로 줄였고, RPS는 8,531에서 17,371로 약 2배 증가했으며 p95는 32.96ms에서 11.93ms로 감소했습니다. 또한 Sequence, Hash, Snowflake 생성 전략을 비교해 현재 단일 인스턴스에서는 단순한 Sequence를 유지하고, 분산 확장 시 Snowflake를 적용할 수 있도록 설계했습니다. +MySQL 기반 URL Shortener를 만들고 100 VU 부하에서 HikariCP 10개가 모두 사용되고 약 85개의 요청이 대기하는 것을 확인했습니다. +Redis Cache Aside를 적용해 DB 조회를 366,248회에서 1회로 줄였고 RPS는 8,531에서 17,371로 약 2배 증가했으며 p95는 32.96ms에서 11.93ms로 줄었습니다. +이후 Redis 장애에는 MySQL Fallback과 Circuit Breaker를 적용했고, Sentinel을 구성해 Master 장애 시 8.567초 만에 Replica가 승격되는 동안 938,870건의 요청을 실패 없이 처리했습니다. ### 핵심 질문 -1. 왜 이 구조를 선택했는가? +1. 왜 Redis를 적용했는가? - * 읽기 비중이 높고 동일 URL이 반복 조회되므로 MySQL을 원본 저장소로 유지하면서 Redis Cache Aside로 조회 부하를 줄였다. + * 100 VU에서 HikariCP Active가 10에 도달하고 Pending이 약 85까지 증가했다. 반복 DB 조회를 줄이는 것이 먼저라고 판단했다. -2. 다른 대안은 무엇이었는가? +2. 왜 Virtual Thread로 해결하지 않았는가? - * DB Only, 로컬 캐시, Redis Write Through를 검토할 수 있다. 로컬 캐시는 다중 인스턴스 정합성 문제가 있고, Write Through는 생성 경로가 복잡해 현재 요구사항에는 Cache Aside가 적합하다고 판단했다. + * Thread 수는 줄었지만 HikariCP 10개가 그대로 병목이었다. 실행 모델보다 DB Connection 수가 먼저 제한 요소였다. -3. 성능 개선을 어떻게 검증했는가? +3. Sequence와 Snowflake 중 무엇을 선택했는가? - * 캐시 활성화 여부만 변경하고 VU, 실행 시간, Thread, HikariCP 크기를 동일하게 통제해 k6와 Prometheus·Grafana로 비교했다. + * 단일 MySQL에서는 Sequence가 단순하다. 다중 App에서는 DB ID에 의존하지 않는 Snowflake를 사용하고 nodeId를 분리했다. -4. 현재 구조의 SPOF는 무엇인가? +4. Redis가 죽으면 어떻게 되는가? - * 단일 애플리케이션, MySQL, Redis 인스턴스가 각각 SPOF다. + * 첫 실패는 MySQL로 Fallback하고, 장애가 반복되면 Circuit Breaker가 Redis 호출을 차단한다. Sentinel은 Replica를 새 Master로 승격해 Redis 경로를 복구한다. -5. 운영 환경에서는 어떻게 확장할 것인가? +5. 현재 남은 SPOF는 무엇인가? - * Load Balancer와 다중 애플리케이션 인스턴스를 구성하고, Snowflake nodeId를 분리한다. Redis 고가용성과 MySQL 복제 및 샤딩도 트래픽 규모에 따라 적용한다. + * Nginx와 MySQL이다. Redis Sentinel도 같은 Docker 호스트에서 실행했기 때문에 호스트 장애까지 해결한 것은 아니다. 6. 가장 큰 트레이드오프는 무엇인가? - * Redis로 성능을 높이는 대신 캐시 정합성, 장애 대응, 운영 복잡도를 추가로 관리해야 한다는 점이다. + * Redis로 DB 부하는 줄였지만 Fallback, Circuit Breaker, Sentinel까지 장애 대응 구조가 추가돼 운영 복잡도가 커졌다. ### 답변에 사용할 수치 -* 데이터 규모: Redis Stress Test 리다이렉트 요청 약 459만 건 -* RPS: 100 VU 기준 17,371.33 -* p95: 11.93ms -* p99: 21.52ms -* 오류율: 0% -* 개선 전후 수치: RPS 8,531.95 → 17,371.33, p95 32.96ms → 11.93ms -* 생성 전략 RPS: Sequence 1,624.60 / Hash 1,298.60 / Snowflake 1,660.20 +* Redis 적용: RPS 8,531.95 → 17,371.33 +* Redis 적용: p95 32.96ms → 11.93ms +* DB Lookup: 366,248 → 1 +* 생성 전략 RPS: Sequence 1,624.60 / Hash 1298.60 / Snowflake 1,660.20 +* App Failover: 446,850건, 실패율 0% +* Circuit Breaker: 장애 구간 p95 약 200ms → 약 20~30ms +* Sentinel Failover: 8.567초, 938,870건, 실패율 0% ## 9. 다음 프로젝트에 반영할 점 -* 유지할 방식: 요구사항 정의 → Baseline 측정 → 지표 분석 → 개선 → 동일 조건 재측정 -* 변경할 방식: 조건별 한 번이 아니라 3회 이상 실행하고 중앙값을 비교한다. -* 새롭게 실험할 기술: 다중 인스턴스, Load Balancer, 메시지 큐 기반 비동기 통계 처리, 장애 복구 테스트 +* 유지: 요구사항 정의 → Baseline 측정 → 병목 확인 → 개선 → 장애 주입 → 재측정 +* 개선: 조건별 1회가 아니라 3회 이상 실행하고 중앙값을 비교한다. +* 개선: 실험을 시작하기 전에 결과 파일과 Grafana 캡처 위치를 먼저 정한다. +* 다음 실험: 한 호스트가 아닌 독립 Failure Domain, DB Failover, 메시지 큐 기반 비동기 처리