diff --git a/.gitignore b/.gitignore
index c235132..e4e880e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -46,3 +46,5 @@ out/
.gradle/
.idea/
results/multi-instance/
+results/resilience/circuit-breaker/
+results/sentinel/
\ No newline at end of file
diff --git a/docker-compose.sentinel.yml b/docker-compose.sentinel.yml
new file mode 100644
index 0000000..676b72e
--- /dev/null
+++ b/docker-compose.sentinel.yml
@@ -0,0 +1,262 @@
+services:
+
+ redis-master:
+ image: redis:7.4-alpine
+ command:
+ - redis-server
+ - --appendonly
+ - "no"
+ ports:
+ - "6379:6379"
+ volumes:
+ - redis-master-data:/data
+ healthcheck:
+ test: ["CMD", "redis-cli", "ping"]
+ interval: 5s
+ timeout: 3s
+ retries: 10
+
+ redis-replica-1:
+ image: redis:7.4-alpine
+ command:
+ - redis-server
+ - --replicaof
+ - redis-master
+ - "6379"
+ - --replica-announce-ip
+ - redis-replica-1
+ - --replica-announce-port
+ - "6379"
+ - --appendonly
+ - "no"
+ depends_on:
+ redis-master:
+ condition: service_healthy
+ volumes:
+ - redis-replica-1-data:/data
+ healthcheck:
+ test: ["CMD", "redis-cli", "ping"]
+ interval: 5s
+ timeout: 3s
+ retries: 10
+
+ redis-replica-2:
+ image: redis:7.4-alpine
+ command:
+ - redis-server
+ - --replicaof
+ - redis-master
+ - "6379"
+ - --replica-announce-ip
+ - redis-replica-2
+ - --replica-announce-port
+ - "6379"
+ - --appendonly
+ - "no"
+ depends_on:
+ redis-master:
+ condition: service_healthy
+ volumes:
+ - redis-replica-2-data:/data
+ healthcheck:
+ test: ["CMD", "redis-cli", "ping"]
+ interval: 5s
+ timeout: 3s
+ retries: 10
+
+ sentinel-1:
+ image: redis:7.4-alpine
+ command: >
+ sh -c "
+ cp /etc/redis/sentinel-template.conf /tmp/sentinel.conf &&
+ redis-server /tmp/sentinel.conf --sentinel
+ "
+ volumes:
+ - ./redis/sentinel/sentinel.conf:/etc/redis/sentinel-template.conf:ro
+ depends_on:
+ redis-master:
+ condition: service_healthy
+ redis-replica-1:
+ condition: service_healthy
+ redis-replica-2:
+ condition: service_healthy
+ healthcheck:
+ test: [ "CMD", "redis-cli", "-p", "26379", "ping" ]
+ interval: 5s
+ timeout: 3s
+ retries: 10
+
+ sentinel-2:
+ image: redis:7.4-alpine
+ command: >
+ sh -c "
+ cp /etc/redis/sentinel-template.conf /tmp/sentinel.conf &&
+ redis-server /tmp/sentinel.conf --sentinel
+ "
+ volumes:
+ - ./redis/sentinel/sentinel.conf:/etc/redis/sentinel-template.conf:ro
+ depends_on:
+ redis-master:
+ condition: service_healthy
+ redis-replica-1:
+ condition: service_healthy
+ redis-replica-2:
+ condition: service_healthy
+ healthcheck:
+ test: [ "CMD", "redis-cli", "-p", "26379", "ping" ]
+ interval: 5s
+ timeout: 3s
+ retries: 10
+
+ sentinel-3:
+ image: redis:7.4-alpine
+ command: >
+ sh -c "
+ cp /etc/redis/sentinel-template.conf /tmp/sentinel.conf &&
+ redis-server /tmp/sentinel.conf --sentinel
+ "
+ volumes:
+ - ./redis/sentinel/sentinel.conf:/etc/redis/sentinel-template.conf:ro
+ depends_on:
+ redis-master:
+ condition: service_healthy
+ redis-replica-1:
+ condition: service_healthy
+ redis-replica-2:
+ condition: service_healthy
+ healthcheck:
+ test: [ "CMD", "redis-cli", "-p", "26379", "ping" ]
+ interval: 5s
+ timeout: 3s
+ retries: 10
+
+ mysql:
+ image: mysql:8.4
+ environment:
+ MYSQL_DATABASE: url_shortener
+ MYSQL_USER: url_shortener
+ MYSQL_PASSWORD: ${DB_PASSWORD:?DB_PASSWORD is required}
+ MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:?MYSQL_ROOT_PASSWORD is required}
+
+ ports:
+ - "3306:3306"
+
+ volumes:
+ - mysql-data:/var/lib/mysql
+
+ healthcheck:
+ test:
+ [
+ "CMD-SHELL",
+ "mysqladmin ping -h localhost -u root -p$$MYSQL_ROOT_PASSWORD"
+ ]
+ interval: 5s
+ timeout: 3s
+ retries: 10
+
+ app:
+ build:
+ context: .
+ dockerfile: Dockerfile
+
+ image: url-shortener:local
+
+ ports:
+ - "${APP_PORT:-8080}:8080"
+
+ environment:
+ SPRING_PROFILES_ACTIVE: sentinel
+
+ APP_NAME: ${APP_NAME:-url-shortener}
+ VIRTUAL_THREADS_ENABLED: ${VIRTUAL_THREADS_ENABLED:-false}
+
+ SPRING_DATASOURCE_URL: jdbc:mysql://mysql:3306/${DB_NAME:-url_shortener}?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=Asia/Seoul
+ SPRING_DATASOURCE_USERNAME: ${DB_USERNAME:-url_shortener}
+ SPRING_DATASOURCE_PASSWORD: ${DB_PASSWORD:?DB_PASSWORD is required}
+
+ REDIS_SENTINEL_MASTER: url-shortener-master
+ REDIS_SENTINEL_NODE_1: sentinel-1:26379
+ REDIS_SENTINEL_NODE_2: sentinel-2:26379
+ REDIS_SENTINEL_NODE_3: sentinel-3:26379
+
+ REDIS_CONNECT_TIMEOUT: ${REDIS_CONNECT_TIMEOUT:-200ms}
+ REDIS_COMMAND_TIMEOUT: ${REDIS_COMMAND_TIMEOUT:-200ms}
+
+ CACHE_ENABLED: ${CACHE_ENABLED:-true}
+ DB_POOL_MAX_SIZE: ${DB_POOL_MAX_SIZE:-10}
+
+ SHORT_CODE_STRATEGY: ${SHORT_CODE_STRATEGY:-sequence}
+ SHORT_CODE_NODE_ID: ${SHORT_CODE_NODE_ID:-1}
+
+ depends_on:
+ mysql:
+ condition: service_healthy
+
+ sentinel-1:
+ condition: service_healthy
+
+ sentinel-2:
+ condition: service_healthy
+
+ sentinel-3:
+ condition: service_healthy
+
+ healthcheck:
+ test:
+ [
+ "CMD",
+ "curl",
+ "-fsS",
+ "http://127.0.0.1:8080/actuator/health"
+ ]
+ interval: 10s
+ timeout: 3s
+ retries: 5
+ start_period: 20s
+
+ prometheus:
+ image: prom/prometheus:v3.13.0
+
+ ports:
+ - "${PROMETHEUS_PORT:-9090}:9090"
+
+ volumes:
+ - ./monitoring/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro
+ - prometheus-data:/prometheus
+
+ command:
+ - --config.file=/etc/prometheus/prometheus.yml
+ - --storage.tsdb.path=/prometheus
+ - --web.enable-lifecycle
+
+ depends_on:
+ app:
+ condition: service_healthy
+
+ grafana:
+ image: grafana/grafana:13.1.1-ubuntu
+
+ ports:
+ - "${GRAFANA_PORT:-3000}:3000"
+
+ environment:
+ GF_SECURITY_ADMIN_USER: ${GRAFANA_ADMIN_USER:-admin}
+ GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD:-admin}
+ GF_USERS_ALLOW_SIGN_UP: "false"
+
+ volumes:
+ - grafana-data:/var/lib/grafana
+ - ./monitoring/grafana/provisioning/datasources:/etc/grafana/provisioning/datasources:ro
+ - ./monitoring/grafana/provisioning/dashboards:/etc/grafana/provisioning/dashboards:ro
+ - ./monitoring/grafana/dashboards:/var/lib/grafana/dashboards:ro
+
+ depends_on:
+ - prometheus
+
+volumes:
+ mysql-data:
+ redis-master-data:
+ redis-replica-1-data:
+ redis-replica-2-data:
+ prometheus-data:
+ grafana-data:
\ No newline at end of file
diff --git a/docs/01-requirements.md b/docs/01-requirements.md
index 2d59b3d..b796497 100644
--- a/docs/01-requirements.md
+++ b/docs/01-requirements.md
@@ -77,6 +77,10 @@ URL Shortener는 긴 URL을 짧은 코드로 변환하고, 단축 URL 요청이
- 단일 App 장애 시 Nginx가 다른 App 인스턴스로 GET 요청을 전환한다.
- 현재 서비스 경로의 단일 장애 지점은 Nginx와 MySQL이며, Redis 장애는 MySQL Fallback으로 기능을 유지한다.
- Redis 장애가 반복되면 Circuit Breaker가 Redis 호출을 차단해 반복적인 Timeout을 줄인다.
+- Redis 요청 실패 시 MySQL Fallback을 통해 리다이렉트 기능을 유지한다.
+- Redis 장애가 반복되면 Circuit Breaker가 Redis 호출을 차단해 반복적인 Timeout을 줄인다.
+- Redis Master 장애 시 Sentinel이 Replica를 새로운 Master로 승격한다.
+- Failover가 진행되는 동안 Circuit Breaker와 MySQL Fallback으로 사용자 요청을 처리한다.
### 확장성
@@ -117,6 +121,8 @@ URL Shortener는 긴 URL을 짧은 코드로 변환하고, 단축 URL 요청이
* 다중 애플리케이션 인스턴스 및 Nginx Failover 실험
* 다중 인스턴스 Snowflake nodeId 검증
* Redis Circuit Breaker 적용 및 장애 구간 응답 지연 비교
+* Redis Sentinel 기반 Master·Replica 구성
+* Redis Master 장애와 자동 Failover 검증
### 제외
@@ -144,4 +150,7 @@ URL Shortener는 긴 URL을 짧은 코드로 변환하고, 단축 URL 요청이
* [ ] 코드 생성 전략별 장단점과 트레이드오프를 설명할 수 있다.
* [ ] 다중 인스턴스에서 서로 다른 nodeId로 단축 코드 유일성을 검증했다.
* [ ] 단일 App 장애 시 다른 인스턴스로 요청이 전환되는 것을 확인했다.
-* [ ] Redis 장애 시 Circuit Breaker를 통해 반복적인 Redis Timeout을 줄였다.
\ No newline at end of file
+* [ ] Redis 장애 시 Circuit Breaker를 통해 반복적인 Redis Timeout을 줄였다.
+* [ ] Redis Master 장애 시 Sentinel이 Replica를 자동 승격한다.
+* [ ] Failover 과정에서도 리다이렉트 요청 실패율 0%를 유지한다.
+* [ ] Failover 이후 Redis Cache 조회 경로가 자동 복구된다.
\ No newline at end of file
diff --git a/docs/03-architecture.md b/docs/03-architecture.md
index 792deb0..bcb23b9 100644
--- a/docs/03-architecture.md
+++ b/docs/03-architecture.md
@@ -7,7 +7,8 @@
* 설계에서 우선한 요소: 단순한 초기 구조와 측정 가능한 베이스라인
* 감수한 트레이드오프: 초기에는 모든 조회 요청이 MySQL에 집중된다.
* 초기 코드 생성 전략: Sequence ID + Base62
-* 최종 설계: 단일 인스턴스에서는 Sequence를 기본으로 사용하고, 다중 인스턴스에서는 Snowflake 적용을 검토한다.
+* 최종 코드 생성 전략: 단일 인스턴스에서는 Sequence + Base62를 기본으로 사용하고, 다중 인스턴스에서는 서로 다른 nodeId를 할당한 Snowflake + Base62의 유일성을 검증했다.
+* 장애 대응 전략: 애플리케이션 계층은 Nginx 기반 Failover, Redis 계층은 Circuit Breaker·MySQL Fallback과 Sentinel 기반 자동 Failover를 적용한다.
## 2. 전체 아키텍처
@@ -19,9 +20,16 @@ flowchart LR
App1[Spring Boot App1
nodeId=1]
App2[Spring Boot App2
nodeId=2]
- Redis[(Redis)]
DB[(MySQL)]
+ Sentinel1[Sentinel 1]
+ Sentinel2[Sentinel 2]
+ Sentinel3[Sentinel 3]
+
+ RedisMaster[(Current Redis Master)]
+ RedisReplica1[(Redis Replica)]
+ RedisReplica2[(Redis Replica)]
+
Prometheus[Prometheus]
Grafana[Grafana]
k6[k6]
@@ -32,12 +40,27 @@ flowchart LR
Nginx --> App1
Nginx --> App2
- App1 --> Redis
- App2 --> Redis
-
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 --> RedisMaster
+ App2 --> RedisMaster
+
+ Sentinel1 -. Monitor .-> RedisMaster
+ Sentinel2 -. Monitor .-> RedisMaster
+ Sentinel3 -. Monitor .-> RedisMaster
+
+ RedisMaster --> RedisReplica1
+ RedisMaster --> RedisReplica2
+
Prometheus --> App1
Prometheus --> App2
Grafana --> Prometheus
@@ -45,11 +68,18 @@ flowchart LR
Nginx가 클라이언트 요청을 두 개의 Spring Boot 인스턴스로 분산한다.
-두 애플리케이션은 상태를 저장하지 않으며 동일한 MySQL과 Redis를 사용한다.
-MySQL은 원본 데이터를 보관하는 Source of Truth이고,
-Redis는 리다이렉트 조회 성능을 위한 보조 저장소다.
+두 애플리케이션은 상태를 저장하지 않으며 동일한 MySQL을 사용한다.
+MySQL은 원본 데이터를 보관하는 Source of Truth이다.
+
+Redis는 리다이렉트 조회 성능을 위한 보조 저장소이며,
+Master 1개와 Replica 2개를 3개의 Sentinel이 감시한다.
-Redis Cache Miss 또는 연결 실패 시 MySQL을 조회한다.
+애플리케이션은 Sentinel을 통해 현재 Redis Master를 탐색하고,
+실제 캐시 요청은 현재 Master에 전달한다.
+
+Redis Master 장애 시 Sentinel이 Replica 하나를 새로운 Master로 승격한다.
+Failover가 진행되는 동안 발생하는 Redis 요청 실패는
+Circuit Breaker와 MySQL Fallback을 통해 처리한다.
리다이렉트 GET 요청에서 특정 애플리케이션 연결에 실패하면
Nginx가 다른 인스턴스로 요청을 재시도한다.
@@ -57,6 +87,9 @@ Nginx가 다른 인스턴스로 요청을 재시도한다.
POST 생성 요청은 처리 성공 여부가 불분명한 상태에서 재시도할 경우
중복 생성 가능성이 있으므로 자동 재시도 대상으로 두지 않는다.
+다중 애플리케이션 Failover와 Redis Sentinel Failover는
+각각 별도의 로컬 Docker 환경에서 검증했다.
+
## 3. 주요 컴포넌트
| 컴포넌트 | 역할 | 확장 방법 | 장애 영향 |
@@ -67,8 +100,8 @@ POST 생성 요청은 처리 성공 여부가 불분명한 상태에서 재시
| Prometheus | 애플리케이션 지표 수집 | 현재는 단일 인스턴스 | 성능 지표 수집 불가 |
| Grafana | 성능 지표 시각화 | 현재는 단일 인스턴스 | 대시보드 조회 불가 |
| k6 | 부하 테스트 실행 | VU 단계적 증가 | 서비스에는 영향 없음 |
-| Redis | 원본 URL 조회 캐시 | Sentinel, Cluster | 장애 시 MySQL Fallback으로 지연 증가 |
-
+| Redis | 원본 URL 조회 캐시 | Sentinel 기반 Master·Replica Failover, 필요 시 Cluster 검토 | Master 장애 시 Sentinel 자동 Failover, 전환 중 MySQL Fallback |
+| Redis Sentinel | Redis Master 감시 및 자동 Failover | Sentinel 3개, quorum 2 | Sentinel 과반수 상실 시 자동 Failover 제한 |
## 4. 요청 흐름
### URL 생성
@@ -81,13 +114,16 @@ POST 생성 요청은 처리 성공 여부가 불분명한 상태에서 재시
### URL 리다이렉트
-1. Circuit Breaker가 CLOSED이면 `shortCode`로 Redis를 조회한다.
-2. Cache Hit이면 원본 URL을 반환한다.
-3. Cache Miss이면 MySQL의 `short_code` 인덱스로 조회하고 Redis에 저장한다.
-4. Redis 조회에 실패하면 MySQL로 Fallback한다.
-5. Redis 실패가 반복돼 Circuit Breaker가 OPEN되면 Redis 호출을 생략하고 바로 MySQL을 조회한다.
-6. Redis 복구가 확인되면 Circuit Breaker가 CLOSED로 돌아가 Cache Aside 경로를 다시 사용한다.
-7. 원본 URL을 `302 Found`로 반환한다.
+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 경로를 다시 사용한다.
+10. 원본 URL을 `302 Found`로 반환한다.
### 실패 흐름
@@ -239,19 +275,22 @@ Clock Rollback을 확인했다.
* 동시성 제어: MySQL Auto Increment로 ID 유일성을 보장한다.
* 중복 요청 처리: 동일한 원본 URL의 중복 생성을 허용한다.
* 저장 실패 처리: DB 저장에 실패하면 단축 URL을 반환하지 않는다.
-* DB와 캐시 간 정합성: 초기 구조에서는 캐시를 사용하지 않는다.
-
+* DB와 캐시 간 정합성: MySQL을 Source of Truth로 두고 Redis에는 조회 결과만 캐시한다.
+* Cache Miss 시 MySQL을 조회한 뒤 Redis에 저장한다.
+* Redis 저장 실패는 원본 데이터 정합성에 영향을 주지 않으며 DB 조회 결과를 그대로 반환한다.
+* Redis Master 장애 시 Sentinel이 Replica를 승격하며, Failover 공백 구간에는 MySQL Fallback으로 조회 기능을 유지한다.
Hash와 난수 방식에서는 `short_code`에 Unique Constraint를 적용해 코드 중복을 방지한다.
## 10. 장애 대응
-| 장애 상황 | 영향 | 감지 방법 | 대응 방법 |
-| ------------- | -------------- | ----------------------- | ------------------ |
+| 장애 상황 | 영향 | 감지 방법 | 대응 방법 |
+|---|---|---|---|
| 단일 API 서버 장애 | 해당 인스턴스 처리 중단 | Health Check, Prometheus `up` | Nginx가 다른 App 인스턴스로 요청 전환 |
-| DB 장애 | URL 생성 및 조회 불가 | Connection 오류, Actuator | 503 반환, DB 복구 |
-| Prometheus 장애 | 지표 수집 불가 | Scrape 상태 | 컨테이너 재시작 |
-| Grafana 장애 | 대시보드 조회 불가 | 컨테이너 상태 | 컨테이너 재시작 |
-| Redis 장애 | 응답 지연 및 DB 부하 증가 | Cache Error, Fallback 지표 | Circuit Breaker OPEN 후 MySQL Fallback |
+| 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 장애 시 요청을 처리할 대체 저장소가 없다.
@@ -266,11 +305,22 @@ Circuit이 OPEN 상태로 전환된다.
OPEN 상태에서는 Redis GET 자체를 호출하지 않고
즉시 MySQL로 Fallback한다.
-일정 시간이 지난 뒤 제한된 요청으로 Redis 복구 여부를 확인하고,
-정상 응답이 확인되면 다시 Cache Aside 경로로 복귀한다.
+Redis 계층은 Master 1개, Replica 2개와 Sentinel 3개로 구성했다.
+
+Master 장애 시 Sentinel quorum을 통해 Replica 하나를 새로운 Master로 승격하고,
+애플리케이션의 Lettuce 클라이언트가 변경된 Master를 다시 탐색한다.
+
+100 VU 환경에서 현재 Redis Master를 강제로 중단한 결과,
+Sentinel은 8.567초 후 `redis-replica-2`를 새로운 Master로 승격했다.
+
+Failover가 진행되는 동안 Circuit Breaker와 MySQL Fallback이 요청을 처리해
+총 938,870건의 리다이렉트 요청에서 실패율 0%를 유지했다.
-이는 기능 지속과 장애 구간의 반복 Timeout을 줄이기 위한
-Graceful Degradation이며 Redis 자체를 이중화한 것은 아니다.
+Failover 이후 Cache Hit이 다시 증가하면서
+Redis 조회 경로가 자동으로 복구되는 것을 확인했다.
+
+Circuit Breaker와 Fallback은 Failover 공백 구간에서 사용자 요청을 보호하고,
+Sentinel은 Redis 계층 자체를 자동 복구하는 역할을 담당한다.
App1 장애 시 Nginx가 App2를 통해 GET 리다이렉트 요청을 계속 처리한다.
@@ -281,15 +331,21 @@ App1 복구 후 다시 요청 처리에 참여하는 것을 확인했다.
## 11. 단일 장애 지점
* Spring Boot는 App1과 App2로 구성해 단일 애플리케이션 장애 지점을 개선했다.
-* Nginx는 현재 단일 인스턴스이므로 진입 지점의 SPOF로 남아 있다.
-* MySQL은 단일 인스턴스로 구성되어 있어 장애 시 생성 및 조회가 불가능하다.
-* Redis는 단일 인스턴스지만 장애 시 MySQL Fallback을 통해 리다이렉트 기능을 유지할 수 있다.
+* Redis는 Master 1개와 Replica 2개를 구성하고 Sentinel 3개를 통해 단일 Redis 노드 장애에 대한 자동 Failover를 검증했다.
+* Nginx는 현재 단일 인스턴스이므로 외부 요청 진입 지점의 SPOF로 남아 있다.
+* MySQL은 단일 인스턴스로 구성되어 있어 장애 시 URL 생성 및 조회가 불가능하다.
+* Prometheus와 Grafana도 단일 인스턴스지만 서비스 요청 처리에는 직접적인 영향을 주지 않는다.
+
+Redis 노드와 Sentinel은 모두 동일한 Docker Desktop 호스트에서 실행했기 때문에,
+호스트 자체가 장애 나는 경우 전체 Redis 계층이 함께 영향을 받는다.
+
+따라서 이번 실험은 Redis 프로세스 또는 컨테이너 단위 Failover를 검증한 것이며,
+독립적인 Failure Domain에 배치된 실제 운영 환경 수준의 고가용성을 의미하지 않는다.
-Prometheus와 Grafana도 단일 인스턴스지만
-서비스 요청 처리에는 직접적인 영향을 주지 않는다.
+운영 환경에서는 Load Balancer 이중화와 MySQL Replica 및 장애 조치를 추가로 고려할 수 있다.
-운영 환경에서는 Load Balancer 이중화, MySQL Replica와 장애 조치,
-Redis Sentinel 또는 Cluster 등을 추가로 고려할 수 있다.
+Redis 저장 용량 또는 처리량이 단일 노드의 한계를 초과할 경우에는
+고가용성과 별도로 Redis Cluster 기반 Sharding을 검토할 수 있다.
## 12. 확장 전략
@@ -309,7 +365,8 @@ Redis Sentinel 또는 Cluster 등을 추가로 고려할 수 있다.
### 캐시 확장
-부하 테스트를 통해 DB 조회 병목이 확인되면 Redis Cache-Aside를 적용한다.
+DB Only 부하 테스트에서 반복적인 MySQL 조회가 병목으로 확인돼
+Redis Cache-Aside를 적용했다.
```text
Client
@@ -319,10 +376,18 @@ Client
```
- 전략: Redis Cache Aside
-- 키: `short-url:{shortCode}`
+- 키: ```short-url:{shortCode}```
- TTL: 1시간
-- Redis 장애: MySQL Fallback
-- 한계: 장애 중 DB 부하와 응답 지연 증가
+- Redis 요청 실패: Circuit Breaker + MySQL Fallback
+- Redis Master 장애: Sentinel 기반 Replica 자동 승격
+- Source of Truth: MySQL
+- 한계: Failover 공백 구간의 DB 부하 증가와 동일 호스트 Failure Domain
+
+현재 문제는 Redis 저장 용량 부족이 아니라 단일 Master 장애였기 때문에
+Redis Cluster는 적용하지 않았다.
+
+향후 Redis 단일 노드의 저장 용량 또는 처리량이 병목으로 확인되면
+Cluster 기반 Sharding을 검토한다.
## 13. 보안
@@ -354,6 +419,7 @@ Client
* Redis Cache Error 수
* MySQL Fallback 수
* Redis Circuit Breaker Rejected 수
+* Redis Master Failover 소요 시간
### Logs
@@ -361,6 +427,7 @@ Client
* 주요 이벤트: URL 생성 실패, 조회 실패, DB 오류
* 오류 로그: 예외 종류와 요청 경로
* 민감정보 제외 기준: 원본 URL과 Query Parameter 전체를 기록하지 않는다.
+* Sentinel Master 전환 이벤트와 Failover 시작·완료 시각
### Alerts
diff --git a/docs/04-experiment.md b/docs/04-experiment.md
index 588398d..76c9a64 100644
--- a/docs/04-experiment.md
+++ b/docs/04-experiment.md
@@ -721,6 +721,106 @@ Timeout 비용을 줄이는 역할을 한다.

+## 20. Redis Sentinel 자동 Failover
+
+Circuit Breaker와 MySQL Fallback을 통해 Redis 장애 중에도
+서비스 요청을 처리할 수 있었지만, Redis 자체는 단일 인스턴스로 남아 있었다.
+
+Redis 계층의 자동 복구를 검증하기 위해
+1개의 Master, 2개의 Replica, 3개의 Sentinel로 구성된
+Redis Sentinel 환경을 구축했다.
+
+```text
+ Spring Boot
+ |
+ Sentinel x 3
+ |
+ Current Master
+ / | \
+ Redis Redis Redis
+```
+
+Sentinel은 Master 장애를 감지하면 Replica 중 하나를 새로운 Master로
+승격하고, Spring Boot의 Lettuce 클라이언트는 Sentinel을 통해
+변경된 Master를 다시 탐색하도록 구성했다.
+
+### 실험 조건
+
+| 항목 | 값 |
+|---|---:|
+| VU | 100 |
+| 실행 시간 | 120초 |
+| Master 중단 | 실행 30초 후 |
+| 기존 Master 재시작 | 실행 약 60초 후 |
+| Redis 노드 | 3개 |
+| Sentinel | 3개 |
+| Sentinel quorum | 2 |
+| down-after-milliseconds | 5초 |
+| Redis Timeout | 200ms |
+
+### 장애 전환 결과
+
+실험 시작 당시 Sentinel이 관리하는 Master는
+`redis-replica-1`이었다.
+
+실행 30초 후 현재 Master를 강제로 중단했고,
+Sentinel은 `redis-replica-2`를 새로운 Master로 승격했다.
+
+| 지표 | 결과 |
+|---|---:|
+| 기존 Master | redis-replica-1 |
+| 새로운 Master | redis-replica-2 |
+| Sentinel Failover 시간 | 8.567초 |
+| 총 요청 수 | 938,870 |
+| 처리량 | 7,823.30 req/s |
+| 평균 응답시간 | 12.65ms |
+| p95 | 23.72ms |
+| 최대 응답시간 | 1.98s |
+| 요청 실패율 | 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 계층 자체를 자동 복구하는 역할을 담당한다.
+
+#### Grafana 측정 결과
+
+
+
## 19. 실험 한계
- 로컬 Docker 환경에서 실행했다.
@@ -738,6 +838,12 @@ Timeout 비용을 줄이는 역할을 한다.
- 다중 인스턴스 실험은 로컬 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 단일 노드의 저장 용량 또는 처리량 한계를 해결하기 위한 구조는 아니다.
## 20. 후속 실험
@@ -752,4 +858,4 @@ Timeout 비용을 줄이는 역할을 한다.
- [x] Redis 장애 시 MySQL Fallback 및 자동 복구 검증
- [x] 다중 애플리케이션 인스턴스와 장애 전환 검증
- [x] Circuit Breaker를 통한 Redis 장애 구간 Timeout 감소
-- [ ] Redis Sentinel 또는 Cluster 기반 고가용성 구성
+- [x] Redis Sentinel 기반 자동 Failover 검증
diff --git a/docs/images/redis-sentinel-failover-100vu.png b/docs/images/redis-sentinel-failover-100vu.png
new file mode 100644
index 0000000..91fe491
Binary files /dev/null and b/docs/images/redis-sentinel-failover-100vu.png differ
diff --git a/k6/sentinel-failover-test.js b/k6/sentinel-failover-test.js
new file mode 100644
index 0000000..546d482
--- /dev/null
+++ b/k6/sentinel-failover-test.js
@@ -0,0 +1,31 @@
+import http from 'k6/http';
+import { check } from 'k6';
+
+const BASE_URL = __ENV.BASE_URL || 'http://localhost:8080';
+const SHORT_CODE = __ENV.SHORT_CODE;
+
+export const options = {
+ scenarios: {
+ sentinel_failover: {
+ executor: 'constant-vus',
+ vus: Number(__ENV.VUS || 100),
+ duration: __ENV.DURATION || '120s',
+ },
+ },
+};
+
+export default function () {
+ const response = http.get(
+ `${BASE_URL}/api/v1/${SHORT_CODE}`,
+ {
+ redirects: 0,
+ tags: {
+ name: 'redirect',
+ },
+ }
+ );
+
+ check(response, {
+ 'redirect status is 302': (res) => res.status === 302,
+ });
+}
\ No newline at end of file
diff --git a/redis/sentinel/sentinel.conf b/redis/sentinel/sentinel.conf
new file mode 100644
index 0000000..8b3e007
--- /dev/null
+++ b/redis/sentinel/sentinel.conf
@@ -0,0 +1,10 @@
+port 26379
+
+sentinel resolve-hostnames yes
+sentinel announce-hostnames yes
+
+sentinel monitor url-shortener-master redis-master 6379 2
+
+sentinel down-after-milliseconds url-shortener-master 5000
+sentinel failover-timeout url-shortener-master 30000
+sentinel parallel-syncs url-shortener-master 1
\ No newline at end of file
diff --git a/scripts/run-sentinel-failover-test.sh b/scripts/run-sentinel-failover-test.sh
new file mode 100755
index 0000000..a13b83a
--- /dev/null
+++ b/scripts/run-sentinel-failover-test.sh
@@ -0,0 +1,265 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+COMPOSE_FILE="docker-compose.sentinel.yml"
+
+BASE_URL="${BASE_URL:-http://localhost:8080}"
+VUS="${VUS:-100}"
+DURATION="${DURATION:-120s}"
+
+if [ -z "${SHORT_CODE:-}" ]; then
+ echo "SHORT_CODE 환경변수가 필요합니다."
+ echo "예: SHORT_CODE=abc123 ./scripts/run-sentinel-failover-test.sh"
+ exit 1
+fi
+
+TIMESTAMP="$(date '+%Y%m%d-%H%M%S')"
+RESULT_DIR="results/sentinel/failover/${TIMESTAMP}"
+
+mkdir -p "${RESULT_DIR}"
+
+get_master() {
+ docker compose -f "${COMPOSE_FILE}" \
+ exec -T sentinel-1 \
+ redis-cli -p 26379 --raw \
+ SENTINEL get-master-addr-by-name url-shortener-master \
+ | sed -n '1p' \
+ | tr -d '\r'
+}
+
+now_ms() {
+ python3 -c 'import time; print(int(time.time() * 1000))'
+}
+
+now_iso() {
+ date '+%Y-%m-%dT%H:%M:%S%z'
+}
+
+FAILED_MASTER=""
+
+cleanup() {
+ if [ -n "${FAILED_MASTER}" ]; then
+ docker compose -f "${COMPOSE_FILE}" \
+ start "${FAILED_MASTER}" > /dev/null 2>&1 || true
+ fi
+}
+
+trap cleanup EXIT
+
+echo "=== Sentinel Failover Test ==="
+echo "SHORT_CODE=${SHORT_CODE}"
+echo "VUS=${VUS}"
+echo "DURATION=${DURATION}"
+echo "RESULT_DIR=${RESULT_DIR}"
+echo
+
+echo "1. 애플리케이션 상태 확인"
+
+curl -fsS "${BASE_URL}/actuator/health"
+echo
+echo
+
+echo "2. Sentinel 합의 상태 확인"
+
+for i in 1 2 3; do
+ echo -n "sentinel-${i}: "
+
+ docker compose -f "${COMPOSE_FILE}" \
+ exec -T "sentinel-${i}" \
+ redis-cli -p 26379 --raw \
+ SENTINEL get-master-addr-by-name url-shortener-master \
+ | head -n 1
+done
+
+echo
+
+echo "3. Cache Warm-up"
+
+for i in $(seq 1 10); do
+ curl -s \
+ -o /dev/null \
+ -w "%{http_code}\n" \
+ "${BASE_URL}/api/v1/${SHORT_CODE}"
+done
+
+echo
+
+MASTER_BEFORE="$(get_master)"
+
+if [ -z "${MASTER_BEFORE}" ]; then
+ echo "현재 Redis Master를 확인할 수 없습니다."
+ exit 1
+fi
+
+echo "현재 Master: ${MASTER_BEFORE}"
+
+TEST_START="$(now_iso)"
+TEST_START_MS="$(now_ms)"
+
+echo
+echo "4. k6 시작"
+
+k6 run \
+ --summary-export="${RESULT_DIR}/summary.json" \
+ -e BASE_URL="${BASE_URL}" \
+ -e SHORT_CODE="${SHORT_CODE}" \
+ -e VUS="${VUS}" \
+ -e DURATION="${DURATION}" \
+ k6/sentinel-failover-test.js \
+ | tee "${RESULT_DIR}/k6.log" &
+
+K6_PID=$!
+
+#
+# 0 ~ 30초 : 정상
+#
+
+sleep 30
+
+FAILED_MASTER="$(get_master)"
+
+MASTER_STOP_TIME="$(now_iso)"
+MASTER_STOP_MS="$(now_ms)"
+
+echo
+echo "======================================"
+echo "Redis Master 장애 발생"
+echo "Master: ${FAILED_MASTER}"
+echo "Time: ${MASTER_STOP_TIME}"
+echo "======================================"
+
+docker compose -f "${COMPOSE_FILE}" \
+ stop "${FAILED_MASTER}"
+
+#
+# Sentinel이 새로운 Master를 선택하는 시간 측정
+#
+
+(
+ DEADLINE=$(( $(now_ms) + 30000 ))
+
+ while true; do
+ CURRENT_MS="$(now_ms)"
+
+ if [ "${CURRENT_MS}" -gt "${DEADLINE}" ]; then
+ echo "failover_timeout" \
+ > "${RESULT_DIR}/master-after.txt"
+ break
+ fi
+
+ NEW_MASTER="$(get_master 2>/dev/null || true)"
+
+ if [ -n "${NEW_MASTER}" ] \
+ && [ "${NEW_MASTER}" != "${FAILED_MASTER}" ]; then
+
+ SWITCH_TIME="$(now_iso)"
+ SWITCH_MS="$(now_ms)"
+
+ echo "${NEW_MASTER}" \
+ > "${RESULT_DIR}/master-after.txt"
+
+ echo "${SWITCH_TIME}" \
+ > "${RESULT_DIR}/master-switch-time.txt"
+
+ echo "${SWITCH_MS}" \
+ > "${RESULT_DIR}/master-switch-ms.txt"
+
+ echo
+ echo "======================================"
+ echo "Sentinel Failover 완료"
+ echo "New Master: ${NEW_MASTER}"
+ echo "Time: ${SWITCH_TIME}"
+ echo "======================================"
+
+ break
+ fi
+
+ sleep 0.2
+ done
+) &
+
+WATCH_PID=$!
+
+#
+# 30 ~ 60초 : 기존 Master DOWN
+#
+
+sleep 30
+
+MASTER_RESTART_TIME="$(now_iso)"
+
+echo
+echo "======================================"
+echo "기존 Master 재시작"
+echo "Node: ${FAILED_MASTER}"
+echo "Time: ${MASTER_RESTART_TIME}"
+echo "======================================"
+
+docker compose -f "${COMPOSE_FILE}" \
+ start "${FAILED_MASTER}"
+
+FAILED_MASTER=""
+
+#
+# 60 ~ 120초 : 복구 안정화
+#
+
+set +e
+wait "${K6_PID}"
+K6_EXIT_CODE=$?
+set -e
+
+wait "${WATCH_PID}" || true
+
+TEST_END="$(now_iso)"
+
+MASTER_AFTER="$(get_master)"
+
+SWITCH_MS=""
+
+if [ -f "${RESULT_DIR}/master-switch-ms.txt" ]; then
+ SWITCH_MS="$(cat "${RESULT_DIR}/master-switch-ms.txt")"
+fi
+
+FAILOVER_MS=""
+
+if [ -n "${SWITCH_MS}" ]; then
+ FAILOVER_MS=$(( SWITCH_MS - MASTER_STOP_MS ))
+fi
+
+{
+ echo "test_start=${TEST_START}"
+ echo "short_code=${SHORT_CODE}"
+ echo "vus=${VUS}"
+ echo "duration=${DURATION}"
+
+ echo "master_before=${MASTER_BEFORE}"
+ echo "master_stopped=${MASTER_STOP_TIME}"
+
+ echo "master_after=${MASTER_AFTER}"
+
+ if [ -n "${FAILOVER_MS}" ]; then
+ echo "sentinel_failover_ms=${FAILOVER_MS}"
+ fi
+
+ echo "old_master_restarted=${MASTER_RESTART_TIME}"
+ echo "test_end=${TEST_END}"
+ echo "k6_exit_code=${K6_EXIT_CODE}"
+
+} > "${RESULT_DIR}/timeline.txt"
+
+docker compose -f "${COMPOSE_FILE}" \
+ logs sentinel-1 sentinel-2 sentinel-3 \
+ > "${RESULT_DIR}/sentinel.log"
+
+echo
+echo "======================================"
+echo "Test Complete"
+echo "======================================"
+
+cat "${RESULT_DIR}/timeline.txt"
+
+echo
+echo "Result:"
+echo "${RESULT_DIR}"
\ No newline at end of file
diff --git a/src/main/resources/application-sentinel.yml b/src/main/resources/application-sentinel.yml
new file mode 100644
index 0000000..ff4a55c
--- /dev/null
+++ b/src/main/resources/application-sentinel.yml
@@ -0,0 +1,16 @@
+spring:
+ config:
+ activate:
+ on-profile: sentinel
+
+ data:
+ redis:
+ sentinel:
+ master: ${REDIS_SENTINEL_MASTER:url-shortener-master}
+ nodes:
+ - ${REDIS_SENTINEL_NODE_1:sentinel-1:26379}
+ - ${REDIS_SENTINEL_NODE_2:sentinel-2:26379}
+ - ${REDIS_SENTINEL_NODE_3:sentinel-3:26379}
+
+ connect-timeout: 200ms
+ timeout: 200ms
\ No newline at end of file