diff --git a/.gitignore b/.gitignore
index 0f6b909..c235132 100644
--- a/.gitignore
+++ b/.gitignore
@@ -45,3 +45,4 @@ out/
# Build and IDE
.gradle/
.idea/
+results/multi-instance/
diff --git a/docker-compose.multi.yml b/docker-compose.multi.yml
new file mode 100644
index 0000000..0163e4c
--- /dev/null
+++ b/docker-compose.multi.yml
@@ -0,0 +1,194 @@
+x-app-environment: &app-environment
+ 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}
+
+ SPRING_DATA_REDIS_HOST: redis
+ SPRING_DATA_REDIS_PORT: 6379
+ 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: distributed
+
+x-app-common: &app-common
+ build:
+ context: .
+ dockerfile: Dockerfile
+
+ image: url-shortener:local
+
+ expose:
+ - "8080"
+
+ depends_on:
+ mysql:
+ condition: service_healthy
+ redis:
+ 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
+
+services:
+ app1:
+ <<: *app-common
+
+ environment:
+ <<: *app-environment
+ SHORT_CODE_NODE_ID: "1"
+
+ app2:
+ <<: *app-common
+
+ environment:
+ <<: *app-environment
+ SHORT_CODE_NODE_ID: "2"
+
+ nginx:
+ image: nginx:1.30.4-alpine
+
+ ports:
+ - "${APP_PORT:-8080}:80"
+
+ volumes:
+ - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
+
+ depends_on:
+ app1:
+ condition: service_healthy
+ app2:
+ condition: service_healthy
+
+ healthcheck:
+ test:
+ [
+ "CMD-SHELL",
+ "wget -qO- http://127.0.0.1/nginx-health | grep -q ok"
+ ]
+ interval: 5s
+ timeout: 3s
+ retries: 10
+
+ mysql:
+ image: mysql:8.4
+
+ environment:
+ MYSQL_DATABASE: ${DB_NAME:-url_shortener}
+ MYSQL_USER: ${DB_USERNAME:-url_shortener}
+ MYSQL_PASSWORD: ${DB_PASSWORD:?DB_PASSWORD is required}
+ MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:?MYSQL_ROOT_PASSWORD is required}
+
+ ports:
+ - "${MYSQL_PORT:-3306}:3306"
+
+ volumes:
+ - mysql-data-multi:/var/lib/mysql
+
+ healthcheck:
+ test:
+ [
+ "CMD-SHELL",
+ "mysqladmin ping -h localhost -u root -p$$MYSQL_ROOT_PASSWORD"
+ ]
+ interval: 5s
+ timeout: 3s
+ retries: 10
+
+ redis:
+ image: redis:7.4-alpine
+
+ ports:
+ - "${REDIS_PORT:-6379}:6379"
+
+ volumes:
+ - redis-data-multi:/data
+
+ healthcheck:
+ test:
+ [
+ "CMD",
+ "redis-cli",
+ "ping"
+ ]
+ interval: 5s
+ timeout: 3s
+ retries: 10
+
+ prometheus:
+ image: prom/prometheus:v3.13.0
+
+ ports:
+ - "${PROMETHEUS_PORT:-9090}:9090"
+
+ volumes:
+ - ./monitoring/prometheus/prometheus-multi.yml:/etc/prometheus/prometheus.yml:ro
+ - prometheus-data-multi:/prometheus
+
+ command:
+ - --config.file=/etc/prometheus/prometheus.yml
+ - --storage.tsdb.path=/prometheus
+ - --web.enable-lifecycle
+
+ depends_on:
+ app1:
+ condition: service_healthy
+ app2:
+ 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
+
+ k6:
+ image: grafana/k6:2.1.0
+
+ profiles:
+ - test
+
+ environment:
+ BASE_URL: http://nginx
+
+ volumes:
+ - ./k6:/scripts:ro
+
+ depends_on:
+ nginx:
+ condition: service_healthy
+
+volumes:
+ mysql-data-multi:
+ redis-data-multi:
+ prometheus-data-multi:
+ grafana-data:
\ No newline at end of file
diff --git a/docs/01-requirements.md b/docs/01-requirements.md
index eb7ca6e..03db430 100644
--- a/docs/01-requirements.md
+++ b/docs/01-requirements.md
@@ -74,7 +74,8 @@ URL Shortener는 긴 URL을 짧은 코드로 변환하고, 단축 URL 요청이
- Redis 장애 중에도 리다이렉트 요청의 오류율을 1% 미만으로 유지한다.
- Redis 복구 후 Cache Aside 조회 경로로 자동 복귀한다.
- 캐시 데이터가 유실돼도 MySQL을 통해 원본 URL을 복구할 수 있어야 한다.
-- 현재 구조의 단일 장애 지점은 Spring Boot, MySQL, Redis이다.
+- 단일 App 장애 시 Nginx가 다른 App 인스턴스로 GET 요청을 전환한다.
+- 현재 서비스 경로의 단일 장애 지점은 Nginx와 MySQL이며, Redis 장애는 MySQL Fallback으로 기능을 유지한다.
### 확장성
@@ -112,6 +113,8 @@ URL Shortener는 긴 URL을 짧은 코드로 변환하고, 단축 URL 요청이
* k6 부하 테스트
* 개선 전후 동일 조건 재측정
* Redis 장애 시 MySQL Fallback 실험
+* 다중 애플리케이션 인스턴스 및 Nginx Failover 실험
+* 다중 인스턴스 Snowflake nodeId 검증
### 제외
@@ -137,3 +140,5 @@ URL Shortener는 긴 URL을 짧은 코드로 변환하고, 단축 URL 요청이
* [ ] 개선 전후의 p95, p99, RPS, DB 조회 수를 비교했다.
* [ ] Redis 장애 시 대응 방법을 확인했다.
* [ ] 코드 생성 전략별 장단점과 트레이드오프를 설명할 수 있다.
+* [ ] 다중 인스턴스에서 서로 다른 nodeId로 단축 코드 유일성을 검증했다.
+* [ ] 단일 App 장애 시 다른 인스턴스로 요청이 전환되는 것을 확인했다.
\ No newline at end of file
diff --git a/docs/03-architecture.md b/docs/03-architecture.md
index 987af93..1110287 100644
--- a/docs/03-architecture.md
+++ b/docs/03-architecture.md
@@ -14,40 +14,55 @@
```mermaid
flowchart LR
Client[Client]
- API[Spring Boot API]
+ Nginx[Nginx]
+
+ App1[Spring Boot App1
nodeId=1]
+ App2[Spring Boot App2
nodeId=2]
+
Redis[(Redis)]
DB[(MySQL)]
+
Prometheus[Prometheus]
Grafana[Grafana]
k6[k6]
- Client --> API
- API --> Redis
- API --> DB
- Prometheus --> API
+ Client --> Nginx
+ k6 --> Nginx
+
+ Nginx --> App1
+ Nginx --> App2
+
+ App1 --> Redis
+ App2 --> Redis
+
+ App1 --> DB
+ App2 --> DB
+
+ Prometheus --> App1
+ Prometheus --> App2
Grafana --> Prometheus
- k6 --> API
```
-MySQL은 원본 데이터를 보관하는 Source of Truth이며,
-Redis는 리다이렉트 조회 성능을 위한 보조 저장소로 사용한다.
+Nginx가 클라이언트 요청을 두 개의 Spring Boot 인스턴스로 분산한다.
+
+두 애플리케이션은 상태를 저장하지 않으며 동일한 MySQL과 Redis를 사용한다.
+MySQL은 원본 데이터를 보관하는 Source of Truth이고,
+Redis는 리다이렉트 조회 성능을 위한 보조 저장소다.
Redis Cache Miss 또는 연결 실패 시 MySQL을 조회한다.
-```text
-요구사항 정의
-→ MySQL 기반 구조 구현
-→ 부하 테스트
-→ 병목 분석
-→ 구조 개선
-→ 동일 조건 재측정
-```
+리다이렉트 GET 요청에서 특정 애플리케이션 연결에 실패하면
+Nginx가 다른 인스턴스로 요청을 재시도한다.
+
+POST 생성 요청은 처리 성공 여부가 불분명한 상태에서 재시도할 경우
+중복 생성 가능성이 있으므로 자동 재시도 대상으로 두지 않는다.
## 3. 주요 컴포넌트
| 컴포넌트 | 역할 | 확장 방법 | 장애 영향 |
| ---------- | ----------------- | ------------------------------- | ----------- |
-| API Server | URL 생성, 검증, 리다이렉트 | 무상태 서버 수평 확장 | 요청 처리 불가 |
+| Nginx | 요청 분산 및 App 장애 시 Failover | Load Balancer 이중화 | 장애 시 외부 요청 진입 불가 |
+| API Server | URL 생성, 검증, 리다이렉트 | App1·App2 무상태 수평 확장 | 단일 App 장애 시 다른 인스턴스가 처리 |
| MySQL | 원본 URL 영구 저장 | Replica, Partitioning, Sharding | 생성 및 조회 불가 |
| Prometheus | 애플리케이션 지표 수집 | 현재는 단일 인스턴스 | 성능 지표 수집 불가 |
| Grafana | 성능 지표 시각화 | 현재는 단일 인스턴스 | 대시보드 조회 불가 |
@@ -199,6 +214,23 @@ Redirect 요청이 계속 서버에 전달되므로 301보다 서버 부하가
초기 구현은 Sequence Base62로 진행하고, 이후 세 방식의 RPS, p95, DB 조회 수와 충돌 횟수를 비교한다.
+### 다중 인스턴스에서의 Snowflake
+
+다중 인스턴스 환경에서는 각 애플리케이션에 서로 다른 nodeId를 할당했다.
+
+- App1: nodeId=1
+- App2: nodeId=2
+
+애플리케이션 내부에서는 `synchronized`로 timestamp와 sequence 갱신을 보호하고,
+서버 간 ID 충돌은 서로 다른 nodeId를 통해 방지한다.
+
+부하 테스트 중 Docker 환경에서 시스템 시간이 4~8ms 역행하는
+Clock Rollback을 확인했다.
+
+이전 timestamp로 ID를 생성하지 않고,
+작은 시간 역행에서는 마지막 생성 시각까지 시계가 복구되기를 제한된 시간 동안 기다린다.
+허용 범위를 초과하는 시간 역행은 ID 중복 위험을 막기 위해 실패 처리한다.
+
## 9. 데이터 정합성
* 트랜잭션 범위: 원본 URL을 MySQL에 저장하는 단일 트랜잭션
@@ -213,7 +245,7 @@ Hash와 난수 방식에서는 `short_code`에 Unique Constraint를 적용해
| 장애 상황 | 영향 | 감지 방법 | 대응 방법 |
| ------------- | -------------- | ----------------------- | ------------------ |
-| API 서버 장애 | URL 생성 및 조회 불가 | Health Check, HTTP 오류율 | 서버 재시작, 향후 다중 인스턴스 |
+| 단일 API 서버 장애 | 해당 인스턴스 처리 중단 | Health Check, Prometheus `up` | Nginx가 다른 App 인스턴스로 요청 전환 |
| DB 장애 | URL 생성 및 조회 불가 | Connection 오류, Actuator | 503 반환, DB 복구 |
| Prometheus 장애 | 지표 수집 불가 | Scrape 상태 | 컨테이너 재시작 |
| Grafana 장애 | 대시보드 조회 불가 | 컨테이너 상태 | 컨테이너 재시작 |
@@ -227,21 +259,34 @@ Redis 장애가 확인된 요청에서는 Redis SET을 생략해 Timeout이 중
이는 기능 지속을 위한 Graceful Degradation이며 Redis 자체의 고가용성을 구성한 것은 아니다.
+App1 장애 시 Nginx가 App2를 통해 GET 리다이렉트 요청을 계속 처리한다.
+
+Failover 실험에서 App1을 강제로 중단했지만
+리다이렉트 요청 실패율 0%를 유지했고,
+App1 복구 후 다시 요청 처리에 참여하는 것을 확인했다.
+
## 11. 단일 장애 지점
-* 현재 존재하는 SPOF: Spring Boot 단일 인스턴스, MySQL, Redis
-* 프로젝트 범위에서 허용한 이유: 로컬 환경에서 초기 구조의 병목을 확인하기 위한 실험이기 때문이다.
-* 운영 환경에서의 개선 방법: Load Balancer, 다중 API 서버, MySQL Replica와 장애 조치 구성
+* Spring Boot는 App1과 App2로 구성해 단일 애플리케이션 장애 지점을 개선했다.
+* Nginx는 현재 단일 인스턴스이므로 진입 지점의 SPOF로 남아 있다.
+* MySQL은 단일 인스턴스로 구성되어 있어 장애 시 생성 및 조회가 불가능하다.
+* Redis는 단일 인스턴스지만 장애 시 MySQL Fallback을 통해 리다이렉트 기능을 유지할 수 있다.
+
+Prometheus와 Grafana도 단일 인스턴스지만
+서비스 요청 처리에는 직접적인 영향을 주지 않는다.
-Prometheus와 Grafana도 단일 인스턴스지만 서비스 요청 처리에는 직접적인 영향을 주지 않는다.
+운영 환경에서는 Load Balancer 이중화, MySQL Replica와 장애 조치,
+Redis Sentinel 또는 Cluster 등을 추가로 고려할 수 있다.
## 12. 확장 전략
### 애플리케이션 확장
-* 여러 Spring Boot 인스턴스를 Load Balancer 뒤에 배치한다.
+* Nginx 뒤에 두 개의 Spring Boot 인스턴스를 배치했다.
* 애플리케이션 서버에는 세션이나 URL 상태를 저장하지 않는다.
-* 다중 인스턴스 구성은 핵심 실험 이후 검토한다.
+* App1과 App2는 동일한 MySQL과 Redis를 사용한다.
+* Snowflake 사용 시 각 인스턴스에 서로 다른 nodeId를 할당한다.
+* 단일 App 장애 시 Nginx를 통해 다른 인스턴스로 GET 요청을 전환한다.
### 데이터베이스 확장
diff --git a/docs/04-experiment.md b/docs/04-experiment.md
index dc7fb97..d6ce463 100644
--- a/docs/04-experiment.md
+++ b/docs/04-experiment.md
@@ -546,8 +546,81 @@ Redis 복구 후 Cache Aside 경로로 자동 전환되는 것을 확인했다.

+## 18. 다중 인스턴스 및 Failover
-## 18. 실험 한계
+단일 애플리케이션 장애가 전체 서비스 장애로 이어지는 문제를 줄이기 위해
+App 인스턴스를 2개로 확장하고 Nginx를 통해 요청을 분산했다.
+
+### Snowflake 다중 인스턴스
+
+각 인스턴스에 서로 다른 Snowflake nodeId를 할당했다.
+
+| 인스턴스 | nodeId |
+|---|---:|
+| App1 | 1 |
+| App2 | 2 |
+
+동시 생성 테스트에서 Nginx를 통해 두 인스턴스에 요청이 거의 동일하게 분산됐으며,
+생성된 shortCode의 중복 여부를 검증했다.
+
+테스트 과정에서 Snowflake 내부 동시성 문제가 아닌
+시스템 Clock Rollback도 확인했다.
+
+```text
+App1: backwardMillis=8
+App2: backwardMillis=4
+```
+
+이에 작은 시간 역행에서는 이전 timestamp로 ID를 생성하지 않고
+시계가 마지막 생성 시각까지 복구되기를 기다리도록 처리했다.
+큰 시간 역행은 ID 중복 위험을 막기 위해 실패 처리한다.
+
+### 애플리케이션 Failover
+
+100 VU의 리다이렉트 요청을 지속하면서 App1을 강제로 중지한 뒤 다시 실행했다.
+
+| 항목 | 조건 |
+|---|---|
+| VU | 100 |
+| 실행 시간 | 120초 |
+| 정상 구간 | 0~30초 |
+| App1 중지 | 30~60초 |
+| App1 재시작 | 60초 |
+| 요청 | GET Redirect |
+
+### 결과
+
+| 지표 | 결과 |
+|---|---:|
+| 요청 수 | 446,850 |
+| 평균 RPS | 3,723.29 |
+| 평균 응답 시간 | 26.68ms |
+| p95 | 67.40ms |
+| 최대 응답 시간 | 2,922.56ms |
+| 실패율 | 0% |
+| Check 성공률 | 100% |
+
+App1 중지 후 Prometheus의 `up` 값이 0으로 변경됐으며,
+App2가 단독으로 리다이렉트 요청을 처리했다.
+
+App1 장애 중에도 전체 요청의 실패율은 0%를 유지했다.
+App1 재기동 후 Healthy 상태로 복구됐으며
+다시 요청 처리에 참여하는 것도 확인했다.
+
+#### Grafana 측정 결과
+
+
+
+단일 애플리케이션 구조에서는 App 장애가 전체 요청 처리 불가로 이어질 수 있지만,
+두 개의 App 인스턴스와 Nginx를 구성한 뒤에는
+한 인스턴스가 중단되어도 다른 인스턴스가 요청을 계속 처리했다.
+
+다만 Nginx, MySQL은 여전히 단일 인스턴스이므로
+시스템 전체의 SPOF를 제거한 것은 아니다.
+이번 실험은 애플리케이션 계층의 단일 장애 지점을 개선하는 데 범위를 한정한다.
+
+
+## 19. 실험 한계
- 로컬 Docker 환경에서 실행했다.
- k6, 애플리케이션, MySQL, Redis가 같은 장비의 자원을 사용했다.
@@ -559,11 +632,11 @@ Redis 복구 후 Cache Aside 경로로 자동 전환되는 것을 확인했다.
- Stress Test의 k6 최종 결과는 모든 VU 구간을 합산한 값이므로, 특정 VU 구간의 값은 Grafana 시계열을 통해 판단했다.
- Redis Stress Test의 처리량 한계가 애플리케이션, Redis 또는 로컬 환경 중 어디에서 발생했는지는 추가로 분리하지 않았다.
- 단축 코드 생성 전략도 조건별 한 번만 측정해 Sequence와 Snowflake의 작은 차이가 실행 환경의 변동인지 확인하지 못했다.
-- Snowflake 전략은 단일 애플리케이션 인스턴스에서만 실행했으며, 서로 다른 nodeId를 사용하는 다중 인스턴스 환경은 검증하지 않았다.
- Redis 장애 실험은 프로세스 중지만 재현했으며 네트워크 지연과 패킷 손실은 검증하지 않았다.
- Redis 장애 중 더 높은 부하에서는 MySQL과 커넥션 풀이 포화될 수 있다.
+- 다중 인스턴스 실험은 로컬 Docker 환경에서 App 2개와 Nginx 1개로 수행했으며, 실제 독립 서버 장애를 재현한 것은 아니다.
-## 19. 후속 실험
+## 20. 후속 실험
- [x] Redis Cache Aside 적용
- [x] Redis 적용 전후 부하 테스트
@@ -574,6 +647,6 @@ Redis 복구 후 Cache Aside 경로로 자동 전환되는 것을 확인했다.
- [x] 더 높은 VU로 Stress Test 수행
- [x] Sequence ID + Base62, Hash, Snowflake ID + Base62 비교
- [x] Redis 장애 시 MySQL Fallback 및 자동 복구 검증
+- [x] 다중 애플리케이션 인스턴스와 장애 전환 검증
- [ ] Circuit Breaker를 통한 Redis 장애 구간 Timeout 감소
- [ ] Redis Sentinel 또는 Cluster 기반 고가용성 구성
-- [ ] 다중 애플리케이션 인스턴스와 장애 전환 검증
\ No newline at end of file
diff --git a/docs/images/multi-instance-failover.png b/docs/images/multi-instance-failover.png
new file mode 100644
index 0000000..f21d2e9
Binary files /dev/null and b/docs/images/multi-instance-failover.png differ
diff --git a/k6/multi-instance-failover-test.js b/k6/multi-instance-failover-test.js
new file mode 100644
index 0000000..d026a89
--- /dev/null
+++ b/k6/multi-instance-failover-test.js
@@ -0,0 +1,48 @@
+import http from 'k6/http';
+import { check } from 'k6';
+import { Counter } from 'k6/metrics';
+
+const BASE_URL = __ENV.BASE_URL || 'http://localhost:8080';
+const SHORT_CODE = __ENV.SHORT_CODE;
+
+if (!SHORT_CODE) {
+ throw new Error('SHORT_CODE environment variable is required');
+}
+
+const upstreamRetry = new Counter('nginx_upstream_retry');
+
+export const options = {
+ vus: Number(__ENV.VUS || 100),
+ duration: __ENV.DURATION || '120s',
+
+ thresholds: {
+ http_req_failed: ['rate<0.01'],
+ checks: ['rate>0.99'],
+ },
+};
+
+export default function () {
+ const response = http.get(
+ `${BASE_URL}/api/v1/${SHORT_CODE}`,
+ {
+ redirects: 0,
+ tags: {
+ experiment: 'app-failover',
+ },
+ }
+ );
+
+ check(response, {
+ 'redirect status is 302': (r) => r.status === 302,
+ 'location exists': (r) =>
+ typeof r.headers.Location === 'string' &&
+ r.headers.Location.length > 0,
+ });
+
+ const upstream = response.headers['X-Upstream-Addr'];
+
+ // Nginx가 첫 upstream 실패 후 다른 upstream으로 재시도한 경우
+ if (upstream && upstream.includes(',')) {
+ upstreamRetry.add(1);
+ }
+}
\ No newline at end of file
diff --git a/monitoring/grafana/dashboards/spring-boot-overview.json b/monitoring/grafana/dashboards/spring-boot-overview.json
index 53eac58..3e88536 100644
--- a/monitoring/grafana/dashboards/spring-boot-overview.json
+++ b/monitoring/grafana/dashboards/spring-boot-overview.json
@@ -4,24 +4,7 @@
"metadata": {
"name": "spring-boot-overview",
"namespace": "default",
- "uid": "9f5e1718-e0de-4b16-82e0-fb1fdb5f47b9",
- "resourceVersion": "1786000355142018",
- "generation": 1,
- "creationTimestamp": "2026-08-06T07:12:35Z",
- "labels": {
- "grafana.app/deprecatedInternalID": "96116541288448"
- },
- "annotations": {
- "grafana.app/createdBy": "access-policy:service",
- "grafana.app/folder": "cfu8feuqxi7lsb",
- "grafana.app/managedBy": "classic-file-provisioning",
- "grafana.app/managerId": "System Design Dashboards",
- "grafana.app/sourceChecksum": "0737391e824d48cce503bc64fcc70134",
- "grafana.app/sourcePath": "/var/lib/grafana/dashboards/spring-boot-overview.json",
- "grafana.app/sourceTimestamp": "1786000349000",
- "grafana.app/folderTitle": "System Design",
- "grafana.app/folderUrl": "/dashboards/f/cfu8feuqxi7lsb/system-design"
- }
+ "uid": "9f5e1718-e0de-4b16-82e0-fb1fdb5f47b9"
},
"spec": {
"annotations": [
@@ -2079,4 +2062,4 @@
"title": "Spring Boot Overview",
"variables": []
}
-}
\ No newline at end of file
+}
diff --git a/monitoring/grafana/provisioning/dashboards/dashboard.yml b/monitoring/grafana/provisioning/dashboards/dashboard.yml
index e79abc5..ad90921 100644
--- a/monitoring/grafana/provisioning/dashboards/dashboard.yml
+++ b/monitoring/grafana/provisioning/dashboards/dashboard.yml
@@ -3,11 +3,15 @@ apiVersion: 1
providers:
- name: System Design Dashboards
orgId: 1
+
folder: System Design
+ folderUid: system-design
+
type: file
disableDeletion: false
updateIntervalSeconds: 30
allowUiUpdates: false
+
options:
path: /var/lib/grafana/dashboards
foldersFromFilesStructure: false
\ No newline at end of file
diff --git a/monitoring/prometheus/prometheus-multi.yml b/monitoring/prometheus/prometheus-multi.yml
new file mode 100644
index 0000000..0236bdf
--- /dev/null
+++ b/monitoring/prometheus/prometheus-multi.yml
@@ -0,0 +1,23 @@
+global:
+ scrape_interval: 5s
+ evaluation_interval: 5s
+
+scrape_configs:
+ - job_name: spring-boot
+ metrics_path: /actuator/prometheus
+ scrape_interval: 5s
+
+ static_configs:
+ - targets:
+ - app1:8080
+
+ labels:
+ environment: local-multi
+ app_instance: app1
+
+ - targets:
+ - app2:8080
+
+ labels:
+ environment: local-multi
+ app_instance: app2
\ No newline at end of file
diff --git a/nginx/nginx.conf b/nginx/nginx.conf
new file mode 100644
index 0000000..032b28a
--- /dev/null
+++ b/nginx/nginx.conf
@@ -0,0 +1,61 @@
+worker_processes auto;
+
+events {
+ worker_connections 1024;
+}
+
+http {
+ log_format upstream_log
+ '$remote_addr '
+ '"$request" '
+ 'status=$status '
+ 'upstream=$upstream_addr '
+ 'upstream_status=$upstream_status '
+ 'request_time=$request_time '
+ 'upstream_time=$upstream_response_time';
+
+ upstream url_shortener {
+ server app1:8080 max_fails=1 fail_timeout=5s;
+ server app2:8080 max_fails=1 fail_timeout=5s;
+
+ keepalive 32;
+ }
+
+ server {
+ listen 80;
+
+ access_log /var/log/nginx/access.log upstream_log;
+
+ location = /nginx-health {
+ access_log off;
+ default_type text/plain;
+ return 200 "ok\n";
+ }
+
+ location / {
+ proxy_pass http://url_shortener;
+
+ proxy_http_version 1.1;
+ proxy_set_header Connection "";
+
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+
+ proxy_connect_timeout 1s;
+ proxy_read_timeout 5s;
+
+ proxy_next_upstream
+ error
+ timeout
+ http_502
+ http_503
+ http_504;
+
+ proxy_next_upstream_tries 2;
+
+ add_header X-Upstream-Addr $upstream_addr always;
+ }
+ }
+}
\ No newline at end of file
diff --git a/scripts/run-app-failover-test.sh b/scripts/run-app-failover-test.sh
new file mode 100755
index 0000000..a5f665b
--- /dev/null
+++ b/scripts/run-app-failover-test.sh
@@ -0,0 +1,218 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+COMPOSE_FILE="${ROOT_DIR}/docker-compose.multi.yml"
+
+BASE_URL="${BASE_URL:-http://localhost:8080}"
+VUS="${VUS:-100}"
+DURATION="${DURATION:-120s}"
+
+RESULT_DIR="${ROOT_DIR}/results/multi-instance/failover"
+RUN_ID="$(date +%Y%m%d-%H%M%S)"
+RUN_DIR="${RESULT_DIR}/${RUN_ID}"
+
+mkdir -p "${RUN_DIR}"
+
+cleanup() {
+ echo
+ echo "[cleanup] Ensuring app1 is running..."
+
+ docker compose \
+ -f "${COMPOSE_FILE}" \
+ start app1 >/dev/null 2>&1 || true
+}
+
+trap cleanup EXIT
+
+sleep_until() {
+ local target="$1"
+
+ while (( SECONDS < target )); do
+ sleep 1
+ done
+}
+
+wait_for_app1() {
+ echo "Waiting for app1 health..."
+
+ for _ in {1..60}; do
+ container_id="$(
+ docker compose \
+ -f "${COMPOSE_FILE}" \
+ ps -q app1
+ )"
+
+ if [[ -n "${container_id}" ]]; then
+ health="$(
+ docker inspect \
+ --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' \
+ "${container_id}" 2>/dev/null || true
+ )"
+
+ if [[ "${health}" == "healthy" ]]; then
+ echo "app1 is healthy."
+ return 0
+ fi
+ fi
+
+ sleep 1
+ done
+
+ echo "ERROR: app1 did not become healthy."
+ return 1
+}
+
+echo "========================================"
+echo "Multi-instance failover test"
+echo "========================================"
+echo "VU : ${VUS}"
+echo "Duration : ${DURATION}"
+echo "Result : ${RUN_DIR}"
+echo
+
+echo "[1/7] Checking containers"
+
+docker compose \
+ -f "${COMPOSE_FILE}" \
+ ps
+
+echo
+echo "[2/7] Creating test URL"
+
+response="$(
+ curl -fsS \
+ -X POST \
+ "${BASE_URL}/api/v1/data/shorten" \
+ -H 'Content-Type: application/json' \
+ -d "{
+ \"longUrl\":
+ \"https://example.com/failover/${RUN_ID}\"
+ }"
+)"
+
+SHORT_CODE="$(echo "${response}" | jq -r '.shortCode')"
+
+if [[ -z "${SHORT_CODE}" || "${SHORT_CODE}" == "null" ]]; then
+ echo "ERROR: shortCode was not created."
+ exit 1
+fi
+
+echo "shortCode=${SHORT_CODE}"
+
+echo
+echo "[3/7] Warming Redis cache"
+
+status="$(
+ curl -s \
+ -o /dev/null \
+ -w '%{http_code}' \
+ "${BASE_URL}/api/v1/${SHORT_CODE}"
+)"
+
+if [[ "${status}" != "302" ]]; then
+ echo "ERROR: warm-up request failed. status=${status}"
+ exit 1
+fi
+
+echo "Warm-up succeeded."
+
+cat > "${RUN_DIR}/timeline.txt" < "${RUN_DIR}/k6.log" 2>&1 &
+
+K6_PID=$!
+
+echo
+echo "===== Phase 1: both apps healthy ====="
+
+sleep_until 30
+
+echo "app1_stopped=$(date -Iseconds)" \
+ >> "${RUN_DIR}/timeline.txt"
+
+echo
+echo "[5/7] Stopping app1"
+
+docker compose \
+ -f "${COMPOSE_FILE}" \
+ stop app1
+
+echo
+echo "===== Phase 2: app1 down ====="
+
+sleep_until 60
+
+echo "app1_started=$(date -Iseconds)" \
+ >> "${RUN_DIR}/timeline.txt"
+
+echo
+echo "[6/7] Starting app1"
+
+docker compose \
+ -f "${COMPOSE_FILE}" \
+ start app1
+
+wait_for_app1
+
+echo "app1_healthy=$(date -Iseconds)" \
+ >> "${RUN_DIR}/timeline.txt"
+
+echo
+echo "===== Phase 3: app1 recovered ====="
+
+sleep_until 120
+
+set +e
+wait "${K6_PID}"
+K6_STATUS=$?
+set -e
+
+echo "test_end=$(date -Iseconds)" \
+ >> "${RUN_DIR}/timeline.txt"
+
+docker compose \
+ -f "${COMPOSE_FILE}" \
+ logs --no-color nginx \
+ --since=5m \
+ > "${RUN_DIR}/nginx.log"
+
+echo
+echo "[7/7] Result"
+
+cat "${RUN_DIR}/timeline.txt"
+
+echo
+jq '{
+ requests: .metrics.http_reqs.count,
+ rps: (.metrics.http_reqs.rate * 100 | round / 100),
+ average_ms: (.metrics.http_req_duration.avg * 100 | round / 100),
+ p95_ms: (.metrics.http_req_duration["p(95)"] * 100 | round / 100),
+ max_ms: (.metrics.http_req_duration.max * 100 | round / 100),
+ failure_rate_percent: (.metrics.http_req_failed.value * 100),
+ check_success_rate_percent: (.metrics.checks.value * 100)
+}' "${RUN_DIR}/summary.json"
+
+echo
+echo "Result directory:"
+echo "${RUN_DIR}"
+
+exit "${K6_STATUS}"
\ No newline at end of file
diff --git a/scripts/run-multi-instance-create-test.sh b/scripts/run-multi-instance-create-test.sh
new file mode 100755
index 0000000..8c2c132
--- /dev/null
+++ b/scripts/run-multi-instance-create-test.sh
@@ -0,0 +1,182 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+BASE_URL="${BASE_URL:-http://localhost:8080}"
+REQUESTS="${REQUESTS:-2000}"
+CONCURRENCY="${CONCURRENCY:-50}"
+
+ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+RESULT_DIR="${ROOT_DIR}/results/multi-instance/create"
+RUN_ID="$(date +%Y%m%d-%H%M%S)"
+RUN_DIR="${RESULT_DIR}/${RUN_ID}"
+
+mkdir -p "${RUN_DIR}/responses"
+
+echo "========================================"
+echo "Multi-instance Snowflake create test"
+echo "========================================"
+echo "Base URL : ${BASE_URL}"
+echo "Requests : ${REQUESTS}"
+echo "Concurrency : ${CONCURRENCY}"
+echo "Result dir : ${RUN_DIR}"
+echo
+
+echo "[1/5] Nginx health check"
+
+curl -fsS "${BASE_URL}/nginx-health" >/dev/null
+
+echo "Nginx is healthy."
+echo
+
+echo "[2/5] Application environment"
+
+docker compose -f "${ROOT_DIR}/docker-compose.multi.yml" \
+ exec -T app1 sh -lc '
+ echo "app1 strategy=$SHORT_CODE_STRATEGY nodeId=$SHORT_CODE_NODE_ID"
+ '
+
+docker compose -f "${ROOT_DIR}/docker-compose.multi.yml" \
+ exec -T app2 sh -lc '
+ echo "app2 strategy=$SHORT_CODE_STRATEGY nodeId=$SHORT_CODE_NODE_ID"
+ '
+
+echo
+echo "[3/5] Sending concurrent create requests"
+
+export BASE_URL
+export RUN_DIR
+export RUN_ID
+
+seq 1 "${REQUESTS}" |
+ xargs -P "${CONCURRENCY}" -I {} \
+ sh -c '
+ index="$1"
+
+ curl -sS \
+ -D "${RUN_DIR}/responses/${index}.headers" \
+ -o "${RUN_DIR}/responses/${index}.json" \
+ -w "%{http_code}\n" \
+ -X POST "${BASE_URL}/api/v1/data/shorten" \
+ -H "Content-Type: application/json" \
+ -d "{
+ \"longUrl\":
+ \"https://example.com/multi-instance/${RUN_ID}/${index}\"
+ }" \
+ > "${RUN_DIR}/responses/${index}.status" \
+ || echo "curl_error" \
+ > "${RUN_DIR}/responses/${index}.status"
+ ' _ {}
+
+echo "Requests completed."
+echo
+
+echo "[4/5] Aggregating results"
+
+find "${RUN_DIR}/responses" \
+ -name '*.status' \
+ -print0 |
+ xargs -0 cat |
+ sort |
+ uniq -c |
+ awk '{$1=$1; print}' \
+ > "${RUN_DIR}/status-counts.txt"
+
+find "${RUN_DIR}/responses" \
+ -name '*.headers' \
+ -print0 |
+ xargs -0 grep -hi '^X-Upstream-Addr:' |
+ sed 's/\r$//' |
+ awk '{print $2}' |
+ sort |
+ uniq -c |
+ awk '{$1=$1; print}' \
+ > "${RUN_DIR}/upstream-counts.txt"
+
+find "${RUN_DIR}/responses" \
+ -name '*.json' \
+ -print0 |
+ xargs -0 -n1 jq -r \
+ 'if .shortCode then .shortCode else empty end' \
+ > "${RUN_DIR}/short-codes.txt"
+
+TOTAL_CODES="$(
+ wc -l < "${RUN_DIR}/short-codes.txt" |
+ tr -d ' '
+)"
+
+UNIQUE_CODES="$(
+ sort -u "${RUN_DIR}/short-codes.txt" |
+ wc -l |
+ tr -d ' '
+)"
+
+DUPLICATE_CODES="$((TOTAL_CODES - UNIQUE_CODES))"
+
+SUCCESS_COUNT="$(
+ awk '
+ $2 ~ /^20[0-9]$/ {
+ total += $1
+ }
+ END {
+ print total + 0
+ }
+ ' "${RUN_DIR}/status-counts.txt"
+)"
+
+FAILED_COUNT="$((REQUESTS - SUCCESS_COUNT))"
+
+cat > "${RUN_DIR}/summary.txt" < MAX_NODE_ID) {
throw new IllegalArgumentException("nodeId는 0 이상 " + MAX_NODE_ID + " 이하여야 합니다.");
@@ -43,8 +47,19 @@ public synchronized long nextId() {
// 서버가 여러 대라면 서버 간 중복은 서로 다른 nodeId로 방지
long currentTimestamp = currentTimeMillis();
- if (currentTimestamp < lastTimestamp) { // 원인 : NTP 시간 보정, 가상머신 시간 변경, 서버 시간 수동 변경
- throw new IllegalStateException("시스템 시간이 이전 시각으로 이동했습니다.");
+ if (currentTimestamp < lastTimestamp) {
+ long backwardMillis = lastTimestamp - currentTimestamp;
+
+ if (backwardMillis > MAX_CLOCK_BACKWARD_MILLIS) {
+ throw new IllegalStateException(
+ "허용 범위를 초과해 시스템 시간이 이전 시각으로 이동했습니다. "
+ + "backwardMillis=" + backwardMillis
+ + ", lastTimestamp=" + lastTimestamp
+ + ", currentTimestamp=" + currentTimestamp
+ );
+ }
+
+ currentTimestamp = waitUntilRecovered(lastTimestamp);
}
if (currentTimestamp == lastTimestamp) { // 같은 밀리초 안에서 여러 ID를 만들고 있다
@@ -69,6 +84,30 @@ public synchronized long nextId() {
return timeStampPart | nodePart | sequence;
}
+ private long waitUntilRecovered(long targetTimestamp) {
+ long deadlineNanos = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(MAX_CLOCK_BACKWARD_MILLIS);
+ long currentTimestamp = currentTimeMillis();
+
+ while (currentTimestamp < targetTimestamp) {
+ if (System.nanoTime() >= deadlineNanos) {
+ long backwardMillis = targetTimestamp - currentTimestamp;
+
+ throw new IllegalStateException(
+ "시스템 시간이 제한 시간 내 복구되지 않았습니다. "
+ + "backwardMillis=" + backwardMillis
+ + ", lastTimestamp=" + targetTimestamp
+ + ", currentTimestamp=" + currentTimestamp
+ );
+ }
+
+ LockSupport.parkNanos(100_000L);
+
+ currentTimestamp = currentTimeMillis();
+ }
+
+ return currentTimestamp;
+ }
+
private long waitUntilNextMillis(long timestamp) {
long currentTimestamp = currentTimeMillis();
diff --git a/src/test/java/com/backendsystemdesignlab/urlshortener/generator/SnowflakeIdGeneratorTest.java b/src/test/java/com/backendsystemdesignlab/urlshortener/generator/SnowflakeIdGeneratorTest.java
index 4ea8e4f..76a421b 100644
--- a/src/test/java/com/backendsystemdesignlab/urlshortener/generator/SnowflakeIdGeneratorTest.java
+++ b/src/test/java/com/backendsystemdesignlab/urlshortener/generator/SnowflakeIdGeneratorTest.java
@@ -2,9 +2,7 @@
import org.junit.jupiter.api.Test;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Set;
+import java.util.*;
import java.util.stream.IntStream;
import static org.assertj.core.api.Assertions.assertThat;
@@ -68,4 +66,37 @@ class SnowflakeIdGeneratorTest {
() -> new SnowflakeIdGenerator(1024L)
).isInstanceOf(IllegalArgumentException.class);
}
+
+ @Test
+ void 작은_시간_역행은_복구를_기다린다() {
+ SnowflakeIdGenerator generator = new SnowflakeIdGenerator(1L) {
+ private final Queue times = new ArrayDeque<>(List.of(1_000L, 995L, 998L, 1_000L));
+
+ @Override
+ protected long currentTimeMillis() {
+ return times.remove();
+ }
+ };
+
+ long first = generator.nextId();
+ long second = generator.nextId();
+
+ assertNotEquals(first, second);
+ }
+
+ @Test
+ void 큰_시간_역행은_ID_생성을_중단한다() {
+ SnowflakeIdGenerator generator = new SnowflakeIdGenerator(1L) {
+ private final Queue times = new ArrayDeque<>(List.of(1_000L, 900L));
+
+ @Override
+ protected long currentTimeMillis() {
+ return times.remove();
+ }
+ };
+
+ generator.nextId();
+
+ assertThrows(IllegalStateException.class, generator::nextId);
+ }
}
\ No newline at end of file