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 경로로 자동 복귀했다.

@@ -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 측정 결과

-단일 애플리케이션 구조에서는 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 측정 결과

-## 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, 메시지 큐 기반 비동기 처리