From 834501092e5d8f71941cfb374c5835f6775dcfc2 Mon Sep 17 00:00:00 2001 From: LocKey Date: Fri, 7 Aug 2026 13:17:06 +0900 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20=EB=8B=A4=EC=A4=91=20=EC=95=A0?= =?UTF-8?q?=ED=94=8C=EB=A6=AC=EC=BC=80=EC=9D=B4=EC=85=98=20=EC=9D=B8?= =?UTF-8?q?=EC=8A=A4=ED=84=B4=EC=8A=A4=EC=99=80=20Nginx=20=EB=A1=9C?= =?UTF-8?q?=EB=93=9C=EB=B0=B8=EB=9F=B0=EC=8B=B1=20=EA=B5=AC=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker-compose.multi.yml | 194 +++++++++++++++++++++ docs/images/multi-instance-failover.png | Bin 0 -> 52770 bytes k6/multi-instance-failover-test.js | 0 monitoring/prometheus/prometheus-multi.yml | 23 +++ nginx/nginx.conf | 61 +++++++ scripts/run-app-failover-test.sh | 0 scripts/run-multi-instance-create-test.sh | 182 +++++++++++++++++++ 7 files changed, 460 insertions(+) create mode 100644 docker-compose.multi.yml create mode 100644 docs/images/multi-instance-failover.png create mode 100644 k6/multi-instance-failover-test.js create mode 100644 monitoring/prometheus/prometheus-multi.yml create mode 100644 nginx/nginx.conf create mode 100644 scripts/run-app-failover-test.sh create mode 100644 scripts/run-multi-instance-create-test.sh 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/images/multi-instance-failover.png b/docs/images/multi-instance-failover.png new file mode 100644 index 0000000000000000000000000000000000000000..f21d2e945a2d09b1dfa814d430d812a58c7b77a7 GIT binary patch literal 52770 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
    9vQz5oQj_Mhu{@I&cTiSa>PY77O(cJ2N6}OeC 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

    4po1u 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 *ybw@)K6D0` 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 literal 0 HcmV?d00001 diff --git a/k6/multi-instance-failover-test.js b/k6/multi-instance-failover-test.js new file mode 100644 index 0000000..e69de29 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 100644 index 0000000..e69de29 diff --git a/scripts/run-multi-instance-create-test.sh b/scripts/run-multi-instance-create-test.sh new file mode 100644 index 0000000..a713e67 --- /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}" \ + -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" < Date: Fri, 7 Aug 2026 13:17:12 +0900 Subject: [PATCH 2/5] =?UTF-8?q?test:=20=EB=8B=A4=EC=A4=91=20=EC=9D=B8?= =?UTF-8?q?=EC=8A=A4=ED=84=B4=EC=8A=A4=20ID=20=EC=83=9D=EC=84=B1=EA=B3=BC?= =?UTF-8?q?=20=EC=9E=A5=EC=95=A0=20=EC=A0=84=ED=99=98=20=EA=B2=80=EC=A6=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- k6/multi-instance-failover-test.js | 48 ++++ scripts/run-app-failover-test.sh | 218 ++++++++++++++++++ scripts/run-multi-instance-create-test.sh | 2 +- .../generator/SnowflakeIdGenerator.java | 43 +++- .../generator/SnowflakeIdGeneratorTest.java | 37 ++- 5 files changed, 342 insertions(+), 6 deletions(-) mode change 100644 => 100755 scripts/run-app-failover-test.sh mode change 100644 => 100755 scripts/run-multi-instance-create-test.sh diff --git a/k6/multi-instance-failover-test.js b/k6/multi-instance-failover-test.js index e69de29..d026a89 100644 --- a/k6/multi-instance-failover-test.js +++ 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/scripts/run-app-failover-test.sh b/scripts/run-app-failover-test.sh old mode 100644 new mode 100755 index e69de29..a5f665b --- a/scripts/run-app-failover-test.sh +++ 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 old mode 100644 new mode 100755 index a713e67..8c2c132 --- a/scripts/run-multi-instance-create-test.sh +++ b/scripts/run-multi-instance-create-test.sh @@ -56,7 +56,7 @@ seq 1 "${REQUESTS}" | curl -sS \ -D "${RUN_DIR}/responses/${index}.headers" \ -o "${RUN_DIR}/responses/${index}.json" \ - -w "%{http_code}" \ + -w "%{http_code}\n" \ -X POST "${BASE_URL}/api/v1/data/shorten" \ -H "Content-Type: application/json" \ -d "{ diff --git a/src/main/java/com/backendsystemdesignlab/urlshortener/generator/SnowflakeIdGenerator.java b/src/main/java/com/backendsystemdesignlab/urlshortener/generator/SnowflakeIdGenerator.java index de4a864..44e2afa 100644 --- a/src/main/java/com/backendsystemdesignlab/urlshortener/generator/SnowflakeIdGenerator.java +++ b/src/main/java/com/backendsystemdesignlab/urlshortener/generator/SnowflakeIdGenerator.java @@ -4,6 +4,8 @@ import org.springframework.stereotype.Component; import java.time.Instant; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.locks.LockSupport; @Component public class SnowflakeIdGenerator { @@ -30,6 +32,8 @@ public class SnowflakeIdGenerator { private long lastTimestamp = -1L; // 마지막 생성 시각 private long sequence = 0L; // 같은 밀리초 안에서의 순번 + private static final long MAX_CLOCK_BACKWARD_MILLIS = 20L; + public SnowflakeIdGenerator(@Value("${app.short-code.node-id:1}") long nodeId) { if (nodeId < 0 || nodeId > 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 From c3e2bebb0253670e248e8c0cbf9402520493bad9 Mon Sep 17 00:00:00 2001 From: LocKey Date: Fri, 7 Aug 2026 13:17:17 +0900 Subject: [PATCH 3/5] =?UTF-8?q?docs:=20=EB=8B=A4=EC=A4=91=20=EC=9D=B8?= =?UTF-8?q?=EC=8A=A4=ED=84=B4=EC=8A=A4=20=EC=9E=A5=EC=95=A0=20=EC=A0=84?= =?UTF-8?q?=ED=99=98=20=EC=8B=A4=ED=97=98=20=EA=B2=B0=EA=B3=BC=20=EC=A0=95?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/01-requirements.md | 7 +++- docs/03-architecture.md | 93 ++++++++++++++++++++++++++++++----------- docs/04-experiment.md | 81 +++++++++++++++++++++++++++++++++-- 3 files changed, 152 insertions(+), 29 deletions(-) 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 경로로 자동 전환되는 것을 확인했다. ![Redis 장애 캐시 및 DB 지표](images/redis-fallback-100vu-cache-db.png) +## 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 측정 결과 + +![다중 인스턴스 Failover](images/multi-instance-failover.png) + +단일 애플리케이션 구조에서는 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 From dcb80afc0777213819c7fec218a5a5b77738f885 Mon Sep 17 00:00:00 2001 From: LocKey Date: Fri, 7 Aug 2026 13:18:04 +0900 Subject: [PATCH 4/5] =?UTF-8?q?chore:=20=EB=8B=A4=EC=A4=91=20=EC=9D=B8?= =?UTF-8?q?=EC=8A=A4=ED=84=B4=EC=8A=A4=20=EB=AA=A8=EB=8B=88=ED=84=B0?= =?UTF-8?q?=EB=A7=81=20=EB=8C=80=EC=8B=9C=EB=B3=B4=EB=93=9C=20=EA=B5=AC?= =?UTF-8?q?=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dashboards/spring-boot-overview.json | 21 ++----------------- .../provisioning/dashboards/dashboard.yml | 4 ++++ 2 files changed, 6 insertions(+), 19 deletions(-) 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 From ff046d5bad69a73ba84bf8daca2d638e9ac93cba Mon Sep 17 00:00:00 2001 From: LocKey Date: Fri, 7 Aug 2026 13:18:12 +0900 Subject: [PATCH 5/5] =?UTF-8?q?chore:=20=EB=8B=A4=EC=A4=91=20=EC=9D=B8?= =?UTF-8?q?=EC=8A=A4=ED=84=B4=EC=8A=A4=20=EC=8B=A4=ED=97=98=20=EA=B2=B0?= =?UTF-8?q?=EA=B3=BC=20=ED=8C=8C=EC=9D=BC=20=EC=A0=9C=EC=99=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + 1 file changed, 1 insertion(+) 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/