Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
132 changes: 131 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`)해, 한 모듈의 마이그레이션 충돌이 다른 모듈을 막지 않습니다.

## 실행

Expand Down Expand Up @@ -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` 에 기록합니다.
Expand Down Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions docs/perf/k6/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
users.json
results-on.json
results-off.json
run-on.log
run-off.log
prepare.log
77 changes: 77 additions & 0 deletions docs/perf/k6/README.md
Original file line number Diff line number Diff line change
@@ -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 상태로 뜹니다.
55 changes: 55 additions & 0 deletions docs/perf/k6/prepare.sh
Original file line number Diff line number Diff line change
@@ -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 <<SQL
SET sql_log_bin = 0;
UPDATE wallets w
JOIN users u ON u.id = w.user_id
SET w.balance = $WALLET_BALANCE,
w.version = w.version + 1
WHERE u.email LIKE '${EMAIL_PREFIX}-%@${DOMAIN}';
SELECT CONCAT('topped up wallets: ', ROW_COUNT()) AS info;
SQL

echo "[prepare] writing users.json (N=$N) ..."
{
echo "["
for i in $(seq 1 "$N"); do
sep=","
[ "$i" -eq "$N" ] && sep=""
echo " {\"email\":\"${EMAIL_PREFIX}-${i}@${DOMAIN}\",\"password\":\"$PASSWORD\"}$sep"
done
echo "]"
} > "$DIR/users.json"

echo "[prepare] done. users.json with $N entries at $DIR/users.json"
Loading
Loading