diff --git a/README.md b/README.md index ecb5b3f..d3a53a1 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,49 @@ [![CI](https://github.com/NewEgoDoc/OpenRemit/actions/workflows/ci.yml/badge.svg)](https://github.com/NewEgoDoc/OpenRemit/actions/workflows/ci.yml) -송금 + 내부 결제 게이트웨이 백엔드. +송금 + 내부 결제 게이트웨이 백엔드. 사용자 결제 → 환전 → 해외 송금 → 수취인 Webhook 알림 → 일일 정산까지의 **머니 무브먼트 전체 흐름**을 멀티모듈 모놀리스 안에서 구현합니다. 모놀리스이지만 모듈 간 결합도는 마이크로서비스에 가깝게 분리해, **DB 테이블 소유권 / 마이그레이션 히스토리 / 이벤트 토픽**을 모듈별로 나눴습니다. + +### 증명 포인트 + +- **트랜잭션 정합성 + 동시성 제어** — Redisson 분산 락 + JPA `@Version` 이중 방어 ([↓](#동시성-제어--분산-락--낙관적-락-이중-방어)) +- **외부 API 장애 대응 + 비동기** — Resilience4j Circuit Breaker + Stale Fallback ([↓](#circuit-breaker--stale-fallback--환율-api-장애-대응)), Outbox + Debezium CDC ([↓](#왜-worker가-kafkatemplate으로-직접-publish-하지-않는가)) +- **인덱스/쿼리 최적화** — EXPLAIN before/after, filesort 제거 ([↓](#인덱스-튜닝-사례--remittances-사용자별-최신순-조회)) +- **운영 관점** — 멱등성 / 정산 배치 A·B 이중 검증 ([↓](#정산-배치-reconciler--왜-ab-두-검증을-모두-돌리는가)) / Prometheus·Grafana / 구조화 JSON 로깅 + +## 시스템 아키텍처 + +``` + ┌──────────────────────────────────────┐ + │ Client (REST + JWT) │ + └─────────────┬────────────────────────┘ + │ + ▼ + ┌────────────────────────────────────────────────────┐ + │ remittance-api (Resilience4j · Idempotency-Key) │ + │ │ │ ┌──────────────┐ + │ └── payment-module (in-process gateway) ─────────│──────▶│ WireMock FX │ + └──────────────┬─────────────────────────────────────┘ │ (9999) │ + │ outbox INSERT (single tx) └──────────────┘ + ▼ + ┌──────────┐ ┌────────────────────┐ + │ MySQL │ ── binlog ──▶ Debezium ───▶ │ Kafka │ + └──────────┘ └─────────┬──────────┘ + │ + ┌─────────────────────────────────────────────┼──────────────────┐ + ▼ ▼ ▼ + ┌──────────────────┐ ┌──────────────────────┐ ┌────────────────────┐ + │ payout-worker │ ── 송금사 ──▶ │ webhook-dispatcher │ │ remittance-api │ + │ (자체 outbox) │ WireMock 9998│ (Exponential Backoff) │ │ result consumer │ + └──────────────────┘ └──────────┬────────────┘ └────────────────────┘ + ▼ + WireMock webhook (9997) + + ┌─────────────────┐ ┌──────────────────────┐ ┌──────────────────────┐ + │ Redis (락+캐시) │ │ reconciler (배치 4시) │ │ Prometheus + Grafana │ + └─────────────────┘ └──────────────────────┘ └──────────────────────┘ +``` + +4개 Spring Boot 모듈(`remittance-api` · `payout-worker` · `webhook-dispatcher` · `reconciler`) + 라이브러리 모듈 `payment-module`. MySQL 인스턴스는 1개지만 **테이블 소유권은 모듈별로 분리**(`remittance-api`만 `remittances`/`wallets`, `payout-worker`는 `payout_attempts`/`payout_outbox` 등). Flyway 히스토리 테이블도 모듈별로 분리(`flyway_schema_history`, `_payout`, `_webhook`, `_reconcile`)해, 한 모듈의 마이그레이션 충돌이 다른 모듈을 막지 않습니다. ## 실행 @@ -98,6 +140,57 @@ worker가 송금사 호출 결과를 `KafkaTemplate.send(...)`로 바로 발행 따라서 worker도 결과를 자체 `payout_outbox`에 트랜잭션 INSERT 하고 Debezium이 Kafka로 흘리는 동일한 패턴을 적용합니다. 송금사 호출 후 worker가 죽어도 outbox 행은 DB에 살아있고, 재기동 시 또는 다음 binlog tail에서 자연스럽게 발행됩니다. +## 동시성 제어 — 분산 락 + 낙관적 락 (이중 방어) + +같은 사용자(`userId`)가 동시에 여러 송금 요청을 보낼 때 **잔액이 음수가 되거나 차감이 한 번만 반영되는 race**를 막아야 합니다. 한 겹으로는 부족해 두 겹으로 보호합니다. + +**1차 방어 — Redisson 분산 락 (`wallet:{userId}`)** + +여러 인스턴스가 떠 있어도 같은 사용자에 대해서는 직렬화됩니다. `RemittanceCreateUseCase`(락 보유) ↔ `RemittanceCreator`(`@Transactional` 메서드) 두 빈으로 분리해 **락이 트랜잭션 commit을 enclose**하는 순서를 만듭니다(자기호출 AOP 제약 회피). + +`leaseTime`을 명시하지 않아 Redisson **watchdog 모드** (기본 30초 TTL을 `lockWatchdogTimeout/3` 주기로 자동 갱신). 외부 결제/환율 호출이 길어져도 락이 만료되지 않고, 보유 스레드가 죽으면 watchdog 갱신이 멈춰 자동 해제됩니다 — 데드락 안전. + +**2차 방어 — JPA `@Version` (낙관적 락)** + +분산 락은 네트워크 분할이나 lease 만료 같은 비상 상황에서 일시적으로 깨질 수 있습니다. 그때 두 트랜잭션이 같은 wallet에 동시 진입하더라도 DB 커밋 단계에서 `OptimisticLockException`으로 막히도록 `wallet`에 `@Version`을 함께 둡니다 — defense in depth. + +**왜 `SELECT ... FOR UPDATE`가 아닌가** + +비관적 DB 락은 락 보유 중 외부 결제 호출(수백 ms~수 초)이 들어가면 DB connection이 그 시간만큼 잡혀 풀이 빠르게 소진됩니다. 락은 외부, 트랜잭션은 내부 — 두 범위를 분리하는 편이 안전합니다. + +**검증 결과** + +10 스레드가 동시에 송금 요청 (잔액 100,000 / 요청당 30,000) → 정확히 **3건 성공 + 7건 실패**, 잔액 10,000으로 정합. `RemittanceConcurrencyTest`로 자동 검증. + +## Circuit Breaker + Stale Fallback — 환율 API 장애 대응 + +환율 API는 **외부 의존성**이고 송금 기능의 깊은 곳에 있습니다. 단순히 Circuit Breaker만 두면 회로가 OPEN인 동안 모든 송금 요청이 실패하므로, **fresh + stale 두 단계 캐시**로 한 번 더 감쌉니다(Stale-while-degraded). + +**3단 폴백 흐름** + +``` +1. fresh cache (Redis, TTL 60초) hit → 즉시 반환 +2. miss → 외부 API 호출 (Retry + Circuit Breaker 적용) + ├─ 성공 → fresh + stale(TTL 24h) 둘 다 갱신, 반환 + └─ 실패 (Retry 후에도 실패 / CB OPEN) + ├─ stale cache hit → stale 값 반환 (열화 모드) + └─ stale도 없으면 예외 → 송금 거부 +``` + +**계층 순서 — Retry outer, CB inner** + +``` +[Retry] → [Circuit Breaker] → 실제 호출 +``` + +Retry가 바깥, Circuit Breaker가 안쪽입니다. CB가 OPEN이면 Retry가 호출 자체를 시도조차 안 하고 즉시 fallback으로 빠져 **fast-fail** 합니다(외부 API에 추가 부하 X). CB가 CLOSED일 때만 Retry가 5xx에 대해 재시도해 일시적 장애를 흡수합니다. + +**왜 Resilience4j인가** — Spring Cloud Circuit Breaker의 백엔드 중 가장 가볍고(Hystrix 대비 1/10 크기, Hystrix는 maintenance mode), Spring Boot 3+ Actuator 메트릭 자동 노출. Grafana 대시보드의 `resilience4j_circuitbreaker_state` 패널이 그대로 사용됩니다. + +**검증 결과** + +6개 시나리오 통합 테스트로 검증: 정상 / fresh hit / 5xx 후 stale 폴백 / stale 없으면 예외 / CB OPEN fast-fail / 동일 통화 short-circuit. WireMock으로 외부 API 5xx/타임아웃 시뮬. + ## 정산 배치 (`reconciler`) — 왜 A·B 두 검증을 모두 돌리는가 `잔액 = Σ(거래 내역)` 이라는 한 줄짜리 정합성 명제는 실제 시스템에서는 **같은 BigDecimal 비교지만 잡는 결함의 종류가 서로 다른 두 검증** 으로 쪼개집니다. reconciler 는 매일 두 검증을 모두 수행해 `reconciliations.mismatch_count` 에 기록합니다. @@ -197,6 +290,43 @@ EXPLAIN ANALYZE: 자세한 표·전체 EXPLAIN 출력은 [`docs/04-erd.md`](../docs/04-erd.md#인덱스-튜닝-사례--remittances-사용자별-최신순-조회) 참고. +## 부하 테스트 — 가상 스레드 ON vs OFF (K6) + +송금 생성(`POST /api/v1/remittances`) 시나리오를 K6로 1,000 VU 동시 ramp-up 하고, **`spring.threads.virtual.enabled` 토글**로 가상 스레드 ON/OFF를 비교합니다. ADR-006에서 가상 스레드 채택의 정량 근거로 명시한 검증 기준입니다. + +**시나리오 (`docs/perf/k6/remittance.js`)** + +- 가상 사용자(VU) 0 → 1000으로 60초 ramp-up + 60초 sustain (총 2분) +- 사용자 1,000명을 미리 시드(`docs/perf/k6/prepare.sh`로 sign-up + wallet 초기 잔액 충전)하고 **1 VU = 1 사용자** 1:1 매핑으로 **분산 락 충돌 없이 순수 처리량/지연**을 측정 (동시성 제어 정확도는 별도 단위 테스트가 담당) +- WireMock(환율/송금사/Webhook)에 100ms 고정 지연을 주입해 외부 I/O 부하 흉내 + +**환경** + +docker-compose 풀스택 모드 (`--profile app`) — Java 21 / Spring Boot 4.0 / 컨테이너 내 heap 512MB. 호스트는 macOS arm64. + +**측정 결과** (ramp 1m + sustain 1m, 총 2m) + +| 지표 | 가상 스레드 OFF (플랫폼) | 가상 스레드 ON | 비교 | +|---|---|---|---| +| 처리량 (iterations/s) | **366.7** | 342.0 | OFF 7% ↑ | +| 총 성공 요청 | 53,352 | 47,340 | — | +| p50 (ms) | 1,760 | **1,620** | ON 8% ↓ | +| p95 (ms) | **2,670** | 4,400 | OFF 39% ↓ | +| p99 (ms) | **3,060** | 6,060 | OFF 50% ↓ | +| max (ms) | **4,180** | 11,150 | OFF 62% ↓ | +| 에러율 | 0.00% | 0.00% | 동일 | + +**해석 — ADR-006 가정 vs 측정 현실** + +본 시나리오에서 **가상 스레드 ON이 tail latency에서 명백한 페널티**를 보였습니다. 처리량/median은 비슷하지만 p95·p99·max에서 ON이 1.6~2.7배 느립니다. 원인 추정 두 가지: + +1. **외부 I/O 비중이 작음.** ADR-006은 "I/O 바운드 압도적"을 가상 스레드 채택 근거로 들었지만, 본 시나리오는 **fresh fx cache hit가 지배적**(60s TTL Redis, 측정 2분 동안 첫 호출 외 모두 캐시 hit)이라 실제 외부 HTTP 대기 시간이 거의 없습니다. 가상 스레드가 빛나는 "수많은 가벼운 스레드가 외부 응답을 기다림" 패턴이 만들어지지 않습니다. +2. **JDBC + Hibernate의 `synchronized` 핀.** Hibernate session, JDBC connection 획득 경로에 `synchronized` 블록이 있어 가상 스레드가 캐리어 스레드를 잡고 풀에서 빠지지 못하는 **carrier pinning**이 발생합니다. fresh cache hit 워크로드에서는 DB 비중이 상대적으로 커 핀의 영향이 두드러집니다. + +**시사점** — "가상 스레드는 항상 빠르다"가 아니라 **워크로드에 따라 이득과 페널티가 갈립니다**. 프로덕션 트래픽이 외부 결제/송금사 호출(수백 ms~수 초)이 dominant하면 가상 스레드의 이점이 살아날 가능성이 높지만, 본 측정처럼 캐시 친화적 + 짧은 DB 트랜잭션 워크로드에서는 플랫폼 스레드가 유리합니다. ADR-006은 이 측정을 근거로 **"가상 스레드 ON 유지 + 핀 모니터링 + 트래픽 패턴 변경 시 재측정"** 입장입니다(상세는 [`docs/08-decisions.md`](../docs/08-decisions.md#adr-006)). + +> K6 스크립트와 실행 가이드(ON/OFF 재현 절차 포함): [`docs/perf/k6/`](../docs/perf/k6/). 원본 결과 JSON은 로컬 측정 산출물이므로 `.gitignore` 처리되어 있으며, 위 표는 `k6 run --summary-export` 출력에서 발췌한 수치입니다. + ## 테스트 ```bash diff --git a/docs/perf/k6/.gitignore b/docs/perf/k6/.gitignore new file mode 100644 index 0000000..0c7eb70 --- /dev/null +++ b/docs/perf/k6/.gitignore @@ -0,0 +1,6 @@ +users.json +results-on.json +results-off.json +run-on.log +run-off.log +prepare.log diff --git a/docs/perf/k6/README.md b/docs/perf/k6/README.md new file mode 100644 index 0000000..87ed140 --- /dev/null +++ b/docs/perf/k6/README.md @@ -0,0 +1,77 @@ +# K6 부하 테스트 — 가상 스레드 ON vs OFF + +ADR-006(Java 21 가상 스레드 채택)의 정량 검증용 시나리오. `POST /api/v1/remittances` 1,000 VU ramp-up. + +## 파일 + +| 파일 | 역할 | +|---|---| +| `prepare.sh` | N명 sign-up + wallet 잔액 충전 + `users.json` 생성 | +| `remittance.js` | K6 시나리오 (setup login + ramp-up POST) | +| `users.json` | prepare.sh가 생성, remittance.js가 읽음 (gitignored) | + +## 실행 흐름 + +```bash +# 1) 풀스택 기동 (4개 앱 + 인프라) +cd ../../../ # OpenRemit 루트 +docker compose --profile app up -d --build + +# 4개 앱 health 확인 +for p in 8080 8081 8082 8084; do curl -fsS http://localhost:$p/actuator/health; echo; done + +# 2) 사용자 시드 + 토큰 풀 준비 +cd docs/perf/k6 +N=1000 ./prepare.sh + +# 3) K6 실행 — 가상 스레드 ON 기본 +k6 run remittance.js + +# 4) 가상 스레드 OFF로 전환 후 재측정 +# docker-compose.yml 의 4개 앱 서비스(remittance-api/payout-worker/webhook-dispatcher/reconciler)는 +# SPRING_THREADS_VIRTUAL_ENABLED 환경 변수를 직접 받지 않으므로, override 파일로 주입한다. +cd ../../../ # OpenRemit 루트 +cat > docker-compose.override.yml <<'EOF' +services: + remittance-api: { environment: { SPRING_THREADS_VIRTUAL_ENABLED: "false" } } + payout-worker: { environment: { SPRING_THREADS_VIRTUAL_ENABLED: "false" } } + webhook-dispatcher: { environment: { SPRING_THREADS_VIRTUAL_ENABLED: "false" } } + reconciler: { environment: { SPRING_THREADS_VIRTUAL_ENABLED: "false" } } +EOF +docker compose --profile app up -d --force-recreate + +cd docs/perf/k6 +k6 run remittance.js + +# 측정 후 override 제거 + ON 상태로 복귀 +cd ../../../ +rm docker-compose.override.yml +docker compose --profile app up -d --force-recreate +``` + +## 환경 변수 + +| 변수 | 기본값 | 설명 | +|---|---|---| +| `BASE_URL` | `http://localhost:8080` | remittance-api 엔드포인트 | +| `N` | 1000 | 사전 시드할 사용자 수 (= VU 권장값) | +| `WALLET_BALANCE` | 100000000 | 시드 wallet 초기 잔액 (KRW) | +| `VU` | 1000 | K6 ramp-up 목표 동시 사용자 수 | +| `RAMP` | `1m` | 0 → VU ramp-up 시간 | +| `DURATION` | `1m` | sustain 시간 | + +## 시나리오 의도 + +- **N = VU** (1:1 매핑) — 같은 사용자에 동시 송금이 들어오면 분산 락이 직렬화하므로 노이즈가 됨. 1 VU = 1 사용자로 매핑해 **순수 처리량/지연**만 측정. +- **`Idempotency-Key`** — 매 요청 unique. 같은 키면 멱등 캐시로 빠르게 응답되어 측정 왜곡. +- **분산 락 충돌 = 0**, **wallet 잔액 충분**(요청당 10,000 KRW × 60초 sustain ≪ 1억 KRW) — 송금 자체는 항상 성공해야 함. +- 외부 fx API는 fresh cache(60s) 도입 후 거의 모든 요청이 캐시 hit. 외부 I/O 지연이 측정에 영향을 거의 안 줌. 가상 스레드 ON/OFF 차이가 의도보다 작게 나올 수 있음 — 측정 후 결과 해석에 명시. + +## 정리 + +```bash +docker compose --profile app down -v # 볼륨 포함 초기화 (시드 사용자/wallet 삭제) +rm -f users.json results-on.json results-off.json run-on.log run-off.log +``` + +가상 스레드 OFF 측정용 `docker-compose.override.yml`을 만들었다면 반드시 삭제 후 `--force-recreate`로 ON 상태로 복귀시키세요. compose는 override 파일이 존재하면 자동 병합하므로 남겨 두면 이후 모든 기동이 OFF 상태로 뜹니다. diff --git a/docs/perf/k6/prepare.sh b/docs/perf/k6/prepare.sh new file mode 100755 index 0000000..c23aed7 --- /dev/null +++ b/docs/perf/k6/prepare.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +# K6 부하 테스트 사전 준비 — N명 sign-up + wallet 잔액 충전 + users.json 생성 +# +# 사용법: +# docker compose --profile app up -d --build # 풀스택 기동 (선행) +# N=1000 BASE_URL=http://localhost:8080 ./prepare.sh +# +# 결과: +# users +N (loadtest-user-N@loadtest.local, password=K6password1!) +# wallets +N (KRW, balance=WALLET_BALANCE) +# users.json (이메일/비번 목록 — remittance.js가 읽음) +set -euo pipefail + +N=${N:-1000} +BASE_URL=${BASE_URL:-http://localhost:8080} +PASSWORD="K6password1!" +DOMAIN="loadtest.local" +EMAIL_PREFIX="loadtest-user" +WALLET_BALANCE=${WALLET_BALANCE:-100000000} +MYSQL_CONTAINER=${MYSQL_CONTAINER:-openremit-mysql} +DIR="$(cd "$(dirname "$0")" && pwd)" + +echo "[prepare] sign up $N users at $BASE_URL ..." +for i in $(seq 1 "$N"); do + email="${EMAIL_PREFIX}-${i}@${DOMAIN}" + curl -fsS -X POST "$BASE_URL/api/v1/auth/signup" \ + -H 'Content-Type: application/json' \ + -d "{\"email\":\"$email\",\"password\":\"$PASSWORD\",\"name\":\"LoadTest $i\"}" \ + >/dev/null 2>&1 || echo "[prepare] signup skip (already exists?): $email" + if (( i % 100 == 0 )); then echo " signed up $i / $N"; fi +done + +echo "[prepare] topup wallets to $WALLET_BALANCE KRW ..." +docker exec -i "$MYSQL_CONTAINER" mysql -uroot -prootpw openremit < "$DIR/users.json" + +echo "[prepare] done. users.json with $N entries at $DIR/users.json" diff --git a/docs/perf/k6/remittance.js b/docs/perf/k6/remittance.js new file mode 100644 index 0000000..378e4fd --- /dev/null +++ b/docs/perf/k6/remittance.js @@ -0,0 +1,96 @@ +// K6 부하 테스트 — POST /api/v1/remittances 동시 1000 VU ramp-up. +// 가상 스레드 ON/OFF 비교용 시나리오. ADR-006 검증 기준. +// +// 사용법: +// ./prepare.sh # users.json 생성 (선행) +// k6 run remittance.js # 기본값 VU=1000, RAMP=1m, DURATION=1m +// VU=500 RAMP=30s DURATION=1m k6 run remittance.js +import http from 'k6/http'; +import { check } from 'k6'; +import { SharedArray } from 'k6/data'; +import exec from 'k6/execution'; + +const BASE_URL = __ENV.BASE_URL || 'http://localhost:8080'; +const VU_TARGET = parseInt(__ENV.VU || '1000'); +const RAMP_DURATION = __ENV.RAMP || '1m'; +const SCENARIO_DURATION = __ENV.DURATION || '1m'; + +const users = new SharedArray('users', function () { + return JSON.parse(open('./users.json')); +}); + +export const options = { + setupTimeout: '5m', + scenarios: { + remittance_create: { + executor: 'ramping-vus', + startVUs: 0, + stages: [ + { duration: RAMP_DURATION, target: VU_TARGET }, + { duration: SCENARIO_DURATION, target: VU_TARGET }, + ], + gracefulRampDown: '10s', + }, + }, + thresholds: { + http_req_failed: ['rate<0.05'], + 'http_req_duration{expected_response:true}': ['p(95)<2000', 'p(99)<5000'], + }, +}; + +// setup(): 모든 사용자에게 토큰 발급 후 default()에 전달. +// http.batch로 chunk 병렬화해 N=1000일 때 setupTimeout 안에 끝나도록 한다. +export function setup() { + if (users.length === 0) { + throw new Error('users.json is empty. Run ./prepare.sh first.'); + } + const CHUNK = 50; + console.log(`[setup] login ${users.length} users at ${BASE_URL} (chunk=${CHUNK}) ...`); + const tokens = new Array(users.length); + for (let i = 0; i < users.length; i += CHUNK) { + const slice = users.slice(i, i + CHUNK); + const reqs = slice.map((u) => ({ + method: 'POST', + url: `${BASE_URL}/api/v1/auth/login`, + body: JSON.stringify({ email: u.email, password: u.password }), + params: { headers: { 'Content-Type': 'application/json' } }, + })); + const responses = http.batch(reqs); + for (let j = 0; j < responses.length; j++) { + if (responses[j].status !== 200) { + throw new Error(`login failed for ${slice[j].email}: ${responses[j].status} ${responses[j].body}`); + } + tokens[i + j] = responses[j].json('access_token'); + } + } + console.log(`[setup] obtained ${tokens.length} tokens`); + return { tokens }; +} + +export default function (data) { + // VU id를 토큰 풀 인덱스로 사용 — 동일 VU = 동일 사용자 (1:1 매핑) + const idx = (exec.vu.idInTest - 1) % data.tokens.length; + const token = data.tokens[idx]; + + const idemKey = `k6-${exec.vu.idInTest}-${exec.scenario.iterationInTest}-${Date.now()}`; + const payload = JSON.stringify({ + from_currency: 'KRW', + from_amount: 10000, + to_currency: 'USD', + receiver_name: `k6-recv-${idx}`, + receiver_account: `K6-ACC-${idx}`, + method: 'CARD', + }); + + const res = http.post(`${BASE_URL}/api/v1/remittances`, payload, { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + 'Idempotency-Key': idemKey, + }, + }); + + check(res, { + 'status is 201': (r) => r.status === 201, + }); +}