Initialize RTGS prototype repository

3센터 A-A-A RTGS 실시간총액결제 프로토타입 최초 버전관리 시작.
- backend: Kotlin/Gradle 멀티모듈(sequencer, common, 채널/센터 모듈)
- frontend: Vite + TS
- infra: docker-compose, prometheus/grafana, ELK 네이티브 스크립트
- loadtest(k6), sim(장애/순서/정합성 시나리오), docs(PoC 보고서/복원력방안)
This commit is contained in:
rtgs
2026-07-15 15:37:32 +09:00
commit 58ca23b5d9
140 changed files with 11832 additions and 0 deletions

54
sim/README.md Normal file
View File

@@ -0,0 +1,54 @@
# RTGS 검증 하니스 (sim/)
복원력방안 **부록A**가 지목한 미검증 급소를, 로컬 1센터(DC1) 프로토타입으로 재현·측정한다.
점검대상 3축(기능성·복원성·가용성) 중 로컬에서 자동 검증 가능한 S1·S2·S4를 스크립트로 제공한다.
> 실행 환경: 포터블 **Git Bash**. 실행 중인 DC1 스택(인프라 + 6서비스)을 전제로 한다.
> 실행 예: `bash sim/s1_consistency.sh`
| 스크립트 | 점검축 | 검증 내용 | 합격 기준 |
|---|---|---|---|
| `s1_consistency.sh [건수]` | 기능성 | 평상시 부하 후 전 계층 대사 | 총액 보존 · 미완결 0 · 음수계좌 0 · 원장=조회사본 |
| `s2_failover.sh [건수]` | 복원성 | 부하 중 Hermes 강제종료·재기동 | 투입 전건 완결(유실 0) · 총액 보존 |
| `s4_order.sh` | 기능성 | 저널에 전역순번 **역전 주입** | 3건 전건 완결 · 저널 순번 순서 기록 · 총액 보존 |
| `reconcile.sql` | 기능성 | 수동 대사 리포트 | (판단용 6개 지표 출력) |
## 각 시나리오 원리
### S1 — 정합성 (기능성)
무작위 이체 N건을 정식 pacs.008.001.08로 투입 → 정산 드레인 대기 → 대사.
**총액 보존 불변식**(이체 전후 계좌 합계 동일 = 돈이 생기거나 사라지지 않음 = 이중지급 0)과
원장(transfer)·조회사본(settlement_view)·선저널(journal_log) 계층 일치를 확인한다.
### S2 — 무손실 승계 (복원성)
부하 투입 중 원장엔진 **Hermes를 강제 종료**했다가 재기동한다. Hermes는
- 정산 **전에 선저널(write-ahead)** 을 남기고(무손실 승계의 근거),
- Kafka 오프셋을 **record 커밋**하므로, 재기동 시 마지막 커밋 지점부터 저널을 **재생**한다.
→ 죽은 순간의 in-flight 건도 유실 없이 이어서 완결된다. 투입 전건이 ACCC/RJCT에 도달하고
총액이 보존되면 합격.
### S4 — 순서오류 탐지·교정 (기능성)
순번기를 잠시 멈춘 뒤(경쟁 발번 차단), 저널(rtgs.journal, 단일 파티션)에 전역순번을
**역전 순서(M+2 → M+3 → M+1)** 로 직접 주입한다. Hermes는 `expected` 순번과 비교해
- 앞선 순번(M+2, M+3)은 **pending 버퍼에 보관(gap 감지)**,
- 기다리던 M+1이 도착하면 적용 후 **버퍼를 순서대로 드레인**한다.
→ 3건이 저널에 M+1, M+2, M+3 순서로 기록되고 전건 완결되면 재정렬 성공.
검증 후 순번기를 재기동하면 **순번 영속화(2-1)** 덕에 MAX(global_seq) 다음부터 안전하게 이어서 발번한다.
## 주의
- **S2/S4는 서비스(Hermes/Sequencer)를 죽였다 살린다.** 양팀장님의 수동 기능 테스트와
겹치지 않을 때 실행하세요(각 스크립트가 스스로 서비스를 재기동합니다).
- 스크립트는 계좌 잔액을 리셋하지 않는다(총액 보존만 검증). 상태를 깔끔히 하려면
관리자 탭 → **DB 초기화** 후 실행.
## 향후 (고사양 PC 이관 후)
- **S3 정족수/펜싱**(가용성): 3센터에서 소수측 분할 → 1센터 다운 지속·2센터 다운 안전정지. 다센터 필요.
- **S5 스파이크**(성능): 순번기 상한 초과 부하 → Kafka 완충. k6 `MODE=spike`로 근사.
- 순번기 단독 처리량 상한(TPS) 측정.
## 검증 결과 (2026-07-10, 로컬 DC1)
- **S1 PASS** — 20건, 총액 190억 보존, 미완결 0, 원장=사본 일치.
- **S2 PASS** — 30건 투입 중 Hermes 강제종료·재기동, 전건 완결·유실 0·총액 보존.
- **S4 PASS** — seq 965→966→964 역전 주입 → 964,965,966 순서 기록·전건 ACCC·총액 보존.

57
sim/lib.sh Normal file
View File

@@ -0,0 +1,57 @@
#!/usr/bin/env bash
# RTGS 검증 하니스 공통 함수 (복원력방안 부록A 재현: S1/S2/S4)
# 포터블 Git Bash에서 실행. 실행 중인 DC1 스택(인프라+6서비스)을 전제로 한다.
set -u
PG_BIN="/c/ai-dev/apps/postgresql-16.4/bin/psql.exe"
KAFKA_PROD="/c/ai-dev/apps/kafka-3.8.1/bin/windows/kafka-console-producer.bat"
JPS="/c/ai-dev/apps/jdk-21/bin/jps"
JAVA="C:\\ai-dev\\apps\\jdk-21\\bin\\java.exe"
JAR_DIR="C:\\ai-dev\\workspace\\rtgs\\backend"
CHANEL="http://localhost:8091"
NS="urn:iso:std:iso:20022:tech:xsd:pacs.008.001.08"
PSQL() { PGCLIENTENCODING=UTF8 "$PG_BIN" -h localhost -p 5433 -U rtgs -d rtgs "$@"; }
q() { PSQL -t -A -c "$1" | tr -d '[:space:]'; } # 스칼라 결과(공백 제거)
pass() { echo " [PASS] $1"; }
fail() { echo " [FAIL] $1"; FAILED=1; }
hr() { echo "------------------------------------------------------------"; }
# 정식 pacs.008.001.08 전문 생성 후 접수(curl). 인자: from to amount bmi
submit() {
local from=$1 to=$2 amt=$3 bmi=$4
local xml="<?xml version=\"1.0\" encoding=\"UTF-8\"?><Document xmlns=\"$NS\"><FIToFICstmrCdtTrf>\
<GrpHdr><MsgId>$bmi</MsgId><CreDtTm>2026-07-10T12:00:00</CreDtTm><NbOfTxs>1</NbOfTxs>\
<SttlmInf><SttlmMtd>CLRG</SttlmMtd></SttlmInf></GrpHdr><CdtTrfTxInf>\
<PmtId><EndToEndId>$bmi</EndToEndId></PmtId><IntrBkSttlmAmt Ccy=\"KRW\">$amt</IntrBkSttlmAmt>\
<ChrgBr>SLEV</ChrgBr><Dbtr><Nm>BANK-$from</Nm></Dbtr>\
<DbtrAcct><Id><Othr><Id>ACC-$from</Id></Othr></Id></DbtrAcct>\
<DbtrAgt><FinInstnId><ClrSysMmbId><MmbId>$from</MmbId></ClrSysMmbId></FinInstnId></DbtrAgt>\
<CdtrAgt><FinInstnId><ClrSysMmbId><MmbId>$to</MmbId></ClrSysMmbId></FinInstnId></CdtrAgt>\
<Cdtr><Nm>BANK-$to</Nm></Cdtr><CdtrAcct><Id><Othr><Id>ACC-$to</Id></Othr></Id></CdtrAcct>\
</CdtTrfTxInf></FIToFICstmrCdtTrf></Document>"
curl -s -m 8 -X POST "$CHANEL/pay/customer" -H "Content-Type: application/xml" --data-binary "$xml" >/dev/null
}
# 22자리 유일 BMI 생성. 인자: 기관코드(4) 인덱스
mkbmi() { printf '20260710%04d%010d' "$1" "$(( ($(date +%s) % 1000000) * 100 + $2 ))"; }
# 미완결(PDNG/ACSP) 잔류가 0이 될 때까지 대기(최대 초). 인자: timeout_sec
wait_drain() {
local t=${1:-40} n
for ((i=0;i<t;i++)); do
n=$(q "SELECT count(*) FROM transfer WHERE status IN ('PDNG','ACSP')")
[ "$n" = "0" ] && return 0
sleep 1
done
return 1
}
# 서비스 PID(jar 이름으로). 인자: 서비스명(sequencer/hermes 등)
pid_of() { "$JPS" -l | grep -i "$1-0.1.0.jar" | awk '{print $1}' | head -1; }
# 서비스 기동(새 콘솔 창). 인자: 표시명 서비스명
start_svc() {
cmd //c start "$1" cmd //k "$JAVA -jar $JAR_DIR\\$2\\build\\libs\\$2-0.1.0.jar"
}

24
sim/reconcile.sql Normal file
View File

@@ -0,0 +1,24 @@
-- RTGS 계층 대사 리포트 (S1 정합성).
-- 실행: psql -h localhost -p 5433 -U rtgs -d rtgs -f reconcile.sql
\pset border 2
\echo ''
\echo '== 1) 계좌 총액 (보존 불변식: 이체 전후 항상 동일해야 함) =='
SELECT sum(balance) AS total_balance, count(*) AS accounts FROM account;
\echo '== 2) 거래 상태 분포 =='
SELECT status, count(*) AS cnt FROM transfer GROUP BY status ORDER BY status;
\echo '== 3) 미완결 잔류 (ACSP/PDNG에 머문 건 = 있으면 이상) =='
SELECT count(*) AS stuck FROM transfer WHERE status IN ('PDNG','ACSP');
\echo '== 4) 원장 vs 조회사본 대사 (ACCC 건수 일치해야) =='
SELECT (SELECT count(*) FROM transfer WHERE status='ACCC') AS ledger_accc,
(SELECT count(*) FROM settlement_view WHERE final_status='ACCC') AS view_accc;
\echo '== 5) 선저널 대사 (정산건 <= journal_log 기록건) =='
SELECT (SELECT count(*) FROM transfer WHERE status IN ('ACSP','ACCC')) AS settled,
(SELECT count(*) FROM journal_log) AS journal_rows;
\echo '== 6) 잔액 음수 계좌 (있으면 이중지급/오류) =='
SELECT count(*) AS negative_accounts FROM account WHERE balance < 0;
\echo ''

40
sim/s1_consistency.sh Normal file
View File

@@ -0,0 +1,40 @@
#!/usr/bin/env bash
# S1 — 정합성(기능성 축): 평상시 부하 후 전 계층 대사·총액 보존·이중지급 0 검증.
# 복원력방안 부록A S1 재현(로컬 1센터). 실행: bash sim/s1_consistency.sh [건수]
cd "$(dirname "$0")"; source ./lib.sh
N=${1:-30}
FAILED=0
BANKS=(1001 1002 1003 1004 1005 1006 1007 1010)
hr; echo "S1 정합성 검증 — 이체 ${N}건 투입"; hr
TOTAL_BEFORE=$(q "SELECT sum(balance) FROM account")
ACCC_BEFORE=$(q "SELECT count(*) FROM transfer WHERE status='ACCC'")
echo " 이체 전 총액 = $TOTAL_BEFORE"
for ((i=1;i<=N;i++)); do
fi=${BANKS[$((RANDOM % ${#BANKS[@]}))]}
ti=${BANKS[$((RANDOM % ${#BANKS[@]}))]}
while [ "$ti" = "$fi" ]; do ti=${BANKS[$((RANDOM % ${#BANKS[@]}))]}; done
amt=$(( (RANDOM % 500) + 1 ))
submit "$fi" "$ti" "$amt" "$(mkbmi "$fi" "$i")"
done
echo " 투입 완료. 정산 드레인 대기..."
wait_drain 60 && echo " 드레인 완료" || echo " (경고) 드레인 타임아웃"
TOTAL_AFTER=$(q "SELECT sum(balance) FROM account")
ACCC_AFTER=$(q "SELECT count(*) FROM transfer WHERE status='ACCC'")
STUCK=$(q "SELECT count(*) FROM transfer WHERE status IN ('PDNG','ACSP')")
NEG=$(q "SELECT count(*) FROM account WHERE balance < 0")
LEDGER=$(q "SELECT count(*) FROM transfer WHERE status='ACCC'")
VIEW=$(q "SELECT count(*) FROM settlement_view WHERE final_status='ACCC'")
hr; echo "결과"; hr
[ "$TOTAL_AFTER" = "$TOTAL_BEFORE" ] && pass "총액 보존: $TOTAL_AFTER (변화 없음)" || fail "총액 불일치: $TOTAL_BEFORE -> $TOTAL_AFTER"
[ "$STUCK" = "0" ] && pass "미완결 잔류 0" || fail "미완결(PDNG/ACSP) $STUCK 건 잔류"
[ "$NEG" = "0" ] && pass "잔액 음수 계좌 0 (이중지급 없음)" || fail "음수 계좌 $NEG"
[ "$LEDGER" = "$VIEW" ] && pass "원장=조회사본 대사 일치 (ACCC $LEDGER)" || fail "원장 $LEDGER vs 사본 $VIEW 불일치"
echo " (참고) 이번 배치 ACCC 증가분 = $((ACCC_AFTER - ACCC_BEFORE))"
hr
[ "$FAILED" = "0" ] && echo "S1 PASS ✅" || echo "S1 FAIL ❌"
exit $FAILED

52
sim/s2_failover.sh Normal file
View File

@@ -0,0 +1,52 @@
#!/usr/bin/env bash
# S2 — 무손실 승계(복원성 축): 부하 중 원장엔진(Hermes)을 강제 종료했다가 재기동해도
# 선저널(write-ahead)+Kafka 오프셋 재생으로 유실 0·전건 완결되는지 검증.
# 복원력방안 부록A S2 재현. 실행: bash sim/s2_failover.sh [건수]
# ⚠ Hermes를 죽였다 살리므로 수동 기능테스트와 겹치지 않을 때 실행 권장.
cd "$(dirname "$0")"; source ./lib.sh
N=${1:-40}
FAILED=0
BANKS=(1001 1002 1003 1004 1005 1006 1007 1010)
declare -a BMIS
hr; echo "S2 무손실 승계 검증 — 이체 ${N}건 투입 중 Hermes 강제 종료·재기동"; hr
TOTAL_BEFORE=$(q "SELECT sum(balance) FROM account")
echo " 이체 전 총액 = $TOTAL_BEFORE"
KILL_AT=$((N/3)); RESTARTED=0
for ((i=1;i<=N;i++)); do
fi=${BANKS[$((RANDOM % ${#BANKS[@]}))]}
ti=${BANKS[$((RANDOM % ${#BANKS[@]}))]}
while [ "$ti" = "$fi" ]; do ti=${BANKS[$((RANDOM % ${#BANKS[@]}))]}; done
bmi=$(mkbmi "$fi" "$i"); BMIS+=("$bmi")
submit "$fi" "$ti" "$(( (RANDOM % 300) + 1 ))" "$bmi"
if [ "$i" = "$KILL_AT" ] && [ "$RESTARTED" = "0" ]; then
HP=$(pid_of hermes)
echo " >> $i건째: Hermes(PID $HP) 강제 종료"
[ -n "$HP" ] && taskkill //F //PID "$HP" //T >/dev/null 2>&1
sleep 3
echo " >> Hermes 재기동"
start_svc "RTGS Hermes (S2 재기동)" hermes
RESTARTED=1
fi
sleep 0.2
done
echo " 투입 완료. Hermes 복귀·정산 드레인 대기(최대 90s)..."
wait_drain 90 && echo " 드레인 완료" || echo " (경고) 드레인 타임아웃 — 아래 잔류 확인"
# 투입한 BMI 전건이 종료상태(ACCC/RJCT)에 도달했는지 — 단일 쿼리(IN 리스트)로 확인
INLIST=$(printf "'%s'," "${BMIS[@]}"); INLIST=${INLIST%,}
TERM=$(q "SELECT count(*) FROM transfer WHERE bmi IN ($INLIST) AND status IN ('ACCC','RJCT')")
STUCK_LIST=$(PSQL -t -A -c "SELECT bmi||'('||status||')' FROM transfer WHERE bmi IN ($INLIST) AND status NOT IN ('ACCC','RJCT')" | tr '\n' ' ')
TOTAL_AFTER=$(q "SELECT sum(balance) FROM account")
NEG=$(q "SELECT count(*) FROM account WHERE balance < 0")
hr; echo "결과"; hr
[ "$TERM" = "$N" ] && pass "투입 $N건 전건 완결(ACCC/RJCT) — 유실 0" || fail "$((N-TERM))건 미완결:$STUCK_LIST"
[ "$TOTAL_AFTER" = "$TOTAL_BEFORE" ] && pass "총액 보존: $TOTAL_AFTER" || fail "총액 불일치: $TOTAL_BEFORE -> $TOTAL_AFTER"
[ "$NEG" = "0" ] && pass "잔액 음수 계좌 0" || fail "음수 계좌 $NEG"
hr
[ "$FAILED" = "0" ] && echo "S2 PASS ✅ (선저널+오프셋 재생으로 무손실 승계)" || echo "S2 FAIL ❌"
exit $FAILED

63
sim/s4_order.sh Normal file
View File

@@ -0,0 +1,63 @@
#!/usr/bin/env bash
# S4 — 순서오류 탐지·교정(기능성 축): 저널(rtgs.journal)에 전역순번을 역전시켜 주입해도
# 원장엔진(Hermes)이 gap을 버퍼링→순서대로 재정렬 적용하는지 검증.
# 복원력방안 부록A S4 재현. 실행: bash sim/s4_order.sh
# ⚠ 순번기(Sequencer)를 잠시 멈추고 저널에 직접 주입한다(2-1 순번 영속화 덕에 재기동 시 안전 복원).
# 수동 기능테스트와 겹치지 않을 때 실행 권장.
cd "$(dirname "$0")"; source ./lib.sh
export KAFKA_HEAP_OPTS="-Xmx256M -Xms256M" # kafka .bat의 wmic 분기 회피(포터블)
FAILED=0
hr; echo "S4 순서오류 탐지·교정 검증 — 저널에 seq 역전 주입"; hr
# 0) 드레인(잔여 없게) + 순번기 정지(경쟁 발번 차단)
wait_drain 30 >/dev/null
SP=$(pid_of sequencer)
echo " Sequencer(PID $SP) 정지 — 주입 동안 경쟁 발번 차단"
[ -n "$SP" ] && taskkill //F //PID "$SP" //T >/dev/null 2>&1
sleep 3
TOTAL_BEFORE=$(q "SELECT sum(balance) FROM account")
M=$(q "SELECT COALESCE(MAX(global_seq),0) FROM journal_log")
echo " 현재 최대 순번 M=$M → 주입 순번 $((M+1)),$((M+2)),$((M+3)) (전달 순서는 역전)"
TS="$(date +%s)000"
B1="20260710S4$(printf '%010d' $((M+1)))" # seq M+1 1002->1003
B2="20260710S4$(printf '%010d' $((M+2)))" # seq M+2 1004->1005
B3="20260710S4$(printf '%010d' $((M+3)))" # seq M+3 1006->1007
entry() { # seq bmi from to amt
echo "{\"globalSeq\":$1,\"seqEpochMillis\":$TS,\"originCenter\":\"DC1\",\"core\":{\"bmi\":\"$2\",\"msgType\":\"pacs.008.001.08\",\"senderCode\":\"$3\",\"receiverCode\":\"$4\",\"debtorAcct\":\"ACC-$3\",\"creditorAcct\":\"ACC-$4\",\"amount\":$5,\"currency\":\"KRW\",\"origHash\":\"simS4\"}}"
}
# 전달 순서: M+2, M+3, M+1 (역전/gap 주입)
{
entry $((M+2)) "$B2" 1004 1005 22
entry $((M+3)) "$B3" 1006 1007 33
entry $((M+1)) "$B1" 1002 1003 11
} > /tmp/s4_inject.txt
echo " 주입 순서: seq $((M+2))$((M+3))$((M+1))"
"$KAFKA_PROD" --topic rtgs.journal --bootstrap-server localhost:9092 < /tmp/s4_inject.txt 2>/dev/null
echo " Hermes 재정렬·적용 대기..."
wait_drain 40 >/dev/null; sleep 2
s1=$(q "SELECT status FROM transfer WHERE bmi='$B1'")
s2=$(q "SELECT status FROM transfer WHERE bmi='$B2'")
s3=$(q "SELECT status FROM transfer WHERE bmi='$B3'")
# journal_log에 순번이 순서대로 기록되었는지
ORDER=$(q "SELECT string_agg(global_seq::text, ',' ORDER BY global_seq) FROM journal_log WHERE global_seq IN ($((M+1)),$((M+2)),$((M+3)))")
TOTAL_AFTER=$(q "SELECT sum(balance) FROM account")
hr; echo "결과"; hr
[ "$s1" = "ACCC" ] && [ "$s2" = "ACCC" ] && [ "$s3" = "ACCC" ] \
&& pass "역전 주입 3건 전건 정상 완결(ACCC) — 재정렬 성공" \
|| fail "완결 실패: seq$((M+1))=$s1 seq$((M+2))=$s2 seq$((M+3))=$s3"
[ "$ORDER" = "$((M+1)),$((M+2)),$((M+3))" ] \
&& pass "저널 순번 순서대로 기록: $ORDER" || fail "순번 기록 이상: $ORDER"
[ "$TOTAL_AFTER" = "$TOTAL_BEFORE" ] && pass "총액 보존: $TOTAL_AFTER" || fail "총액 불일치: $TOTAL_BEFORE -> $TOTAL_AFTER"
# 순번기 재기동(2-1 영속화: MAX(global_seq) 복원해 안전하게 이어서 발번)
echo " Sequencer 재기동(순번 $((M+3)) 이후부터 복원 발번)"
start_svc "RTGS Sequencer :8090 (S4 복귀)" sequencer
hr
[ "$FAILED" = "0" ] && echo "S4 PASS ✅ (gap 버퍼링→순서 재정렬)" || echo "S4 FAIL ❌"
exit $FAILED