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:
32
.gitignore
vendored
Normal file
32
.gitignore
vendored
Normal file
@@ -0,0 +1,32 @@
|
||||
# === Gradle / Kotlin (backend) ===
|
||||
.gradle/
|
||||
**/build/
|
||||
**/bin/
|
||||
!gradle/wrapper/gradle-wrapper.jar
|
||||
!**/src/**/build/
|
||||
*.class
|
||||
|
||||
# === Node / Vite (frontend) ===
|
||||
node_modules/
|
||||
dist/
|
||||
*.local
|
||||
|
||||
# === Logs ===
|
||||
*.log
|
||||
logs/
|
||||
|
||||
# === Env / secrets ===
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# === IDE / OS ===
|
||||
.idea/
|
||||
*.iml
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# === Runtime / data (infra native runs) ===
|
||||
*.pid
|
||||
data/
|
||||
*.tmp
|
||||
15
.vscode/settings.json
vendored
Normal file
15
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"//": "rtgs 백엔드는 JDK21로 빌드/import (전역 JAVA_HOME=jdk-26과 분리). Gradle 8.10.2는 JDK26 미지원.",
|
||||
"java.jdt.ls.java.home": "C:\\ai-dev\\apps\\jdk-21",
|
||||
"java.import.gradle.enabled": true,
|
||||
"java.import.gradle.wrapper.enabled": true,
|
||||
"java.import.gradle.java.home": "C:\\ai-dev\\apps\\jdk-21",
|
||||
"java.configuration.runtimes": [
|
||||
{
|
||||
"name": "JavaSE-21",
|
||||
"path": "C:\\ai-dev\\apps\\jdk-21",
|
||||
"default": true
|
||||
}
|
||||
],
|
||||
"java.compile.nullAnalysis.mode": "automatic"
|
||||
}
|
||||
37
backend/build.gradle.kts
Normal file
37
backend/build.gradle.kts
Normal file
@@ -0,0 +1,37 @@
|
||||
plugins {
|
||||
// Applied per-subproject; declared here (apply false) so versions are shared.
|
||||
kotlin("jvm") version "2.0.20" apply false
|
||||
kotlin("plugin.spring") version "2.0.20" apply false
|
||||
kotlin("plugin.jpa") version "2.0.20" apply false
|
||||
id("org.springframework.boot") version "3.3.4" apply false
|
||||
id("io.spring.dependency-management") version "1.1.6" apply false
|
||||
}
|
||||
|
||||
allprojects {
|
||||
group = "kr.or.bok.rtgs"
|
||||
version = "0.1.0"
|
||||
|
||||
repositories {
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
subprojects {
|
||||
apply(plugin = "org.jetbrains.kotlin.jvm")
|
||||
|
||||
// All modules target Java 21 bytecode (cloud parity: Temurin 21).
|
||||
the<JavaPluginExtension>().toolchain {
|
||||
languageVersion.set(JavaLanguageVersion.of(21))
|
||||
}
|
||||
|
||||
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().configureEach {
|
||||
compilerOptions {
|
||||
freeCompilerArgs.add("-Xjsr305=strict")
|
||||
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_21)
|
||||
}
|
||||
}
|
||||
|
||||
tasks.withType<Test>().configureEach {
|
||||
useJUnitPlatform()
|
||||
}
|
||||
}
|
||||
19
backend/chanel/build.gradle.kts
Normal file
19
backend/chanel/build.gradle.kts
Normal file
@@ -0,0 +1,19 @@
|
||||
plugins {
|
||||
kotlin("jvm")
|
||||
kotlin("plugin.spring")
|
||||
id("org.springframework.boot")
|
||||
id("io.spring.dependency-management")
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":common"))
|
||||
implementation("org.springframework.boot:spring-boot-starter-web")
|
||||
implementation("org.springframework.boot:spring-boot-starter-actuator")
|
||||
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
|
||||
implementation("org.springframework.kafka:spring-kafka")
|
||||
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
|
||||
implementation("org.jetbrains.kotlin:kotlin-reflect")
|
||||
runtimeOnly("org.postgresql:postgresql")
|
||||
testImplementation("org.springframework.boot:spring-boot-starter-test")
|
||||
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package kr.or.bok.rtgs.chanel
|
||||
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication
|
||||
import org.springframework.boot.autoconfigure.domain.EntityScan
|
||||
import org.springframework.boot.runApplication
|
||||
import org.springframework.data.jpa.repository.config.EnableJpaRepositories
|
||||
|
||||
@SpringBootApplication
|
||||
@EntityScan("kr.or.bok.rtgs.common.ledger")
|
||||
@EnableJpaRepositories("kr.or.bok.rtgs.chanel.jpa")
|
||||
class ChanelApplication
|
||||
|
||||
fun main(args: Array<String>) {
|
||||
runApplication<ChanelApplication>(*args)
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package kr.or.bok.rtgs.chanel
|
||||
|
||||
import kr.or.bok.rtgs.chanel.jpa.AccountRepository
|
||||
import kr.or.bok.rtgs.chanel.jpa.NotificationRepository
|
||||
import kr.or.bok.rtgs.chanel.jpa.TransferRepository
|
||||
import kr.or.bok.rtgs.common.iso20022.Iso20022Codec
|
||||
import org.springframework.http.MediaType
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
import org.springframework.web.bind.annotation.PathVariable
|
||||
import org.springframework.web.bind.annotation.PostMapping
|
||||
import org.springframework.web.bind.annotation.RequestBody
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
|
||||
/**
|
||||
* 전문송수신 제어 — 신청/접수(pacs.008) 및 조회(inquiry/accounts) API.
|
||||
* 조회는 권위 원장(PostgreSQL)에서 읽는다(프로토타입; 부하 분리 시 조회 전용 사본으로 확장).
|
||||
*/
|
||||
@RestController
|
||||
class ChanelController(
|
||||
private val service: ChanelService,
|
||||
private val transfers: TransferRepository,
|
||||
private val accounts: AccountRepository,
|
||||
private val notifications: NotificationRepository,
|
||||
) {
|
||||
/** 자금이체 신청/접수: pacs.008 XML → pacs.002 XML. */
|
||||
@PostMapping(
|
||||
"/pay/customer",
|
||||
consumes = [MediaType.APPLICATION_XML_VALUE, MediaType.TEXT_XML_VALUE, MediaType.TEXT_PLAIN_VALUE],
|
||||
produces = [MediaType.APPLICATION_XML_VALUE],
|
||||
)
|
||||
fun payCustomer(@RequestBody rawXml: String): String =
|
||||
Iso20022Codec.writePacs002(service.accept(rawXml))
|
||||
|
||||
/** 거래 상태 조회. */
|
||||
@GetMapping("/inquiry/{bmi}")
|
||||
fun inquiry(@PathVariable bmi: String): Map<String, Any?> {
|
||||
val t = transfers.findById(bmi).orElse(null)
|
||||
?: return mapOf("bmi" to bmi, "status" to "IN_FLIGHT", "note" to "not yet persisted")
|
||||
return mapOf(
|
||||
"bmi" to t.bmi,
|
||||
"globalSeq" to t.globalSeq,
|
||||
"status" to t.status.name,
|
||||
"senderCode" to t.senderCode,
|
||||
"receiverCode" to t.receiverCode,
|
||||
"amount" to t.amount,
|
||||
"originCenter" to t.originCenter,
|
||||
"updatedAt" to t.updatedAt,
|
||||
"reason" to t.reason,
|
||||
)
|
||||
}
|
||||
|
||||
/** 참가기관 당좌계좌 잔액 목록(대사·모니터링용). */
|
||||
@GetMapping("/accounts")
|
||||
fun accounts(): List<Map<String, Any?>> =
|
||||
accounts.findAll().sortedBy { it.code }.map {
|
||||
mapOf("code" to it.code, "name" to it.name, "balance" to it.balance)
|
||||
}
|
||||
|
||||
/** 결과통보 송부 내역(최근 100건). 신청/수취기관 앞 pacs.002 아웃박스. */
|
||||
@GetMapping("/notifications")
|
||||
fun notificationList(): List<Map<String, Any?>> =
|
||||
notifications.findTop100ByOrderByCreatedAtDesc().map(::toMap)
|
||||
|
||||
/** 특정 거래의 결과통보(신청기관·수취기관) 내역 — 원문 pacs.002 포함. */
|
||||
@GetMapping("/notifications/{bmi}")
|
||||
fun notificationOf(@PathVariable bmi: String): List<Map<String, Any?>> =
|
||||
notifications.findByBmiOrderByRole(bmi).map { toMap(it, includeXml = true) }
|
||||
|
||||
private fun toMap(n: kr.or.bok.rtgs.common.ledger.NotificationEntity, includeXml: Boolean = false): Map<String, Any?> =
|
||||
buildMap {
|
||||
put("bmi", n.bmi); put("toOrg", n.toOrg); put("role", n.role)
|
||||
put("finalStatus", n.finalStatus); put("originCenter", n.originCenter)
|
||||
put("reason", n.reason); put("delivered", n.delivered)
|
||||
put("createdAt", n.createdAt); put("deliveredAt", n.deliveredAt)
|
||||
if (includeXml) put("pacs002", n.pacs002Xml)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package kr.or.bok.rtgs.chanel
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import kr.or.bok.rtgs.chanel.jpa.RawMessageRepository
|
||||
import kr.or.bok.rtgs.common.BmiGenerator
|
||||
import kr.or.bok.rtgs.common.ledger.RawMessageEntity
|
||||
import kr.or.bok.rtgs.common.InboundRequest
|
||||
import kr.or.bok.rtgs.common.Topics
|
||||
import kr.or.bok.rtgs.common.TxSts
|
||||
import kr.or.bok.rtgs.common.iso20022.Iso20022Codec
|
||||
import kr.or.bok.rtgs.common.iso20022.Pacs002
|
||||
import kr.or.bok.rtgs.common.iso20022.XmlValidator
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.kafka.core.KafkaTemplate
|
||||
import org.springframework.stereotype.Service
|
||||
import java.time.Instant
|
||||
|
||||
/**
|
||||
* CHANEL(전문송수신) — 참가기관 pacs.008 접수 → XSD검증 → 경량명령문 추출 →
|
||||
* 원전문 MongoDB 저장 → 입구(rtgs.inbound) 발행. 응답으로 pacs.002 생성.
|
||||
*/
|
||||
@Service
|
||||
class ChanelService(
|
||||
private val kafka: KafkaTemplate<String, String>,
|
||||
private val mapper: ObjectMapper,
|
||||
private val rawRepo: RawMessageRepository,
|
||||
@Value("\${rtgs.center-id:DC1}") private val centerId: String,
|
||||
) {
|
||||
private val log = LoggerFactory.getLogger(javaClass)
|
||||
private val validator = XmlValidator()
|
||||
private val fallbackBmi = BmiGenerator("9999") // 요청에 BMI 없을 때 접수센터가 부여
|
||||
|
||||
fun accept(rawXml: String): Pacs002 {
|
||||
// 1) XSD 유효성 검사
|
||||
val v = validator.validate(rawXml)
|
||||
if (!v.valid) {
|
||||
log.warn("XSD invalid: {}", v.error)
|
||||
return reject("", "", "", "XSD validation failed: ${v.error}")
|
||||
}
|
||||
|
||||
// 2) 파싱 + 경량명령문 추출
|
||||
val req = try {
|
||||
Iso20022Codec.parsePacs008(rawXml)
|
||||
} catch (e: Exception) {
|
||||
return reject("", "", "", "parse error: ${e.message}")
|
||||
}
|
||||
val bmi = req.bizMsgIdr?.takeIf { it.isNotBlank() } ?: fallbackBmi.next()
|
||||
val core = try {
|
||||
Iso20022Codec.toCoreMessage(req, rawXml, bmi)
|
||||
} catch (e: Exception) {
|
||||
return reject(bmi, req.to, req.from, "business rule: ${e.message}")
|
||||
}
|
||||
|
||||
// 3) 원전문 저장(PostgreSQL text; 클라우드는 문서형 DB)
|
||||
rawRepo.save(
|
||||
RawMessageEntity(
|
||||
bmi = core.bmi, msgType = core.msgType, rawXml = rawXml,
|
||||
origHash = core.origHash, receivedAt = System.currentTimeMillis(), originCenter = centerId,
|
||||
)
|
||||
)
|
||||
|
||||
// 4) 입구(rtgs.inbound) 발행 → 순번기로
|
||||
val inbound = InboundRequest(core = core, originCenter = centerId)
|
||||
kafka.send(Topics.INBOUND, core.bmi, mapper.writeValueAsString(inbound))
|
||||
log.info("RCVD bmi={} {}->{} amt={} center={}", core.bmi, core.senderCode, core.receiverCode, core.amount, centerId)
|
||||
|
||||
// 5) 접수 응답(RCVD)
|
||||
return Pacs002(
|
||||
orgnlBizMsgIdr = core.bmi,
|
||||
from = "RTGS-$centerId",
|
||||
to = core.senderCode,
|
||||
txSts = TxSts.RCVD.name,
|
||||
clrSysRef = core.bmi,
|
||||
creDtTm = Instant.now().toString(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun reject(bmi: String, to: String, sender: String, reason: String) = Pacs002(
|
||||
orgnlBizMsgIdr = bmi,
|
||||
from = "RTGS-$centerId",
|
||||
to = sender,
|
||||
txSts = TxSts.RJCT.name,
|
||||
clrSysRef = reason,
|
||||
creDtTm = Instant.now().toString(),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package kr.or.bok.rtgs.chanel
|
||||
|
||||
import org.springframework.jdbc.core.JdbcTemplate
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
import org.springframework.web.bind.annotation.RequestParam
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
|
||||
/**
|
||||
* 서비스별 처리 현황(대사) 조회 — 읽기 전용. 각 서비스가 남긴 PostgreSQL 테이블을 최신순으로 반환.
|
||||
* - transfer : 원장(Dior 접수 PDNG → Hermes 결제 ACSP → Prada 완결 ACCC 흐름)
|
||||
* - journal_log : Hermes 선저널(write-ahead)
|
||||
* - settlement_view: Prada 조회 사본
|
||||
* - raw_message : Chanel 원전문(pacs.008)
|
||||
*/
|
||||
@RestController
|
||||
class LedgerController(private val jdbc: JdbcTemplate) {
|
||||
|
||||
private fun q(sql: String, limit: Int) = jdbc.queryForList(sql.replace("{L}", limit.coerceIn(1, 200).toString()))
|
||||
|
||||
@GetMapping("/ledger/transfers")
|
||||
fun transfers(@RequestParam(defaultValue = "20") limit: Int) = q(
|
||||
"SELECT bmi, global_seq, status, sender_code, receiver_code, amount, origin_center, updated_at " +
|
||||
"FROM transfer ORDER BY global_seq DESC NULLS LAST LIMIT {L}", limit)
|
||||
|
||||
@GetMapping("/ledger/journal")
|
||||
fun journal(@RequestParam(defaultValue = "20") limit: Int) = q(
|
||||
"SELECT global_seq, bmi, applied, seq_epoch_millis FROM journal_log ORDER BY global_seq DESC LIMIT {L}", limit)
|
||||
|
||||
@GetMapping("/ledger/views")
|
||||
fun views(@RequestParam(defaultValue = "20") limit: Int) = q(
|
||||
"SELECT bmi, global_seq, final_status, debtor_balance_after, creditor_balance_after, processed_center, finalized_at " +
|
||||
"FROM settlement_view ORDER BY global_seq DESC NULLS LAST LIMIT {L}", limit)
|
||||
|
||||
@GetMapping("/ledger/rawmessages")
|
||||
fun rawmessages(@RequestParam(defaultValue = "20") limit: Int) = q(
|
||||
"SELECT bmi, msg_type, origin_center, received_at FROM raw_message ORDER BY received_at DESC LIMIT {L}", limit)
|
||||
|
||||
/** 요약: 상태별 건수 + 총액(대사) */
|
||||
@GetMapping("/ledger/summary")
|
||||
fun summary(): Map<String, Any?> {
|
||||
val byStatus = jdbc.queryForList("SELECT status, count(*) AS cnt FROM transfer GROUP BY status ORDER BY status")
|
||||
val total = jdbc.queryForObject("SELECT sum(balance) FROM account", Long::class.java)
|
||||
val accts = jdbc.queryForObject("SELECT count(*) FROM account", Long::class.java)
|
||||
return mapOf("byStatus" to byStatus, "totalBalance" to total, "accounts" to accts)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package kr.or.bok.rtgs.chanel
|
||||
|
||||
import kr.or.bok.rtgs.chanel.jpa.RawMessageRepository
|
||||
import org.springframework.jdbc.core.JdbcTemplate
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
import org.springframework.web.bind.annotation.PathVariable
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
|
||||
/**
|
||||
* 메타/원문 조회 API.
|
||||
* - /meta/labels: transfer 컬럼의 한글명(DB 코멘트=데이터 사전)을 조회 필드명(camelCase)에 매핑해 반환.
|
||||
* 화면 라벨의 단일 출처가 DB 코멘트가 되도록 함.
|
||||
* - /rawmessage/{bmi}: 접수된 ISO20022 pacs.008 원문 전문(XML) 조회.
|
||||
*/
|
||||
@RestController
|
||||
class MetaController(
|
||||
private val jdbc: JdbcTemplate,
|
||||
private val rawRepo: RawMessageRepository,
|
||||
) {
|
||||
@GetMapping("/meta/labels")
|
||||
fun labels(): Map<String, String> {
|
||||
val rows = jdbc.queryForList(
|
||||
"""
|
||||
SELECT a.attname AS col, col_description(a.attrelid, a.attnum) AS ko
|
||||
FROM pg_attribute a
|
||||
WHERE a.attrelid = 'transfer'::regclass AND a.attnum > 0 AND NOT a.attisdropped
|
||||
ORDER BY a.attnum
|
||||
""".trimIndent()
|
||||
)
|
||||
val m = LinkedHashMap<String, String>()
|
||||
for (r in rows) {
|
||||
val col = r["col"] as? String ?: continue
|
||||
val ko = r["ko"] as? String ?: continue
|
||||
m[toCamel(col)] = ko
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
@GetMapping("/rawmessage/{bmi}")
|
||||
fun rawMessage(@PathVariable bmi: String): Map<String, Any?> {
|
||||
val e = rawRepo.findById(bmi).orElse(null)
|
||||
?: return mapOf("bmi" to bmi, "found" to false)
|
||||
return mapOf(
|
||||
"bmi" to e.bmi,
|
||||
"msgType" to e.msgType,
|
||||
"receivedAt" to e.receivedAt,
|
||||
"originCenter" to e.originCenter,
|
||||
"rawXml" to e.rawXml,
|
||||
"found" to true,
|
||||
)
|
||||
}
|
||||
|
||||
private fun toCamel(s: String): String =
|
||||
s.split("_").mapIndexed { i, p -> if (i == 0) p else p.replaceFirstChar { it.uppercase() } }.joinToString("")
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package kr.or.bok.rtgs.chanel
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import kr.or.bok.rtgs.chanel.jpa.NotificationRepository
|
||||
import kr.or.bok.rtgs.common.SettlementNotice
|
||||
import kr.or.bok.rtgs.common.Topics
|
||||
import kr.or.bok.rtgs.common.TxSts
|
||||
import kr.or.bok.rtgs.common.iso20022.Iso20022Codec
|
||||
import kr.or.bok.rtgs.common.iso20022.Pacs002
|
||||
import kr.or.bok.rtgs.common.ledger.NotificationEntity
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.kafka.annotation.KafkaListener
|
||||
import org.springframework.stereotype.Service
|
||||
import java.time.Instant
|
||||
|
||||
/**
|
||||
* 결과통보(이체결과 송부) — Gucci에서 Chanel로 이관(2026-07-10). Chanel 헌장 "접수 + 처리결과 통보".
|
||||
*
|
||||
* Prada가 완결(ACCC/RJCT) 후 발행한 rtgs.notify를 받아, **접수센터(originCenter)의 Chanel만**
|
||||
* 신청기관(송신)·수취기관 앞 최종 pacs.002를 생성해 송부한다(개요 프로세스 (4)).
|
||||
* - 신청기관(APPLICANT): 항상 결과 통보(성공/반려)
|
||||
* - 수취기관(BENEFICIARY): 완결(ACCC) 시 입금 통보
|
||||
* 로컬은 실제 외부 전송 대신 아웃박스(notification) 기록 + 로그로 송부를 모사한다.
|
||||
* (실외부 채널/콜백 전송·전달보장은 향후 Gucci 관문이 담당)
|
||||
*/
|
||||
@Service
|
||||
class NotificationService(
|
||||
private val notifications: NotificationRepository,
|
||||
private val mapper: ObjectMapper,
|
||||
@Value("\${rtgs.center-id:DC1}") private val centerId: String,
|
||||
) {
|
||||
private val log = LoggerFactory.getLogger(javaClass)
|
||||
|
||||
// 센터별 그룹(chanel-DC1/DC2/DC3): 각 센터가 notify 전량 소비 후 origin만 통보.
|
||||
@KafkaListener(topics = [Topics.NOTIFY], groupId = "chanel-\${rtgs.center-id:DC1}", concurrency = "1")
|
||||
fun onNotice(payload: String) {
|
||||
val n = mapper.readValue(payload, SettlementNotice::class.java)
|
||||
if (n.originCenter != centerId) return // 접수센터만 통보 책임
|
||||
|
||||
val pacs002 = Iso20022Codec.writePacs002(
|
||||
Pacs002(
|
||||
orgnlBizMsgIdr = n.bmi,
|
||||
from = "RTGS-$centerId",
|
||||
to = n.senderCode,
|
||||
txSts = n.finalStatus.name,
|
||||
clrSysRef = n.reason ?: "seq:${n.globalSeq}",
|
||||
creDtTm = Instant.now().toString(),
|
||||
)
|
||||
)
|
||||
|
||||
// 신청기관(송신) 앞 결과 통보 — 성공/반려 모두
|
||||
send(n, n.senderCode, "APPLICANT", pacs002)
|
||||
// 수취기관 앞 입금 통보 — 완결(ACCC)만
|
||||
if (n.finalStatus == TxSts.ACCC) send(n, n.receiverCode, "BENEFICIARY", pacs002)
|
||||
|
||||
log.info("NOTIFY 송부 bmi={} status={} → 신청기관 {}{}",
|
||||
n.bmi, n.finalStatus, n.senderCode,
|
||||
if (n.finalStatus == TxSts.ACCC) " · 수취기관 ${n.receiverCode}" else "")
|
||||
}
|
||||
|
||||
private fun send(n: SettlementNotice, toOrg: String, role: String, pacs002: String) {
|
||||
val now = System.currentTimeMillis()
|
||||
// 생성 시점엔 아웃박스에 pending(delivered=false)으로 기록.
|
||||
// 실제 외부 기관 앞 송부(콜백 POST·재시도·ACK)는 Gucci 관문(G2)이 담당한다.
|
||||
notifications.save(
|
||||
NotificationEntity(
|
||||
id = "${n.bmi}:$toOrg", bmi = n.bmi, toOrg = toOrg, role = role,
|
||||
finalStatus = n.finalStatus.name, pacs002Xml = pacs002,
|
||||
originCenter = n.originCenter, reason = n.reason,
|
||||
delivered = false, attempts = 0, createdAt = now, deliveredAt = null,
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package kr.or.bok.rtgs.chanel.jpa
|
||||
|
||||
import kr.or.bok.rtgs.common.ledger.Account
|
||||
import kr.or.bok.rtgs.common.ledger.NotificationEntity
|
||||
import kr.or.bok.rtgs.common.ledger.RawMessageEntity
|
||||
import kr.or.bok.rtgs.common.ledger.TransferRecord
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
|
||||
/** 조회(inquiry)용 읽기 접근 + 원전문 저장 — 권위 원장(PostgreSQL). */
|
||||
interface AccountRepository : JpaRepository<Account, String>
|
||||
|
||||
interface TransferRepository : JpaRepository<TransferRecord, String>
|
||||
|
||||
interface RawMessageRepository : JpaRepository<RawMessageEntity, String>
|
||||
|
||||
/** 결과통보 아웃박스 — 신청/수취기관 앞 송부한 pacs.002 기록. */
|
||||
interface NotificationRepository : JpaRepository<NotificationEntity, String> {
|
||||
fun findByBmiOrderByRole(bmi: String): List<NotificationEntity>
|
||||
fun findTop100ByOrderByCreatedAtDesc(): List<NotificationEntity>
|
||||
}
|
||||
34
backend/chanel/src/main/resources/application.yml
Normal file
34
backend/chanel/src/main/resources/application.yml
Normal file
@@ -0,0 +1,34 @@
|
||||
spring:
|
||||
application:
|
||||
name: chanel
|
||||
datasource:
|
||||
url: jdbc:postgresql://${POSTGRES_HOST:localhost}:${POSTGRES_PORT:5433}/${POSTGRES_DB:rtgs}
|
||||
username: ${POSTGRES_USER:rtgs}
|
||||
password: ${POSTGRES_PASSWORD:rtgs}
|
||||
jpa:
|
||||
hibernate:
|
||||
ddl-auto: none
|
||||
open-in-view: false
|
||||
kafka:
|
||||
bootstrap-servers: ${KAFKA_BOOTSTRAP:localhost:9092}
|
||||
consumer:
|
||||
group-id: chanel
|
||||
auto-offset-reset: earliest
|
||||
key-deserializer: org.apache.kafka.common.serialization.StringDeserializer
|
||||
value-deserializer: org.apache.kafka.common.serialization.StringDeserializer
|
||||
producer:
|
||||
key-serializer: org.apache.kafka.common.serialization.StringSerializer
|
||||
value-serializer: org.apache.kafka.common.serialization.StringSerializer
|
||||
acks: all
|
||||
|
||||
server:
|
||||
port: 8091
|
||||
|
||||
rtgs:
|
||||
center-id: ${CENTER_ID:DC1}
|
||||
|
||||
management:
|
||||
endpoints:
|
||||
web:
|
||||
exposure:
|
||||
include: health,info,prometheus,metrics
|
||||
29
backend/common/build.gradle.kts
Normal file
29
backend/common/build.gradle.kts
Normal file
@@ -0,0 +1,29 @@
|
||||
plugins {
|
||||
kotlin("jvm")
|
||||
kotlin("plugin.jpa") // all-open + no-arg for @Entity (Kotlin classes are final by default)
|
||||
id("io.spring.dependency-management")
|
||||
}
|
||||
|
||||
dependencyManagement {
|
||||
imports {
|
||||
mavenBom("org.springframework.boot:spring-boot-dependencies:3.3.4")
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
|
||||
implementation("com.fasterxml.jackson.dataformat:jackson-dataformat-xml")
|
||||
// JPA annotations only (entities shared across ledger services); runtime provided by services.
|
||||
compileOnly("jakarta.persistence:jakarta.persistence-api")
|
||||
// 로그 -> Logstash(TCP json). 공유 logback-spring.xml과 함께 전 서비스에 전파(runtime).
|
||||
implementation("net.logstash.logback:logstash-logback-encoder:7.4")
|
||||
implementation("io.micrometer:micrometer-registry-prometheus") // 메트릭(B4)
|
||||
// 하트비트 자동설정(B5) 컴파일용 — 런타임은 각 서비스가 제공
|
||||
compileOnly("org.springframework.boot:spring-boot-autoconfigure")
|
||||
compileOnly("org.springframework:spring-context")
|
||||
compileOnly("org.springframework:spring-jdbc")
|
||||
compileOnly("org.slf4j:slf4j-api")
|
||||
testImplementation("org.jetbrains.kotlin:kotlin-test-junit5")
|
||||
testImplementation("org.junit.jupiter:junit-jupiter")
|
||||
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package kr.or.bok.rtgs.common
|
||||
|
||||
import java.time.LocalDate
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
|
||||
/**
|
||||
* 거래고유식별자(BMI, Business Message Identifier) 생성기 — 총 22자리.
|
||||
* 구성: 영업일(8, YYYYMMDD) + 기관코드(4) + 일련번호(10).
|
||||
* BMI는 접수 센터에서 거래마다 유일하게 부여되며, 이후 저널을 통해 타 센터로 전파되어
|
||||
* 멱등성(idempotency) 중복체크의 키가 된다(같은 거래는 두 번 반영되지 않음).
|
||||
*/
|
||||
class BmiGenerator(private val orgCode: String) {
|
||||
private val serial = AtomicLong(0)
|
||||
|
||||
init {
|
||||
require(orgCode.length <= 4) { "orgCode must be <= 4 chars: $orgCode" }
|
||||
}
|
||||
|
||||
fun next(businessDate: LocalDate = LocalDate.now()): String {
|
||||
val date = businessDate.format(DATE_FMT) // 8
|
||||
val org = orgCode.padStart(4, '0') // 4
|
||||
val seq = serial.incrementAndGet().toString().padStart(10, '0') // 10
|
||||
return (date + org + seq).let {
|
||||
require(it.length == 22) { "BMI length != 22: $it (${it.length})" }
|
||||
it
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val DATE_FMT: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyyMMdd")
|
||||
|
||||
/** BMI 구성요소 파싱(검증/조회용). */
|
||||
fun businessDateOf(bmi: String): String = bmi.substring(0, 8)
|
||||
fun orgCodeOf(bmi: String): String = bmi.substring(8, 12)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package kr.or.bok.rtgs.common
|
||||
|
||||
/** Kafka 토픽 이름 — 입구/저널/결과. 전 센터 공유 단일 클러스터(순서 공유). */
|
||||
object Topics {
|
||||
const val INBOUND = "rtgs.inbound" // Chanel -> Sequencer (접수 원시 요청)
|
||||
const val JOURNAL = "rtgs.journal" // Sequencer -> 전 센터 (전역순번 저널, pacs.008 계열)
|
||||
const val RESULT = "rtgs.result" // Hermes -> Prada (결제결과, pacs.002 계열)
|
||||
const val NOTIFY = "rtgs.notify" // Prada(완결) -> Chanel (결과통보 트리거, 접수센터가 신청/수취기관 앞 송부)
|
||||
}
|
||||
|
||||
/** Hazelcast IMap 이름 — 인메모리 코어 연산(센터별 클러스터). */
|
||||
object Maps {
|
||||
const val ACCOUNTS = "accounts" // 참가기관 당좌계좌 잔액(코어 연산 캐시)
|
||||
const val TRANSFERS = "transfers" // 거래 상태(BMI -> 상태/금액), 멱등 중복체크
|
||||
}
|
||||
|
||||
/** PostgreSQL 원장 테이블 — 권위 저장소(강한 일관성). */
|
||||
object Tables {
|
||||
const val ACCOUNT = "account" // 참가기관 당좌계좌 권위 잔액
|
||||
const val TRANSFER = "transfer" // 거래 원장(상태/금액/순번)
|
||||
const val JOURNAL = "journal_log" // 선저널(write-ahead) 기록
|
||||
}
|
||||
|
||||
object Msg {
|
||||
// 정식 ISO20022 공식 XSD 버전과 일치(2026-07-10 전환).
|
||||
const val PACS008 = "pacs.008.001.08"
|
||||
const val PACS002 = "pacs.002.001.10"
|
||||
const val BAH = "head.001.001.03"
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package kr.or.bok.rtgs.common
|
||||
|
||||
import java.security.MessageDigest
|
||||
|
||||
/**
|
||||
* 경량 명령문(핵심 전문). 참가기관 원전문(pacs.008 XML, 수 KB)에서 원장 연산에 꼭 필요한
|
||||
* 최소 정보만 추출한 수백 바이트 구조체. 센터 간에는 이것만 저널로 복제한다(복원력방안 2.7).
|
||||
*
|
||||
* - origHash: 원전문(XML) SHA-256 — 무결성·부인방지 지문. 원전문 자체는 별도 저장소(MongoDB)에 보관.
|
||||
* - amount: 이체금액(원 단위 정수). 통화는 KRW 고정(프로토타입).
|
||||
*/
|
||||
data class CoreMessage(
|
||||
val bmi: String, // 거래고유식별자 22자리 (Business Message Identifier)
|
||||
val msgType: String, // 전문유형 (예: "pacs.008.001.11")
|
||||
val senderCode: String, // 송신기관 코드 (ORG_S)
|
||||
val receiverCode: String, // 수신기관 코드 (ORG_R)
|
||||
val debtorAcct: String, // 의뢰인(참가기관 당좌계좌 관점에서는 송신기관 계좌) 식별
|
||||
val creditorAcct: String, // 수취인 계좌 식별
|
||||
val amount: Long, // 이체금액(원)
|
||||
val currency: String = "KRW",
|
||||
val origHash: String, // 원전문 SHA-256(hex)
|
||||
) {
|
||||
init {
|
||||
require(amount > 0) { "amount must be positive: $amount" }
|
||||
require(senderCode != receiverCode) { "sender and receiver must differ: $senderCode" }
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun sha256Hex(raw: String): String {
|
||||
val md = MessageDigest.getInstance("SHA-256")
|
||||
val bytes = md.digest(raw.toByteArray(Charsets.UTF_8))
|
||||
return bytes.joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package kr.or.bok.rtgs.common
|
||||
|
||||
/**
|
||||
* 입구(rtgs.inbound) 봉투 — Chanel(접수센터) -> Sequencer.
|
||||
* originCenter는 최초 접수 센터로, 저널을 통해 전 센터에 공유되어 결과통보 책임 센터를 정한다.
|
||||
*/
|
||||
data class InboundRequest(
|
||||
val core: CoreMessage,
|
||||
val originCenter: String,
|
||||
)
|
||||
|
||||
/**
|
||||
* 결과(rtgs.result) 봉투 — Hermes -> Prada.
|
||||
* 결제처리 결과와 최종 상태(ACSP/RJCT)를 담아 결과동기화로 전달.
|
||||
*/
|
||||
data class ResultMessage(
|
||||
val journalEntry: JournalEntry,
|
||||
val status: TxSts,
|
||||
val processedCenter: String,
|
||||
val debtorBalanceAfter: Long? = null,
|
||||
val creditorBalanceAfter: Long? = null,
|
||||
val reason: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* 결과통보(rtgs.notify) 봉투 — Prada(완결) -> Chanel.
|
||||
* 거래가 최종 상태(ACCC/RJCT)로 확정된 뒤, 접수센터(originCenter)의 Chanel이 신청기관(송신)·
|
||||
* 수취기관 앞 결과 전문(pacs.002)을 생성·송부하도록 트리거한다.
|
||||
*/
|
||||
data class SettlementNotice(
|
||||
val bmi: String,
|
||||
val originCenter: String, // 접수센터 = 결과통보 책임 센터
|
||||
val senderCode: String, // 신청(송신)기관
|
||||
val receiverCode: String, // 수취기관
|
||||
val amount: Long,
|
||||
val globalSeq: Long,
|
||||
val finalStatus: TxSts, // ACCC or RJCT
|
||||
val reason: String? = null,
|
||||
val finalizedAt: Long,
|
||||
)
|
||||
@@ -0,0 +1,16 @@
|
||||
package kr.or.bok.rtgs.common
|
||||
|
||||
/**
|
||||
* 저널 엔트리 — 순번기(Sequencer)가 전역순번을 부여해 발행하는 명단의 한 줄.
|
||||
* 3개 센터가 이 저널을 같은 순서(globalSeq)로 결정론적으로 재생한다.
|
||||
*
|
||||
* 결정론적 재생의 함정 대응(복원력방안 2.3-부속 전제③): 처리 로직에 필요한
|
||||
* "센터마다 달라질 수 있는 값"(수신시각 등)은 순번기가 여기에 미리 박아 배포한다.
|
||||
* 따라서 각 센터는 seqEpochMillis 등 저널에 실린 값만 사용하고, 자체 시각/난수를 쓰지 않는다.
|
||||
*/
|
||||
data class JournalEntry(
|
||||
val globalSeq: Long, // 전역순번 (처리 순서의 권위)
|
||||
val seqEpochMillis: Long, // 순번기가 확정한 결정론적 수신시각(epoch ms)
|
||||
val originCenter: String, // 최초 접수 센터 (결과통보 책임 센터)
|
||||
val core: CoreMessage,
|
||||
)
|
||||
@@ -0,0 +1,26 @@
|
||||
package kr.or.bok.rtgs.common
|
||||
|
||||
/**
|
||||
* 정족수 컨텍스트 — 센터 구성과 과반(quorum) 판정.
|
||||
* 복원력방안 2.1/2.3: 홀수 3센터라야 split-brain 차단. 완결 규율은 "과반 센터가 해당 순번을
|
||||
* 확정한 시점"이 대외 효력을 갖는다.
|
||||
*
|
||||
* 로컬 1센터 개발 시 centerCount=1 → 과반=1(자기 자신)로 즉시 완결. N센터 확장 시 활성.
|
||||
*/
|
||||
data class QuorumContext(
|
||||
val centerId: String, // 이 인스턴스의 센터 (DC1/DC2/DC3)
|
||||
val centerCount: Int = 1, // 전체 근거리 동기 센터 수
|
||||
) {
|
||||
init {
|
||||
require(centerCount >= 1) { "centerCount must be >= 1" }
|
||||
}
|
||||
|
||||
/** 과반 임계값 = floor(N/2)+1. (1->1, 2->2, 3->2) */
|
||||
val quorum: Int get() = centerCount / 2 + 1
|
||||
|
||||
/** 주어진 확정 센터 수가 과반을 충족하는가. */
|
||||
fun hasQuorum(confirmations: Int): Boolean = confirmations >= quorum
|
||||
|
||||
/** 살아있는 센터 수로 서비스 지속 가능 여부(가용성): 과반 유지 시 지속. */
|
||||
fun canServe(aliveCenters: Int): Boolean = aliveCenters >= quorum
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package kr.or.bok.rtgs.common
|
||||
|
||||
/**
|
||||
* 거래 처리상태(ISO 20022 pacs.002 TxSts 계열).
|
||||
* 흐름: RCVD -> ACTC -> (PDNG) -> ACSP -> ACCC, 실패 시 RJCT.
|
||||
* - RCVD: 접수(Received) — Chanel이 인메모리에 최초 저장
|
||||
* - ACTC: 승인(AcceptedTechnicalValidation) — 검증 통과, 순번기로 전달/저널 등록
|
||||
* - PDNG: 대기(Pending) — 타 센터에서 수신되어 아직 결제 전
|
||||
* - ACSP: 예약(AcceptedSettlementInProcess) — 결제처리(원장 반영) 완료, 결과 대기
|
||||
* - ACCC: 입금처리완료(AcceptedSettlementCompleted) — 과반 확정, 최종 완결
|
||||
* - RJCT: 반려(Rejected) — 검증 실패/잔액부족 등
|
||||
*/
|
||||
enum class TxSts {
|
||||
RCVD,
|
||||
ACTC,
|
||||
PDNG,
|
||||
ACSP,
|
||||
ACCC,
|
||||
RJCT,
|
||||
;
|
||||
|
||||
companion object {
|
||||
/** 완결(대외 효력) 상태 여부. */
|
||||
fun isFinal(s: TxSts): Boolean = s == ACCC || s == RJCT
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package kr.or.bok.rtgs.common.iso20022
|
||||
|
||||
import kr.or.bok.rtgs.common.CoreMessage
|
||||
import kr.or.bok.rtgs.common.Msg
|
||||
import org.w3c.dom.Element
|
||||
import org.w3c.dom.Node
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.nio.charset.StandardCharsets
|
||||
import javax.xml.parsers.DocumentBuilderFactory
|
||||
|
||||
/**
|
||||
* ISO 20022 전문 XML <-> 객체 변환 + 핵심전문(CoreMessage) 추출.
|
||||
*
|
||||
* pacs.008.001.08은 네임스페이스가 있는 깊은 중첩 구조라 **DOM 파싱**으로 지역명(local name) 기준
|
||||
* 탐색해 핵심 항목만 뽑는다(네임스페이스 프리픽스에 견고, XXE 차단). pacs.002.001.10 응답은
|
||||
* **정식 네임스페이스 템플릿**으로 생성한다.
|
||||
*/
|
||||
object Iso20022Codec {
|
||||
|
||||
private const val PACS002_NS = "urn:iso:std:iso:20022:tech:xsd:pacs.002.001.10"
|
||||
|
||||
/** XXE 방어 + 네임스페이스 인식 DOM 파서 팩토리. */
|
||||
private val dbf: DocumentBuilderFactory = DocumentBuilderFactory.newInstance().apply {
|
||||
isNamespaceAware = true
|
||||
runCatching { setFeature("http://apache.org/xml/features/disallow-doctype-decl", true) }
|
||||
runCatching { setFeature("http://xml.org/sax/features/external-general-entities", false) }
|
||||
runCatching { setFeature("http://xml.org/sax/features/external-parameter-entities", false) }
|
||||
isXIncludeAware = false
|
||||
isExpandEntityReferences = false
|
||||
}
|
||||
|
||||
/** 접수 pacs.008 원전문에서 핵심 항목 추출. */
|
||||
fun parsePacs008(rawXml: String): Pacs008View {
|
||||
val doc = dbf.newDocumentBuilder()
|
||||
.parse(ByteArrayInputStream(rawXml.toByteArray(StandardCharsets.UTF_8)))
|
||||
doc.documentElement.normalize()
|
||||
|
||||
val tx = firstByLocal(doc.documentElement, "CdtTrfTxInf")
|
||||
?: throw IllegalArgumentException("CdtTrfTxInf 없음")
|
||||
val amtEl = firstByLocal(tx, "IntrBkSttlmAmt")
|
||||
?: throw IllegalArgumentException("IntrBkSttlmAmt 없음")
|
||||
|
||||
return Pacs008View(
|
||||
bizMsgIdr = text(firstByLocal(tx, "EndToEndId")).ifBlank { null },
|
||||
msgDefIdr = Msg.PACS008,
|
||||
from = agentMmbId(childByLocal(tx, "DbtrAgt")),
|
||||
to = agentMmbId(childByLocal(tx, "CdtrAgt")),
|
||||
dbtrAcct = acctId(childByLocal(tx, "DbtrAcct")),
|
||||
cdtrAcct = acctId(childByLocal(tx, "CdtrAcct")),
|
||||
ccy = amtEl.getAttribute("Ccy").ifBlank { "KRW" },
|
||||
amount = text(amtEl).toLongOrNull() ?: 0L,
|
||||
creDtTm = text(firstByLocal(doc.documentElement, "CreDtTm")).ifBlank { null },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 핵심 명령문 추출. bmi가 비어 있으면 접수센터가 부여한다.
|
||||
* 원전문 해시(무결성)와 함께 경량 명령문(CoreMessage)을 만든다.
|
||||
*/
|
||||
fun toCoreMessage(req: Pacs008View, rawXml: String, assignedBmi: String): CoreMessage {
|
||||
val bmi = req.bizMsgIdr?.takeIf { it.isNotBlank() } ?: assignedBmi
|
||||
return CoreMessage(
|
||||
bmi = bmi,
|
||||
msgType = req.msgDefIdr.ifBlank { Msg.PACS008 },
|
||||
senderCode = req.from,
|
||||
receiverCode = req.to,
|
||||
debtorAcct = req.dbtrAcct,
|
||||
creditorAcct = req.cdtrAcct,
|
||||
amount = req.amount,
|
||||
currency = req.ccy.ifBlank { "KRW" },
|
||||
origHash = CoreMessage.sha256Hex(rawXml),
|
||||
)
|
||||
}
|
||||
|
||||
/** pacs.002.001.10(결과통보) 정식 전문 생성. */
|
||||
fun writePacs002(msg: Pacs002): String {
|
||||
val clrSysRef = msg.clrSysRef?.let {
|
||||
"\n <ClrSysRef>${escape(it.take(35))}</ClrSysRef>"
|
||||
} ?: ""
|
||||
return """<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Document xmlns="$PACS002_NS">
|
||||
<FIToFIPmtStsRpt>
|
||||
<GrpHdr>
|
||||
<MsgId>${escape(msg.orgnlBizMsgIdr.ifBlank { "RTGS" }.take(35))}</MsgId>
|
||||
<CreDtTm>${escape(msg.creDtTm ?: "")}</CreDtTm>
|
||||
</GrpHdr>
|
||||
<TxInfAndSts>
|
||||
<OrgnlEndToEndId>${escape(msg.orgnlBizMsgIdr.take(35))}</OrgnlEndToEndId>
|
||||
<TxSts>${escape(msg.txSts)}</TxSts>$clrSysRef
|
||||
</TxInfAndSts>
|
||||
</FIToFIPmtStsRpt>
|
||||
</Document>"""
|
||||
}
|
||||
|
||||
// --- DOM 헬퍼 (지역명 기준 탐색) ---
|
||||
|
||||
/** 직계 자식 중 지역명이 일치하는 첫 요소. */
|
||||
private fun childByLocal(parent: Element?, local: String): Element? {
|
||||
if (parent == null) return null
|
||||
val kids = parent.childNodes
|
||||
for (i in 0 until kids.length) {
|
||||
val n = kids.item(i)
|
||||
if (n.nodeType == Node.ELEMENT_NODE && localName(n) == local) return n as Element
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** 하위(자손) 전체에서 지역명이 일치하는 첫 요소. */
|
||||
private fun firstByLocal(scope: Element, local: String): Element? {
|
||||
val nl = scope.getElementsByTagNameNS("*", local)
|
||||
if (nl.length > 0) return nl.item(0) as Element
|
||||
val nl2 = scope.getElementsByTagName(local) // 네임스페이스 없는 문서 대비
|
||||
return if (nl2.length > 0) nl2.item(0) as Element else null
|
||||
}
|
||||
|
||||
/** Agent(BranchAndFinancialInstitutionIdentification6) → FinInstnId/ClrSysMmbId/MmbId. */
|
||||
private fun agentMmbId(agent: Element?): String =
|
||||
text(agent?.let { firstByLocal(it, "MmbId") })
|
||||
|
||||
/** CashAccount38 → Id/Othr/Id (없으면 IBAN). */
|
||||
private fun acctId(acct: Element?): String {
|
||||
if (acct == null) return ""
|
||||
val id = childByLocal(acct, "Id") ?: return ""
|
||||
childByLocal(id, "Othr")?.let { othr -> return text(childByLocal(othr, "Id")) }
|
||||
return text(childByLocal(id, "IBAN"))
|
||||
}
|
||||
|
||||
private fun localName(n: Node): String = n.localName ?: n.nodeName.substringAfter(':')
|
||||
|
||||
private fun text(e: Element?): String = e?.textContent?.trim() ?: ""
|
||||
|
||||
private fun escape(s: String): String = s
|
||||
.replace("&", "&").replace("<", "<").replace(">", ">")
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package kr.or.bok.rtgs.common.iso20022
|
||||
|
||||
/**
|
||||
* ISO 20022 전문 모델(정식 스키마 기반, 2026-07-10 전환).
|
||||
*
|
||||
* 접수 전문은 **정식 pacs.008.001.08**(FIToFICustomerCreditTransfer), 결과통보는
|
||||
* **정식 pacs.002.001.10**(FIToFIPaymentStatusReport) 구조를 따른다. 검증은 iso20022.org가
|
||||
* 배포하는 공식 XSD(resources/iso20022/xsd/)로 수행한다.
|
||||
*
|
||||
* - pacs.008은 깊은 중첩 + 네임스페이스라 Jackson DTO 대신 **DOM 파싱**(Iso20022Codec)으로 핵심항목을
|
||||
* 추출한다(네임스페이스에 견고). 여기서는 추출 결과를 담는 경량 뷰(Pacs008View)만 정의한다.
|
||||
* - pacs.002는 응답 전용이라 **템플릿 문자열**로 정식 네임스페이스 전문을 생성한다(Pacs002 = 생성 파라미터).
|
||||
*/
|
||||
|
||||
/** pacs.008에서 추출한 핵심 항목(결제에 필요한 최소 집합). */
|
||||
data class Pacs008View(
|
||||
val bizMsgIdr: String?, // EndToEndId(거래식별자 BMI). 없으면 접수센터가 부여
|
||||
val msgDefIdr: String, // 메시지 정의(pacs.008.001.08)
|
||||
val from: String, // 송신기관 코드 = DbtrAgt/FinInstnId/ClrSysMmbId/MmbId
|
||||
val to: String, // 수신기관 코드 = CdtrAgt/FinInstnId/ClrSysMmbId/MmbId
|
||||
val dbtrAcct: String, // 의뢰인(당좌) 계좌 = DbtrAcct/Id/Othr/Id
|
||||
val cdtrAcct: String, // 수취인(당좌) 계좌 = CdtrAcct/Id/Othr/Id
|
||||
val ccy: String, // 통화 = IntrBkSttlmAmt/@Ccy
|
||||
val amount: Long, // 이체금액 = IntrBkSttlmAmt(text)
|
||||
val creDtTm: String?, // 생성시각 = GrpHdr/CreDtTm
|
||||
)
|
||||
|
||||
/** pacs.002(결과통보) 생성 파라미터. 실제 XML은 Iso20022Codec.writePacs002가 정식 구조로 직렬화. */
|
||||
data class Pacs002(
|
||||
val orgnlBizMsgIdr: String = "", // 원거래 BMI → OrgnlEndToEndId
|
||||
val msgDefIdr: String = "pacs.002.001.10",
|
||||
val from: String = "", // 정보용(GrpHdr 참고)
|
||||
val to: String = "",
|
||||
val txSts: String = "", // RCVD/ACTC/ACSP/ACCC/RJCT/PDNG → TxSts
|
||||
val clrSysRef: String? = null, // 결제시스템 참조(전역순번/사유) → ClrSysRef
|
||||
val creDtTm: String? = null,
|
||||
)
|
||||
@@ -0,0 +1,37 @@
|
||||
package kr.or.bok.rtgs.common.iso20022
|
||||
|
||||
import java.io.StringReader
|
||||
import javax.xml.XMLConstants
|
||||
import javax.xml.transform.stream.StreamSource
|
||||
import javax.xml.validation.Schema
|
||||
import javax.xml.validation.SchemaFactory
|
||||
|
||||
/**
|
||||
* XSD 기반 전문 유효성 검사(복원력방안: XSD 스키마로 접수 전문 검증).
|
||||
* **정식 ISO 20022 pacs.008.001.08 공식 XSD**(iso20022.org 배포본)로 검증한다(2026-07-10 전환).
|
||||
* XSD 리소스가 없으면 검증을 건너뛴다(개발 편의).
|
||||
*/
|
||||
class XmlValidator(private val xsdResourcePath: String = "/iso20022/xsd/pacs.008.001.08.xsd") {
|
||||
|
||||
private val schema: Schema? by lazy {
|
||||
val stream = javaClass.getResourceAsStream(xsdResourcePath) ?: return@lazy null
|
||||
val factory = SchemaFactory.newInstance(XMLConstants.W3C_XML_SCHEMA_NS_URI)
|
||||
// XXE 방어: 외부 엔티티/DTD 차단
|
||||
runCatching { factory.setProperty(XMLConstants.ACCESS_EXTERNAL_DTD, "") }
|
||||
runCatching { factory.setProperty(XMLConstants.ACCESS_EXTERNAL_SCHEMA, "") }
|
||||
stream.use { factory.newSchema(StreamSource(it)) }
|
||||
}
|
||||
|
||||
data class Result(val valid: Boolean, val error: String? = null)
|
||||
|
||||
fun validate(xml: String): Result {
|
||||
val s = schema ?: return Result(true) // XSD 미탑재 시 통과
|
||||
return try {
|
||||
val validator = s.newValidator()
|
||||
validator.validate(StreamSource(StringReader(xml)))
|
||||
Result(true)
|
||||
} catch (e: Exception) {
|
||||
Result(false, e.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package kr.or.bok.rtgs.common.ledger
|
||||
|
||||
import jakarta.persistence.Column
|
||||
import jakarta.persistence.Entity
|
||||
import jakarta.persistence.Id
|
||||
import jakarta.persistence.Table
|
||||
import kr.or.bok.rtgs.common.TxSts
|
||||
|
||||
/**
|
||||
* 권위 원장(PostgreSQL, 강한 일관성) 엔티티 — 3개 원장 서비스(Dior/Hermes/Prada)가 공유.
|
||||
* 개인(P2P) 잔액이 아니라 참가기관 당좌계좌 잔액을 기록한다(복원력방안 1.4).
|
||||
*/
|
||||
|
||||
@Entity
|
||||
@Table(name = "account")
|
||||
class Account(
|
||||
@Id
|
||||
@Column(name = "code", length = 4)
|
||||
var code: String = "",
|
||||
|
||||
@Column(name = "name")
|
||||
var name: String = "",
|
||||
|
||||
/** 당좌계좌 잔액(원). 이체 시 강한 일관성으로 증감. */
|
||||
@Column(name = "balance", nullable = false)
|
||||
var balance: Long = 0,
|
||||
)
|
||||
|
||||
@Entity
|
||||
@Table(name = "transfer")
|
||||
class TransferRecord(
|
||||
@Id
|
||||
@Column(name = "bmi", length = 22)
|
||||
var bmi: String = "",
|
||||
|
||||
@Column(name = "global_seq")
|
||||
var globalSeq: Long? = null,
|
||||
|
||||
@Column(name = "status", length = 8, nullable = false)
|
||||
@jakarta.persistence.Enumerated(jakarta.persistence.EnumType.STRING)
|
||||
var status: TxSts = TxSts.PDNG,
|
||||
|
||||
@Column(name = "sender_code", length = 4) var senderCode: String = "",
|
||||
@Column(name = "receiver_code", length = 4) var receiverCode: String = "",
|
||||
@Column(name = "amount", nullable = false) var amount: Long = 0,
|
||||
@Column(name = "origin_center", length = 8) var originCenter: String = "",
|
||||
@Column(name = "created_at") var createdAt: Long = 0,
|
||||
@Column(name = "updated_at") var updatedAt: Long = 0,
|
||||
@Column(name = "reason") var reason: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* 선(先)저널(write-ahead) 로그 — Hermes가 인메모리 잔액 연산 *전에* 순번을 여기에 확정한다.
|
||||
* 노드사 시 살아있는/재기동 센터가 이 로그로 재생해 무손실 승계(복원력방안 부록A S2).
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "journal_log")
|
||||
class JournalLog(
|
||||
@Id
|
||||
@Column(name = "global_seq")
|
||||
var globalSeq: Long = 0,
|
||||
|
||||
@Column(name = "bmi", length = 22) var bmi: String = "",
|
||||
@Column(name = "seq_epoch_millis") var seqEpochMillis: Long = 0,
|
||||
@Column(name = "applied", nullable = false) var applied: Boolean = false,
|
||||
)
|
||||
|
||||
/**
|
||||
* 원전문 저장(로컬은 PostgreSQL text 컬럼; 클라우드/BMT는 문서형 DB(MongoDB)).
|
||||
* 참가기관 pacs.008 원본 XML을 보관 — 센터 간엔 경량명령문만 복제.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "raw_message")
|
||||
class RawMessageEntity(
|
||||
@Id @Column(name = "bmi", length = 22) var bmi: String = "",
|
||||
@Column(name = "msg_type", length = 32) var msgType: String = "",
|
||||
@Column(name = "raw_xml", columnDefinition = "text") var rawXml: String = "",
|
||||
@Column(name = "orig_hash", length = 64) var origHash: String = "",
|
||||
@Column(name = "received_at") var receivedAt: Long = 0,
|
||||
@Column(name = "origin_center", length = 8) var originCenter: String = "",
|
||||
)
|
||||
|
||||
/**
|
||||
* 결과통보 아웃박스 — 접수센터(Chanel)가 신청/수취기관 앞 송부한 결과 전문(pacs.002) 기록.
|
||||
* id = bmi:수신기관 (기관별 1건, 재송부 시 멱등 upsert). 로컬은 실제 외부 송신 대신 기록+로그로
|
||||
* 송부를 모사한다(실외부 전송/콜백은 향후 Gucci 관문 담당).
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "notification")
|
||||
class NotificationEntity(
|
||||
@Id @Column(name = "id", length = 32) var id: String = "",
|
||||
@Column(name = "bmi", length = 22) var bmi: String = "",
|
||||
@Column(name = "to_org", length = 4) var toOrg: String = "", // 수신 대상 기관
|
||||
@Column(name = "role", length = 12) var role: String = "", // APPLICANT(신청기관) / BENEFICIARY(수취기관)
|
||||
@Column(name = "final_status", length = 8) var finalStatus: String = "",
|
||||
@Column(name = "pacs002_xml", columnDefinition = "text") var pacs002Xml: String = "",
|
||||
@Column(name = "origin_center", length = 8) var originCenter: String = "",
|
||||
@Column(name = "reason") var reason: String? = null,
|
||||
@Column(name = "delivered", nullable = false) var delivered: Boolean = false,
|
||||
@Column(name = "attempts", nullable = false) var attempts: Int = 0, // 송부 시도 횟수(재시도)
|
||||
@Column(name = "created_at") var createdAt: Long = 0,
|
||||
@Column(name = "delivered_at") var deliveredAt: Long? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* 조회 전용 사본(로컬은 PostgreSQL; 클라우드/BMT는 문서형 DB). 결과동기화(Prada)가 최종 확정 기록.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "settlement_view")
|
||||
class SettlementViewEntity(
|
||||
@Id @Column(name = "bmi", length = 22) var bmi: String = "",
|
||||
@Column(name = "global_seq") var globalSeq: Long? = null,
|
||||
@Column(name = "final_status", length = 8) var finalStatus: String = "",
|
||||
@Column(name = "sender_code", length = 4) var senderCode: String = "",
|
||||
@Column(name = "receiver_code", length = 4) var receiverCode: String = "",
|
||||
@Column(name = "amount") var amount: Long = 0,
|
||||
@Column(name = "origin_center", length = 8) var originCenter: String = "",
|
||||
@Column(name = "processed_center", length = 8) var processedCenter: String = "",
|
||||
@Column(name = "debtor_balance_after") var debtorBalanceAfter: Long? = null,
|
||||
@Column(name = "creditor_balance_after") var creditorBalanceAfter: Long? = null,
|
||||
@Column(name = "finalized_at") var finalizedAt: Long = 0,
|
||||
)
|
||||
@@ -0,0 +1,56 @@
|
||||
package kr.or.bok.rtgs.common.ops
|
||||
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.boot.autoconfigure.AutoConfiguration
|
||||
import org.springframework.boot.autoconfigure.AutoConfigureAfter
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnSingleCandidate
|
||||
import org.springframework.context.annotation.Bean
|
||||
import org.springframework.jdbc.core.JdbcTemplate
|
||||
import org.springframework.scheduling.annotation.EnableScheduling
|
||||
import org.springframework.scheduling.annotation.Scheduled
|
||||
import javax.sql.DataSource
|
||||
|
||||
/**
|
||||
* B5 서비스 하트비트 — 모든 서비스(공통 자동설정)가 주기적으로 `service_heartbeat`에 생존 신호를 남긴다.
|
||||
* 헤드리스(Sequencer/Dior/Hermes/Prada) 포함 전 서비스의 liveness를 **DB로** 확인(LouisVuitton 대시보드).
|
||||
* DataSource가 있는 서비스에만 활성화. 스키마(service_heartbeat)는 Flyway가 보장.
|
||||
*/
|
||||
@AutoConfiguration
|
||||
@AutoConfigureAfter(name = ["org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration"])
|
||||
@ConditionalOnSingleCandidate(DataSource::class)
|
||||
@EnableScheduling
|
||||
open class HeartbeatAutoConfiguration {
|
||||
@Bean
|
||||
open fun heartbeatWriter(
|
||||
dataSource: DataSource,
|
||||
@Value("\${spring.application.name:rtgs}") service: String,
|
||||
@Value("\${rtgs.center-id:DC1}") center: String,
|
||||
): HeartbeatWriter = HeartbeatWriter(JdbcTemplate(dataSource), service, center)
|
||||
}
|
||||
|
||||
class HeartbeatWriter(
|
||||
private val jdbc: JdbcTemplate,
|
||||
private val service: String,
|
||||
private val center: String,
|
||||
) {
|
||||
private val log = LoggerFactory.getLogger(javaClass)
|
||||
private val pid = ProcessHandle.current().pid()
|
||||
|
||||
@Scheduled(fixedDelayString = "\${rtgs.heartbeat-ms:5000}")
|
||||
fun beat() {
|
||||
try {
|
||||
jdbc.update(
|
||||
"""
|
||||
INSERT INTO service_heartbeat(service, center, last_seen, pid)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT (service, center) DO UPDATE SET last_seen = EXCLUDED.last_seen, pid = EXCLUDED.pid
|
||||
""".trimIndent(),
|
||||
service, center, System.currentTimeMillis(), pid,
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
// 기동 초기 테이블 미생성 등 일시 오류는 다음 주기에 자동 회복(노이즈 최소화).
|
||||
log.debug("heartbeat skip: {}", e.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
kr.or.bok.rtgs.common.ops.HeartbeatAutoConfiguration
|
||||
1127
backend/common/src/main/resources/iso20022/xsd/pacs.002.001.10.xsd
Normal file
1127
backend/common/src/main/resources/iso20022/xsd/pacs.002.001.10.xsd
Normal file
File diff suppressed because it is too large
Load Diff
1120
backend/common/src/main/resources/iso20022/xsd/pacs.008.001.08.xsd
Normal file
1120
backend/common/src/main/resources/iso20022/xsd/pacs.008.001.08.xsd
Normal file
File diff suppressed because it is too large
Load Diff
32
backend/common/src/main/resources/logback-spring.xml
Normal file
32
backend/common/src/main/resources/logback-spring.xml
Normal file
@@ -0,0 +1,32 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
RTGS 공통 로그 설정(전 서비스 공유). 콘솔 + Logstash(TCP :5000, JSON) 전송.
|
||||
Logstash가 안 떠 있어도 TCP appender가 논블로킹으로 재연결하므로 서비스는 정상 동작.
|
||||
-->
|
||||
<configuration>
|
||||
<include resource="org/springframework/boot/logging/logback/defaults.xml"/>
|
||||
|
||||
<springProperty scope="context" name="appName" source="spring.application.name" defaultValue="rtgs"/>
|
||||
<springProperty scope="context" name="centerId" source="rtgs.center-id" defaultValue="DC1"/>
|
||||
|
||||
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
|
||||
<encoder>
|
||||
<pattern>${CONSOLE_LOG_PATTERN}</pattern>
|
||||
<charset>UTF-8</charset>
|
||||
</encoder>
|
||||
</appender>
|
||||
|
||||
<appender name="LOGSTASH" class="net.logstash.logback.appender.LogstashTcpSocketAppender">
|
||||
<destination>localhost:5000</destination>
|
||||
<keepAliveDuration>1 minute</keepAliveDuration>
|
||||
<reconnectionDelay>5 second</reconnectionDelay>
|
||||
<encoder class="net.logstash.logback.encoder.LogstashEncoder">
|
||||
<customFields>{"service":"${appName}","center":"${centerId}"}</customFields>
|
||||
</encoder>
|
||||
</appender>
|
||||
|
||||
<root level="INFO">
|
||||
<appender-ref ref="CONSOLE"/>
|
||||
<appender-ref ref="LOGSTASH"/>
|
||||
</root>
|
||||
</configuration>
|
||||
@@ -0,0 +1,75 @@
|
||||
package kr.or.bok.rtgs.common
|
||||
|
||||
import kr.or.bok.rtgs.common.iso20022.Iso20022Codec
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFailsWith
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class CommonTest {
|
||||
|
||||
@Test
|
||||
fun `bmi is 22 digits and increments`() {
|
||||
val gen = BmiGenerator("1001")
|
||||
val a = gen.next()
|
||||
val b = gen.next()
|
||||
assertEquals(22, a.length)
|
||||
assertEquals("1001", BmiGenerator.orgCodeOf(a))
|
||||
assertTrue(b > a)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `quorum thresholds`() {
|
||||
assertEquals(1, QuorumContext("DC1", 1).quorum)
|
||||
assertEquals(2, QuorumContext("DC1", 3).quorum)
|
||||
assertTrue(QuorumContext("DC1", 3).canServe(2)) // 1센터 다운 -> 지속
|
||||
assertTrue(!QuorumContext("DC1", 3).canServe(1)) // 2센터 다운 -> 장애
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `core message rejects self-transfer and non-positive amount`() {
|
||||
assertFailsWith<IllegalArgumentException> {
|
||||
CoreMessage("b", "t", "1001", "1001", "d", "c", 100, origHash = "h")
|
||||
}
|
||||
assertFailsWith<IllegalArgumentException> {
|
||||
CoreMessage("b", "t", "1001", "1002", "d", "c", 0, origHash = "h")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parse official pacs008 and extract core message`() {
|
||||
val xml = """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Document xmlns="urn:iso:std:iso:20022:tech:xsd:pacs.008.001.08">
|
||||
<FIToFICstmrCdtTrf>
|
||||
<GrpHdr>
|
||||
<MsgId>2026070810010000000001</MsgId>
|
||||
<CreDtTm>2026-07-08T17:00:00</CreDtTm>
|
||||
<NbOfTxs>1</NbOfTxs>
|
||||
<SttlmInf><SttlmMtd>CLRG</SttlmMtd></SttlmInf>
|
||||
</GrpHdr>
|
||||
<CdtTrfTxInf>
|
||||
<PmtId><EndToEndId>2026070810010000000001</EndToEndId></PmtId>
|
||||
<IntrBkSttlmAmt Ccy="KRW">150</IntrBkSttlmAmt>
|
||||
<ChrgBr>SLEV</ChrgBr>
|
||||
<Dbtr><Nm>BANK-1001</Nm></Dbtr>
|
||||
<DbtrAcct><Id><Othr><Id>ACC-1001</Id></Othr></Id></DbtrAcct>
|
||||
<DbtrAgt><FinInstnId><ClrSysMmbId><MmbId>1001</MmbId></ClrSysMmbId></FinInstnId></DbtrAgt>
|
||||
<CdtrAgt><FinInstnId><ClrSysMmbId><MmbId>1002</MmbId></ClrSysMmbId></FinInstnId></CdtrAgt>
|
||||
<Cdtr><Nm>BANK-1002</Nm></Cdtr>
|
||||
<CdtrAcct><Id><Othr><Id>ACC-1002</Id></Othr></Id></CdtrAcct>
|
||||
</CdtTrfTxInf>
|
||||
</FIToFICstmrCdtTrf>
|
||||
</Document>
|
||||
""".trimIndent()
|
||||
val req = Iso20022Codec.parsePacs008(xml)
|
||||
assertEquals("1001", req.from)
|
||||
assertEquals("1002", req.to)
|
||||
assertEquals("ACC-1001", req.dbtrAcct)
|
||||
assertEquals(150, req.amount)
|
||||
assertEquals("KRW", req.ccy)
|
||||
val core = Iso20022Codec.toCoreMessage(req, xml, assignedBmi = "X")
|
||||
assertEquals("2026070810010000000001", core.bmi)
|
||||
assertEquals(64, core.origHash.length) // SHA-256 hex
|
||||
}
|
||||
}
|
||||
20
backend/dior/build.gradle.kts
Normal file
20
backend/dior/build.gradle.kts
Normal file
@@ -0,0 +1,20 @@
|
||||
plugins {
|
||||
kotlin("jvm")
|
||||
kotlin("plugin.spring")
|
||||
id("org.springframework.boot")
|
||||
id("io.spring.dependency-management")
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":common"))
|
||||
implementation("org.springframework.boot:spring-boot-starter")
|
||||
implementation("org.springframework.boot:spring-boot-starter-actuator")
|
||||
implementation("org.springframework.boot:spring-boot-starter-web") // 메트릭/헬스 노출(B4)
|
||||
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
|
||||
implementation("org.springframework.kafka:spring-kafka")
|
||||
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
|
||||
implementation("org.jetbrains.kotlin:kotlin-reflect")
|
||||
runtimeOnly("org.postgresql:postgresql")
|
||||
testImplementation("org.springframework.boot:spring-boot-starter-test")
|
||||
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package kr.or.bok.rtgs.dior
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import kr.or.bok.rtgs.common.ledger.TransferRecord
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication
|
||||
import org.springframework.boot.autoconfigure.domain.EntityScan
|
||||
import org.springframework.boot.runApplication
|
||||
import org.springframework.context.annotation.Bean
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
import org.springframework.data.jpa.repository.Modifying
|
||||
import org.springframework.data.jpa.repository.Query
|
||||
import org.springframework.data.jpa.repository.config.EnableJpaRepositories
|
||||
import org.springframework.data.repository.query.Param
|
||||
|
||||
@SpringBootApplication
|
||||
@EntityScan("kr.or.bok.rtgs.common.ledger")
|
||||
@EnableJpaRepositories(considerNestedRepositories = true)
|
||||
class DiorApplication {
|
||||
@Bean
|
||||
fun objectMapper(): ObjectMapper = jacksonObjectMapper()
|
||||
}
|
||||
|
||||
interface TransferRepository : JpaRepository<TransferRecord, String> {
|
||||
/**
|
||||
* 접수내역을 PDNG로 삽입하되, 이미 존재하면(=Hermes/타 소비자가 먼저 만든 경우) 아무것도 안 함.
|
||||
* 원자적 upsert라 Dior-Hermes 동시 삽입 경합에서 중복키 예외가 발생하지 않는다.
|
||||
*/
|
||||
@Modifying
|
||||
@Query(
|
||||
value = """
|
||||
INSERT INTO transfer
|
||||
(bmi, global_seq, status, sender_code, receiver_code, amount, origin_center, created_at, updated_at)
|
||||
VALUES
|
||||
(:bmi, :seq, 'PDNG', :sender, :receiver, :amount, :origin, :createdAt, :updatedAt)
|
||||
ON CONFLICT (bmi) DO NOTHING
|
||||
""",
|
||||
nativeQuery = true,
|
||||
)
|
||||
fun insertPdngIfAbsent(
|
||||
@Param("bmi") bmi: String,
|
||||
@Param("seq") seq: Long,
|
||||
@Param("sender") sender: String,
|
||||
@Param("receiver") receiver: String,
|
||||
@Param("amount") amount: Long,
|
||||
@Param("origin") origin: String,
|
||||
@Param("createdAt") createdAt: Long,
|
||||
@Param("updatedAt") updatedAt: Long,
|
||||
): Int
|
||||
}
|
||||
|
||||
fun main(args: Array<String>) {
|
||||
runApplication<DiorApplication>(*args)
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package kr.or.bok.rtgs.dior
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import kr.or.bok.rtgs.common.JournalEntry
|
||||
import kr.or.bok.rtgs.common.Topics
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.kafka.annotation.KafkaListener
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
|
||||
/**
|
||||
* DIOR(접수동기화) — 저널(rtgs.journal)을 구독해 접수 내역을 권위 원장(PostgreSQL)에
|
||||
* 대기상태(PDNG)로 영구 저장한다. BMI 중복체크로 멱등성 보장(센터 간 동일 저널 재생).
|
||||
*/
|
||||
@Service
|
||||
class DiorService(
|
||||
private val transfers: TransferRepository,
|
||||
private val mapper: ObjectMapper,
|
||||
) {
|
||||
private val log = LoggerFactory.getLogger(javaClass)
|
||||
|
||||
// 센터별 그룹(dior-DC1/DC2/DC3): 각 센터가 저널 전량을 독립 재생.
|
||||
@KafkaListener(topics = [Topics.JOURNAL], groupId = "dior-\${rtgs.center-id:DC1}", concurrency = "1")
|
||||
@Transactional
|
||||
fun onJournal(payload: String) {
|
||||
val e = mapper.readValue(payload, JournalEntry::class.java)
|
||||
// 원자적 upsert: 이미 있으면(Hermes/타 소비자가 먼저 만든 경우) 아무 일도 안 함 → 중복키 예외 없음.
|
||||
val inserted = transfers.insertPdngIfAbsent(
|
||||
bmi = e.core.bmi,
|
||||
seq = e.globalSeq,
|
||||
sender = e.core.senderCode,
|
||||
receiver = e.core.receiverCode,
|
||||
amount = e.core.amount,
|
||||
origin = e.originCenter,
|
||||
createdAt = e.seqEpochMillis,
|
||||
updatedAt = System.currentTimeMillis(),
|
||||
)
|
||||
if (inserted > 0) log.info("PDNG seq=#{} bmi={}", e.globalSeq, e.core.bmi)
|
||||
else log.debug("PDNG skip(exists) seq=#{} bmi={}", e.globalSeq, e.core.bmi)
|
||||
}
|
||||
}
|
||||
30
backend/dior/src/main/resources/application.yml
Normal file
30
backend/dior/src/main/resources/application.yml
Normal file
@@ -0,0 +1,30 @@
|
||||
spring:
|
||||
application:
|
||||
name: dior
|
||||
datasource:
|
||||
url: jdbc:postgresql://${POSTGRES_HOST:localhost}:${POSTGRES_PORT:5433}/${POSTGRES_DB:rtgs}
|
||||
username: ${POSTGRES_USER:rtgs}
|
||||
password: ${POSTGRES_PASSWORD:rtgs}
|
||||
jpa:
|
||||
hibernate:
|
||||
ddl-auto: none
|
||||
open-in-view: false
|
||||
kafka:
|
||||
bootstrap-servers: ${KAFKA_BOOTSTRAP:localhost:9092}
|
||||
consumer:
|
||||
group-id: dior
|
||||
auto-offset-reset: earliest
|
||||
key-deserializer: org.apache.kafka.common.serialization.StringDeserializer
|
||||
value-deserializer: org.apache.kafka.common.serialization.StringDeserializer
|
||||
|
||||
server:
|
||||
port: 8092
|
||||
|
||||
rtgs:
|
||||
center-id: ${CENTER_ID:DC1}
|
||||
|
||||
management:
|
||||
endpoints:
|
||||
web:
|
||||
exposure:
|
||||
include: health,info,prometheus,metrics
|
||||
13
backend/gradle.properties
Normal file
13
backend/gradle.properties
Normal file
@@ -0,0 +1,13 @@
|
||||
org.gradle.jvmargs=-Xmx1536m -Dfile.encoding=UTF-8
|
||||
org.gradle.parallel=true
|
||||
org.gradle.caching=true
|
||||
kotlin.code.style=official
|
||||
|
||||
# rtgs 빌드는 반드시 JDK21 (Gradle 8.10.2는 JDK26 미지원). CLI/IDE import 모두 이 JDK 사용.
|
||||
org.gradle.java.home=C:/ai-dev/apps/jdk-21
|
||||
|
||||
# Dependency versions (kept in sync with cloud PoC 2/3차)
|
||||
springBootVersion=3.3.4
|
||||
kotlinVersion=2.0.20
|
||||
springDepMgmtVersion=1.1.6
|
||||
hazelcastVersion=5.5.0
|
||||
BIN
backend/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
BIN
backend/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
Binary file not shown.
8
backend/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
8
backend/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
#Wed Jul 08 17:43:33 KST 2026
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-bin.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
252
backend/gradlew
vendored
Normal file
252
backend/gradlew
vendored
Normal file
@@ -0,0 +1,252 @@
|
||||
#!/bin/sh
|
||||
|
||||
#
|
||||
# Copyright © 2015-2021 the original authors.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
#
|
||||
|
||||
##############################################################################
|
||||
#
|
||||
# Gradle start up script for POSIX generated by Gradle.
|
||||
#
|
||||
# Important for running:
|
||||
#
|
||||
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||
# noncompliant, but you have some other compliant shell such as ksh or
|
||||
# bash, then to run this script, type that shell name before the whole
|
||||
# command line, like:
|
||||
#
|
||||
# ksh Gradle
|
||||
#
|
||||
# Busybox and similar reduced shells will NOT work, because this script
|
||||
# requires all of these POSIX shell features:
|
||||
# * functions;
|
||||
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||
# * compound commands having a testable exit status, especially «case»;
|
||||
# * various built-in commands including «command», «set», and «ulimit».
|
||||
#
|
||||
# Important for patching:
|
||||
#
|
||||
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||
#
|
||||
# The "traditional" practice of packing multiple parameters into a
|
||||
# space-separated string is a well documented source of bugs and security
|
||||
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||
# options in "$@", and eventually passing that to Java.
|
||||
#
|
||||
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||
# see the in-line comments for details.
|
||||
#
|
||||
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||
# Darwin, MinGW, and NonStop.
|
||||
#
|
||||
# (3) This script is generated from the Groovy template
|
||||
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||
# within the Gradle project.
|
||||
#
|
||||
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||
#
|
||||
##############################################################################
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
|
||||
# Resolve links: $0 may be a link
|
||||
app_path=$0
|
||||
|
||||
# Need this for daisy-chained symlinks.
|
||||
while
|
||||
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||
[ -h "$app_path" ]
|
||||
do
|
||||
ls=$( ls -ld "$app_path" )
|
||||
link=${ls#*' -> '}
|
||||
case $link in #(
|
||||
/*) app_path=$link ;; #(
|
||||
*) app_path=$APP_HOME$link ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# This is normally unused
|
||||
# shellcheck disable=SC2034
|
||||
APP_BASE_NAME=${0##*/}
|
||||
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s
|
||||
' "$PWD" ) || exit
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD=maximum
|
||||
|
||||
warn () {
|
||||
echo "$*"
|
||||
} >&2
|
||||
|
||||
die () {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
} >&2
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
nonstop=false
|
||||
case "$( uname )" in #(
|
||||
CYGWIN* ) cygwin=true ;; #(
|
||||
Darwin* ) darwin=true ;; #(
|
||||
MSYS* | MINGW* ) msys=true ;; #(
|
||||
NONSTOP* ) nonstop=true ;;
|
||||
esac
|
||||
|
||||
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
||||
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||
else
|
||||
JAVACMD=$JAVA_HOME/bin/java
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
else
|
||||
JAVACMD=java
|
||||
if ! command -v java >/dev/null 2>&1
|
||||
then
|
||||
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||
case $MAX_FD in #(
|
||||
max*)
|
||||
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
MAX_FD=$( ulimit -H -n ) ||
|
||||
warn "Could not query maximum file descriptor limit"
|
||||
esac
|
||||
case $MAX_FD in #(
|
||||
'' | soft) :;; #(
|
||||
*)
|
||||
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
ulimit -n "$MAX_FD" ||
|
||||
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||
esac
|
||||
fi
|
||||
|
||||
# Collect all arguments for the java command, stacking in reverse order:
|
||||
# * args from the command line
|
||||
# * the main class name
|
||||
# * -classpath
|
||||
# * -D...appname settings
|
||||
# * --module-path (only if needed)
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||
|
||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||
if "$cygwin" || "$msys" ; then
|
||||
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
|
||||
|
||||
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
for arg do
|
||||
if
|
||||
case $arg in #(
|
||||
-*) false ;; # don't mess with options #(
|
||||
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||
[ -e "$t" ] ;; #(
|
||||
*) false ;;
|
||||
esac
|
||||
then
|
||||
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||
fi
|
||||
# Roll the args list around exactly as many times as the number of
|
||||
# args, so each arg winds up back in the position where it started, but
|
||||
# possibly modified.
|
||||
#
|
||||
# NB: a `for` loop captures its iteration list before it begins, so
|
||||
# changing the positional parameters here affects neither the number of
|
||||
# iterations, nor the values presented in `arg`.
|
||||
shift # remove old arg
|
||||
set -- "$@" "$arg" # push replacement arg
|
||||
done
|
||||
fi
|
||||
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||
|
||||
# Collect all arguments for the java command:
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||
# and any embedded shellness will be escaped.
|
||||
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||
# treated as '${Hostname}' itself on the command line.
|
||||
|
||||
set -- \
|
||||
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||
-classpath "$CLASSPATH" \
|
||||
org.gradle.wrapper.GradleWrapperMain \
|
||||
"$@"
|
||||
|
||||
# Stop when "xargs" is not available.
|
||||
if ! command -v xargs >/dev/null 2>&1
|
||||
then
|
||||
die "xargs is not available"
|
||||
fi
|
||||
|
||||
# Use "xargs" to parse quoted args.
|
||||
#
|
||||
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||
#
|
||||
# In Bash we could simply go:
|
||||
#
|
||||
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||
# set -- "${ARGS[@]}" "$@"
|
||||
#
|
||||
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||
# character that might be a shell metacharacter, then use eval to reverse
|
||||
# that process (while maintaining the separation between arguments), and wrap
|
||||
# the whole thing up as a single "set" statement.
|
||||
#
|
||||
# This will of course break if any of these variables contains a newline or
|
||||
# an unmatched quote.
|
||||
#
|
||||
|
||||
eval "set -- $(
|
||||
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||
xargs -n1 |
|
||||
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||
tr '\n' ' '
|
||||
)" '"$@"'
|
||||
|
||||
exec "$JAVACMD" "$@"
|
||||
94
backend/gradlew.bat
vendored
Normal file
94
backend/gradlew.bat
vendored
Normal file
@@ -0,0 +1,94 @@
|
||||
@rem
|
||||
@rem Copyright 2015 the original author or authors.
|
||||
@rem
|
||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@rem you may not use this file except in compliance with the License.
|
||||
@rem You may obtain a copy of the License at
|
||||
@rem
|
||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||
@rem
|
||||
@rem Unless required by applicable law or agreed to in writing, software
|
||||
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
@rem See the License for the specific language governing permissions and
|
||||
@rem limitations under the License.
|
||||
@rem
|
||||
@rem SPDX-License-Identifier: Apache-2.0
|
||||
@rem
|
||||
|
||||
@if "%DEBUG%"=="" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%"=="" set DIRNAME=.
|
||||
@rem This is normally unused
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if %ERRORLEVEL% equ 0 goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
||||
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
set EXIT_CODE=%ERRORLEVEL%
|
||||
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||
exit /b %EXIT_CODE%
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
20
backend/gucci/build.gradle.kts
Normal file
20
backend/gucci/build.gradle.kts
Normal file
@@ -0,0 +1,20 @@
|
||||
plugins {
|
||||
kotlin("jvm")
|
||||
kotlin("plugin.spring")
|
||||
id("org.springframework.boot")
|
||||
id("io.spring.dependency-management")
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":common"))
|
||||
implementation("org.springframework.boot:spring-boot-starter-web")
|
||||
implementation("org.springframework.boot:spring-boot-starter-actuator")
|
||||
implementation("org.springframework.boot:spring-boot-starter-jdbc")
|
||||
implementation("org.springframework.security:spring-security-crypto") // BCrypt(경량, 전체 Security 미도입)
|
||||
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
|
||||
implementation("org.jetbrains.kotlin:kotlin-reflect")
|
||||
runtimeOnly("org.postgresql:postgresql")
|
||||
testImplementation("org.springframework.boot:spring-boot-starter-test")
|
||||
testImplementation("org.jetbrains.kotlin:kotlin-test-junit5")
|
||||
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package kr.or.bok.rtgs.gucci
|
||||
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.jdbc.core.JdbcTemplate
|
||||
import org.springframework.security.crypto.password.PasswordEncoder
|
||||
import org.springframework.stereotype.Service
|
||||
|
||||
/** 로그인 실패(자격증명 불일치). */
|
||||
class AuthFailedException(msg: String) : RuntimeException(msg)
|
||||
|
||||
/**
|
||||
* 참가기관/관리자 로그인 인증. 자격증명(비밀키)은 **LouisVuitton이 관리하는 app_user**(권한 마스터)에서
|
||||
* 확인한다(인증=Gucci, 권한마스터=LouisVuitton). 비밀키는 **BCrypt 해시**로 저장·대조. 인증 성공 시 JWT 발급.
|
||||
*/
|
||||
@Service
|
||||
class AuthService(
|
||||
private val jdbc: JdbcTemplate,
|
||||
private val jwt: JwtService,
|
||||
private val encoder: PasswordEncoder,
|
||||
) {
|
||||
private val log = LoggerFactory.getLogger(javaClass)
|
||||
|
||||
data class LoginResult(
|
||||
val token: String, val role: String, val orgCode: String?,
|
||||
val expiresIn: Long, val mustChangePassword: Boolean,
|
||||
)
|
||||
|
||||
fun login(username: String, secret: String, nowMs: Long): LoginResult {
|
||||
val rows = jdbc.queryForList(
|
||||
"SELECT role, org_code, secret, must_change_password FROM app_user WHERE username = ?", username,
|
||||
)
|
||||
val row = rows.firstOrNull() ?: run {
|
||||
log.warn("AUTH login FAIL user={} reason=unknown-user", username)
|
||||
throw AuthFailedException("invalid credentials")
|
||||
}
|
||||
val stored = row["secret"] as? String
|
||||
if (stored.isNullOrBlank() || !encoder.matches(secret, stored)) {
|
||||
log.warn("AUTH login FAIL user={} reason=bad-secret", username)
|
||||
throw AuthFailedException("invalid credentials")
|
||||
}
|
||||
val role = row["role"] as? String ?: ""
|
||||
val orgCode = row["org_code"] as? String
|
||||
val mustChange = row["must_change_password"] as? Boolean ?: false
|
||||
val token = jwt.issue(Principal(username, role, orgCode), nowMs)
|
||||
log.info("AUTH login OK user={} role={} org={} mustChange={}", username, role, orgCode, mustChange)
|
||||
return LoginResult(token, role, orgCode, jwt.ttl(), mustChange)
|
||||
}
|
||||
|
||||
/** 초기암호(또는 비밀번호) 변경. 기존 비번 확인 후 새 비번(BCrypt) 저장 + 변경필요 해제. */
|
||||
fun changePassword(username: String, oldSecret: String, newSecret: String) {
|
||||
if (newSecret.length < 1) throw AuthFailedException("new secret too short")
|
||||
val rows = jdbc.queryForList("SELECT secret FROM app_user WHERE username = ?", username)
|
||||
val stored = rows.firstOrNull()?.get("secret") as? String
|
||||
?: throw AuthFailedException("invalid credentials")
|
||||
if (!encoder.matches(oldSecret, stored)) {
|
||||
log.warn("AUTH changePw FAIL user={} reason=bad-old", username)
|
||||
throw AuthFailedException("invalid credentials")
|
||||
}
|
||||
jdbc.update(
|
||||
"UPDATE app_user SET secret = ?, must_change_password = false WHERE username = ?",
|
||||
encoder.encode(newSecret), username,
|
||||
)
|
||||
log.info("AUTH changePw OK user={}", username)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package kr.or.bok.rtgs.gucci
|
||||
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.http.HttpStatus
|
||||
import org.springframework.http.MediaType
|
||||
import org.springframework.jdbc.core.JdbcTemplate
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
import org.springframework.web.bind.annotation.PathVariable
|
||||
import org.springframework.web.bind.annotation.PostMapping
|
||||
import org.springframework.web.bind.annotation.PutMapping
|
||||
import org.springframework.web.bind.annotation.RequestBody
|
||||
import org.springframework.web.bind.annotation.RequestHeader
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
import org.springframework.web.server.ResponseStatusException
|
||||
|
||||
/**
|
||||
* 콜백 레지스트리 관리(관리자) + 기관 수신 sink(데모).
|
||||
* - 레지스트리: 기관별 결과통보 수신 URL 조회/등록.
|
||||
* - sink: 로컬 데모용 "기관 수신 서버" 모사 — pacs.002를 받아 ACK(200) 응답.
|
||||
*/
|
||||
@RestController
|
||||
class CallbackController(
|
||||
private val jdbc: JdbcTemplate,
|
||||
private val jwt: JwtService,
|
||||
) {
|
||||
private val log = LoggerFactory.getLogger(javaClass)
|
||||
|
||||
data class CallbackReq(val callbackUrl: String = "", val active: Boolean = true)
|
||||
|
||||
/** 콜백 레지스트리 목록(관리자). */
|
||||
@GetMapping("/gucci/callbacks", produces = [MediaType.APPLICATION_JSON_VALUE])
|
||||
fun list(@RequestHeader("Authorization", required = false) authz: String?): List<Map<String, Any?>> {
|
||||
requireAdmin(authz)
|
||||
return jdbc.queryForList("SELECT org_code, callback_url, active FROM institution_endpoint ORDER BY org_code")
|
||||
}
|
||||
|
||||
/** 기관 콜백 URL 등록/수정(관리자). */
|
||||
@PutMapping("/gucci/callbacks/{org}", consumes = [MediaType.APPLICATION_JSON_VALUE])
|
||||
fun upsert(
|
||||
@PathVariable org: String,
|
||||
@RequestBody req: CallbackReq,
|
||||
@RequestHeader("Authorization", required = false) authz: String?,
|
||||
): Map<String, Any?> {
|
||||
requireAdmin(authz)
|
||||
jdbc.update(
|
||||
"INSERT INTO institution_endpoint(org_code, callback_url, active, created_at) VALUES (?,?,?,?) " +
|
||||
"ON CONFLICT (org_code) DO UPDATE SET callback_url = EXCLUDED.callback_url, active = EXCLUDED.active",
|
||||
org, req.callbackUrl, req.active, System.currentTimeMillis(),
|
||||
)
|
||||
log.info("CALLBACK upsert org={} url={} active={}", org, req.callbackUrl, req.active)
|
||||
return mapOf("ok" to true, "orgCode" to org, "callbackUrl" to req.callbackUrl, "active" to req.active)
|
||||
}
|
||||
|
||||
/**
|
||||
* 기관 수신 sink(데모) — 실제 참가기관 수신 서버 대역. pacs.002를 받아 로그 후 ACK.
|
||||
* 로컬 왕복 시연용(운영에선 각 기관의 실제 엔드포인트).
|
||||
*/
|
||||
@PostMapping("/gucci/sink/{org}", consumes = [MediaType.APPLICATION_XML_VALUE, MediaType.TEXT_XML_VALUE])
|
||||
fun sink(@PathVariable org: String, @RequestBody body: String): Map<String, Any?> {
|
||||
log.info("SINK 수신 org={} bytes={} (기관 수신 모사 → ACK)", org, body.length)
|
||||
return mapOf("ack" to true, "org" to org, "received" to body.length)
|
||||
}
|
||||
|
||||
private fun requireAdmin(authz: String?) {
|
||||
val token = authz?.removePrefix("Bearer ")?.trim()
|
||||
?: throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "missing bearer token")
|
||||
val p = try { jwt.verify(token, System.currentTimeMillis()) }
|
||||
catch (e: JwtException) { throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "invalid token") }
|
||||
if (p.role != "ADMIN") throw ResponseStatusException(HttpStatus.FORBIDDEN, "admin only")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package kr.or.bok.rtgs.gucci
|
||||
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.http.MediaType
|
||||
import org.springframework.jdbc.core.JdbcTemplate
|
||||
import org.springframework.scheduling.annotation.Scheduled
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.web.client.RestClient
|
||||
|
||||
/**
|
||||
* G2 결과 콜백 송부(전달 보장) — Chanel이 아웃박스(notification, delivered=false)에 적재한 결과 전문을
|
||||
* 기관별 콜백 URL로 **POST 송부**하고, 2xx ACK 시 delivered=true로 확정한다. 실패는 attempts를 늘려
|
||||
* 다음 주기에 **재시도**하며, 최대 시도 초과분은 보류(운영 알람 대상). 폴링 아웃박스 방식이라
|
||||
* 서비스 재기동에도 유실 없이 이어서 송부한다(at-least-once + 수신측 dedup 전제).
|
||||
*/
|
||||
@Service
|
||||
class DeliveryService(
|
||||
private val jdbc: JdbcTemplate,
|
||||
private val http: RestClient,
|
||||
@Value("\${gucci.delivery-max-attempts:5}") private val maxAttempts: Int,
|
||||
@Value("\${gucci.delivery-batch:50}") private val batch: Int,
|
||||
) {
|
||||
private val log = LoggerFactory.getLogger(javaClass)
|
||||
|
||||
@Scheduled(fixedDelayString = "\${gucci.delivery-interval-ms:3000}")
|
||||
fun deliverPending() {
|
||||
val rows = jdbc.queryForList(
|
||||
"SELECT id, to_org, pacs002_xml, attempts FROM notification " +
|
||||
"WHERE delivered = false AND attempts < ? ORDER BY created_at LIMIT ?",
|
||||
maxAttempts, batch,
|
||||
)
|
||||
if (rows.isEmpty()) return
|
||||
|
||||
for (r in rows) {
|
||||
val id = r["id"] as String
|
||||
val toOrg = r["to_org"] as String
|
||||
val xml = r["pacs002_xml"] as String
|
||||
val attempts = (r["attempts"] as Number).toInt()
|
||||
|
||||
val url = endpointOf(toOrg)
|
||||
if (url == null) {
|
||||
jdbc.update("UPDATE notification SET attempts = attempts + 1 WHERE id = ?", id)
|
||||
log.warn("DELIVER skip id={} org={} reason=no-endpoint attempts={}", id, toOrg, attempts + 1)
|
||||
continue
|
||||
}
|
||||
try {
|
||||
http.post().uri(url).contentType(MediaType.APPLICATION_XML)
|
||||
.body(xml).retrieve().toBodilessEntity() // 2xx ACK 아니면 예외
|
||||
jdbc.update(
|
||||
"UPDATE notification SET delivered = true, attempts = attempts + 1, delivered_at = ? WHERE id = ?",
|
||||
System.currentTimeMillis(), id,
|
||||
)
|
||||
log.info("DELIVER ok id={} org={} url={}", id, toOrg, url)
|
||||
} catch (e: Exception) {
|
||||
jdbc.update("UPDATE notification SET attempts = attempts + 1 WHERE id = ?", id)
|
||||
log.warn("DELIVER fail id={} org={} attempts={} err={}", id, toOrg, attempts + 1, e.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun endpointOf(orgCode: String): String? =
|
||||
jdbc.queryForList(
|
||||
"SELECT callback_url FROM institution_endpoint WHERE org_code = ? AND active = true", orgCode,
|
||||
).firstOrNull()?.get("callback_url") as? String
|
||||
}
|
||||
56
backend/gucci/src/main/kotlin/kr/or/bok/rtgs/gucci/Guards.kt
Normal file
56
backend/gucci/src/main/kotlin/kr/or/bok/rtgs/gucci/Guards.kt
Normal file
@@ -0,0 +1,56 @@
|
||||
package kr.or.bok.rtgs.gucci
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.stereotype.Component
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
/**
|
||||
* 유량제어(rate limit) — subject(기관)별 고정 윈도우 카운터. 순번기 상한·코어 보호(성능목표).
|
||||
* 프로토타입은 인메모리(센터 로컬). 다센터·다인스턴스는 분산 카운터(Redis 등)로 확장.
|
||||
*/
|
||||
@Component
|
||||
class RateLimiter(
|
||||
@Value("\${gucci.rate-limit-per-window:50}") private val limit: Int,
|
||||
@Value("\${gucci.rate-window-seconds:10}") private val windowSeconds: Long,
|
||||
) {
|
||||
private class Window(val startSec: Long) { val count = AtomicInteger(0) }
|
||||
private val windows = ConcurrentHashMap<String, Window>()
|
||||
|
||||
/** 허용이면 true. 초과면 false(429). nowMs 주입(테스트 용이). */
|
||||
fun allow(subject: String, nowMs: Long): Boolean {
|
||||
val bucket = nowMs / 1000 / windowSeconds
|
||||
val w = windows.compute(subject) { _, cur ->
|
||||
if (cur == null || cur.startSec != bucket) Window(bucket) else cur
|
||||
}!!
|
||||
return w.count.incrementAndGet() <= limit
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 재전송(replay) 방지 — 요청별 nonce+timestamp. 시각오차 초과 또는 nonce 재사용이면 거부.
|
||||
* 부인방지(원전문 해시)와 함께 이중제출·재생 공격 차단.
|
||||
*/
|
||||
@Component
|
||||
class ReplayGuard(
|
||||
@Value("\${gucci.replay-skew-seconds:30}") private val skewSeconds: Long,
|
||||
@Value("\${gucci.replay-nonce-ttl-seconds:120}") private val nonceTtlSeconds: Long,
|
||||
) {
|
||||
private val seen = ConcurrentHashMap<String, Long>() // nonce -> 만료 epochSec
|
||||
|
||||
sealed class Result { object Ok : Result(); data class Reject(val reason: String) : Result() }
|
||||
|
||||
/** 검사 + 통과 시 nonce 등록. nowMs 주입. */
|
||||
fun check(nonce: String?, timestampSec: Long?, nowMs: Long): Result {
|
||||
if (nonce.isNullOrBlank() || timestampSec == null) return Result.Reject("missing nonce/timestamp")
|
||||
val nowSec = nowMs / 1000
|
||||
if (kotlin.math.abs(nowSec - timestampSec) > skewSeconds) return Result.Reject("timestamp out of window")
|
||||
prune(nowSec)
|
||||
val prev = seen.putIfAbsent(nonce, nowSec + nonceTtlSeconds)
|
||||
return if (prev != null) Result.Reject("nonce replayed") else Result.Ok
|
||||
}
|
||||
|
||||
private fun prune(nowSec: Long) {
|
||||
if (seen.size > 10_000) seen.entries.removeIf { it.value < nowSec }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package kr.or.bok.rtgs.gucci
|
||||
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication
|
||||
import org.springframework.boot.runApplication
|
||||
import org.springframework.context.annotation.Bean
|
||||
import org.springframework.scheduling.annotation.EnableScheduling
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder
|
||||
import org.springframework.security.crypto.password.PasswordEncoder
|
||||
import org.springframework.web.client.RestClient
|
||||
|
||||
/**
|
||||
* GUCCI (Global User Communication Control Interface) — 외부 경계 관문(북-남 트래픽).
|
||||
*
|
||||
* 로컬 단계 G1: 참가기관 로그인 인증(JWT 발급) + API 인증(토큰·서명·재전송 차단) + 유량제어 +
|
||||
* 코어(Chanel) 리버스프록시 + 감사로그. 센터간 헬스체크·정족수/펜싱·GSLB 라우팅은 다센터(G3) 단계.
|
||||
*
|
||||
* 원칙: **전역 결제순서는 Sequencer 단독**, Gucci는 관문/라우팅/유량제어만(순서 결정 아님).
|
||||
*/
|
||||
@SpringBootApplication
|
||||
@EnableScheduling
|
||||
class GucciApplication {
|
||||
@Bean
|
||||
fun restClient(): RestClient = RestClient.create()
|
||||
|
||||
/** 비밀키 해시(BCrypt+솔트). 로그인 검증·자격증명 마이그레이션에 공용. */
|
||||
@Bean
|
||||
fun passwordEncoder(): PasswordEncoder = BCryptPasswordEncoder()
|
||||
}
|
||||
|
||||
fun main(args: Array<String>) {
|
||||
runApplication<GucciApplication>(*args)
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package kr.or.bok.rtgs.gucci
|
||||
|
||||
import kr.or.bok.rtgs.common.iso20022.Iso20022Codec
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.http.HttpStatus
|
||||
import org.springframework.http.MediaType
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
import org.springframework.web.bind.annotation.PathVariable
|
||||
import org.springframework.web.bind.annotation.PostMapping
|
||||
import org.springframework.web.bind.annotation.RequestBody
|
||||
import org.springframework.web.bind.annotation.RequestHeader
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
import org.springframework.web.client.RestClient
|
||||
import org.springframework.web.server.ResponseStatusException
|
||||
|
||||
/**
|
||||
* GUCCI 관문 API — 인증(로그인·JWT) + 인증된 리버스프록시(→ Chanel) + 유량제어 + 재전송 차단 + 감사로그.
|
||||
* 참가기관은 Gucci(:8095)를 통해 접수/조회하고, 코어(Chanel:8091)는 내부에 둔다.
|
||||
*/
|
||||
@RestController
|
||||
class GucciController(
|
||||
private val auth: AuthService,
|
||||
private val jwt: JwtService,
|
||||
private val rateLimiter: RateLimiter,
|
||||
private val replayGuard: ReplayGuard,
|
||||
private val http: RestClient,
|
||||
@Value("\${gucci.chanel-base-url}") private val chanelBase: String,
|
||||
@Value("\${rtgs.center-id:DC1}") private val centerId: String,
|
||||
) {
|
||||
private val log = LoggerFactory.getLogger(javaClass)
|
||||
private fun now() = System.currentTimeMillis()
|
||||
|
||||
data class LoginReq(val username: String = "", val secret: String = "")
|
||||
data class ChangePwReq(val username: String = "", val oldSecret: String = "", val newSecret: String = "")
|
||||
|
||||
/** ① 로그인 인증 → JWT 발급. */
|
||||
@PostMapping("/gucci/auth/login")
|
||||
fun login(@RequestBody req: LoginReq): Map<String, Any?> = try {
|
||||
val r = auth.login(req.username, req.secret, now())
|
||||
mapOf("token" to r.token, "role" to r.role, "orgCode" to r.orgCode,
|
||||
"tokenType" to "Bearer", "expiresIn" to r.expiresIn, "mustChangePassword" to r.mustChangePassword)
|
||||
} catch (e: AuthFailedException) {
|
||||
throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "invalid credentials")
|
||||
}
|
||||
|
||||
/** ①-2 초기암호/비밀번호 변경. */
|
||||
@PostMapping("/gucci/auth/change-password")
|
||||
fun changePassword(@RequestBody req: ChangePwReq): Map<String, Any?> = try {
|
||||
auth.changePassword(req.username, req.oldSecret, req.newSecret)
|
||||
mapOf("ok" to true)
|
||||
} catch (e: AuthFailedException) {
|
||||
throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "invalid credentials")
|
||||
}
|
||||
|
||||
/** ② 자금이체 신청(접수) — 인증·유량·재전송·기관바인딩 검사 후 Chanel로 전달. */
|
||||
@PostMapping(
|
||||
"/gucci/pay/customer",
|
||||
consumes = [MediaType.APPLICATION_XML_VALUE, MediaType.TEXT_XML_VALUE, MediaType.TEXT_PLAIN_VALUE],
|
||||
produces = [MediaType.APPLICATION_XML_VALUE],
|
||||
)
|
||||
fun pay(
|
||||
@RequestBody rawXml: String,
|
||||
@RequestHeader(value = "Authorization", required = false) authz: String?,
|
||||
@RequestHeader(value = "X-Nonce", required = false) nonce: String?,
|
||||
@RequestHeader(value = "X-Timestamp", required = false) timestamp: String?,
|
||||
): String {
|
||||
val p = authenticate(authz)
|
||||
requireRole(p, "ORG_S", "ADMIN") // 송신기관만 신청
|
||||
enforceRate(p)
|
||||
enforceReplay(p, nonce, timestamp)
|
||||
enforceOrgBinding(p, rawXml) // 토큰 기관 == 전문 송신기관
|
||||
audit("PAY", p, "allow", null)
|
||||
return forwardPost("/pay/customer", rawXml)
|
||||
}
|
||||
|
||||
/** ③ 거래 상태 조회 — 인증된 참가기관/관리자. */
|
||||
@GetMapping("/gucci/inquiry/{bmi}", produces = [MediaType.APPLICATION_JSON_VALUE])
|
||||
fun inquiry(@PathVariable bmi: String, @RequestHeader("Authorization", required = false) authz: String?): String {
|
||||
val p = authenticate(authz); enforceRate(p)
|
||||
audit("INQUIRY", p, "allow", bmi)
|
||||
return forwardGet("/inquiry/$bmi")
|
||||
}
|
||||
|
||||
/** ④ 참가기관 잔액 목록 — 관리자만(대사·모니터링). */
|
||||
@GetMapping("/gucci/accounts", produces = [MediaType.APPLICATION_JSON_VALUE])
|
||||
fun accounts(@RequestHeader("Authorization", required = false) authz: String?): String {
|
||||
val p = authenticate(authz); requireRole(p, "ADMIN"); enforceRate(p)
|
||||
audit("ACCOUNTS", p, "allow", null)
|
||||
return forwardGet("/accounts")
|
||||
}
|
||||
|
||||
/** ⑤ 센터 헬스체크(관측용). 로컬은 단일 센터. 다센터·정족수/펜싱은 G3. */
|
||||
@GetMapping("/gucci/health/centers", produces = [MediaType.APPLICATION_JSON_VALUE])
|
||||
fun centers(): Map<String, Any?> {
|
||||
val chanelUp = runCatching { http.get().uri("$chanelBase/accounts").retrieve().toBodilessEntity() }.isSuccess
|
||||
return mapOf(
|
||||
"self" to centerId,
|
||||
"centers" to listOf(mapOf("id" to centerId, "core(chanel)" to if (chanelUp) "UP" else "DOWN")),
|
||||
"note" to "단일 센터(G1). 다센터 정족수/펜싱은 G3(고사양 PC 이후).",
|
||||
)
|
||||
}
|
||||
|
||||
// ---------- 내부 검사·전달 ----------
|
||||
|
||||
private fun authenticate(authz: String?): Principal {
|
||||
val token = authz?.removePrefix("Bearer ")?.trim()
|
||||
if (token.isNullOrBlank()) throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "missing bearer token")
|
||||
return try { jwt.verify(token, now()) }
|
||||
catch (e: JwtException) { throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "invalid token: ${e.message}") }
|
||||
}
|
||||
|
||||
private fun requireRole(p: Principal, vararg allowed: String) {
|
||||
if (p.role !in allowed) {
|
||||
audit("AUTHZ", p, "deny", "role ${p.role} not in ${allowed.toList()}")
|
||||
throw ResponseStatusException(HttpStatus.FORBIDDEN, "role not permitted")
|
||||
}
|
||||
}
|
||||
|
||||
private fun enforceRate(p: Principal) {
|
||||
if (!rateLimiter.allow(p.subject, now())) {
|
||||
audit("RATE", p, "deny", "rate limit exceeded")
|
||||
throw ResponseStatusException(HttpStatus.TOO_MANY_REQUESTS, "rate limit exceeded")
|
||||
}
|
||||
}
|
||||
|
||||
private fun enforceReplay(p: Principal, nonce: String?, timestamp: String?) {
|
||||
val ts = timestamp?.toLongOrNull()
|
||||
when (val r = replayGuard.check(nonce, ts, now())) {
|
||||
is ReplayGuard.Result.Reject -> {
|
||||
audit("REPLAY", p, "deny", r.reason)
|
||||
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "replay check failed: ${r.reason}")
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
|
||||
/** 토큰의 기관코드와 전문 송신기관(DbtrAgt MmbId) 일치 강제(관리자 예외). */
|
||||
private fun enforceOrgBinding(p: Principal, rawXml: String) {
|
||||
if (p.role == "ADMIN") return
|
||||
val sender = runCatching { Iso20022Codec.parsePacs008(rawXml).from }.getOrNull() ?: return // 파싱 실패는 Chanel XSD가 반려
|
||||
if (p.orgCode != sender) {
|
||||
audit("ORGBIND", p, "deny", "token org ${p.orgCode} != sender $sender")
|
||||
throw ResponseStatusException(HttpStatus.FORBIDDEN, "token org does not match message sender")
|
||||
}
|
||||
}
|
||||
|
||||
private fun forwardPost(path: String, body: String): String = try {
|
||||
http.post().uri("$chanelBase$path")
|
||||
.contentType(MediaType.APPLICATION_XML)
|
||||
.body(body).retrieve().body(String::class.java) ?: ""
|
||||
} catch (e: Exception) {
|
||||
throw ResponseStatusException(HttpStatus.BAD_GATEWAY, "core unavailable: ${e.message}")
|
||||
}
|
||||
|
||||
private fun forwardGet(path: String): String = try {
|
||||
http.get().uri("$chanelBase$path").retrieve().body(String::class.java) ?: ""
|
||||
} catch (e: Exception) {
|
||||
throw ResponseStatusException(HttpStatus.BAD_GATEWAY, "core unavailable: ${e.message}")
|
||||
}
|
||||
|
||||
private fun audit(event: String, p: Principal, decision: String, detail: String?) {
|
||||
log.info("AUDIT event={} subject={} role={} org={} decision={} detail={}",
|
||||
event, p.subject, p.role, p.orgCode, decision, detail ?: "")
|
||||
}
|
||||
}
|
||||
76
backend/gucci/src/main/kotlin/kr/or/bok/rtgs/gucci/Jwt.kt
Normal file
76
backend/gucci/src/main/kotlin/kr/or/bok/rtgs/gucci/Jwt.kt
Normal file
@@ -0,0 +1,76 @@
|
||||
package kr.or.bok.rtgs.gucci
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.stereotype.Component
|
||||
import java.nio.charset.StandardCharsets
|
||||
import java.util.Base64
|
||||
import javax.crypto.Mac
|
||||
import javax.crypto.spec.SecretKeySpec
|
||||
|
||||
/** JWT 검증 실패(만료/서명오류/형식). */
|
||||
class JwtException(msg: String) : RuntimeException(msg)
|
||||
|
||||
/** 토큰에서 복원한 인증 주체. */
|
||||
data class Principal(val subject: String, val role: String, val orgCode: String?)
|
||||
|
||||
/**
|
||||
* 경량 JWT(HS256) — 외부 라이브러리 없이 표준 구조(header.payload.signature)로 서명·검증.
|
||||
* 프로토타입 자족 구현. 운영은 표준 라이브러리/키관리(HSM·회전)로 대체.
|
||||
*/
|
||||
@Component
|
||||
class JwtService(
|
||||
private val mapper: ObjectMapper,
|
||||
@Value("\${gucci.jwt-secret}") private val secret: String,
|
||||
@Value("\${gucci.jwt-ttl-seconds:1800}") private val ttlSeconds: Long,
|
||||
) {
|
||||
private val enc = Base64.getUrlEncoder().withoutPadding()
|
||||
private val dec = Base64.getUrlDecoder()
|
||||
|
||||
fun ttl(): Long = ttlSeconds
|
||||
|
||||
/** 주체 정보로 토큰 발급(iat/exp 포함). nowMs는 호출측이 주입(테스트 용이). */
|
||||
fun issue(p: Principal, nowMs: Long): String {
|
||||
val header = mapOf("alg" to "HS256", "typ" to "JWT")
|
||||
val iat = nowMs / 1000
|
||||
val payload = mapOf(
|
||||
"sub" to p.subject, "role" to p.role, "org" to p.orgCode,
|
||||
"iat" to iat, "exp" to iat + ttlSeconds,
|
||||
)
|
||||
val h = enc.encodeToString(mapper.writeValueAsBytes(header))
|
||||
val pl = enc.encodeToString(mapper.writeValueAsBytes(payload))
|
||||
val signingInput = "$h.$pl"
|
||||
return "$signingInput.${sign(signingInput)}"
|
||||
}
|
||||
|
||||
/** 토큰 검증 후 주체 반환. 서명 불일치·만료·형식오류면 JwtException. */
|
||||
fun verify(token: String, nowMs: Long): Principal {
|
||||
val parts = token.split(".")
|
||||
if (parts.size != 3) throw JwtException("malformed token")
|
||||
val expected = sign("${parts[0]}.${parts[1]}")
|
||||
if (!constantTimeEquals(expected, parts[2])) throw JwtException("bad signature")
|
||||
val claims: Map<String, Any?> = mapper.readValue(dec.decode(parts[1]), Map::class.java)
|
||||
.let { @Suppress("UNCHECKED_CAST") (it as Map<String, Any?>) }
|
||||
val exp = (claims["exp"] as? Number)?.toLong() ?: throw JwtException("no exp")
|
||||
if (nowMs / 1000 > exp) throw JwtException("expired")
|
||||
return Principal(
|
||||
subject = claims["sub"] as? String ?: throw JwtException("no sub"),
|
||||
role = claims["role"] as? String ?: "",
|
||||
orgCode = claims["org"] as? String,
|
||||
)
|
||||
}
|
||||
|
||||
private fun sign(input: String): String {
|
||||
val mac = Mac.getInstance("HmacSHA256")
|
||||
mac.init(SecretKeySpec(secret.toByteArray(StandardCharsets.UTF_8), "HmacSHA256"))
|
||||
return enc.encodeToString(mac.doFinal(input.toByteArray(StandardCharsets.UTF_8)))
|
||||
}
|
||||
|
||||
private fun constantTimeEquals(a: String, b: String): Boolean {
|
||||
val x = a.toByteArray(); val y = b.toByteArray()
|
||||
if (x.size != y.size) return false
|
||||
var r = 0
|
||||
for (i in x.indices) r = r or (x[i].toInt() xor y[i].toInt())
|
||||
return r == 0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package kr.or.bok.rtgs.gucci
|
||||
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.boot.ApplicationRunner
|
||||
import org.springframework.jdbc.core.JdbcTemplate
|
||||
import org.springframework.security.crypto.password.PasswordEncoder
|
||||
import org.springframework.stereotype.Component
|
||||
|
||||
/**
|
||||
* 자격증명 마이그레이션 — 기동 시 `app_user.secret` 중 **평문(BCrypt 아님)** 을 BCrypt 해시로 승격한다.
|
||||
* BCrypt 해시는 `$2`로 시작하므로 이미 해시면 건너뛴다(멱등). 시드/신규 사용자가 평문으로 들어와도
|
||||
* 다음 기동에 자동 해시되어 **평문 저장이 남지 않는다**. 운영에선 사용자 생성 시점에 해시 권장.
|
||||
*/
|
||||
@Component
|
||||
class SecretMigrator(
|
||||
private val jdbc: JdbcTemplate,
|
||||
private val encoder: PasswordEncoder,
|
||||
) : ApplicationRunner {
|
||||
private val log = LoggerFactory.getLogger(javaClass)
|
||||
|
||||
override fun run(args: org.springframework.boot.ApplicationArguments?) {
|
||||
val plain = jdbc.queryForList(
|
||||
"SELECT username, secret FROM app_user WHERE secret IS NOT NULL AND secret NOT LIKE '\$2%'",
|
||||
)
|
||||
if (plain.isEmpty()) return
|
||||
var n = 0
|
||||
for (r in plain) {
|
||||
val u = r["username"] as String
|
||||
val raw = r["secret"] as String
|
||||
jdbc.update("UPDATE app_user SET secret = ? WHERE username = ?", encoder.encode(raw), u)
|
||||
n++
|
||||
}
|
||||
log.warn("자격증명 마이그레이션: 평문 {}건 → BCrypt 해시로 승격", n)
|
||||
}
|
||||
}
|
||||
36
backend/gucci/src/main/resources/application.yml
Normal file
36
backend/gucci/src/main/resources/application.yml
Normal file
@@ -0,0 +1,36 @@
|
||||
spring:
|
||||
application:
|
||||
name: gucci
|
||||
datasource:
|
||||
url: jdbc:postgresql://${POSTGRES_HOST:localhost}:${POSTGRES_PORT:5433}/${POSTGRES_DB:rtgs}
|
||||
username: ${POSTGRES_USER:rtgs}
|
||||
password: ${POSTGRES_PASSWORD:rtgs}
|
||||
|
||||
server:
|
||||
port: 8095
|
||||
|
||||
rtgs:
|
||||
center-id: ${CENTER_ID:DC1}
|
||||
|
||||
gucci:
|
||||
# 하위 코어(전문송수신) 관문 대상
|
||||
chanel-base-url: ${CHANEL_URL:http://localhost:8091}
|
||||
# JWT 서명 비밀키(운영은 외부 시크릿). 프로토타입 기본값.
|
||||
jwt-secret: ${GUCCI_JWT_SECRET:rtgs-gucci-dev-secret-key-please-change-0123456789}
|
||||
jwt-ttl-seconds: 1800
|
||||
# 유량제어: subject(기관)별 window 내 허용 요청수
|
||||
rate-limit-per-window: 50
|
||||
rate-window-seconds: 10
|
||||
# 재전송 방지: 허용 시각오차(초), nonce 보관 TTL(초)
|
||||
replay-skew-seconds: 30
|
||||
replay-nonce-ttl-seconds: 120
|
||||
# G2 결과 콜백 송부: 폴링 주기(ms), 최대 재시도, 배치 크기
|
||||
delivery-interval-ms: 3000
|
||||
delivery-max-attempts: 5
|
||||
delivery-batch: 50
|
||||
|
||||
management:
|
||||
endpoints:
|
||||
web:
|
||||
exposure:
|
||||
include: health,info,prometheus,metrics
|
||||
@@ -0,0 +1,48 @@
|
||||
package kr.or.bok.rtgs.gucci
|
||||
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFailsWith
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/** Gucci 관문 가드(유량제어·재전송·JWT) 결정론적 검증. */
|
||||
class GucciGuardsTest {
|
||||
|
||||
@Test
|
||||
fun `rate limiter allows up to limit per window then blocks, resets next window`() {
|
||||
val rl = RateLimiter(limit = 3, windowSeconds = 10)
|
||||
val t = 100_000L // 100초 시점(같은 10s 윈도우)
|
||||
assertTrue(rl.allow("kookmin_s", t))
|
||||
assertTrue(rl.allow("kookmin_s", t))
|
||||
assertTrue(rl.allow("kookmin_s", t))
|
||||
assertTrue(!rl.allow("kookmin_s", t)) // 4번째 초과 → 차단
|
||||
assertTrue(rl.allow("shinhan_r", t)) // 다른 기관은 독립 버킷
|
||||
assertTrue(rl.allow("kookmin_s", t + 10_000)) // 다음 윈도우 → 리셋
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `replay guard rejects reuse, missing, and out-of-window`() {
|
||||
val rg = ReplayGuard(skewSeconds = 30, nonceTtlSeconds = 120)
|
||||
val nowMs = 1_000_000_000_000L
|
||||
val nowSec = nowMs / 1000
|
||||
assertTrue(rg.check("n1", nowSec, nowMs) is ReplayGuard.Result.Ok)
|
||||
assertTrue(rg.check("n1", nowSec, nowMs) is ReplayGuard.Result.Reject) // 재사용
|
||||
assertTrue(rg.check(null, nowSec, nowMs) is ReplayGuard.Result.Reject) // nonce 없음
|
||||
assertTrue(rg.check("n2", nowSec - 60, nowMs) is ReplayGuard.Result.Reject) // 시각오차 초과
|
||||
assertTrue(rg.check("n3", nowSec, nowMs) is ReplayGuard.Result.Ok)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `jwt round-trips, rejects tamper and expiry`() {
|
||||
val jwt = JwtService(jacksonObjectMapper(), secret = "unit-test-secret-key-0123456789", ttlSeconds = 1800)
|
||||
val now = 1_000_000_000_000L
|
||||
val token = jwt.issue(Principal("kookmin_s", "ORG_S", "1001"), now)
|
||||
val p = jwt.verify(token, now)
|
||||
assertEquals("kookmin_s", p.subject)
|
||||
assertEquals("ORG_S", p.role)
|
||||
assertEquals("1001", p.orgCode)
|
||||
assertFailsWith<JwtException> { jwt.verify(token + "x", now) } // 변조
|
||||
assertFailsWith<JwtException> { jwt.verify(token, now + 1_801_000L) } // 만료(ttl 초과)
|
||||
}
|
||||
}
|
||||
20
backend/hermes/build.gradle.kts
Normal file
20
backend/hermes/build.gradle.kts
Normal file
@@ -0,0 +1,20 @@
|
||||
plugins {
|
||||
kotlin("jvm")
|
||||
kotlin("plugin.spring")
|
||||
id("org.springframework.boot")
|
||||
id("io.spring.dependency-management")
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":common"))
|
||||
implementation("org.springframework.boot:spring-boot-starter")
|
||||
implementation("org.springframework.boot:spring-boot-starter-actuator")
|
||||
implementation("org.springframework.boot:spring-boot-starter-web") // 메트릭/헬스 노출(B4)
|
||||
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
|
||||
implementation("org.springframework.kafka:spring-kafka")
|
||||
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
|
||||
implementation("org.jetbrains.kotlin:kotlin-reflect")
|
||||
runtimeOnly("org.postgresql:postgresql")
|
||||
testImplementation("org.springframework.boot:spring-boot-starter-test")
|
||||
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package kr.or.bok.rtgs.hermes
|
||||
|
||||
import kr.or.bok.rtgs.common.JournalEntry
|
||||
import java.util.concurrent.ConcurrentSkipListMap
|
||||
|
||||
/**
|
||||
* 저널 순차 게이트(B3) — globalSeq **오름차순**으로만 적용되도록 버퍼링/재정렬한다.
|
||||
*
|
||||
* rtgs.journal은 **단일 파티션**이라 Kafka가 offset(=순번기 발행) 순서대로 전달한다. 따라서
|
||||
* "더 큰 seq가 도착했는데 `expected`는 안 온" 상황 = 그 `expected`가 **영영 발행되지 않은
|
||||
* phantom**(B1의 SEQUENCE nextval 소비 후 미기록 등)이라는 의미다. 단일 파티션 순서 보장상
|
||||
* 뒤늦게 채워질 일이 없으므로, 타임아웃 후 skip은 **실엔트리 스킵이 아니라** 결번 정리로 안전하다.
|
||||
*
|
||||
* 순수 로직(스프링 비의존) — 단위테스트로 규율 고정. Hermes 리스너(concurrency=1)와 타임아웃
|
||||
* 스케줄러가 함께 접근하므로 메서드는 @Synchronized.
|
||||
*/
|
||||
class GapBuffer {
|
||||
@Volatile
|
||||
var expected: Long = 0L
|
||||
private set
|
||||
|
||||
@Volatile
|
||||
var lastAdvanceMillis: Long = 0L
|
||||
private set
|
||||
|
||||
private val pending = ConcurrentSkipListMap<Long, JournalEntry>()
|
||||
|
||||
fun pendingCount(): Int = pending.size
|
||||
fun highestPending(): Long? = if (pending.isEmpty()) null else pending.lastKey()
|
||||
|
||||
/** 도착 엔트리를 넣고, 지금 적용 가능한 엔트리들을 **순서대로** 반환(없으면 빈 리스트). */
|
||||
@Synchronized
|
||||
fun offer(entry: JournalEntry, nowMillis: Long): List<JournalEntry> {
|
||||
if (expected == 0L) { // 첫 엔트리가 기대 순번을 앵커링
|
||||
expected = entry.globalSeq
|
||||
lastAdvanceMillis = nowMillis
|
||||
}
|
||||
return when {
|
||||
entry.globalSeq < expected -> emptyList() // old/dup
|
||||
entry.globalSeq > expected -> { pending[entry.globalSeq] = entry; emptyList() } // gap → 버퍼
|
||||
else -> drainFrom(entry, nowMillis) // 기대 순번 → 적용 + 이어받기
|
||||
}
|
||||
}
|
||||
|
||||
/** expected(=phantom 확정)를 건너뛰고, 이어서 적용 가능한 엔트리들을 반환. */
|
||||
@Synchronized
|
||||
fun skipExpected(nowMillis: Long): List<JournalEntry> {
|
||||
if (pending.isEmpty()) return emptyList() // 스킵할 이유 없음(대기자 없음)
|
||||
expected++
|
||||
val ready = mutableListOf<JournalEntry>()
|
||||
while (pending.containsKey(expected)) { ready += pending.remove(expected)!!; expected++ }
|
||||
if (ready.isNotEmpty()) lastAdvanceMillis = nowMillis
|
||||
return ready
|
||||
}
|
||||
|
||||
private fun drainFrom(head: JournalEntry, nowMillis: Long): List<JournalEntry> {
|
||||
val ready = mutableListOf(head)
|
||||
expected++
|
||||
while (pending.containsKey(expected)) { ready += pending.remove(expected)!!; expected++ }
|
||||
lastAdvanceMillis = nowMillis
|
||||
return ready
|
||||
}
|
||||
|
||||
/** pending이 있으면 마지막 진전 이후 경과(ms), 없으면 0(정체 아님). */
|
||||
fun stalledMillis(nowMillis: Long): Long =
|
||||
if (pending.isEmpty()) 0L else (nowMillis - lastAdvanceMillis).coerceAtLeast(0L)
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package kr.or.bok.rtgs.hermes
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import kr.or.bok.rtgs.common.ledger.Account
|
||||
import kr.or.bok.rtgs.common.ledger.JournalLog
|
||||
import kr.or.bok.rtgs.common.ledger.TransferRecord
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication
|
||||
import org.springframework.boot.autoconfigure.domain.EntityScan
|
||||
import org.springframework.boot.runApplication
|
||||
import org.springframework.context.annotation.Bean
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
import org.springframework.data.jpa.repository.Modifying
|
||||
import org.springframework.data.jpa.repository.Query
|
||||
import org.springframework.data.jpa.repository.config.EnableJpaRepositories
|
||||
import org.springframework.data.repository.query.Param
|
||||
import org.springframework.scheduling.annotation.EnableScheduling
|
||||
|
||||
@SpringBootApplication
|
||||
@EntityScan("kr.or.bok.rtgs.common.ledger")
|
||||
@EnableJpaRepositories(considerNestedRepositories = true)
|
||||
@EnableScheduling // B3 gap 타임아웃 스케줄러
|
||||
class HermesApplication {
|
||||
@Bean
|
||||
fun objectMapper(): ObjectMapper = jacksonObjectMapper()
|
||||
}
|
||||
|
||||
interface AccountRepository : JpaRepository<Account, String>
|
||||
interface JournalLogRepository : JpaRepository<JournalLog, Long>
|
||||
|
||||
interface TransferRepository : JpaRepository<TransferRecord, String> {
|
||||
/**
|
||||
* 거래 원장 원자적 upsert. 신규면 INSERT, 이미 있으면(Dior/타 소비자가 먼저 생성) UPDATE.
|
||||
* created_at은 최초 값 유지(갱신 제외). Dior-Hermes 동시 삽입 경합에서 중복키 예외 제거.
|
||||
*/
|
||||
@Modifying
|
||||
@Query(
|
||||
value = """
|
||||
INSERT INTO transfer
|
||||
(bmi, global_seq, status, sender_code, receiver_code, amount, origin_center, created_at, updated_at, reason)
|
||||
VALUES
|
||||
(:bmi, :seq, :status, :sender, :receiver, :amount, :origin, :createdAt, :updatedAt, :reason)
|
||||
ON CONFLICT (bmi) DO UPDATE SET
|
||||
global_seq = EXCLUDED.global_seq,
|
||||
status = EXCLUDED.status,
|
||||
sender_code = EXCLUDED.sender_code,
|
||||
receiver_code = EXCLUDED.receiver_code,
|
||||
amount = EXCLUDED.amount,
|
||||
origin_center = EXCLUDED.origin_center,
|
||||
updated_at = EXCLUDED.updated_at,
|
||||
reason = EXCLUDED.reason
|
||||
""",
|
||||
nativeQuery = true,
|
||||
)
|
||||
fun upsertTransfer(
|
||||
@Param("bmi") bmi: String,
|
||||
@Param("seq") seq: Long,
|
||||
@Param("status") status: String,
|
||||
@Param("sender") sender: String,
|
||||
@Param("receiver") receiver: String,
|
||||
@Param("amount") amount: Long,
|
||||
@Param("origin") origin: String,
|
||||
@Param("createdAt") createdAt: Long,
|
||||
@Param("updatedAt") updatedAt: Long,
|
||||
@Param("reason") reason: String?,
|
||||
): Int
|
||||
}
|
||||
|
||||
fun main(args: Array<String>) {
|
||||
runApplication<HermesApplication>(*args)
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package kr.or.bok.rtgs.hermes
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import io.micrometer.core.instrument.MeterRegistry
|
||||
import io.micrometer.core.instrument.Timer
|
||||
import kr.or.bok.rtgs.common.JournalEntry
|
||||
import kr.or.bok.rtgs.common.ResultMessage
|
||||
import kr.or.bok.rtgs.common.Topics
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.kafka.annotation.KafkaListener
|
||||
import org.springframework.kafka.core.KafkaTemplate
|
||||
import org.springframework.scheduling.annotation.Scheduled
|
||||
import org.springframework.stereotype.Service
|
||||
|
||||
/**
|
||||
* 저널 순차 소비 + 순서 탐지·교정(복원력방안 2.3-부속, S4·B3). 실제 정산은 SettlementApplier(트랜잭션).
|
||||
* **결과(rtgs.result) 발행은 정산 트랜잭션 커밋 이후**에 한다(publish-after-commit) — Prada가
|
||||
* 커밋 전 transfer를 읽어 최종확정을 놓치는 레이스 방지. rtgs.result는 B2에서 applied-ack으로도 쓰인다.
|
||||
*
|
||||
* 순서 게이트는 GapBuffer(단위테스트로 규율 고정). 단일 파티션이라 정상 시 결번이 없고, 결번은
|
||||
* 곧 phantom(발행 안 된 seq)이므로 **타임아웃 후 skip**으로 정체를 자가해소한다(B3).
|
||||
*/
|
||||
@Service
|
||||
class HermesService(
|
||||
private val applier: SettlementApplier,
|
||||
private val kafka: KafkaTemplate<String, String>,
|
||||
private val mapper: ObjectMapper,
|
||||
private val metrics: MeterRegistry,
|
||||
@Value("\${rtgs.gap-timeout-ms:5000}") private val gapTimeoutMs: Long,
|
||||
) {
|
||||
private val log = LoggerFactory.getLogger(javaClass)
|
||||
|
||||
private val gate = GapBuffer()
|
||||
// B4 메트릭: 정산 처리시간(TPS·지연) + gap 버퍼링 + gap 타임아웃(phantom skip)
|
||||
private val settleTimer: Timer = Timer.builder("rtgs.settle").description("정산 처리시간").register(metrics)
|
||||
private val gapCounter = metrics.counter("rtgs.journal.gap")
|
||||
private val gapTimeoutCounter = metrics.counter("rtgs.journal.gap.timeout")
|
||||
|
||||
// 센터별 그룹(hermes-DC1/DC2/DC3)이라 각 센터가 저널 전량을 독립 재생.
|
||||
@KafkaListener(topics = [Topics.JOURNAL], groupId = "hermes-\${rtgs.center-id:DC1}", concurrency = "1")
|
||||
fun onJournal(payload: String) {
|
||||
val entry = mapper.readValue(payload, JournalEntry::class.java)
|
||||
val before = gate.pendingCount()
|
||||
val ready = gate.offer(entry, System.currentTimeMillis())
|
||||
if (ready.isEmpty() && gate.pendingCount() > before) {
|
||||
gapCounter.increment()
|
||||
log.warn("GAP: got seq={} expected={} -> buffered (pending={})", entry.globalSeq, gate.expected, gate.pendingCount())
|
||||
}
|
||||
ready.forEach { apply(it) }
|
||||
}
|
||||
|
||||
/**
|
||||
* B3 정체 감지: pending이 있고 마지막 진전 이후 gapTimeoutMs 초과면, expected는 phantom으로
|
||||
* 확정(단일 파티션 순서 보장)하고 skip해 진행을 재개한다. 실엔트리 스킵이 아니라 결번 정리.
|
||||
*/
|
||||
@Scheduled(fixedDelayString = "\${rtgs.gap-check-ms:1000}")
|
||||
fun checkGapTimeout() {
|
||||
val now = System.currentTimeMillis()
|
||||
if (gate.stalledMillis(now) < gapTimeoutMs) return
|
||||
val missing = gate.expected
|
||||
val ready = gate.skipExpected(now)
|
||||
if (ready.isNotEmpty()) {
|
||||
gapTimeoutCounter.increment()
|
||||
log.error("GAP TIMEOUT: seq={} 미도착 {}ms 초과 → phantom 확정 skip, 재개(적용 {}건, 다음 expected={})",
|
||||
missing, gapTimeoutMs, ready.size, gate.expected)
|
||||
ready.forEach { apply(it) }
|
||||
}
|
||||
}
|
||||
|
||||
/** 정산 적용(시간 계측) 후 결과 발행. */
|
||||
private fun apply(entry: JournalEntry) {
|
||||
val result = settleTimer.recordCallable { applier.apply(entry) }
|
||||
publishAfterCommit(result)
|
||||
if (result != null) metrics.counter("rtgs.settlements", "status", result.status.name).increment()
|
||||
}
|
||||
|
||||
/** 트랜잭션 커밋 후(apply 반환 = 커밋 완료) 결과 발행. null이면 멱등 스킵. */
|
||||
private fun publishAfterCommit(result: ResultMessage?) {
|
||||
if (result == null) return
|
||||
kafka.send(Topics.RESULT, result.journalEntry.core.bmi, mapper.writeValueAsString(result))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package kr.or.bok.rtgs.hermes
|
||||
|
||||
import kr.or.bok.rtgs.common.JournalEntry
|
||||
import kr.or.bok.rtgs.common.ResultMessage
|
||||
import kr.or.bok.rtgs.common.TxSts
|
||||
import kr.or.bok.rtgs.common.ledger.JournalLog
|
||||
import kr.or.bok.rtgs.common.ledger.TransferRecord
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
|
||||
/**
|
||||
* 실제 정산을 강한 일관성 트랜잭션으로 수행. 한 저널 엔트리 = 한 트랜잭션.
|
||||
* 선저널 → 잔액 이체 → transfer 상태확정. **결과 발행은 하지 않고 ResultMessage만 반환**한다
|
||||
* (Kafka 발행은 호출측 HermesService가 트랜잭션 커밋 이후에 수행 — publish-after-commit).
|
||||
* 이렇게 해야 Prada가 커밋 전 transfer를 읽는 레이스(ACSP 잔류)를 방지한다.
|
||||
*/
|
||||
@Service
|
||||
class SettlementApplier(
|
||||
private val accounts: AccountRepository,
|
||||
private val transfers: TransferRepository,
|
||||
private val journalLogs: JournalLogRepository,
|
||||
@Value("\${rtgs.center-id:DC1}") private val centerId: String,
|
||||
) {
|
||||
private val log = LoggerFactory.getLogger(javaClass)
|
||||
|
||||
/** @return 발행할 ResultMessage. 멱등 스킵이면 null. */
|
||||
@Transactional
|
||||
fun apply(e: JournalEntry): ResultMessage? {
|
||||
val bmi = e.core.bmi
|
||||
val existing = transfers.findById(bmi).orElse(null)
|
||||
if (existing != null && (existing.status == TxSts.ACSP || existing.status == TxSts.ACCC)) return null // 멱등
|
||||
|
||||
if (!journalLogs.existsById(e.globalSeq)) {
|
||||
journalLogs.save(JournalLog(globalSeq = e.globalSeq, bmi = bmi, seqEpochMillis = e.seqEpochMillis, applied = false))
|
||||
}
|
||||
|
||||
val now = System.currentTimeMillis()
|
||||
val sender = accounts.findById(e.core.senderCode).orElse(null)
|
||||
val receiver = accounts.findById(e.core.receiverCode).orElse(null)
|
||||
|
||||
if (sender == null || receiver == null) {
|
||||
return settle(e, TxSts.RJCT, now, null, null, "unknown account(s): ${e.core.senderCode}/${e.core.receiverCode}")
|
||||
}
|
||||
if (sender.balance < e.core.amount) {
|
||||
return settle(e, TxSts.RJCT, now, sender.balance, receiver.balance, "insufficient balance")
|
||||
}
|
||||
|
||||
// 이체(강한 일관성): 송신기관 당좌 차감 + 수신기관 당좌 가산
|
||||
sender.balance -= e.core.amount
|
||||
receiver.balance += e.core.amount
|
||||
accounts.save(sender)
|
||||
accounts.save(receiver)
|
||||
val result = settle(e, TxSts.ACSP, now, sender.balance, receiver.balance, null)
|
||||
log.info("ACSP seq=#{} bmi={} {}({})->{}({})", e.globalSeq, bmi,
|
||||
e.core.senderCode, sender.balance, e.core.receiverCode, receiver.balance)
|
||||
return result
|
||||
}
|
||||
|
||||
private fun settle(e: JournalEntry, status: TxSts, now: Long, dBal: Long?, cBal: Long?, reason: String?): ResultMessage {
|
||||
// 원자적 upsert(신규 INSERT / 기존 UPDATE) — Dior와의 동시 삽입 경합에서 중복키 예외 없음.
|
||||
transfers.upsertTransfer(
|
||||
bmi = e.core.bmi, seq = e.globalSeq, status = status.name,
|
||||
sender = e.core.senderCode, receiver = e.core.receiverCode, amount = e.core.amount,
|
||||
origin = e.originCenter, createdAt = e.seqEpochMillis, updatedAt = now, reason = reason,
|
||||
)
|
||||
journalLogs.findById(e.globalSeq).ifPresent { it.applied = true; journalLogs.save(it) }
|
||||
return ResultMessage(
|
||||
journalEntry = e, status = status, processedCenter = centerId,
|
||||
debtorBalanceAfter = dBal, creditorBalanceAfter = cBal, reason = reason,
|
||||
)
|
||||
}
|
||||
}
|
||||
37
backend/hermes/src/main/resources/application.yml
Normal file
37
backend/hermes/src/main/resources/application.yml
Normal file
@@ -0,0 +1,37 @@
|
||||
spring:
|
||||
application:
|
||||
name: hermes
|
||||
datasource:
|
||||
url: jdbc:postgresql://${POSTGRES_HOST:localhost}:${POSTGRES_PORT:5433}/${POSTGRES_DB:rtgs}
|
||||
username: ${POSTGRES_USER:rtgs}
|
||||
password: ${POSTGRES_PASSWORD:rtgs}
|
||||
jpa:
|
||||
hibernate:
|
||||
ddl-auto: none
|
||||
open-in-view: false
|
||||
kafka:
|
||||
bootstrap-servers: ${KAFKA_BOOTSTRAP:localhost:9092}
|
||||
consumer:
|
||||
group-id: hermes
|
||||
auto-offset-reset: earliest
|
||||
enable-auto-commit: false
|
||||
key-deserializer: org.apache.kafka.common.serialization.StringDeserializer
|
||||
value-deserializer: org.apache.kafka.common.serialization.StringDeserializer
|
||||
producer:
|
||||
key-serializer: org.apache.kafka.common.serialization.StringSerializer
|
||||
value-serializer: org.apache.kafka.common.serialization.StringSerializer
|
||||
acks: all
|
||||
listener:
|
||||
ack-mode: record
|
||||
|
||||
server:
|
||||
port: 8093
|
||||
|
||||
rtgs:
|
||||
center-id: ${CENTER_ID:DC1}
|
||||
|
||||
management:
|
||||
endpoints:
|
||||
web:
|
||||
exposure:
|
||||
include: health,info,prometheus,metrics
|
||||
@@ -0,0 +1,80 @@
|
||||
package kr.or.bok.rtgs.hermes
|
||||
|
||||
import kr.or.bok.rtgs.common.CoreMessage
|
||||
import kr.or.bok.rtgs.common.JournalEntry
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
/** B3 순차 게이트(버퍼링·재정렬·phantom skip) 규율 고정 테스트. */
|
||||
class GapBufferTest {
|
||||
|
||||
private fun entry(seq: Long): JournalEntry = JournalEntry(
|
||||
globalSeq = seq,
|
||||
seqEpochMillis = 1_000L + seq,
|
||||
originCenter = "DC1",
|
||||
core = CoreMessage(
|
||||
bmi = "bmi%022d".format(seq), msgType = "pacs.008.001.08",
|
||||
senderCode = "1001", receiverCode = "1002",
|
||||
debtorAcct = "1001", creditorAcct = "1002",
|
||||
amount = 100, origHash = "h",
|
||||
),
|
||||
)
|
||||
|
||||
private fun seqs(list: List<JournalEntry>) = list.map { it.globalSeq }
|
||||
|
||||
@Test
|
||||
fun `순서대로 오면 즉시 적용`() {
|
||||
val g = GapBuffer()
|
||||
assertEquals(listOf(1L), seqs(g.offer(entry(1), 10)))
|
||||
assertEquals(listOf(2L), seqs(g.offer(entry(2), 11)))
|
||||
assertEquals(3L, g.expected)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `역전 도착은 버퍼링 후 결번 채워지면 한꺼번에 방출`() {
|
||||
val g = GapBuffer()
|
||||
assertEquals(listOf(1L), seqs(g.offer(entry(1), 10)))
|
||||
assertEquals(emptyList<Long>(), seqs(g.offer(entry(3), 11))) // gap: 2 미도착 → 버퍼
|
||||
assertEquals(emptyList<Long>(), seqs(g.offer(entry(4), 12))) // 버퍼
|
||||
assertEquals(2, g.pendingCount())
|
||||
assertEquals(listOf(2L, 3L, 4L), seqs(g.offer(entry(2), 13))) // 2 도착 → 2,3,4 연속 방출
|
||||
assertEquals(5L, g.expected)
|
||||
assertEquals(0, g.pendingCount())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `과거 중복 seq는 무시`() {
|
||||
val g = GapBuffer()
|
||||
g.offer(entry(1), 10); g.offer(entry(2), 11)
|
||||
assertEquals(emptyList<Long>(), seqs(g.offer(entry(1), 12))) // 이미 지난 seq
|
||||
assertEquals(3L, g.expected)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `첫 엔트리가 중간 seq여도 그 값으로 앵커링`() {
|
||||
val g = GapBuffer()
|
||||
assertEquals(listOf(5L), seqs(g.offer(entry(5), 10))) // 재기동 후 earliest=5
|
||||
assertEquals(6L, g.expected)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `정체 감지 phantom skip으로 진행 재개`() {
|
||||
val g = GapBuffer()
|
||||
g.offer(entry(1), 100) // 적용, expected=2, lastAdvance=100
|
||||
g.offer(entry(3), 101) // 2 결번 → 3 버퍼
|
||||
assertEquals(50L, g.stalledMillis(150)) // 150-100 정체
|
||||
// 타임아웃: 2는 phantom → skip → 3 방출, expected=4
|
||||
assertEquals(listOf(3L), seqs(g.skipExpected(160)))
|
||||
assertEquals(4L, g.expected)
|
||||
assertEquals(0, g.stalledMillis(200)) // 대기자 없음 → 정체 아님
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `대기자 없으면 정체 0 이고 skip은 무동작`() {
|
||||
val g = GapBuffer()
|
||||
g.offer(entry(1), 10)
|
||||
assertEquals(0L, g.stalledMillis(9999))
|
||||
assertEquals(emptyList<Long>(), seqs(g.skipExpected(9999)))
|
||||
assertEquals(2L, g.expected) // 변화 없음
|
||||
}
|
||||
}
|
||||
22
backend/louisvuitton/build.gradle.kts
Normal file
22
backend/louisvuitton/build.gradle.kts
Normal file
@@ -0,0 +1,22 @@
|
||||
plugins {
|
||||
kotlin("jvm")
|
||||
kotlin("plugin.spring")
|
||||
kotlin("plugin.jpa")
|
||||
id("org.springframework.boot")
|
||||
id("io.spring.dependency-management")
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":common"))
|
||||
implementation("org.springframework.boot:spring-boot-starter-web")
|
||||
implementation("org.springframework.boot:spring-boot-starter-actuator")
|
||||
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
|
||||
implementation("org.flywaydb:flyway-core") // DB 스키마 형상관리(단일 출처)
|
||||
implementation("org.flywaydb:flyway-database-postgresql") // Flyway 10 PostgreSQL 모듈
|
||||
implementation("org.springframework.security:spring-security-crypto") // BCrypt(관리자 비번 변경)
|
||||
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
|
||||
implementation("org.jetbrains.kotlin:kotlin-reflect")
|
||||
runtimeOnly("org.postgresql:postgresql")
|
||||
testImplementation("org.springframework.boot:spring-boot-starter-test")
|
||||
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package kr.or.bok.rtgs.louisvuitton
|
||||
|
||||
import kr.or.bok.rtgs.common.ledger.Account
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.jdbc.core.JdbcTemplate
|
||||
import org.springframework.web.bind.annotation.DeleteMapping
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
import org.springframework.web.bind.annotation.PathVariable
|
||||
import org.springframework.web.bind.annotation.PostMapping
|
||||
import org.springframework.web.bind.annotation.RequestBody
|
||||
import org.springframework.web.bind.annotation.RequestParam
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
|
||||
data class InstitutionReq(val code: String, val name: String, val balance: Long = 1_000_000_000)
|
||||
data class UserReq(val username: String, val displayName: String = "", val role: String = "ORG_S", val orgCode: String? = null)
|
||||
|
||||
/**
|
||||
* 관리자(Louis Vuitton) API. 인증 강제는 후속 단계이므로 현재는 관리 마스터/운영 기능만.
|
||||
*/
|
||||
@RestController
|
||||
class AdminController(
|
||||
private val jdbc: JdbcTemplate,
|
||||
private val accounts: AccountRepository,
|
||||
private val users: AppUserRepository,
|
||||
) {
|
||||
private val log = LoggerFactory.getLogger(javaClass)
|
||||
|
||||
// ---------- 1) 테스트용 DB 초기화 ----------
|
||||
@PostMapping("/admin/reset")
|
||||
@Transactional
|
||||
fun reset(@RequestParam(defaultValue = "1000000000") initialBalance: Long): Map<String, Any?> {
|
||||
jdbc.execute("TRUNCATE transfer, journal_log, settlement_view, raw_message, notification")
|
||||
val updated = jdbc.update("UPDATE account SET balance = ?", initialBalance)
|
||||
log.warn("ADMIN RESET: cleared ledger tables, reset {} accounts to {}", updated, initialBalance)
|
||||
return mapOf("ok" to true, "accountsReset" to updated, "initialBalance" to initialBalance,
|
||||
"cleared" to listOf("transfer", "journal_log", "settlement_view", "raw_message", "notification"),
|
||||
"note" to "Kafka 저널 이력은 보존(오프셋 커밋으로 재처리 안 함). DB만 초기화.")
|
||||
}
|
||||
|
||||
// ---------- 2) 코드 관리: 참가기관(당좌계좌) 마스터 ----------
|
||||
@GetMapping("/admin/institutions")
|
||||
fun institutions() = accounts.findAll().sortedBy { it.code }
|
||||
.map { mapOf("code" to it.code, "name" to it.name, "balance" to it.balance) }
|
||||
|
||||
@PostMapping("/admin/institutions")
|
||||
fun upsertInstitution(@RequestBody r: InstitutionReq): Map<String, Any?> {
|
||||
require(r.code.matches(Regex("[0-9]{4}"))) { "기관코드는 숫자 4자리" }
|
||||
accounts.save(Account(code = r.code, name = r.name, balance = r.balance))
|
||||
return mapOf("ok" to true, "code" to r.code)
|
||||
}
|
||||
|
||||
@DeleteMapping("/admin/institutions/{code}")
|
||||
fun deleteInstitution(@PathVariable code: String): Map<String, Any?> {
|
||||
accounts.deleteById(code)
|
||||
return mapOf("ok" to true, "deleted" to code)
|
||||
}
|
||||
|
||||
/** 처리상태 코드 데이터 사전. */
|
||||
@GetMapping("/admin/status-codes")
|
||||
fun statusCodes() = listOf(
|
||||
mapOf("code" to "RCVD", "name" to "접수", "desc" to "Received - 접수됨"),
|
||||
mapOf("code" to "ACTC", "name" to "승인", "desc" to "AcceptedTechnicalValidation - 검증 통과"),
|
||||
mapOf("code" to "PDNG", "name" to "대기", "desc" to "Pending - 타 센터 수신/결제 전"),
|
||||
mapOf("code" to "ACSP", "name" to "예약", "desc" to "AcceptedSettlementInProcess - 결제 반영, 결과 대기"),
|
||||
mapOf("code" to "ACCC", "name" to "입금처리완료", "desc" to "AcceptedSettlementCompleted - 최종 완결"),
|
||||
mapOf("code" to "RJCT", "name" to "반려", "desc" to "Rejected - 검증 실패/잔액부족 등"),
|
||||
)
|
||||
|
||||
// ---------- 3) 사용자 권한 관리 (관리 마스터) ----------
|
||||
@GetMapping("/admin/users")
|
||||
fun listUsers() = users.findAll().sortedBy { it.username }
|
||||
.map { mapOf("username" to it.username, "displayName" to it.displayName, "role" to it.role, "orgCode" to it.orgCode) }
|
||||
|
||||
@PostMapping("/admin/users")
|
||||
fun upsertUser(@RequestBody r: UserReq): Map<String, Any?> {
|
||||
require(r.role in setOf("ADMIN", "ORG_S", "ORG_R")) { "role은 ADMIN/ORG_S/ORG_R" }
|
||||
users.save(AppUser(username = r.username, displayName = r.displayName, role = r.role,
|
||||
orgCode = r.orgCode, createdAt = System.currentTimeMillis()))
|
||||
return mapOf("ok" to true, "username" to r.username)
|
||||
}
|
||||
|
||||
@DeleteMapping("/admin/users/{username}")
|
||||
fun deleteUser(@PathVariable username: String): Map<String, Any?> {
|
||||
users.deleteById(username)
|
||||
return mapOf("ok" to true, "deleted" to username)
|
||||
}
|
||||
|
||||
// ---------- 4) 대사(정합성) 대시보드 ----------
|
||||
@GetMapping("/admin/summary")
|
||||
fun summary(): Map<String, Any?> = mapOf(
|
||||
"byStatus" to jdbc.queryForList("SELECT status, count(*) AS cnt FROM transfer GROUP BY status ORDER BY status"),
|
||||
"totalBalance" to jdbc.queryForObject("SELECT COALESCE(sum(balance),0) FROM account", Long::class.java),
|
||||
"accounts" to jdbc.queryForObject("SELECT count(*) FROM account", Long::class.java),
|
||||
"transfers" to jdbc.queryForObject("SELECT count(*) FROM transfer", Long::class.java),
|
||||
"maxSeq" to jdbc.queryForObject("SELECT COALESCE(max(global_seq),0) FROM transfer", Long::class.java),
|
||||
)
|
||||
|
||||
// ---------- 5) 서비스 헬스(B5) — 하트비트 기반 liveness(헤드리스 포함) ----------
|
||||
@GetMapping("/admin/health")
|
||||
fun health(): List<Map<String, Any?>> {
|
||||
val now = System.currentTimeMillis()
|
||||
return jdbc.queryForList("SELECT service, center, last_seen, pid FROM service_heartbeat ORDER BY service")
|
||||
.map { r ->
|
||||
val last = (r["last_seen"] as? Number)?.toLong() ?: 0
|
||||
val ageMs = now - last
|
||||
mapOf(
|
||||
"service" to r["service"], "center" to r["center"], "pid" to r["pid"],
|
||||
"lastSeen" to last, "ageMs" to ageMs,
|
||||
"status" to if (ageMs <= 15000) "UP" else "STALE",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun lim(n: Int) = n.coerceIn(1, 200).toString()
|
||||
|
||||
@GetMapping("/admin/ledger/transfers")
|
||||
fun transfers(@RequestParam(defaultValue = "20") limit: Int) = jdbc.queryForList(
|
||||
"SELECT bmi, global_seq, status, sender_code, receiver_code, amount, origin_center, updated_at " +
|
||||
"FROM transfer ORDER BY global_seq DESC NULLS LAST LIMIT ${lim(limit)}")
|
||||
|
||||
@GetMapping("/admin/ledger/journal")
|
||||
fun journal(@RequestParam(defaultValue = "20") limit: Int) = jdbc.queryForList(
|
||||
"SELECT global_seq, bmi, applied FROM journal_log ORDER BY global_seq DESC LIMIT ${lim(limit)}")
|
||||
|
||||
@GetMapping("/admin/ledger/views")
|
||||
fun views(@RequestParam(defaultValue = "20") limit: Int) = jdbc.queryForList(
|
||||
"SELECT bmi, global_seq, final_status, debtor_balance_after, creditor_balance_after, finalized_at " +
|
||||
"FROM settlement_view ORDER BY global_seq DESC NULLS LAST LIMIT ${lim(limit)}")
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package kr.or.bok.rtgs.louisvuitton
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import jakarta.servlet.http.HttpServletRequest
|
||||
import jakarta.servlet.http.HttpServletResponse
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.context.annotation.Configuration
|
||||
import org.springframework.stereotype.Component
|
||||
import org.springframework.web.servlet.HandlerInterceptor
|
||||
import org.springframework.web.servlet.config.annotation.InterceptorRegistry
|
||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer
|
||||
import java.nio.charset.StandardCharsets
|
||||
import java.util.Base64
|
||||
import javax.crypto.Mac
|
||||
import javax.crypto.spec.SecretKeySpec
|
||||
|
||||
/**
|
||||
* 관리자 API 보호 — Gucci가 발급한 JWT를 검증해 role=ADMIN만 admin 경로 실행 허용(A1).
|
||||
* 검증만 수행하므로 Gucci와 동일한 서명키(gucci.jwt-secret)만 공유하면 된다(발급 코드 불필요).
|
||||
*/
|
||||
@Component
|
||||
class AdminJwt(
|
||||
private val mapper: ObjectMapper,
|
||||
@Value("\${gucci.jwt-secret}") private val secret: String,
|
||||
) {
|
||||
private val dec = Base64.getUrlDecoder()
|
||||
private val enc = Base64.getUrlEncoder().withoutPadding()
|
||||
|
||||
/** 유효하고 role=ADMIN이면 사용자명 반환, 아니면 null. */
|
||||
fun adminSubjectOrNull(authz: String?, nowMs: Long): String? {
|
||||
val token = authz?.removePrefix("Bearer ")?.trim() ?: return null
|
||||
val p = token.split(".")
|
||||
if (p.size != 3) return null
|
||||
val expected = sign("${p[0]}.${p[1]}")
|
||||
if (!constantTimeEquals(expected, p[2])) return null
|
||||
val claims: Map<*, *> = runCatching { mapper.readValue(dec.decode(p[1]), Map::class.java) }.getOrNull() ?: return null
|
||||
val exp = (claims["exp"] as? Number)?.toLong() ?: return null
|
||||
if (nowMs / 1000 > exp) return null
|
||||
if ((claims["role"] as? String) != "ADMIN") return null
|
||||
return claims["sub"] as? String
|
||||
}
|
||||
|
||||
private fun sign(input: String): String {
|
||||
val mac = Mac.getInstance("HmacSHA256")
|
||||
mac.init(SecretKeySpec(secret.toByteArray(StandardCharsets.UTF_8), "HmacSHA256"))
|
||||
return enc.encodeToString(mac.doFinal(input.toByteArray(StandardCharsets.UTF_8)))
|
||||
}
|
||||
|
||||
private fun constantTimeEquals(a: String, b: String): Boolean {
|
||||
val x = a.toByteArray(); val y = b.toByteArray()
|
||||
if (x.size != y.size) return false
|
||||
var r = 0; for (i in x.indices) r = r or (x[i].toInt() xor y[i].toInt()); return r == 0
|
||||
}
|
||||
}
|
||||
|
||||
@Component
|
||||
class AdminAuthInterceptor(private val adminJwt: AdminJwt) : HandlerInterceptor {
|
||||
private val log = LoggerFactory.getLogger(javaClass)
|
||||
override fun preHandle(req: HttpServletRequest, res: HttpServletResponse, handler: Any): Boolean {
|
||||
val subject = adminJwt.adminSubjectOrNull(req.getHeader("Authorization"), System.currentTimeMillis())
|
||||
if (subject == null) {
|
||||
log.warn("ADMIN deny path={} (no/invalid ADMIN token)", req.requestURI)
|
||||
res.status = HttpServletResponse.SC_UNAUTHORIZED
|
||||
res.contentType = "application/json;charset=UTF-8"
|
||||
res.writer.write("""{"error":"admin authentication required"}""")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
/** admin 경로에 관리자 인증 인터셉터 적용. */
|
||||
@Configuration
|
||||
class AdminWebConfig(private val interceptor: AdminAuthInterceptor) : WebMvcConfigurer {
|
||||
override fun addInterceptors(registry: InterceptorRegistry) {
|
||||
registry.addInterceptor(interceptor).addPathPatterns("/admin/**")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package kr.or.bok.rtgs.louisvuitton
|
||||
|
||||
import org.springframework.http.HttpStatus
|
||||
import org.springframework.http.ResponseEntity
|
||||
import org.springframework.jdbc.core.JdbcTemplate
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
import org.springframework.web.bind.annotation.PostMapping
|
||||
import org.springframework.web.bind.annotation.RequestBody
|
||||
import org.springframework.web.bind.annotation.RequestMapping
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
|
||||
/**
|
||||
* 부하테스트 콘솔 API (인증 없는 `/console` 경로 — AdminAuthInterceptor는 `/admin` 경로만 보호).
|
||||
* 정적 화면(static/loadtest.html)이 이 엔드포인트를 호출한다. localhost 프로토타입 운영도구.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/console")
|
||||
class LoadTestController(
|
||||
private val loadTest: LoadTestService,
|
||||
private val jdbc: JdbcTemplate,
|
||||
) {
|
||||
@PostMapping("/loadtest/start")
|
||||
fun start(@RequestBody params: LoadTestParams): ResponseEntity<Map<String, Any?>> {
|
||||
return try {
|
||||
if (loadTest.start(params)) ResponseEntity.accepted().body(loadTest.status())
|
||||
else ResponseEntity.status(HttpStatus.CONFLICT)
|
||||
.body(mapOf("error" to "이미 실행 중인 부하테스트가 있습니다."))
|
||||
} catch (e: IllegalStateException) {
|
||||
ResponseEntity.badRequest().body(mapOf("error" to (e.message ?: "잘못된 요청")))
|
||||
}
|
||||
}
|
||||
|
||||
@GetMapping("/loadtest/status")
|
||||
fun status(): Map<String, Any?> = loadTest.status()
|
||||
|
||||
@PostMapping("/loadtest/stop")
|
||||
fun stop(): Map<String, Any?> {
|
||||
loadTest.stop()
|
||||
return mapOf("ok" to true, "running" to loadTest.isRunning)
|
||||
}
|
||||
|
||||
@GetMapping("/loadtest/result")
|
||||
fun result(): Map<String, Any?> = loadTest.result()
|
||||
|
||||
/** 원장 집계(읽기 전용) — 부하 전/후 스냅샷으로 총잔액 보존(0-합) 표시용. */
|
||||
@GetMapping("/ledger/summary")
|
||||
fun ledgerSummary(): Map<String, Any?> = mapOf(
|
||||
"byStatus" to jdbc.queryForList("SELECT status, count(*) AS cnt FROM transfer GROUP BY status ORDER BY status"),
|
||||
"totalBalance" to jdbc.queryForObject("SELECT COALESCE(sum(balance),0) FROM account", Long::class.java),
|
||||
"accounts" to jdbc.queryForObject("SELECT count(*) FROM account", Long::class.java),
|
||||
"transfers" to jdbc.queryForObject("SELECT count(*) FROM transfer", Long::class.java),
|
||||
"maxSeq" to jdbc.queryForObject("SELECT COALESCE(max(global_seq),0) FROM transfer", Long::class.java),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
package kr.or.bok.rtgs.louisvuitton
|
||||
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.stereotype.Service
|
||||
import java.net.URI
|
||||
import java.net.http.HttpClient
|
||||
import java.net.http.HttpRequest
|
||||
import java.net.http.HttpResponse
|
||||
import java.time.Duration
|
||||
import java.time.LocalDate
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.util.concurrent.ConcurrentLinkedQueue
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.ThreadLocalRandom
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
|
||||
/** 콘솔 부하테스트 요청 파라미터(동시 요청 수 기반 closed-loop). */
|
||||
data class LoadTestParams(
|
||||
val concurrency: Int = 10,
|
||||
val durationSec: Int = 15,
|
||||
val chanelUrl: String = "http://localhost:8091",
|
||||
val amountMin: Long = 1,
|
||||
val amountMax: Long = 200,
|
||||
)
|
||||
|
||||
/**
|
||||
* 루이비똥 내장 부하생성기 — Chanel `/pay/customer`에 pacs.008.001.08 전문을 실시간 생성·투입.
|
||||
*
|
||||
* 동시 요청 수 기반 closed-loop: 가상 스레드 워커 N개가 각자 완료 즉시 다음 건을 보내며 D초간 반복.
|
||||
* 외부 k6 의존 없이 서비스 안에서 백엔드와 동일한 전문 포맷을 쓴다(포맷 어긋남 방지).
|
||||
* 한 번에 하나의 테스트만 수행한다(running 가드).
|
||||
*/
|
||||
@Service
|
||||
class LoadTestService(private val accounts: AccountRepository) {
|
||||
|
||||
private val log = LoggerFactory.getLogger(javaClass)
|
||||
|
||||
private val http: HttpClient = HttpClient.newBuilder()
|
||||
.connectTimeout(Duration.ofSeconds(5))
|
||||
.executor(Executors.newVirtualThreadPerTaskExecutor())
|
||||
.build()
|
||||
|
||||
// ---- 실행 상태 ----
|
||||
private val running = AtomicBoolean(false)
|
||||
@Volatile private var params: LoadTestParams = LoadTestParams()
|
||||
@Volatile private var startMs: Long = 0
|
||||
@Volatile private var endMs: Long = 0
|
||||
@Volatile private var deadline: Long = 0
|
||||
@Volatile private var codes: List<String> = emptyList()
|
||||
|
||||
private val sent = AtomicLong(0)
|
||||
private val accepted = AtomicLong(0)
|
||||
private val rejected = AtomicLong(0)
|
||||
private val errors = AtomicLong(0)
|
||||
private val latSumMs = AtomicLong(0)
|
||||
private val latencies = ConcurrentLinkedQueue<Long>() // ms 표본
|
||||
|
||||
// BMI 일련번호: JVM 시작 시각으로 시드 → 재기동/여러 run에도 사실상 유일.
|
||||
private val serialSeq = AtomicLong(System.currentTimeMillis() % 10_000_000_000L)
|
||||
private val bizDate: String = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMMdd"))
|
||||
|
||||
val isRunning: Boolean get() = running.get()
|
||||
|
||||
/** 테스트 시작. 이미 실행 중이면 false. */
|
||||
fun start(p: LoadTestParams): Boolean {
|
||||
if (!running.compareAndSet(false, true)) return false
|
||||
val codeList = accounts.findAll().map { it.code }.filter { it.isNotBlank() }.sorted()
|
||||
if (codeList.size < 2) {
|
||||
running.set(false)
|
||||
throw IllegalStateException("참가기관 계좌가 2개 미만입니다(현재 ${codeList.size}). 부하 생성 불가.")
|
||||
}
|
||||
params = p
|
||||
codes = codeList
|
||||
sent.set(0); accepted.set(0); rejected.set(0); errors.set(0); latSumMs.set(0)
|
||||
latencies.clear()
|
||||
startMs = System.currentTimeMillis()
|
||||
endMs = 0
|
||||
deadline = startMs + p.durationSec.coerceIn(1, 3600) * 1000L
|
||||
val workerCount = p.concurrency.coerceIn(1, 2000)
|
||||
|
||||
// 코디네이터(데몬) — 워커 완료까지 대기 후 종료 마킹. start()는 즉시 반환.
|
||||
Thread {
|
||||
val exec = Executors.newVirtualThreadPerTaskExecutor()
|
||||
try {
|
||||
val tasks = (1..workerCount).map { exec.submit { worker() } }
|
||||
tasks.forEach { runCatching { it.get() } }
|
||||
} finally {
|
||||
exec.shutdown()
|
||||
endMs = System.currentTimeMillis()
|
||||
running.set(false)
|
||||
log.info("[loadtest] done: sent={} accepted={} rejected={} errors={} in {}ms",
|
||||
sent.get(), accepted.get(), rejected.get(), errors.get(), endMs - startMs)
|
||||
}
|
||||
}.apply { isDaemon = true; name = "loadtest-coordinator" }.start()
|
||||
|
||||
log.warn("[loadtest] start: concurrency={} durationSec={} target={} amount={}~{}",
|
||||
workerCount, p.durationSec, p.chanelUrl, p.amountMin, p.amountMax)
|
||||
return true
|
||||
}
|
||||
|
||||
/** 실행 중 워커를 즉시 종료(마감을 현재로 당김). */
|
||||
fun stop() {
|
||||
if (running.get()) {
|
||||
deadline = System.currentTimeMillis()
|
||||
log.warn("[loadtest] stop requested")
|
||||
}
|
||||
}
|
||||
|
||||
private fun worker() {
|
||||
val rnd = ThreadLocalRandom.current()
|
||||
val url = URI.create(params.chanelUrl.trimEnd('/') + "/pay/customer")
|
||||
val lo = params.amountMin
|
||||
val span = (params.amountMax - params.amountMin + 1).coerceAtLeast(1)
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
val s = codes[rnd.nextInt(codes.size)]
|
||||
var r = codes[rnd.nextInt(codes.size)]
|
||||
while (r == s) r = codes[rnd.nextInt(codes.size)]
|
||||
val amount = lo + (rnd.nextLong(span))
|
||||
val xml = buildPacs008(s, r, amount)
|
||||
val req = HttpRequest.newBuilder(url)
|
||||
.timeout(Duration.ofSeconds(30))
|
||||
.header("Content-Type", "application/xml")
|
||||
.POST(HttpRequest.BodyPublishers.ofString(xml))
|
||||
.build()
|
||||
val t0 = System.nanoTime()
|
||||
try {
|
||||
val resp = http.send(req, HttpResponse.BodyHandlers.ofString())
|
||||
record(System.nanoTime() - t0)
|
||||
when {
|
||||
resp.statusCode() == 200 && resp.body().contains("RCVD") -> accepted.incrementAndGet()
|
||||
resp.statusCode() == 200 -> rejected.incrementAndGet()
|
||||
else -> errors.incrementAndGet()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
record(System.nanoTime() - t0)
|
||||
errors.incrementAndGet()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun record(nanos: Long) {
|
||||
val ms = nanos / 1_000_000
|
||||
sent.incrementAndGet()
|
||||
latSumMs.addAndGet(ms)
|
||||
latencies.add(ms)
|
||||
}
|
||||
|
||||
/** BMI = 날짜(8) + 송신기관(4) + 일련번호(10) = 22자리. */
|
||||
private fun buildPacs008(s: String, r: String, amount: Long): String {
|
||||
val serial = (serialSeq.incrementAndGet() % 10_000_000_000L).toString().padStart(10, '0')
|
||||
val bmi = bizDate + s + serial
|
||||
val creDtTm = java.time.LocalDateTime.now().withNano(0)
|
||||
.format(DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss"))
|
||||
return "<?xml version=\"1.0\" encoding=\"UTF-8\"?>" +
|
||||
"<Document xmlns=\"urn:iso:std:iso:20022:tech:xsd:pacs.008.001.08\">" +
|
||||
"<FIToFICstmrCdtTrf>" +
|
||||
"<GrpHdr><MsgId>$bmi</MsgId><CreDtTm>$creDtTm</CreDtTm><NbOfTxs>1</NbOfTxs>" +
|
||||
"<SttlmInf><SttlmMtd>CLRG</SttlmMtd></SttlmInf></GrpHdr>" +
|
||||
"<CdtTrfTxInf>" +
|
||||
"<PmtId><EndToEndId>$bmi</EndToEndId></PmtId>" +
|
||||
"<IntrBkSttlmAmt Ccy=\"KRW\">$amount</IntrBkSttlmAmt>" +
|
||||
"<ChrgBr>SLEV</ChrgBr>" +
|
||||
"<Dbtr><Nm>BANK-$s</Nm></Dbtr>" +
|
||||
"<DbtrAcct><Id><Othr><Id>ACC-$s</Id></Othr></Id></DbtrAcct>" +
|
||||
"<DbtrAgt><FinInstnId><ClrSysMmbId><MmbId>$s</MmbId></ClrSysMmbId></FinInstnId></DbtrAgt>" +
|
||||
"<CdtrAgt><FinInstnId><ClrSysMmbId><MmbId>$r</MmbId></ClrSysMmbId></FinInstnId></CdtrAgt>" +
|
||||
"<Cdtr><Nm>BANK-$r</Nm></Cdtr>" +
|
||||
"<CdtrAcct><Id><Othr><Id>ACC-$r</Id></Othr></Id></CdtrAcct>" +
|
||||
"</CdtTrfTxInf></FIToFICstmrCdtTrf></Document>"
|
||||
}
|
||||
|
||||
/** 실시간 스냅샷(진행 중/후 모두). */
|
||||
fun status(): Map<String, Any?> {
|
||||
val now = if (running.get()) System.currentTimeMillis() else endMs.takeIf { it > 0 } ?: System.currentTimeMillis()
|
||||
val elapsedMs = (now - startMs).coerceAtLeast(0)
|
||||
val elapsedSec = elapsedMs / 1000.0
|
||||
val sentN = sent.get()
|
||||
return mapOf(
|
||||
"running" to running.get(),
|
||||
"params" to params,
|
||||
"elapsedMs" to elapsedMs,
|
||||
"durationSec" to params.durationSec,
|
||||
"sent" to sentN,
|
||||
"accepted" to accepted.get(),
|
||||
"rejected" to rejected.get(),
|
||||
"errors" to errors.get(),
|
||||
"tps" to if (elapsedSec > 0) round2(sentN / elapsedSec) else 0.0,
|
||||
"avgLatencyMs" to if (sentN > 0) latSumMs.get() / sentN else 0,
|
||||
)
|
||||
}
|
||||
|
||||
/** 최종 요약(백분위 포함). 실행 중이면 그 시점까지의 값. */
|
||||
fun result(): Map<String, Any?> {
|
||||
val sentN = sent.get()
|
||||
val acc = accepted.get()
|
||||
val end = if (endMs > 0) endMs else System.currentTimeMillis()
|
||||
val durMs = (end - startMs).coerceAtLeast(1)
|
||||
val durSec = durMs / 1000.0
|
||||
val arr = latencies.toLongArray().also { it.sort() }
|
||||
fun pct(p: Double): Long = if (arr.isEmpty()) 0 else arr[((p / 100.0 * arr.size).toInt()).coerceIn(0, arr.size - 1)]
|
||||
return mapOf(
|
||||
"running" to running.get(),
|
||||
"params" to params,
|
||||
"durationMs" to durMs,
|
||||
"sent" to sentN,
|
||||
"accepted" to acc,
|
||||
"rejected" to rejected.get(),
|
||||
"errors" to errors.get(),
|
||||
"successRate" to if (sentN > 0) round2(acc * 100.0 / sentN) else 0.0,
|
||||
"throughputTps" to if (durSec > 0) round2(sentN / durSec) else 0.0,
|
||||
"latencyMs" to mapOf(
|
||||
"min" to (arr.firstOrNull() ?: 0),
|
||||
"avg" to if (sentN > 0) latSumMs.get() / sentN else 0,
|
||||
"p50" to pct(50.0), "p90" to pct(90.0), "p95" to pct(95.0),
|
||||
"p99" to pct(99.0), "max" to (arr.lastOrNull() ?: 0),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun round2(v: Double): Double = Math.round(v * 100.0) / 100.0
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package kr.or.bok.rtgs.louisvuitton
|
||||
|
||||
import jakarta.persistence.Column
|
||||
import jakarta.persistence.Entity
|
||||
import jakarta.persistence.Id
|
||||
import jakarta.persistence.Table
|
||||
import kr.or.bok.rtgs.common.ledger.Account
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication
|
||||
import org.springframework.boot.autoconfigure.domain.EntityScan
|
||||
import org.springframework.boot.runApplication
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
import org.springframework.data.jpa.repository.config.EnableJpaRepositories
|
||||
|
||||
/**
|
||||
* LUIS VUITTON (시스템관리) — 관리자 기능: DB 초기화, 코드(참가기관) 관리, 사용자 권한 관리, 대사.
|
||||
* (3차 PoC 미구현분. 인증/로그인 강제는 후속 단계; 지금은 사용자·권한 "관리 마스터"만.)
|
||||
*/
|
||||
@SpringBootApplication
|
||||
@EntityScan(basePackages = ["kr.or.bok.rtgs.common.ledger", "kr.or.bok.rtgs.louisvuitton"])
|
||||
@EnableJpaRepositories(considerNestedRepositories = true, basePackages = ["kr.or.bok.rtgs.louisvuitton"])
|
||||
class LouisVuittonApplication
|
||||
|
||||
/** 사용자·권한 관리 마스터. role = ADMIN / ORG_S(송신기관) / ORG_R(수신기관). */
|
||||
@Entity
|
||||
@Table(name = "app_user")
|
||||
class AppUser(
|
||||
@Id @Column(name = "username", length = 32) var username: String = "",
|
||||
@Column(name = "display_name", length = 64) var displayName: String = "",
|
||||
@Column(name = "role", length = 16) var role: String = "ORG_S",
|
||||
@Column(name = "org_code", length = 4) var orgCode: String? = null,
|
||||
@Column(name = "created_at") var createdAt: Long = 0,
|
||||
)
|
||||
|
||||
interface AppUserRepository : JpaRepository<AppUser, String>
|
||||
interface AccountRepository : JpaRepository<Account, String>
|
||||
|
||||
fun main(args: Array<String>) {
|
||||
runApplication<LouisVuittonApplication>(*args)
|
||||
}
|
||||
31
backend/louisvuitton/src/main/resources/application.yml
Normal file
31
backend/louisvuitton/src/main/resources/application.yml
Normal file
@@ -0,0 +1,31 @@
|
||||
spring:
|
||||
application:
|
||||
name: louisvuitton
|
||||
datasource:
|
||||
url: jdbc:postgresql://${POSTGRES_HOST:localhost}:${POSTGRES_PORT:5433}/${POSTGRES_DB:rtgs}
|
||||
username: ${POSTGRES_USER:rtgs}
|
||||
password: ${POSTGRES_PASSWORD:rtgs}
|
||||
jpa:
|
||||
hibernate:
|
||||
ddl-auto: none
|
||||
open-in-view: false
|
||||
flyway:
|
||||
enabled: true
|
||||
baseline-on-migrate: true # 기존(비어있지 않은) 운영 DB는 V1을 baseline 처리(무변경)
|
||||
baseline-version: 1
|
||||
locations: classpath:db/migration
|
||||
|
||||
server:
|
||||
port: 8099
|
||||
|
||||
rtgs:
|
||||
center-id: ${CENTER_ID:DC1}
|
||||
|
||||
gucci:
|
||||
jwt-secret: ${GUCCI_JWT_SECRET:rtgs-gucci-dev-secret-key-please-change-0123456789} # Gucci와 동일 키로 토큰 검증
|
||||
|
||||
management:
|
||||
endpoints:
|
||||
web:
|
||||
exposure:
|
||||
include: health,info,prometheus,metrics
|
||||
@@ -0,0 +1,161 @@
|
||||
-- RTGS 권위 원장 스키마 (PostgreSQL, 강한 일관성). 엔티티: common/ledger/Entities.kt 와 정합.
|
||||
-- ddl-auto=none 이므로 이 스크립트가 스키마를 생성한다(컨테이너 최초 기동 시 1회).
|
||||
|
||||
CREATE TABLE IF NOT EXISTS account (
|
||||
code VARCHAR(4) PRIMARY KEY,
|
||||
name VARCHAR(64) NOT NULL,
|
||||
balance BIGINT NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS transfer (
|
||||
bmi VARCHAR(22) PRIMARY KEY,
|
||||
global_seq BIGINT,
|
||||
status VARCHAR(8) NOT NULL,
|
||||
sender_code VARCHAR(4),
|
||||
receiver_code VARCHAR(4),
|
||||
amount BIGINT NOT NULL,
|
||||
origin_center VARCHAR(8),
|
||||
created_at BIGINT,
|
||||
updated_at BIGINT,
|
||||
reason VARCHAR(255)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_transfer_seq ON transfer (global_seq);
|
||||
CREATE INDEX IF NOT EXISTS idx_transfer_status ON transfer (status);
|
||||
|
||||
-- 선(先)저널(write-ahead) 로그: 무손실 승계·감사
|
||||
CREATE TABLE IF NOT EXISTS journal_log (
|
||||
global_seq BIGINT PRIMARY KEY,
|
||||
bmi VARCHAR(22),
|
||||
seq_epoch_millis BIGINT,
|
||||
applied BOOLEAN NOT NULL DEFAULT FALSE
|
||||
);
|
||||
|
||||
-- 원전문 저장 (로컬 PostgreSQL text; 클라우드/BMT는 문서형 DB(MongoDB)).
|
||||
CREATE TABLE IF NOT EXISTS raw_message (
|
||||
bmi VARCHAR(22) PRIMARY KEY,
|
||||
msg_type VARCHAR(32),
|
||||
raw_xml TEXT,
|
||||
orig_hash VARCHAR(64),
|
||||
received_at BIGINT,
|
||||
origin_center VARCHAR(8)
|
||||
);
|
||||
|
||||
-- 조회 전용 사본 (로컬 PostgreSQL; 클라우드/BMT는 문서형 DB).
|
||||
CREATE TABLE IF NOT EXISTS settlement_view (
|
||||
bmi VARCHAR(22) PRIMARY KEY,
|
||||
global_seq BIGINT,
|
||||
final_status VARCHAR(8),
|
||||
sender_code VARCHAR(4),
|
||||
receiver_code VARCHAR(4),
|
||||
amount BIGINT,
|
||||
origin_center VARCHAR(8),
|
||||
processed_center VARCHAR(8),
|
||||
debtor_balance_after BIGINT,
|
||||
creditor_balance_after BIGINT,
|
||||
finalized_at BIGINT
|
||||
);
|
||||
|
||||
-- 결과통보 아웃박스 — 접수센터(Chanel)가 신청/수취기관 앞 송부한 결과 전문(pacs.002).
|
||||
CREATE TABLE IF NOT EXISTS notification (
|
||||
id VARCHAR(32) PRIMARY KEY, -- bmi:수신기관
|
||||
bmi VARCHAR(22),
|
||||
to_org VARCHAR(4), -- 수신 대상 기관
|
||||
role VARCHAR(12), -- APPLICANT(신청) / BENEFICIARY(수취)
|
||||
final_status VARCHAR(8),
|
||||
pacs002_xml TEXT,
|
||||
origin_center VARCHAR(8),
|
||||
reason TEXT,
|
||||
delivered BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
attempts INTEGER NOT NULL DEFAULT 0,
|
||||
created_at BIGINT,
|
||||
delivered_at BIGINT
|
||||
);
|
||||
ALTER TABLE notification ADD COLUMN IF NOT EXISTS attempts INTEGER NOT NULL DEFAULT 0;
|
||||
CREATE INDEX IF NOT EXISTS idx_notification_bmi ON notification (bmi);
|
||||
CREATE INDEX IF NOT EXISTS idx_notification_pending ON notification (delivered);
|
||||
|
||||
-- Gucci 콜백 레지스트리 — 기관별 결과통보 수신 URL. Gucci(관문)가 pacs.002를 여기로 송부(재시도/ACK).
|
||||
-- 로컬 데모는 Gucci 자체 sink(:8095/gucci/sink/{org})로 자기수신해 왕복을 시연.
|
||||
CREATE TABLE IF NOT EXISTS institution_endpoint (
|
||||
org_code VARCHAR(4) PRIMARY KEY,
|
||||
callback_url VARCHAR(256),
|
||||
active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at BIGINT
|
||||
);
|
||||
INSERT INTO institution_endpoint(org_code, callback_url, active, created_at) VALUES
|
||||
('1001','http://localhost:8095/gucci/sink/1001',TRUE,0),
|
||||
('1002','http://localhost:8095/gucci/sink/1002',TRUE,0),
|
||||
('1008','http://localhost:8095/gucci/sink/1008',TRUE,0),
|
||||
('1010','http://localhost:8095/gucci/sink/1010',TRUE,0)
|
||||
ON CONFLICT (org_code) DO NOTHING;
|
||||
COMMENT ON COLUMN notification.bmi IS '거래식별자(BMI)';
|
||||
COMMENT ON COLUMN notification.to_org IS '통보대상기관';
|
||||
COMMENT ON COLUMN notification.role IS '대상역할(신청/수취)';
|
||||
COMMENT ON COLUMN notification.final_status IS '최종상태';
|
||||
COMMENT ON COLUMN notification.pacs002_xml IS '결과전문(pacs.002)';
|
||||
COMMENT ON COLUMN notification.delivered IS '송부여부';
|
||||
COMMENT ON COLUMN notification.created_at IS '생성시각';
|
||||
|
||||
-- 참가기관 당좌계좌 시드 (19개). 초기 잔액 10억원(부하테스트 1~200원 이체가 고갈되지 않도록).
|
||||
INSERT INTO account (code, name, balance) VALUES
|
||||
('1001', 'KOOKMIN', 1000000000),
|
||||
('1002', 'SHINHAN', 1000000000),
|
||||
('1003', 'WOORI', 1000000000),
|
||||
('1004', 'HANA', 1000000000),
|
||||
('1005', 'NONGHYUP', 1000000000),
|
||||
('1006', 'IBK', 1000000000),
|
||||
('1007', 'SC', 1000000000),
|
||||
('1008', 'CITI', 1000000000),
|
||||
('1009', 'KDB', 1000000000),
|
||||
('1010', 'SUHYUP', 1000000000),
|
||||
('1011', 'DGB', 1000000000),
|
||||
('1012', 'BNK_BUSAN', 1000000000),
|
||||
('1013', 'BNK_KYONGNAM', 1000000000),
|
||||
('1014', 'KWANGJU', 1000000000),
|
||||
('1015', 'JEONBUK', 1000000000),
|
||||
('1016', 'JEJU', 1000000000),
|
||||
('1017', 'KAKAOBANK', 1000000000),
|
||||
('1018', 'KBANK', 1000000000),
|
||||
('1019', 'TOSSBANK', 1000000000)
|
||||
ON CONFLICT (code) DO NOTHING;
|
||||
|
||||
-- 데이터 사전: 컬럼 한글명(코멘트) — 화면 라벨의 단일 출처
|
||||
COMMENT ON COLUMN transfer.bmi IS '거래식별자(BMI)';
|
||||
COMMENT ON COLUMN transfer.global_seq IS '전역순번';
|
||||
COMMENT ON COLUMN transfer.status IS '처리상태';
|
||||
COMMENT ON COLUMN transfer.sender_code IS '송신기관코드';
|
||||
COMMENT ON COLUMN transfer.receiver_code IS '수신기관코드';
|
||||
COMMENT ON COLUMN transfer.amount IS '이체금액(원)';
|
||||
COMMENT ON COLUMN transfer.origin_center IS '접수센터';
|
||||
COMMENT ON COLUMN transfer.created_at IS '접수시각';
|
||||
COMMENT ON COLUMN transfer.updated_at IS '최종수정시각';
|
||||
COMMENT ON COLUMN transfer.reason IS '사유';
|
||||
COMMENT ON COLUMN account.code IS '기관코드';
|
||||
COMMENT ON COLUMN account.name IS '기관명';
|
||||
COMMENT ON COLUMN account.balance IS '당좌계좌잔액(원)';
|
||||
|
||||
-- 사용자·권한 관리 마스터(Louis Vuitton). role: ADMIN/ORG_S/ORG_R. (로그인 강제는 후속)
|
||||
CREATE TABLE IF NOT EXISTS app_user (
|
||||
username VARCHAR(32) PRIMARY KEY,
|
||||
display_name VARCHAR(64),
|
||||
role VARCHAR(16) NOT NULL,
|
||||
org_code VARCHAR(4),
|
||||
secret VARCHAR(128), -- Gucci 로그인 자격증명. 시드는 평문 dev값, 기동 시 Gucci가 BCrypt 해시로 승격
|
||||
created_at BIGINT
|
||||
);
|
||||
-- 기존 DB 호환: secret 컬럼 추가(있으면 무시)
|
||||
ALTER TABLE app_user ADD COLUMN IF NOT EXISTS secret VARCHAR(128);
|
||||
COMMENT ON COLUMN app_user.username IS '사용자ID';
|
||||
COMMENT ON COLUMN app_user.display_name IS '사용자명';
|
||||
COMMENT ON COLUMN app_user.role IS '역할(ADMIN/ORG_S/ORG_R)';
|
||||
COMMENT ON COLUMN app_user.org_code IS '매핑기관코드';
|
||||
INSERT INTO app_user(username,display_name,role,org_code,secret,created_at) VALUES
|
||||
('admin','시스템관리자','ADMIN',NULL,'admin-secret',0),
|
||||
('kookmin_s','국민은행 송신담당','ORG_S','1001','kookmin-secret',0),
|
||||
('shinhan_r','신한은행 수신담당','ORG_R','1002','shinhan-secret',0)
|
||||
ON CONFLICT (username) DO NOTHING;
|
||||
-- 기존 행에 dev 시크릿 보정(널인 경우만)
|
||||
UPDATE app_user SET secret = 'admin-secret' WHERE username='admin' AND secret IS NULL;
|
||||
UPDATE app_user SET secret = 'kookmin-secret' WHERE username='kookmin_s' AND secret IS NULL;
|
||||
UPDATE app_user SET secret = 'shinhan-secret' WHERE username='shinhan_r' AND secret IS NULL;
|
||||
COMMENT ON COLUMN app_user.secret IS 'Gucci로그인비밀키';
|
||||
@@ -0,0 +1,8 @@
|
||||
-- A1 관리자 로그인: 초기암호 변경 플래그 + 신속 테스트 관리자(a/1)
|
||||
ALTER TABLE app_user ADD COLUMN IF NOT EXISTS must_change_password BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
COMMENT ON COLUMN app_user.must_change_password IS '초기암호변경필요';
|
||||
|
||||
-- 신속 테스트용 관리자 a / 1 (secret 평문 → Gucci 기동 시 BCrypt 해시로 승격)
|
||||
INSERT INTO app_user(username, display_name, role, org_code, secret, must_change_password, created_at)
|
||||
VALUES ('a', '테스트관리자', 'ADMIN', NULL, '1', FALSE, 0)
|
||||
ON CONFLICT (username) DO NOTHING;
|
||||
@@ -0,0 +1,12 @@
|
||||
-- B5 서비스 하트비트 — 각 서비스가 주기적으로 생존 신호 기록(헤드리스 포함 liveness를 DB로 확인)
|
||||
CREATE TABLE IF NOT EXISTS service_heartbeat (
|
||||
service VARCHAR(32),
|
||||
center VARCHAR(8),
|
||||
last_seen BIGINT,
|
||||
pid BIGINT,
|
||||
PRIMARY KEY (service, center)
|
||||
);
|
||||
COMMENT ON COLUMN service_heartbeat.service IS '서비스명';
|
||||
COMMENT ON COLUMN service_heartbeat.center IS '센터';
|
||||
COMMENT ON COLUMN service_heartbeat.last_seen IS '최종생존시각';
|
||||
COMMENT ON COLUMN service_heartbeat.pid IS '프로세스ID';
|
||||
@@ -0,0 +1,30 @@
|
||||
-- B1 순번기 무손실(durable-before-publish). 순번=DB SEQUENCE(원자적·durable), 발행 전 outbox에
|
||||
-- 커밋 → 크래시/리더승계 시 미발행분 재발행(at-least-once). 소비측은 seq 멱등이라 중복 무해.
|
||||
|
||||
-- 전역 순번 시퀀스. 기존 원장 최대값 다음부터 이어서 발번(재기동·기존DB 호환).
|
||||
CREATE SEQUENCE IF NOT EXISTS global_seq_seq AS BIGINT START 1;
|
||||
SELECT setval(
|
||||
'global_seq_seq',
|
||||
GREATEST(
|
||||
(SELECT COALESCE(MAX(global_seq), 0) FROM journal_log),
|
||||
(SELECT COALESCE(MAX(global_seq), 0) FROM transfer),
|
||||
1
|
||||
),
|
||||
true -- is_called=true → 다음 nextval은 max+1
|
||||
);
|
||||
|
||||
-- 저널 아웃박스. payload = rtgs.journal로 발행되는 JournalEntry JSON 원문(재발행 시 그대로 송신).
|
||||
CREATE TABLE IF NOT EXISTS journal_outbox (
|
||||
global_seq BIGINT PRIMARY KEY,
|
||||
bmi VARCHAR(22),
|
||||
payload TEXT NOT NULL,
|
||||
published BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
created_at BIGINT
|
||||
);
|
||||
-- 미발행분 재발행 스캔용(부분 인덱스).
|
||||
CREATE INDEX IF NOT EXISTS idx_outbox_unpublished ON journal_outbox (global_seq) WHERE NOT published;
|
||||
|
||||
COMMENT ON TABLE journal_outbox IS '순번기 저널 아웃박스(무손실 발행)';
|
||||
COMMENT ON COLUMN journal_outbox.global_seq IS '전역순번';
|
||||
COMMENT ON COLUMN journal_outbox.payload IS '발행 저널원문(JournalEntry JSON)';
|
||||
COMMENT ON COLUMN journal_outbox.published IS '발행완료여부';
|
||||
218
backend/louisvuitton/src/main/resources/static/loadtest.html
Normal file
218
backend/louisvuitton/src/main/resources/static/loadtest.html
Normal file
@@ -0,0 +1,218 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>RTGS 부하테스트 콘솔 — LouisVuitton</title>
|
||||
<style>
|
||||
:root { --bg:#0f1420; --card:#1a2232; --line:#2b3648; --fg:#e6ecf5; --muted:#8a97ab;
|
||||
--accent:#c9a24b; --ok:#3fb950; --warn:#d29922; --bad:#f85149; --bar:#2f81f7; }
|
||||
* { box-sizing:border-box; }
|
||||
body { margin:0; font-family:"Segoe UI","Malgun Gothic",sans-serif; background:var(--bg); color:var(--fg); }
|
||||
header { padding:18px 28px; border-bottom:1px solid var(--line); display:flex; align-items:baseline; gap:12px; }
|
||||
header h1 { font-size:18px; margin:0; letter-spacing:.5px; }
|
||||
header .tag { color:var(--accent); font-weight:700; }
|
||||
header .sub { color:var(--muted); font-size:12px; }
|
||||
main { max-width:960px; margin:0 auto; padding:24px; }
|
||||
.card { background:var(--card); border:1px solid var(--line); border-radius:10px; padding:18px 20px; margin-bottom:18px; }
|
||||
.card h2 { font-size:14px; margin:0 0 14px; color:var(--muted); font-weight:600; text-transform:uppercase; letter-spacing:1px; }
|
||||
.grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(150px,1fr)); gap:14px; }
|
||||
label { display:block; font-size:12px; color:var(--muted); margin-bottom:5px; }
|
||||
input { width:100%; padding:8px 10px; background:#0d1420; border:1px solid var(--line); border-radius:6px;
|
||||
color:var(--fg); font-size:14px; }
|
||||
input:focus { outline:none; border-color:var(--bar); }
|
||||
.actions { margin-top:16px; display:flex; gap:10px; align-items:center; }
|
||||
button { padding:9px 20px; border:none; border-radius:6px; font-size:14px; font-weight:600; cursor:pointer; }
|
||||
#startBtn { background:var(--bar); color:#fff; }
|
||||
#stopBtn { background:transparent; color:var(--bad); border:1px solid var(--bad); }
|
||||
button:disabled { opacity:.4; cursor:not-allowed; }
|
||||
.msg { font-size:13px; color:var(--warn); }
|
||||
.stats { display:grid; grid-template-columns:repeat(auto-fit,minmax(120px,1fr)); gap:12px; }
|
||||
.stat { background:#0d1420; border:1px solid var(--line); border-radius:8px; padding:12px 14px; }
|
||||
.stat .k { font-size:11px; color:var(--muted); }
|
||||
.stat .v { font-size:22px; font-weight:700; margin-top:4px; }
|
||||
.v.ok { color:var(--ok); } .v.bad { color:var(--bad); } .v.warn { color:var(--warn); }
|
||||
.progress { height:8px; background:#0d1420; border-radius:5px; overflow:hidden; margin:6px 0 16px; }
|
||||
.progress > div { height:100%; width:0; background:var(--bar); transition:width .4s; }
|
||||
table { width:100%; border-collapse:collapse; font-size:13px; }
|
||||
th,td { text-align:left; padding:7px 8px; border-bottom:1px solid var(--line); }
|
||||
th { color:var(--muted); font-weight:600; }
|
||||
td.num, th.num { text-align:right; font-variant-numeric:tabular-nums; }
|
||||
.barrow { display:flex; align-items:center; gap:10px; margin:5px 0; font-size:12px; }
|
||||
.barrow .lbl { width:44px; color:var(--muted); }
|
||||
.barrow .track { flex:1; height:14px; background:#0d1420; border-radius:4px; overflow:hidden; }
|
||||
.barrow .track > div { height:100%; background:var(--bar); }
|
||||
.barrow .val { width:78px; text-align:right; font-variant-numeric:tabular-nums; }
|
||||
.pill { display:inline-block; padding:2px 8px; border-radius:10px; font-size:11px; font-weight:700; }
|
||||
.pill.run { background:rgba(63,185,80,.15); color:var(--ok); }
|
||||
.pill.idle { background:rgba(138,151,171,.15); color:var(--muted); }
|
||||
.conserve { font-size:13px; line-height:1.9; }
|
||||
.conserve .good { color:var(--ok); font-weight:700; }
|
||||
.conserve .broke { color:var(--bad); font-weight:700; }
|
||||
.hidden { display:none; }
|
||||
.foot { color:var(--muted); font-size:11px; margin-top:8px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>RTGS 부하테스트 콘솔 <span class="tag">LouisVuitton</span></h1>
|
||||
<span class="sub">pacs.008.001.08 실시간 생성 → Chanel 접수 · 동시 요청 수 기반</span>
|
||||
</header>
|
||||
<main>
|
||||
<!-- 파라미터 -->
|
||||
<section class="card">
|
||||
<h2>파라미터</h2>
|
||||
<div class="grid">
|
||||
<div><label>동시 요청 수 (VU)</label><input id="concurrency" type="number" min="1" max="2000" value="10"></div>
|
||||
<div><label>지속시간 (초)</label><input id="durationSec" type="number" min="1" max="3600" value="15"></div>
|
||||
<div><label>대상 Chanel URL</label><input id="chanelUrl" type="text" value="http://localhost:8091"></div>
|
||||
<div><label>금액 최소 (원)</label><input id="amountMin" type="number" min="1" value="1"></div>
|
||||
<div><label>금액 최대 (원)</label><input id="amountMax" type="number" min="1" value="200"></div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button id="startBtn">▶ 시작</button>
|
||||
<button id="stopBtn" disabled>■ 중지</button>
|
||||
<span id="statePill" class="pill idle">대기</span>
|
||||
<span id="msg" class="msg"></span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 실시간 -->
|
||||
<section class="card">
|
||||
<h2>실시간 진행</h2>
|
||||
<div class="progress"><div id="progBar"></div></div>
|
||||
<div class="stats">
|
||||
<div class="stat"><div class="k">경과 / 지속</div><div class="v" id="s_elapsed">0s / 0s</div></div>
|
||||
<div class="stat"><div class="k">전송</div><div class="v" id="s_sent">0</div></div>
|
||||
<div class="stat"><div class="k">접수 (RCVD)</div><div class="v ok" id="s_accepted">0</div></div>
|
||||
<div class="stat"><div class="k">반려</div><div class="v warn" id="s_rejected">0</div></div>
|
||||
<div class="stat"><div class="k">오류</div><div class="v bad" id="s_errors">0</div></div>
|
||||
<div class="stat"><div class="k">현재 TPS</div><div class="v" id="s_tps">0</div></div>
|
||||
<div class="stat"><div class="k">평균 지연</div><div class="v" id="s_lat">0 ms</div></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 최종 요약 -->
|
||||
<section class="card hidden" id="resultCard">
|
||||
<h2>최종 요약</h2>
|
||||
<div class="stats" style="margin-bottom:18px">
|
||||
<div class="stat"><div class="k">총 전송</div><div class="v" id="r_sent">0</div></div>
|
||||
<div class="stat"><div class="k">성공률 (RCVD)</div><div class="v ok" id="r_rate">0%</div></div>
|
||||
<div class="stat"><div class="k">처리량</div><div class="v" id="r_tps">0 TPS</div></div>
|
||||
<div class="stat"><div class="k">실제 처리시간</div><div class="v" id="r_dur">0s</div></div>
|
||||
</div>
|
||||
<table style="margin-bottom:6px">
|
||||
<tr><th>구분</th><th class="num">건수</th><th class="num">비율</th></tr>
|
||||
<tr><td>접수 (RCVD)</td><td class="num" id="rb_acc">0</td><td class="num" id="rb_accp">0%</td></tr>
|
||||
<tr><td>반려 (RJCT 등)</td><td class="num" id="rb_rej">0</td><td class="num" id="rb_rejp">0%</td></tr>
|
||||
<tr><td>오류 (HTTP/예외)</td><td class="num" id="rb_err">0</td><td class="num" id="rb_errp">0%</td></tr>
|
||||
</table>
|
||||
<h2 style="margin-top:22px">응답 지연 백분위 (ms)</h2>
|
||||
<div id="latBars"></div>
|
||||
</section>
|
||||
|
||||
<!-- 원장 정합성 -->
|
||||
<section class="card hidden" id="conserveCard">
|
||||
<h2>원장 정합성 (부하 전 / 후)</h2>
|
||||
<div class="conserve" id="conserveBody"></div>
|
||||
<div class="foot">총잔액은 이체가 계좌 간 이동일 뿐이므로 부하 전후 동일해야 한다(0-합 보존). transfer 건수는 접수분만큼 증가.</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
const $ = id => document.getElementById(id);
|
||||
const fmt = n => (n==null?0:n).toLocaleString('ko-KR');
|
||||
let timer = null, before = null;
|
||||
|
||||
function setRunning(run) {
|
||||
$('startBtn').disabled = run;
|
||||
$('stopBtn').disabled = !run;
|
||||
$('statePill').textContent = run ? '실행 중' : '대기';
|
||||
$('statePill').className = 'pill ' + (run ? 'run' : 'idle');
|
||||
}
|
||||
|
||||
async function jget(u){ const r = await fetch(u); return r.json(); }
|
||||
|
||||
function renderStatus(s) {
|
||||
const dur = (s.durationSec||0), el = (s.elapsedMs||0)/1000;
|
||||
$('progBar').style.width = Math.min(100, dur? el/dur*100 : 0) + '%';
|
||||
$('s_elapsed').textContent = el.toFixed(0) + 's / ' + dur + 's';
|
||||
$('s_sent').textContent = fmt(s.sent);
|
||||
$('s_accepted').textContent = fmt(s.accepted);
|
||||
$('s_rejected').textContent = fmt(s.rejected);
|
||||
$('s_errors').textContent = fmt(s.errors);
|
||||
$('s_tps').textContent = s.tps;
|
||||
$('s_lat').textContent = fmt(s.avgLatencyMs) + ' ms';
|
||||
}
|
||||
|
||||
function renderResult(r) {
|
||||
$('resultCard').classList.remove('hidden');
|
||||
$('r_sent').textContent = fmt(r.sent);
|
||||
$('r_rate').textContent = r.successRate + '%';
|
||||
$('r_tps').textContent = r.throughputTps + ' TPS';
|
||||
$('r_dur').textContent = ((r.durationMs||0)/1000).toFixed(1) + 's';
|
||||
const sent = r.sent||0, pc = n => sent? (n*100/sent).toFixed(1)+'%' : '0%';
|
||||
$('rb_acc').textContent = fmt(r.accepted); $('rb_accp').textContent = pc(r.accepted);
|
||||
$('rb_rej').textContent = fmt(r.rejected); $('rb_rejp').textContent = pc(r.rejected);
|
||||
$('rb_err').textContent = fmt(r.errors); $('rb_errp').textContent = pc(r.errors);
|
||||
const L = r.latencyMs || {}, keys = [['min','min'],['avg','avg'],['p50','p50'],['p90','p90'],['p95','p95'],['p99','p99'],['max','max']];
|
||||
const mx = Math.max(1, L.max||1);
|
||||
$('latBars').innerHTML = keys.map(([k,lbl]) =>
|
||||
`<div class="barrow"><span class="lbl">${lbl}</span>`+
|
||||
`<span class="track"><div style="width:${Math.min(100,(L[k]||0)/mx*100)}%"></div></span>`+
|
||||
`<span class="val">${fmt(L[k])} ms</span></div>`).join('');
|
||||
}
|
||||
|
||||
function renderConserve(after) {
|
||||
if (!before || !after) return;
|
||||
$('conserveCard').classList.remove('hidden');
|
||||
const same = before.totalBalance === after.totalBalance;
|
||||
const dTx = (after.transfers||0) - (before.transfers||0);
|
||||
$('conserveBody').innerHTML =
|
||||
`총잔액 전 <b>${fmt(before.totalBalance)}</b> → 후 <b>${fmt(after.totalBalance)}</b> `+
|
||||
(same ? `<span class="good">✔ 보존됨 (0-합)</span>` : `<span class="broke">✘ 불일치! 차액 ${fmt(after.totalBalance-before.totalBalance)}</span>`) +
|
||||
`<br>transfer 건수 전 <b>${fmt(before.transfers)}</b> → 후 <b>${fmt(after.transfers)}</b> (증가 <b>${fmt(dTx)}</b>)` +
|
||||
`<br>계좌 수 <b>${fmt(after.accounts)}</b> · 최대 globalSeq <b>${fmt(after.maxSeq)}</b>`;
|
||||
}
|
||||
|
||||
async function poll() {
|
||||
const s = await jget('/console/loadtest/status');
|
||||
renderStatus(s);
|
||||
if (!s.running) {
|
||||
clearInterval(timer); timer = null;
|
||||
setRunning(false);
|
||||
const [r, after] = await Promise.all([jget('/console/loadtest/result'), jget('/console/ledger/summary')]);
|
||||
renderResult(r); renderConserve(after);
|
||||
}
|
||||
}
|
||||
|
||||
$('startBtn').onclick = async () => {
|
||||
$('msg').textContent = '';
|
||||
$('resultCard').classList.add('hidden'); $('conserveCard').classList.add('hidden');
|
||||
const body = {
|
||||
concurrency: +$('concurrency').value, durationSec: +$('durationSec').value,
|
||||
chanelUrl: $('chanelUrl').value.trim(),
|
||||
amountMin: +$('amountMin').value, amountMax: +$('amountMax').value,
|
||||
};
|
||||
before = await jget('/console/ledger/summary');
|
||||
const res = await fetch('/console/loadtest/start', {
|
||||
method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body) });
|
||||
if (!res.ok) { const e = await res.json(); $('msg').textContent = e.error || ('시작 실패 ('+res.status+')'); return; }
|
||||
setRunning(true);
|
||||
if (timer) clearInterval(timer);
|
||||
timer = setInterval(poll, 1000); poll();
|
||||
};
|
||||
|
||||
$('stopBtn').onclick = async () => {
|
||||
await fetch('/console/loadtest/stop', {method:'POST'});
|
||||
$('msg').textContent = '중지 요청됨 — 잔여 요청 마무리 중...';
|
||||
};
|
||||
|
||||
// 페이지 로드 시 진행 중인 테스트가 있으면 이어서 표시.
|
||||
(async () => {
|
||||
const s = await jget('/console/loadtest/status');
|
||||
if (s.running) { before = await jget('/console/ledger/summary'); setRunning(true); timer = setInterval(poll, 1000); poll(); }
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
20
backend/prada/build.gradle.kts
Normal file
20
backend/prada/build.gradle.kts
Normal file
@@ -0,0 +1,20 @@
|
||||
plugins {
|
||||
kotlin("jvm")
|
||||
kotlin("plugin.spring")
|
||||
id("org.springframework.boot")
|
||||
id("io.spring.dependency-management")
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":common"))
|
||||
implementation("org.springframework.boot:spring-boot-starter")
|
||||
implementation("org.springframework.boot:spring-boot-starter-actuator")
|
||||
implementation("org.springframework.boot:spring-boot-starter-web") // 메트릭/헬스 노출(B4)
|
||||
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
|
||||
implementation("org.springframework.kafka:spring-kafka")
|
||||
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
|
||||
implementation("org.jetbrains.kotlin:kotlin-reflect")
|
||||
runtimeOnly("org.postgresql:postgresql")
|
||||
testImplementation("org.springframework.boot:spring-boot-starter-test")
|
||||
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package kr.or.bok.rtgs.prada
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication
|
||||
import org.springframework.boot.autoconfigure.domain.EntityScan
|
||||
import org.springframework.boot.runApplication
|
||||
import org.springframework.context.annotation.Bean
|
||||
import org.springframework.data.jpa.repository.config.EnableJpaRepositories
|
||||
|
||||
@SpringBootApplication
|
||||
@EntityScan("kr.or.bok.rtgs.common.ledger")
|
||||
@EnableJpaRepositories("kr.or.bok.rtgs.prada.jpa")
|
||||
class PradaApplication {
|
||||
@Bean
|
||||
fun objectMapper(): ObjectMapper = jacksonObjectMapper()
|
||||
}
|
||||
|
||||
fun main(args: Array<String>) {
|
||||
runApplication<PradaApplication>(*args)
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package kr.or.bok.rtgs.prada
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import io.micrometer.core.instrument.MeterRegistry
|
||||
import kr.or.bok.rtgs.common.QuorumContext
|
||||
import kr.or.bok.rtgs.common.ResultMessage
|
||||
import kr.or.bok.rtgs.common.SettlementNotice
|
||||
import kr.or.bok.rtgs.common.Topics
|
||||
import kr.or.bok.rtgs.common.TxSts
|
||||
import kr.or.bok.rtgs.common.ledger.SettlementViewEntity
|
||||
import kr.or.bok.rtgs.prada.jpa.SettlementViewRepository
|
||||
import kr.or.bok.rtgs.prada.jpa.TransferRepository
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.kafka.annotation.KafkaListener
|
||||
import org.springframework.kafka.core.KafkaTemplate
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
|
||||
/**
|
||||
* PRADA(결과동기화) — 결제결과(rtgs.result)를 받아 완결 규율(과반 확정)을 적용하고,
|
||||
* 권위 원장(PostgreSQL)의 거래 상태를 최종(ACCC)으로 확정 + 조회 사본 기록.
|
||||
*
|
||||
* 완결 규율(B2): `rtgs.result`(= applied-ack)을 **전 센터분** 소비하여 bmi별 서로 다른
|
||||
* `processedCenter` 수를 집계(QuorumAggregator)한다. 과반(2/3) 도달 전에는 ACSP(예약),
|
||||
* 도달 시 ACCC(반려면 RJCT)로 승격. 1센터에서는 과반=1이라 첫 결과에 즉시 완결(기존 동작 보존).
|
||||
*
|
||||
* 완결 확정 후 **결과통보(rtgs.notify)** 를 발행한다. 실제 신청/수취기관 앞 pacs.002 송부는
|
||||
* 접수센터(Chanel)가 담당(개요 프로세스 (4)). 발행은 **트랜잭션 커밋 후**에 해 스테일 통보를 막는다.
|
||||
* 중복 통보 방지: **접수센터(origin) Prada만** notify 발행(각 센터 Prada가 독립 완결하므로).
|
||||
*/
|
||||
@Service
|
||||
class PradaService(
|
||||
private val transfers: TransferRepository,
|
||||
private val views: SettlementViewRepository,
|
||||
private val mapper: ObjectMapper,
|
||||
private val kafka: KafkaTemplate<String, String>,
|
||||
private val metrics: MeterRegistry,
|
||||
@Value("\${rtgs.center-id:DC1}") private val centerId: String,
|
||||
@Value("\${rtgs.center-count:1}") private val centerCount: Int,
|
||||
) {
|
||||
private val log = LoggerFactory.getLogger(javaClass)
|
||||
private val aggregator by lazy { QuorumAggregator(QuorumContext(centerId, centerCount)) }
|
||||
|
||||
// 센터별 그룹(prada-DC1/DC2/DC3)이라 각 센터 Prada가 전 센터 result를 독립 소비.
|
||||
@KafkaListener(topics = [Topics.RESULT], groupId = "prada-\${rtgs.center-id:DC1}", concurrency = "1")
|
||||
fun onResult(payload: String) {
|
||||
val r = mapper.readValue(payload, ResultMessage::class.java)
|
||||
val notice = finalize(r) // @Transactional (반환 = 커밋 완료, 과반 최초 도달 시에만 non-null)
|
||||
// 완결(ACCC/RJCT)만, 그리고 접수센터(origin) Prada만 결과통보(중복 방지).
|
||||
if (notice != null && notice.originCenter == centerId) {
|
||||
metrics.counter("rtgs.finality", "status", notice.finalStatus.name).increment() // B4
|
||||
kafka.send(Topics.NOTIFY, notice.bmi, mapper.writeValueAsString(notice))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 완결 규율 적용 + 원장/사본 확정. 과반 **최초 도달** 시에만 완결 승격 + SettlementNotice 반환.
|
||||
* 과반 미달이면 ACSP(예약)로 기록하고 null 반환. 이미 완결된 bmi는 멱등 스킵(null).
|
||||
*/
|
||||
@Transactional
|
||||
fun finalize(r: ResultMessage): SettlementNotice? {
|
||||
val e = r.journalEntry
|
||||
val bmi = e.core.bmi
|
||||
val now = System.currentTimeMillis()
|
||||
|
||||
// 이미 완결된 건에 늦게 도착한 ack: 상태를 되돌리지 않도록 멱등 스킵.
|
||||
if (aggregator.isFinalized(bmi)) return null
|
||||
|
||||
// rtgs.result = applied-ack. 보고 센터(processedCenter) 집계 → 과반 최초 도달 여부.
|
||||
val reachedQuorum = aggregator.record(bmi, r.processedCenter)
|
||||
val finalStatus = when {
|
||||
!reachedQuorum -> TxSts.ACSP // 과반 미달: 예약 상태 유지(아직 완결 아님)
|
||||
r.status == TxSts.RJCT -> TxSts.RJCT // 과반 도달 + 반려
|
||||
else -> TxSts.ACCC // 과반 도달 + 정상: 완결
|
||||
}
|
||||
|
||||
transfers.findById(bmi).ifPresent {
|
||||
it.status = finalStatus
|
||||
it.updatedAt = now
|
||||
if (r.reason != null) it.reason = r.reason
|
||||
transfers.save(it)
|
||||
}
|
||||
|
||||
views.save(
|
||||
SettlementViewEntity(
|
||||
bmi = e.core.bmi, globalSeq = e.globalSeq, finalStatus = finalStatus.name,
|
||||
senderCode = e.core.senderCode, receiverCode = e.core.receiverCode, amount = e.core.amount,
|
||||
originCenter = e.originCenter, processedCenter = r.processedCenter,
|
||||
debtorBalanceAfter = r.debtorBalanceAfter, creditorBalanceAfter = r.creditorBalanceAfter,
|
||||
finalizedAt = now,
|
||||
)
|
||||
)
|
||||
log.info("{} seq=#{} bmi={} (origin={})", finalStatus, e.globalSeq, e.core.bmi, e.originCenter)
|
||||
|
||||
return if (finalStatus == TxSts.ACCC || finalStatus == TxSts.RJCT) {
|
||||
SettlementNotice(
|
||||
bmi = e.core.bmi, originCenter = e.originCenter,
|
||||
senderCode = e.core.senderCode, receiverCode = e.core.receiverCode,
|
||||
amount = e.core.amount, globalSeq = e.globalSeq,
|
||||
finalStatus = finalStatus, reason = r.reason, finalizedAt = now,
|
||||
)
|
||||
} else null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package kr.or.bok.rtgs.prada
|
||||
|
||||
import kr.or.bok.rtgs.common.QuorumContext
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
/**
|
||||
* 정족수 집계기(B2) — bmi별로 "적용 완료(applied)"를 보고한 **서로 다른 센터**를 모아
|
||||
* 과반(quorum) 도달 여부를 판정한다.
|
||||
*
|
||||
* 신호원: `rtgs.result` = applied-ack. Hermes가 정산 트랜잭션 **커밋 후에만** 발행하므로
|
||||
* (publish-after-commit) "그 센터가 해당 seq를 durable 적용했다"는 사실과 정확히 동일하다.
|
||||
* `ResultMessage.processedCenter`가 보고 센터.
|
||||
*
|
||||
* 저장(§9.4 확정): **인메모리**. 재기동 시 맵은 비지만 — 이미 완결(ACCC/RJCT)된 건은 원장
|
||||
* (`transfer.status`)에 영속되어 안전하고, 미완결(ACSP)만 이후 도착 ack로 재집계된다.
|
||||
*
|
||||
* 순수 로직(스프링/JPA 비의존)이라 단위테스트로 규율을 고정한다. Prada 리스너는 concurrency=1
|
||||
* 단일 스레드지만, 방어적으로 동시성 안전 자료구조를 쓴다.
|
||||
*/
|
||||
class QuorumAggregator(private val quorum: QuorumContext) {
|
||||
private val votes = ConcurrentHashMap<String, MutableSet<String>>()
|
||||
private val finalized = ConcurrentHashMap.newKeySet<String>()
|
||||
|
||||
/** 이미 완결 승격된 bmi인가(멱등 가드 — 완결 후 늦게 도착한 ack가 상태를 되돌리지 못하게). */
|
||||
fun isFinalized(bmi: String): Boolean = bmi in finalized
|
||||
|
||||
/**
|
||||
* center의 적용 보고를 기록하고 과반 **최초 도달** 여부를 반환.
|
||||
* @return true = 이 호출로 과반에 처음 도달(완결로 승격). 이미 완결이거나 아직 미달이면 false.
|
||||
*/
|
||||
fun record(bmi: String, center: String): Boolean {
|
||||
if (bmi in finalized) return false
|
||||
val set = votes.computeIfAbsent(bmi) { ConcurrentHashMap.newKeySet() }
|
||||
set.add(center)
|
||||
if (quorum.hasQuorum(set.size)) {
|
||||
val firstReach = finalized.add(bmi) // add 성공 = 최초 1회만
|
||||
if (firstReach) votes.remove(bmi) // 완결 후 표 정리(메모리 회수)
|
||||
return firstReach
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/** 현재까지 집계된 서로 다른 센터 수(지표/디버그). */
|
||||
fun count(bmi: String): Int = votes[bmi]?.size ?: 0
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package kr.or.bok.rtgs.prada.jpa
|
||||
|
||||
import kr.or.bok.rtgs.common.ledger.SettlementViewEntity
|
||||
import kr.or.bok.rtgs.common.ledger.TransferRecord
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
|
||||
interface TransferRepository : JpaRepository<TransferRecord, String>
|
||||
|
||||
/** 조회 전용 사본(로컬 PostgreSQL; 클라우드는 문서형 DB). */
|
||||
interface SettlementViewRepository : JpaRepository<SettlementViewEntity, String>
|
||||
35
backend/prada/src/main/resources/application.yml
Normal file
35
backend/prada/src/main/resources/application.yml
Normal file
@@ -0,0 +1,35 @@
|
||||
spring:
|
||||
application:
|
||||
name: prada
|
||||
datasource:
|
||||
url: jdbc:postgresql://${POSTGRES_HOST:localhost}:${POSTGRES_PORT:5433}/${POSTGRES_DB:rtgs}
|
||||
username: ${POSTGRES_USER:rtgs}
|
||||
password: ${POSTGRES_PASSWORD:rtgs}
|
||||
jpa:
|
||||
hibernate:
|
||||
ddl-auto: none
|
||||
open-in-view: false
|
||||
kafka:
|
||||
bootstrap-servers: ${KAFKA_BOOTSTRAP:localhost:9092}
|
||||
consumer:
|
||||
group-id: prada
|
||||
auto-offset-reset: earliest
|
||||
key-deserializer: org.apache.kafka.common.serialization.StringDeserializer
|
||||
value-deserializer: org.apache.kafka.common.serialization.StringDeserializer
|
||||
producer:
|
||||
key-serializer: org.apache.kafka.common.serialization.StringSerializer
|
||||
value-serializer: org.apache.kafka.common.serialization.StringSerializer
|
||||
acks: all
|
||||
|
||||
server:
|
||||
port: 8094
|
||||
|
||||
rtgs:
|
||||
center-id: ${CENTER_ID:DC1}
|
||||
center-count: ${CENTER_COUNT:1}
|
||||
|
||||
management:
|
||||
endpoints:
|
||||
web:
|
||||
exposure:
|
||||
include: health,info,prometheus,metrics
|
||||
@@ -0,0 +1,62 @@
|
||||
package kr.or.bok.rtgs.prada
|
||||
|
||||
import kr.or.bok.rtgs.common.QuorumContext
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
/** B2 정족수 집계 규율 고정 테스트. */
|
||||
class QuorumAggregatorTest {
|
||||
|
||||
@Test
|
||||
fun `1센터는 자기 보고 1건으로 즉시 과반`() {
|
||||
val agg = QuorumAggregator(QuorumContext("DC1", 1)) // quorum=1
|
||||
assertTrue(agg.record("bmiA", "DC1"), "1센터: 첫 보고에 과반 도달")
|
||||
assertTrue(agg.isFinalized("bmiA"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `3센터는 2개 서로 다른 센터에서 과반 도달`() {
|
||||
val agg = QuorumAggregator(QuorumContext("DC1", 3)) // quorum=2
|
||||
assertFalse(agg.record("bmiA", "DC1"), "1개 센터: 아직 미달")
|
||||
assertEquals(1, agg.count("bmiA"))
|
||||
assertTrue(agg.record("bmiA", "DC2"), "2개 센터: 과반 최초 도달")
|
||||
assertTrue(agg.isFinalized("bmiA"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `같은 센터 중복 보고는 집계되지 않는다`() {
|
||||
val agg = QuorumAggregator(QuorumContext("DC1", 3)) // quorum=2
|
||||
assertFalse(agg.record("bmiA", "DC1"))
|
||||
assertFalse(agg.record("bmiA", "DC1"), "동일 센터 재보고: 중복 제거 → 여전히 미달")
|
||||
assertEquals(1, agg.count("bmiA"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `과반 최초 도달은 단 한 번만 true`() {
|
||||
val agg = QuorumAggregator(QuorumContext("DC1", 3)) // quorum=2
|
||||
agg.record("bmiA", "DC1")
|
||||
assertTrue(agg.record("bmiA", "DC2"), "최초 도달")
|
||||
assertFalse(agg.record("bmiA", "DC3"), "이미 완결: 세 번째 보고는 false(멱등)")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `완결 후 늦은 ack는 상태를 되돌리지 않는다`() {
|
||||
val agg = QuorumAggregator(QuorumContext("DC1", 3))
|
||||
agg.record("bmiA", "DC1")
|
||||
agg.record("bmiA", "DC2") // 완결
|
||||
assertTrue(agg.isFinalized("bmiA"))
|
||||
assertFalse(agg.record("bmiA", "DC3"))
|
||||
assertTrue(agg.isFinalized("bmiA"), "여전히 완결 유지")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `서로 다른 bmi는 독립 집계`() {
|
||||
val agg = QuorumAggregator(QuorumContext("DC1", 3))
|
||||
assertFalse(agg.record("bmiA", "DC1"))
|
||||
assertFalse(agg.record("bmiB", "DC2"))
|
||||
assertFalse(agg.isFinalized("bmiA"))
|
||||
assertFalse(agg.isFinalized("bmiB"))
|
||||
}
|
||||
}
|
||||
21
backend/sequencer/build.gradle.kts
Normal file
21
backend/sequencer/build.gradle.kts
Normal file
@@ -0,0 +1,21 @@
|
||||
plugins {
|
||||
kotlin("jvm")
|
||||
kotlin("plugin.spring")
|
||||
id("org.springframework.boot")
|
||||
id("io.spring.dependency-management")
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":common"))
|
||||
implementation("org.springframework.boot:spring-boot-starter")
|
||||
implementation("org.springframework.boot:spring-boot-starter-actuator")
|
||||
implementation("org.springframework.boot:spring-boot-starter-web") // 메트릭/헬스 노출(B4)
|
||||
implementation("org.springframework.boot:spring-boot-starter-jdbc")
|
||||
implementation("org.springframework.kafka:spring-kafka")
|
||||
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
|
||||
implementation("org.jetbrains.kotlin:kotlin-reflect")
|
||||
runtimeOnly("org.postgresql:postgresql")
|
||||
testImplementation("org.springframework.boot:spring-boot-starter-test")
|
||||
testImplementation("org.jetbrains.kotlin:kotlin-test-junit5")
|
||||
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package kr.or.bok.rtgs.sequencer
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication
|
||||
import org.springframework.boot.runApplication
|
||||
import org.springframework.context.annotation.Bean
|
||||
import org.springframework.scheduling.annotation.EnableScheduling
|
||||
|
||||
@SpringBootApplication
|
||||
@EnableScheduling // B1 outbox 재발행 스케줄러
|
||||
class SequencerApplication {
|
||||
// 비웹 서비스: spring-web(Jackson2ObjectMapperBuilder)이 없어 ObjectMapper 자동설정이 안 되므로 명시 제공.
|
||||
@Bean
|
||||
fun objectMapper(): ObjectMapper = jacksonObjectMapper()
|
||||
}
|
||||
|
||||
fun main(args: Array<String>) {
|
||||
runApplication<SequencerApplication>(*args)
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package kr.or.bok.rtgs.sequencer
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import kr.or.bok.rtgs.common.InboundRequest
|
||||
import kr.or.bok.rtgs.common.JournalEntry
|
||||
import kr.or.bok.rtgs.common.Topics
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.jdbc.core.JdbcTemplate
|
||||
import org.springframework.kafka.annotation.KafkaListener
|
||||
import org.springframework.kafka.core.KafkaTemplate
|
||||
import org.springframework.scheduling.annotation.Scheduled
|
||||
import org.springframework.stereotype.Service
|
||||
|
||||
/**
|
||||
* 전역 순번기(Sequencer). 입구(rtgs.inbound)에서 접수 요청을 받아 전역순번을 부여하고,
|
||||
* 결정론적 값(수신시각)을 확정해 저널(rtgs.journal)로 발행한다.
|
||||
*
|
||||
* **B1 삼중화 — durable-before-publish (무손실):**
|
||||
* 1. 순번 = Postgres SEQUENCE `global_seq_seq`.nextval (원자적·durable). 인메모리 카운터 폐기.
|
||||
* 2. JournalEntry JSON을 `journal_outbox`에 커밋(published=false) → 그 후 rtgs.journal 발행 → published=true.
|
||||
* 3. 발행 전 크래시/리더승계로 죽어도, 재발행 스케줄러가 미발행분을 재송신(at-least-once).
|
||||
* 소비측(Dior/Hermes)은 seq 기준 멱등이라 중복은 무해.
|
||||
*
|
||||
* **리더선출**: 순번기 3인스턴스를 같은 컨슈머그룹 `sequencer`로 rtgs.inbound(단일 파티션) 구독 →
|
||||
* Kafka가 파티션을 1개 인스턴스에만 배정 = 자동 단일 리더. 리더 死 시 리밸런스로 자동 승계.
|
||||
* 승계 리더는 재발행 스케줄러로 outbox 미발행분을 이어서 내보낸다.
|
||||
*
|
||||
* **펜싱**: nextval이 원자적이라 순간적 이중리더가 있어도 서로 다른 seq만 발급 → 저널 오염 없음.
|
||||
* (nextval 소비 후 outbox insert 실패 시 그 seq는 phantom 결번이 되며, 소비측 B3 타임아웃이 정리.)
|
||||
*/
|
||||
@Service
|
||||
class SequencerService(
|
||||
private val kafka: KafkaTemplate<String, String>,
|
||||
private val mapper: ObjectMapper,
|
||||
private val jdbc: JdbcTemplate,
|
||||
@Value("\${rtgs.outbox-republish-grace-ms:3000}") private val republishGraceMs: Long,
|
||||
) {
|
||||
private val log = LoggerFactory.getLogger(javaClass)
|
||||
|
||||
@KafkaListener(topics = [Topics.INBOUND], groupId = "sequencer", concurrency = "1")
|
||||
fun onInbound(payload: String) {
|
||||
val req = mapper.readValue(payload, InboundRequest::class.java)
|
||||
// 1) durable 순번 발번(SEQUENCE)
|
||||
val globalSeq = jdbc.queryForObject("SELECT nextval('global_seq_seq')", Long::class.java)!!
|
||||
val entry = JournalEntry(
|
||||
globalSeq = globalSeq,
|
||||
seqEpochMillis = System.currentTimeMillis(),
|
||||
originCenter = req.originCenter,
|
||||
core = req.core,
|
||||
)
|
||||
val json = mapper.writeValueAsString(entry)
|
||||
// 2) 발행 전 outbox 커밋(durable-before-publish)
|
||||
jdbc.update(
|
||||
"INSERT INTO journal_outbox(global_seq, bmi, payload, published, created_at) VALUES (?,?,?,FALSE,?)",
|
||||
globalSeq, entry.core.bmi, json, entry.seqEpochMillis,
|
||||
)
|
||||
// 3) 발행 후 published=true
|
||||
publish(globalSeq, entry.core.bmi, json)
|
||||
log.info("SEQ #{} bmi={} {}->{} amt={} origin={}",
|
||||
globalSeq, req.core.bmi, req.core.senderCode, req.core.receiverCode, req.core.amount, req.originCenter)
|
||||
}
|
||||
|
||||
/**
|
||||
* 미발행 아웃박스 재발행(무손실·리더승계). 인라인 발행과의 경합을 피하려고 grace 이후 항목만.
|
||||
* 단일 파티션 순서 유지를 위해 global_seq 오름차순으로 재발행.
|
||||
*/
|
||||
@Scheduled(fixedDelayString = "\${rtgs.outbox-republish-ms:2000}")
|
||||
fun republishUnpublished() {
|
||||
val cutoff = System.currentTimeMillis() - republishGraceMs
|
||||
val rows = jdbc.queryForList(
|
||||
"SELECT global_seq, bmi, payload FROM journal_outbox WHERE NOT published AND created_at < ? ORDER BY global_seq",
|
||||
cutoff,
|
||||
)
|
||||
if (rows.isEmpty()) return
|
||||
for (row in rows) {
|
||||
val seq = (row["global_seq"] as Number).toLong()
|
||||
val bmi = (row["bmi"] as String?) ?: ""
|
||||
val json = row["payload"] as String
|
||||
publish(seq, bmi, json)
|
||||
log.warn("REPUBLISH seq=#{} bmi={} (미발행분 재송신)", seq, bmi)
|
||||
}
|
||||
}
|
||||
|
||||
/** rtgs.journal 발행 후 outbox를 published=true로 마킹. */
|
||||
private fun publish(globalSeq: Long, bmi: String, json: String) {
|
||||
kafka.send(Topics.JOURNAL, bmi, json)
|
||||
jdbc.update("UPDATE journal_outbox SET published = TRUE WHERE global_seq = ?", globalSeq)
|
||||
}
|
||||
}
|
||||
30
backend/sequencer/src/main/resources/application.yml
Normal file
30
backend/sequencer/src/main/resources/application.yml
Normal file
@@ -0,0 +1,30 @@
|
||||
spring:
|
||||
application:
|
||||
name: sequencer
|
||||
datasource:
|
||||
url: jdbc:postgresql://${POSTGRES_HOST:localhost}:${POSTGRES_PORT:5433}/${POSTGRES_DB:rtgs}
|
||||
username: ${POSTGRES_USER:rtgs}
|
||||
password: ${POSTGRES_PASSWORD:rtgs}
|
||||
kafka:
|
||||
bootstrap-servers: ${KAFKA_BOOTSTRAP:localhost:9092}
|
||||
consumer:
|
||||
group-id: sequencer
|
||||
auto-offset-reset: earliest
|
||||
key-deserializer: org.apache.kafka.common.serialization.StringDeserializer
|
||||
value-deserializer: org.apache.kafka.common.serialization.StringDeserializer
|
||||
producer:
|
||||
key-serializer: org.apache.kafka.common.serialization.StringSerializer
|
||||
value-serializer: org.apache.kafka.common.serialization.StringSerializer
|
||||
acks: all
|
||||
|
||||
server:
|
||||
port: 8090
|
||||
|
||||
rtgs:
|
||||
center-id: ${CENTER_ID:DC1}
|
||||
|
||||
management:
|
||||
endpoints:
|
||||
web:
|
||||
exposure:
|
||||
include: health,info,prometheus,metrics
|
||||
12
backend/settings.gradle.kts
Normal file
12
backend/settings.gradle.kts
Normal file
@@ -0,0 +1,12 @@
|
||||
rootProject.name = "rtgs"
|
||||
|
||||
include(
|
||||
"common",
|
||||
"sequencer",
|
||||
"chanel",
|
||||
"dior",
|
||||
"hermes",
|
||||
"prada",
|
||||
"louisvuitton",
|
||||
"gucci",
|
||||
)
|
||||
BIN
docs/(참고1) RTGS_ActiveActive_복원력방안.pdf
Normal file
BIN
docs/(참고1) RTGS_ActiveActive_복원력방안.pdf
Normal file
Binary file not shown.
173
docs/(참고2) RTGS_ActiveActive_복원력방안에 대한 의견.txt
Normal file
173
docs/(참고2) RTGS_ActiveActive_복원력방안에 대한 의견.txt
Normal file
@@ -0,0 +1,173 @@
|
||||
=========================================================================
|
||||
리뷰결과
|
||||
=========================================================================
|
||||
1. 리뷰어1(최과장) — 항목별 해석 + 저의 판단
|
||||
리뷰어1은 서두에서 자기 방법론을 밝혔습니다. "이 보고서는 앞 절에서 단순하게 던지고 뒤 절에서 조건을 붙여 정정하는 구조라, 특정 절만 보면 틀려 보인다. 뒤에서 해소되면 OK, 안 되면 표현을 고쳐라." — 이건 공정한 태도입니다. 우리 보고서 구조를 정확히 읽었습니다.
|
||||
|
||||
① (1.3) "거액결제 완결 단위를 다자간 동시처리로 단정했다"
|
||||
해석: 우리는 표에서 "거액결제 = 다자간 동시처리(여러 건 묶어 처리)"라고 썼습니다. 리뷰어는 "거액결제는 실제로는 건별 실시간 총액결제(RTGS)가 기본이고, 다자간 차액결제는 일부일 뿐인데, 마치 거액 전체가 묶음처리인 것처럼 단정했다"고 지적합니다. 그리고 "거액 vs 소액의 진짜 차이는 '묶음이냐 건별이냐'가 아니라 '복구 시한이 있느냐 없느냐'"라고 봅니다.
|
||||
|
||||
저의 판단: 부분 수용 (타당). 리뷰어 지적이 사실관계로 맞습니다. 한은금융망(거액 RTGS)의 심장은 건별 실시간 총액결제이고, 다자간 동시처리(다자간 차액결제 동시처리, DvP·차액 등)는 그 일부입니다. 우리 보고서 1.3의 표와 76번 줄은 거액 전체를 "다자간 동시처리라 못 쪼갠다"로 단정하는데, 이는 과도한 단순화입니다.
|
||||
|
||||
다만 우리 논지 자체는 살아있습니다. "거액은 분산이 어렵다"의 진짜 근거는 다자간 동시처리(그로스가 아닌 배치성 정산)와 다자간 잔액 상호의존성이고, 소액 P2P는 건별 독립이라 분산이 쉽다는 대비는 유효합니다. 즉 결론은 유지, 전제 서술만 정정하면 됩니다.
|
||||
리뷰어가 제시한 "진짜 차이 = 복구 시한 유무"는 매우 날카롭습니다. 이건 오히려 우리 보고서의 핵심 주장(소액은 RTO≈0이라 무중단이 최강 난제)과 정확히 일치합니다. 이 프레임을 받아들이면 1.3이 더 강해집니다.
|
||||
② (2.1) "센터 간 지연이 감내 가능하다는 결론이 이론적 거리 기반 수치뿐 — 실측 필요"
|
||||
해석: 우리는 "광교–강남 25km, 남대문 10km라 동기복제 가능(왕복 수 ms)"이라고 썼는데, 리뷰어는 "그건 직선거리로 계산한 이론값이고, 실제 통신사 회선은 돌아가고 중간 장비를 거쳐 더 느리다. 실측하라"고 합니다.
|
||||
|
||||
저의 판단: 완전 수용 (매우 타당). 이건 리뷰어2의 28페이지 지적, 35페이지 지적과 완전히 같은 말입니다(세 리뷰가 한 점으로 수렴 = 신호가 강함). 우리 보고서도 이미 6.1-3, 6.4-2에서 "실측 필요"로 잡아뒀지만, 본문 2.1의 서술 톤이 "수 ms라 성립한다"고 단정적입니다(320번 줄). "이론 거리 기준 근사이며 회선·보안장비 지연 포함 실측이 성립 전제"라고 본문에서 못박아 톤을 낮춰야 합니다.
|
||||
|
||||
③ (2.2) "순차 코어는 수평확장해도 처리량이 안 늘고 상한이 있다 — 확장 가능 영역(주변)과 불가 영역(코어)이 구분 안 됨"
|
||||
해석: "노드를 늘리면 처리량이 비례해 는다(scale-out)"고 했는데, 리뷰어는 "그건 주변부(입구·검증·조회) 얘기다. 코어(순번기, 한 줄로 세우는 부분)는 아무리 노드를 늘려도 '한 줄'이라 안 늘어난다. 이 둘을 섞어 쓰면 독자가 '이 시스템은 무한 확장된다'고 오해한다"고 합니다.
|
||||
|
||||
저의 판단: 이미 반영됨 — 단, 강화 여지. 우리 보고서는 이 구분을 이미 명확히 하고 있습니다: 240번 줄 "수평 확장이 그대로 통하는 곳은 병렬 구간… 직렬 코어(순번기)는 노드를 늘려도 '한 줄'이라는 본질이 바뀌지 않으므로", 2.3-부속 전제① "단일 순번기 병목". 리뷰어가 이 대목을 못 보고 지적했거나, 핵심 권고 4번(24번 줄)처럼 요약부에서 "확장 가능"만 강조된 것을 문제 삼은 것으로 보입니다.
|
||||
|
||||
→ 부분 수용: 본문은 이미 구분돼 있으나, 요약(0장)과 2.2 도입부에서 "확장"을 말할 때 "단, 코어는 예외"를 한 박자 먼저 붙여 오해 소지를 줄이는 게 좋습니다. 서술 위치 조정 문제이지 논리 결함은 아닙니다.
|
||||
|
||||
④ (2.3) "저널이 확정 순서로 기록된 뒤 그 순서대로 소비하면 순서 역전이 불가능한데, 순서오류를 별도 위험으로 서술한 건 전제와 모순"
|
||||
해석: "번호표를 순서대로 뽑아 순서대로 처리하는데 어떻게 순서가 뒤집히나? 순서오류가 진짜 난다면 그건 너희 저널 설계 자체가 잘못된 것이다."
|
||||
|
||||
저의 판단: 기각 (근거 있음) — 단, 서술 명확화는 수용. 이 지적은 리뷰어가 분산 시스템의 현실을 놓친 것입니다. 핵심 구분은 "순번 부여(numbering)"와 "순번 적용(applying)"은 다른 단계라는 점입니다.
|
||||
|
||||
순번기가 번호를 정확히 1,2,3으로 매겨도, 세 센터가 그 저널을 네트워크로 병렬 수신하는 과정에서 패킷 재정렬·버퍼·비동기 I/O로 도착·적용 순서가 드물게 어긋날 수 있습니다. 이건 실제 분산 시스템의 정상적 현상이고, 우리 보고서 288번 줄이 이미 정확히 이렇게 설명하고 있습니다. 국내 PoC에서도 실제 관찰됐다고 명시돼 있습니다.
|
||||
리뷰어가 "순서오류가 실재하면 저널 소비 구조의 설계 문제"라고 한 것은 절반만 맞습니다 — 맞아요, 그래서 우리는 그걸 "탐지·교정 장치"로 설계에 포함시킨 겁니다. 완벽한 순차 소비를 가정하지 않는 것이 오히려 정직한 엔지니어링입니다.
|
||||
리뷰어의 오해는 "동기화가 확정 순서로 완료되었다는 가정하에서"라는 그의 단서에 드러납니다. 바로 그 가정(완벽 동기 완료)을 결제 시스템은 무비판적으로 믿으면 안 된다는 것이 우리 2.3-부속의 논지입니다.
|
||||
→ 따라서 논리적으로 기각하되, 리뷰어가 오해했다는 것은 서술이 그만큼 오해를 유발했다는 뜻이므로, 2.3-부속 도입부에 "순번 부여 ≠ 순번 적용" 구분을 더 앞세워 방어를 명시적으로 하겠습니다. (기각이지만 표현 보강.)
|
||||
|
||||
⑤ (2.5) "결제엔 IBM MQ가 적합하다고 해놓고, 뒤에서 MQ와 GSLB를 혼용"
|
||||
해석: "MQ가 좋다더니 왜 GSLB랑 섞어 쓰냐, 헷갈린다."
|
||||
|
||||
저의 판단: 기각 (명백히 근거 있음). 이건 리뷰어가 우리 2.5절의 핵심 논지를 정면으로 놓친 것입니다. 우리 보고서 369~388번 줄은 정확히 이 오해를 풀려고 **"GSLB와 MQ는 대체 관계가 아니라 다른 계층"**임을 표까지 그려 설명하고, **경로별 분담(결제 전문=MQ 페일오버 / 접속·조회 API=GSLB)**을 명시적으로 정의합니다. 혼용이 아니라 의도적 역할 분리입니다.
|
||||
|
||||
→ 기각. 다만 리뷰어1·리뷰어2가 모두 이 부분을 헷갈렸다면(리뷰어2는 헷갈리지 않았지만), 이 절이 길고 복잡해 오해 소지가 있다는 신호입니다. 2.5 도입에 "결론 한 줄"(결제=MQ, API=GSLB, 둘은 다른 층)을 먼저 박아 넣으면 오해가 줄어듭니다. 표현 보강만.
|
||||
|
||||
⑥ (4) "FedNow 잔액기록을 '분산 인메모리 그리드'로 썼는데, 실제 FedNow는 애플리케이션 레벨 Raft 저널복제 + 코어별 인메모리 처리 — TIPS와 다른 메커니즘"
|
||||
해석: "해외사례 표에서 FedNow 방식을 틀리게 적었다. FedNow는 사실 우리(저널복제) 방식에 더 가깝다."
|
||||
|
||||
저의 판단: 수용 (사실 정정, 그리고 우리에게 유리). 리뷰어 지적이 사실로 맞다면 — FedNow가 Raft 기반 저널 복제라면 — 이는 우리 4장 표 637번 줄 "분산 인메모리 그리드(구현 사례 기준)"를 정정해야 합니다. 그리고 오히려 우리 논지를 강화합니다: 우리의 "저널 순차처리" 방식이 TIPS뿐 아니라 FedNow와도 일치한다는 뜻이니까요.
|
||||
|
||||
단, 검증 필요: FedNow 내부 아키텍처는 상당부분 비공개입니다(우리 보고서도 649번 줄에서 "미공개"라 인정). 리뷰어가 "확인됨(confirmed)"이라 했지만, 출처가 불명확합니다. "Raft 기반 저널 복제로 알려짐/추정" 수준으로 표현하고, 단정은 피하겠습니다. 무엇보다 리스크가 낮은 방향의 수정(우리에게 유리 + 부정확한 단정 제거)이라 수용합니다.
|
||||
⑦ (6.1) "샤딩을 교차거래 비율 측정에 따라 최적화 옵션으로 채택한다는 서술 정정 필요 — FedNow 샤딩은 원장이 아니라 전문·메시지 복제/결과저장용 NoSQL에 적용"
|
||||
해석: "샤딩(쪼개기)을 원장(잔액장부)에 적용하는 옵션처럼 써놨는데, FedNow의 샤딩은 원장이 아니라 **주변부(전문 저장·NoSQL)**에만 쓴다. 원장을 쪼개는 건 위험하니 그렇게 읽히면 안 된다."
|
||||
|
||||
저의 판단: 수용 (타당, 안전한 방향). 이 지적은 우리 보고서의 다른 부분과 오히려 정합적입니다. 우리는 이미 217번 줄과 2.1 샤딩 각주에서 "샤딩(계좌 소유 분담)은 본안이 아니고, 교차거래 비율 낮을 때의 부하분산 최적화 옵션"으로 격하해뒀습니다. 그런데 리뷰어 말대로 "원장 계좌 샤딩"으로 읽힐 여지가 있고, 그건 우리의 "원장은 단일 순번으로 강한 일관성" 대원칙과 충돌합니다.
|
||||
|
||||
→ 수용: 샤딩 옵션을 언급할 때 **"원장을 쪼개는 것이 아니라 주변부(전문 저장·조회 사본)에 한한다"**는 단서를 명확히 붙입니다. 6.4-1의 "최후엔 샤딩 옵션 재검토"도 같은 맥락에서 오해되지 않게 다듬습니다.
|
||||
|
||||
2. 리뷰어1이 우리 보고서 방향에 주는 영향 — 종합
|
||||
핵심: 리뷰어1은 우리 보고서의 골격(근거리 3센터 A-A-A + 저널 순차처리)을 흔들지 않습니다. 7개 지적 중 방향을 바꾸는 것은 하나도 없고, 모두 "표현 정정·명확화" 수준입니다. 이는 역설적으로 우리 설계가 논리적으로 견고하다는 방증입니다.
|
||||
|
||||
지적 판단 보고서 영향 작업 강도
|
||||
① 거액=다자간 단정 부분수용 1.3 전제 서술 정정("건별 RTGS 기본, 다자간은 일부") + 리뷰어의 "복구시한" 프레임 흡수 中
|
||||
② 지연 실측 완전수용 2.1 톤 하향("이론 근사, 회선·장비 포함 실측이 전제") 中
|
||||
③ 코어/주변 확장 구분 부분수용 0장·2.2 도입에 "코어는 확장 예외" 선제 명시 小
|
||||
④ 순서오류 모순 기각+보강 2.3-부속에 "번호부여≠번호적용" 방어 강화 小
|
||||
⑤ MQ/GSLB 혼용 기각+보강 2.5 도입에 결론 한 줄 선제 小
|
||||
⑥ FedNow 메커니즘 수용 4장 표 정정("Raft 저널복제로 알려짐") 小
|
||||
⑦ 샤딩 범위 수용 샤딩=주변부 한정 단서 명확화 小
|
||||
방향 추천: 리뷰어1 지적은 "우리 논지를 유지하되 방어를 더 촘촘히" 하는 재료입니다. 특히 ①의 "복구시한 유무" 프레임과 ⑥의 "FedNow도 저널복제" 사실은 받아들이면 오히려 우리 주장이 강해집니다. 기각한 ④⑤도 "리뷰어조차 헷갈렸다"는 신호로 받아 서술을 더 친절하게 만드는 계기로 씁니다.
|
||||
|
||||
부장님, 여기까지가 **리뷰어1(1·2단계)**입니다. 이어서 **리뷰어2(3단계)**로 넘어가겠습니다.
|
||||
|
||||
3. 리뷰어2(양팀장님) — 항목별 해석 + 저의 판단
|
||||
리뷰어2는 페이지별 하이라이트+메모 형식입니다. 10개 메모 중 대부분이 "좋다 + 이걸 더하라"입니다. 성격별로 묶어 보겠습니다.
|
||||
|
||||
A. BMT로 실증하라 (메모 3건: p1×2, p11) — 가장 강한 반복 신호
|
||||
해석: "저널 순차처리로 이중지급·순서 막는다는 것, 3센터 AAA 가용성, 15,000 TPS '접수처리' 수치 — 이거 다 말로만 하지 말고 BMT(실증시험)로 확인해야 진짜다. 특히 15,000 TPS는 '접수'까지지 '최종 이체확인'까지가 아니다."
|
||||
|
||||
저의 판단: 완전 수용 (핵심 관통). 이건 리뷰어2의 가장 일관된 메시지이고 전적으로 옳습니다. 우리 보고서는 6.4 BMT 8개 항목으로 이미 대응하고 있고, "15,000 TPS는 접수 한정·end-to-end 아님"도 242번 줄·6.4-8에서 명시했습니다. 리뷰어2가 요구하는 것을 우리가 이미 상당부분 갖췄다는 게 확인됩니다.
|
||||
|
||||
→ 다만 리뷰어2가 명시적으로 요청한 것 중 신규 추가할 것: "3센터 AAA와 2센터 AA도 가능한지 BMT에서 함께 확인"(p1 첫 메모). 이건 우리에게 없던 좋은 지적입니다 — 5.3-②에 "2센터 잠정 개시" 옵션은 있지만, BMT 항목에 "2센터 AA 대안의 복원력 실측"을 명시하면 의사결정 폭이 넓어집니다. 수용.
|
||||
|
||||
B. 최대 목표치(peak) 산정 필요 (메모 p10)
|
||||
해석: "2,000 TPS 기준선은 좋다. 근데 그건 하루 2만건 거액 수준이다. 소액은 거액의 100배 이상 건수인데, 기준선만 있고 **최대목표치(스파이크 정점)**가 없다. 그건 잡아야 한다."
|
||||
|
||||
저의 판단: 완전 수용 (날카롭고 정당). 이건 리뷰어2 지적 중 가장 실질적입니다. 우리 보고서는 "고정 숫자 안 박고 scalable" 논리로 238번 줄 최대치 산정을 회피하는 경향이 있는데, 리뷰어 말이 맞습니다 — 확장성만 강조하고 정점 목표를 안 잡으면 용량 설계·비용 산정이 불가능합니다. "scalable하니 됐다"는 우리 논리의 약한 고리를 정확히 찔렀습니다.
|
||||
|
||||
→ 수용: 2.2에 **"기준선(2,000) + 최대목표치(스파이크 정점 배수) 산정"**을 추가하고, 6.1-1에 "최대목표치 산정" 항목을 넣습니다. 다만 정확한 수치는 이용규모 예측이 필요하므로 "산정 방법론과 확인필요"로 잡습니다. (숫자를 지금 지어내지 않음 — 리뷰어도 "산정 필요"라 했지 "얼마"라 하지 않음.)
|
||||
|
||||
C. 정책·비즈니스 선결 (메모 p1 원장동기화, p5 심플, p2 참가기관)
|
||||
해석:
|
||||
|
||||
p1: "센터간 원장 동기화 해결책이 핵심. 3센터가 순서만 보장하면 센터간 동기화 관리가 불필요함을 보장하라."
|
||||
p5: "RTGS 서비스를 최대한 심플하게. 단방향만, 취소·반려는 새 거래로."
|
||||
p2: "정책 필요성 서술 좋다. 참가기관 관점(통신망 신설, 거액/소액 구분 이용, 장애 대응 변화)도 추가하라."
|
||||
저의 판단: 대체로 수용.
|
||||
|
||||
p1은 이미 우리 핵심 논지 — "동기화가 아니라 하나의 순서를 셋이 재생"(17번 줄)이 정확히 "순서 보장 → 동기화 관리 불필요"입니다. 리뷰어2가 우리 방향에 동의·강조한 것. 그 "보장"을 더 명시적으로 못박으라는 요구로 받아 2.3에 한 줄 강화. 수용.
|
||||
p5 "심플하게(단방향·취소는 신규거래)": 매우 좋은 실무 원칙이나 이건 비즈니스 프로세스 설계 영역(금융결제국 소관)입니다. 우리 보고서 482번 줄이 이미 "비즈니스 요구가 먼저 확정되어야"라고 원칙을 세워뒀습니다. → 부분 수용: 5.3 또는 1장에 "서비스 단순성 원칙(단방향·취소는 신규거래 등)을 비즈니스 설계 시 지향"을 시사점으로 한 줄 추가하되, 우리가 확정하지 않고 금융결제국 협의 사안으로 넘깁니다. (리뷰어1의 "비즈니스 요구가 기술을 끌어야" 프레임과도 정합.)
|
||||
p2 참가기관 관점: 정당합니다. 우리 2.5 참가기관 접속·420번 줄에 참가기관 대응이 있지만, "통신망 신설·거액/소액 구분 이용·장애 대응 변화"를 참가기관 부담 관점에서 1장 또는 2.5에 명시적으로 정리하면 보고서가 더 균형잡힙니다. 수용.
|
||||
D. 거리·비용 현실 (메모 p28, p35) — 리뷰어1 ②와 수렴
|
||||
해석:
|
||||
|
||||
p28: "TIPS 3센터는 각각 15km 이내인데, 광교–강남–본부는 그 2~3배다. 이걸 감안하라. 10Gbps 회선 비용 평가도."
|
||||
p35: "3센터 물리거리 latency + 중간 보안장비 latency까지 감안한 BMT 필요."
|
||||
저의 판단: 완전 수용 (리뷰어1 ②와 3중 수렴 = 최강 신호). 세 지적(리뷰어1 ②, 리뷰어2 p28·p35)이 모두 **"거리·회선·보안장비 지연을 이론값이 아니라 실측으로"**를 가리킵니다. 이건 반드시 반영해야 합니다.
|
||||
|
||||
**특히 "TIPS 15km vs 우리 2~3배"**는 아프지만 정확한 지적입니다. 우리 보고서는 211번 줄 등에서 "TIPS 구조를 충실히 재현"이라 하는데, 거리 조건이 TIPS보다 불리하다는 점을 정직하게 명시해야 합니다. "TIPS를 좇는다"면서 물리 전제(거리)가 다르다는 걸 숨기면 방어 불가능해집니다.
|
||||
10Gbps 회선 비용·보안장비 지연: 6.1-15(비용)·6.4-2(합의지연)에 이미 관련 항목이 있으나, "보안장비 통과 지연"과 "회선 등급별 비용"을 명시적으로 추가합니다.
|
||||
→ 수용: 2.1에 "TIPS 대비 거리 열위(TIPS ≤15km vs 우리 10~30km)를 정직하게 명시 + 보안장비 지연 포함 실측이 성립 전제", 6.1/6.4에 회선비용·보안장비 지연 항목 보강.
|
||||
|
||||
E. 조직·개발 모델 (메모 p34)
|
||||
해석: "신기술이라 완전 외주개발이면 당행 직원 내재화 불가능. 최소 공동개발이 필수다."
|
||||
|
||||
저의 판단: 완전 수용 (강한 실무 통찰). 우리 보고서는 752번 줄에서 "실행모델(자체/공동/외주) 비교 선택"이라고 중립적으로 써뒀는데, 리뷰어2는 "중립적으로 두지 말고, 완전외주는 내재화 불가이므로 최소 공동개발이 필수"라고 방향을 특정하라고 요구합니다. 이건 타당합니다 — 코어(순번기·원장 엔진)를 완전 외주하면 24/365 운영·장애대응 역량이 조직에 안 남습니다.
|
||||
|
||||
→ 수용: 5.3-⑤와 6.2-3에서 "완전 외주 지양, 최소 공동개발 이상을 코어 내재화의 하한선으로" 명시. (리뷰어의 방향 제시를 우리 판단으로 재검증한 결과 동의.)
|
||||
|
||||
F. 시각화 (메모 p36)
|
||||
해석: "부록 A 시뮬레이션 결과를 그래프로 보여줄 수 있나?"
|
||||
|
||||
저의 판단: 조건부 수용 (좋으나 우선순위·검증 유의). 부록 A의 S1~S5 결과(잔액 일치, 오거절 1,300건 등)를 그래프화하면 설득력이 올라갑니다. 다만:
|
||||
|
||||
시뮬레이션이 실제 실행된 것인지 sim 폴더 확인이 필요합니다(그래프는 실데이터라야 의미). 지어낸 그래프는 금물.
|
||||
리뷰어2 종합 (4단계 일부) — 방향 영향
|
||||
메모 판단 보고서 영향 강도
|
||||
A. BMT 실증(3건) 수용 대부분 반영됨 + "2센터 AA 대안 BMT" 신규 中
|
||||
B. 최대목표치 완전수용 2.2·6.1에 peak 목표 산정 추가 (약한고리 보강) 中
|
||||
C-p1 순서보장→동기화불요 수용(동의) 2.3 한 줄 강화 小
|
||||
C-p5 서비스 심플 부분수용 시사점 한 줄(금융결제국 협의) 小
|
||||
C-p2 참가기관 관점 수용 1장/2.5에 참가기관 부담 정리 中
|
||||
D. 거리·회선·보안장비 완전수용 2.1 거리열위 정직 명시 + 6장 비용/지연 中
|
||||
E. 최소 공동개발 완전수용 5.3-⑤·6.2 방향 특정 小
|
||||
F. 그래프 조건부 sim 데이터 있으면 그래프 1개 小
|
||||
리뷰어2도 골격을 흔들지 않습니다. 오히려 **우리 방향에 동의하며 "더 정직하게, 더 실측으로, 더 정책까지"**를 요구합니다. 가장 값진 지적은 **B(최대목표치)**와 D(거리 열위 정직화) — 우리 보고서의 두 약한 고리입니다.
|
||||
|
||||
=========================================================================
|
||||
리뷰결과 의견1
|
||||
=========================================================================
|
||||
제 의도와는 다른 해석이 있어 내용을 일부 추가합니다. (제가 내용을 너무 단순화하여 잘못 쓴 것 같아요)
|
||||
|
||||
(2.3)
|
||||
|
||||
IP 계층에서는 패킷이 뒤섞여 도착할 수 있으나, TCP는 단일 커넥션 내에서 이를 재정렬해 애플리케이션에 순서대로 전달
|
||||
따라서 순서 역전의 실질적 원인은 네트워크 계층 자체가 아니라,
|
||||
(1) 처리량 확보를 위한 멀티 파티션/멀티 스레드 병렬화
|
||||
(2) 3개 센터가 각자 독립된 스트림으로 수신/적용 하는 구조로
|
||||
(1)은 파티션, 재정렬 설계로, (2)는 센터간 진행상태 동기화로 대응
|
||||
|
||||
가정(완벽 동기 완료)을 결제 시스템은 무비판적으로 믿으면 안 된다
|
||||
=>믿는 것이 아니라, 순번부여+3센터 동기화 로직 자체가 애플리케이션 레벨에서 보장되어야 한다는 의미
|
||||
|
||||
|
||||
(2.5 - 아마도 주기능(결제)는 MQ로, 관리/부가 서비스는 GSLB로 두 가지 방법 병행한다는 제 보고서 내용이 들어간 듯 보입니다)
|
||||
|
||||
MQ 페일오버 클러스터와 GSLB를 나눈 스코프가 적절하지 않음
|
||||
결제전문 경로의 "접속+인증"은 하나의 연속된 절차로서 MQ경로에 포함되어야 함 (설계에 따라 연결 후 별도 인증절차를 거칠 수도 있음)
|
||||
GSLB는 관리용 웹페이지 접속 등 부가 기능에 사용
|
||||
|
||||
18p 본안 A, 대안 B 비교 표에서,
|
||||
본안A에 MQ와 GSLB가 모두 포함되어 있는데 서로 다른 두 매커니즘을 하나의 열에 합쳐서 서술함
|
||||
- "부하·전환" 행의 "중앙에서 동적 조정·자동 전환(설정 변경 불요)"은 GSLB와 MQ 페일오버의 속성을 혼합 서술
|
||||
- "중앙에서 동적 조정"은 GSLB 특성 -> 중앙이 DNS 응답 변경으로 능동적·사전적 트래픽 유도
|
||||
- "설정 변경 불요"는 MQ 페일오버 특성 -> 참가기관 클라이언트가 로컬 연결목록 기반, 장애 후 자체 재연결
|
||||
- 전환 주체(중앙 vs 참가기관)·시점(사전 vs 사후)이 상이한 두 방식을 단일 셀에 병기
|
||||
|
||||
이상입니다.
|
||||
|
||||
=========================================================================
|
||||
리뷰결과 의견2
|
||||
=========================================================================
|
||||
처리 순서가 제일 중요합니다.
|
||||
순서는 맨앞단에 GSLB에서 3센터 공통으로 지정해 줘야는데
|
||||
순서만 지정하는 거라 이 부분에서 병목현상은 없을텐데
|
||||
이 GSLB를 3센터 중에 한 곳에서 수행할지 또다른 곳에 놓을지를 가용성 보장 측면에서 결정해야 할 것 같습니다.
|
||||
BIN
docs/2센터흐름도.png
Normal file
BIN
docs/2센터흐름도.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 227 KiB |
80
docs/RTGS 3센터 이관·기동 체크리스트.md
Normal file
80
docs/RTGS 3센터 이관·기동 체크리스트.md
Normal file
@@ -0,0 +1,80 @@
|
||||
# RTGS 3센터 삼중화 — 이관·기동 체크리스트
|
||||
|
||||
> 대상: 고사양 PC(40코어/128GB) 이관 후 **테스트 시작**용. 코드(B1/B2/B3)·설정공통화·스크립트는
|
||||
> 8코어 PC에서 구현·빌드·단위테스트 완료(2026-07-13). 이 문서대로 기동하면 3센터 실측 가능.
|
||||
|
||||
## 0. 구현 요약 (이번에 반영된 것)
|
||||
| 항목 | 내용 | 파일 |
|
||||
|---|---|---|
|
||||
| B1 무손실 순번기 | SEQUENCE `global_seq_seq` + `journal_outbox`(durable-before-publish) + 재발행 스케줄러 | `sequencer/SequencerService.kt`, `louisvuitton/.../db/migration/V4__seq_outbox.sql` |
|
||||
| B1 리더선출 | 순번기 그룹 `sequencer` 단일 리더(Kafka 리밸런스). **전역 단일 인스턴스 = DC1** | 동상 |
|
||||
| B2 정족수 | `rtgs.result`=applied-ack, `QuorumAggregator`가 센터 집계 → 과반(2/3) ACCC | `prada/QuorumAggregator.kt`, `prada/PradaService.kt` |
|
||||
| B3 gap 타임아웃 | `GapBuffer` + 스케줄러: 정체 시 phantom skip(단일 파티션 근거) + `rtgs.journal.gap.timeout` 지표 | `hermes/GapBuffer.kt`, `hermes/HermesService.kt` |
|
||||
| 설정공통화 | 원장/엣지 groupId 센터접미사(`hermes-DC1`…) 파라미터화, 순번기만 단일 그룹 | dior/hermes/prada/chanel Service |
|
||||
| 이관 스크립트 | `run-dc2/dc3.cmd`, `infra/init-3centers-db.cmd`, `run-dc1.cmd`(3센터 모드 지원) | 루트/infra |
|
||||
|
||||
- 단위테스트: `QuorumAggregatorTest`(6), `GapBufferTest`(6) — `gradlew build` 통과.
|
||||
|
||||
## 1. 이관 검증 (고사양 PC 도착 직후)
|
||||
1. `C:\ai-dev` 트리를 동일 경로로 복사(포터블 무설치 철학 — 경로만 같으면 됨).
|
||||
2. 빌드 확인: `cd C:\ai-dev\workspace\rtgs\backend && gradlew.bat build`
|
||||
- JDK21 고정(`gradle.properties`의 `org.gradle.java.home`)이라 별도 설정 불필요.
|
||||
- 기대: `BUILD SUCCESSFUL`, 단위테스트 12건 통과.
|
||||
3. 단일센터 스모크(선택): `run-dc1.cmd`(기본 count=1, db=rtgs) → Chanel :8091로 1건 이체 → ACCC 확인.
|
||||
|
||||
## 2. 3센터 기동 절차 (순서 중요)
|
||||
```
|
||||
1) 공유 인프라 + DC1(3센터 모드) 기동:
|
||||
set CENTER_COUNT=3 & set POSTGRES_DB=rtgs_dc1 & run-dc1.cmd
|
||||
→ postgres:5433, kafka:9092, 단일 순번기 :8090, DC1 원장/엣지(809x) 기동.
|
||||
|
||||
2) 3개 DB 생성(멱등):
|
||||
infra\init-3centers-db.cmd
|
||||
→ rtgs_dc1/dc2/dc3 (스키마는 각 센터 LouisVuitton의 Flyway가 생성).
|
||||
※ DC1은 이미 기동 중이라 rtgs_dc1은 DC1 LouisVuitton이 마이그레이션함.
|
||||
|
||||
3) DC2 기동: run-dc2.cmd (원장/엣지만, 819x, db=rtgs_dc2)
|
||||
4) DC3 기동: run-dc3.cmd (원장/엣지만, 829x, db=rtgs_dc3)
|
||||
```
|
||||
- **순번기는 전역 단일(DC1 :8090)**. DC2/DC3는 저널(rtgs.journal, 단일 파티션)을 **독립 그룹**으로
|
||||
전량 재생 → 각자 rtgs_dc2/dc3 원장에 동일 상태 도달(결정론적 재생).
|
||||
- 완결: 각 센터 Prada가 `rtgs.result`를 전 센터분 집계 → 서로 다른 센터 **2/3** 도달 시 ACCC.
|
||||
|
||||
## 3. 검증 항목 (§설계서 6장 대응)
|
||||
| 시나리오 | 방법 | 기대 |
|
||||
|---|---|---|
|
||||
| 평상시 3센터 | Chanel(:8091 등)로 이체 N건 | 3 DB의 transfer/account **완전 일치**(총액·상태·순번), 즉시 과반 ACCC |
|
||||
| 1센터 강제종료 | DC3 창 닫기 → 이체 계속 | 남은 2센터 ack=2 → 과반 유지 → **완결 지속**, 이중지급 0 |
|
||||
| 재해센터 복구 | DC3 run-dc3 재기동 | 밀린 저널 따라잡기(earliest 재생) → 잔액 일치 수렴 |
|
||||
| 2센터 종료 | DC2·DC3 종료 → 이체 | ack=1 → 과반 미달 → **ACSP(완결 보류)**, 오처리 0 |
|
||||
| 2센터 복구 | DC2·DC3 재기동 | 따라잡기 후 보류분 **ACCC 재개** |
|
||||
| 순서 gap | (인위적 phantom) | `rtgs.journal.gap.timeout` 증가 + skip 후 진행 재개 |
|
||||
|
||||
**대사(정합성) 확인 쿼리** (psql, 각 DB 반복):
|
||||
```
|
||||
psql -h localhost -p 5433 -U rtgs -d rtgs_dc1 -c "SELECT status,count(*),sum(amount) FROM transfer GROUP BY status ORDER BY status;"
|
||||
psql ... -d rtgs_dc2 -c "동일"
|
||||
psql ... -d rtgs_dc3 -c "동일"
|
||||
-- 3개 결과가 동일해야 함. account 총합도 세 DB 동일해야 함.
|
||||
```
|
||||
|
||||
## 4. 포트·토폴로지 참조
|
||||
| 서비스 | DC1 | DC2 | DC3 | 그룹 |
|
||||
|---|---|---|---|---|
|
||||
| Sequencer | 8090 | (없음) | (없음) | `sequencer`(단일 리더) |
|
||||
| Chanel | 8091 | 8191 | 8291 | `chanel-DCx` |
|
||||
| Dior | 8092 | 8192 | 8292 | `dior-DCx` |
|
||||
| Hermes | 8093 | 8193 | 8293 | `hermes-DCx` |
|
||||
| Prada | 8094 | 8194 | 8294 | `prada-DCx` |
|
||||
| Gucci | 8095 | 8195 | 8295 | (무상태) |
|
||||
| LouisVuitton | 8099 | 8199 | 8299 | (Flyway/관리) |
|
||||
- 공유: PostgreSQL 5433(rtgs_dc1/dc2/dc3), Kafka 9092(단일 파티션 저널), Prometheus 9090, Grafana 3000, ELK.
|
||||
|
||||
## 5. 주의·후속 (테스트 중 확인)
|
||||
- **순번기 HA(선택 검증)**: 리더선출 시연하려면 순번기 인스턴스를 추가 기동하되 **반드시 동일
|
||||
SEQUENCE DB**(POSTGRES_DB=rtgs_dc1)를 바라보게 할 것. 서로 다른 DB면 순번 충돌.
|
||||
- **Gucci 콜백 시드**: `institution_endpoint`가 `localhost:8095`(DC1 Gucci)로 시드됨. 센터별
|
||||
콜백 왕복까지 볼 땐 DC2/DC3 endpoint URL 조정 필요(핵심 정족수 검증엔 무관).
|
||||
- **phantom skip 임계**: `rtgs.gap-timeout-ms`(기본 5000) — 실측 부하에 맞게 조정 가능(env/프로퍼티).
|
||||
- **재발행 grace**: `rtgs.outbox-republish-grace-ms`(기본 3000) — 인라인 발행과의 경합 회피 창.
|
||||
- 21 JVM 동시기동은 고사양 PC 전제. 8코어에선 경량 스모크만 권장.
|
||||
187
docs/RTGS 삼중화(B1-B3) 설계.md
Normal file
187
docs/RTGS 삼중화(B1-B3) 설계.md
Normal file
@@ -0,0 +1,187 @@
|
||||
# RTGS 3센터 삼중화 설계 (B1·B2·B3) — 구현 착수 확정본
|
||||
|
||||
| 항목 | 내용 |
|
||||
|---|---|
|
||||
| 목적 | 2센터 PoC → **3센터 Active-Active-Active** 전환. 코드 구현은 현 PC, 전체 3센터 실측은 고사양 PC 이관 후 |
|
||||
| 범위 | B1 순번기 이중화·무손실, B2 정족수(2/3) 완결, B3 순서결번 처리 + 멀티센터 런타임·설정 공통화 |
|
||||
| 전제 | 코드는 이미 `CENTER_ID`/`CENTER_COUNT` 파라미터화. **§9 결정사항 확정 완료**(단일 인스턴스 3DB·인메모리 ack·순번기 3인스턴스) |
|
||||
| 실행 환경 | 실측 전제 = 고사양 PC(40코어/128GB) 3터미널. **단, 현 개발 PC는 8코어** → 이 PC는 코드/빌드/단위테스트/경량 스모크(1~2센터)까지, 전체 3센터 21 JVM 실측은 이관 후 |
|
||||
| 상태 | **설계 확정 + 코드 구현·빌드·단위테스트 완료(2026-07-13, 8코어 PC) · 3센터 실측은 고사양 PC** |
|
||||
| 구현 산출물 | B1/B2/B3 코드 + `V4__seq_outbox.sql` + 단위테스트(QuorumAggregator·GapBuffer) + `run-dc2/dc3.cmd`·`init-3centers-db.cmd`. 기동·검증은 **`docs/RTGS 3센터 이관·기동 체크리스트.md`** 참조 |
|
||||
|
||||
---
|
||||
|
||||
## 0. 핵심 원리 (재확인)
|
||||
- **결정론적 재생**: 3센터가 **같은 저널을 같은 순서로** 재생하면 각자 동일 상태 도달 → **원장 데이터 동기화 불필요**(양팀장님 이론 맞음).
|
||||
- 센터 간 조율이 필요한 건 딱 둘: **(a) 하나의 전역 순서 생성**(순번기) + **(b) 완결 선언 시 과반 확인**(정족수).
|
||||
- 재해 규율: **1센터 down = 서비스 지속**(복구 후 밀린 저널 **진도처리=따라잡기**) / **2센터 down = 서비스 중단(안전정지)** / 복구 후 **3센터 정상 재개**.
|
||||
|
||||
---
|
||||
|
||||
## 1. 현재 상태 (전환 대상)
|
||||
| 구분 | 현재(1센터) | 삼중화 전환 |
|
||||
|---|---|---|
|
||||
| Sequencer | 단일 인스턴스, seq=인메모리→DB max복원 | **durable-before-publish + 리더선출(3인스턴스) + 펜싱** |
|
||||
| 완결(Prada) | `confirmations=1` **하드코딩** → 즉시 ACCC | **센터별 applied ack 집계 → 과반(2/3) 후 ACCC** |
|
||||
| 순서(Hermes) | gap 버퍼링(무한대기 가능) | **gap 타임아웃 시 durable(Kafka/journal_log)에서 결번 pull** |
|
||||
| 원장 DB | 단일 `rtgs` | **센터별 `rtgs_dc1/dc2/dc3`** |
|
||||
| 서비스 | 1세트(7) | **3세트(센터별) + 단일 순번기 그룹** |
|
||||
| 설정 | 서비스별 yml 중복 | **공통 yml + env 파라미터화**(A안) |
|
||||
|
||||
---
|
||||
|
||||
## 2. 멀티센터 런타임 구성 (단일 호스트, 3터미널)
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph SHARED["공유 인프라 (단일)"]
|
||||
K["Kafka :9092<br/>rtgs.inbound / rtgs.journal(단일파티션) / rtgs.result(=applied-ack) / rtgs.notify"]
|
||||
PG[("PostgreSQL :5433<br/>rtgs_dc1 · rtgs_dc2 · rtgs_dc3")]
|
||||
OBS["Prometheus:9090 · Grafana:3000 · ELK"]
|
||||
end
|
||||
subgraph SEQ["전역 순번기 (그룹=sequencer, 1 active=리더)"]
|
||||
S1["seq@DC1"]:::a --- S2["seq@DC2"]:::s --- S3["seq@DC3"]:::s
|
||||
end
|
||||
DC1["DC1 스택 809x<br/>Chanel/Dior/Hermes/Prada/Gucci/LV"]
|
||||
DC2["DC2 스택 819x<br/>동일"]
|
||||
DC3["DC3 스택 829x<br/>동일"]
|
||||
K --- SEQ
|
||||
K --- DC1 & DC2 & DC3
|
||||
DC1 --> PG
|
||||
DC2 --> PG
|
||||
DC3 --> PG
|
||||
classDef a fill:#cfe8cf; classDef s fill:#eee
|
||||
```
|
||||
|
||||
**포트 오프셋** (센터별 +100): DC1 8090~8099·5174 / DC2 8190~8199·5175 / DC3 8290~8299·5176.
|
||||
**공유**: Kafka 9092 · PostgreSQL 5433(3 DB) · Prometheus 9090 · Grafana 3000 · ELK 9200/5000/5601.
|
||||
**컨슈머 그룹**: 원장서비스는 **센터별 접미사**(예: `hermes-DC1/DC2/DC3`) → 각 센터가 저널 전량 독립 재생.
|
||||
순번기는 **단일 그룹 `sequencer`**(3인스턴스, 1파티션 → 1개만 활성 = 리더).
|
||||
|
||||
> **구현지점(현 코드 상태)**: `@KafkaListener(groupId="hermes")` 등 groupId가 **하드코딩**되어 있음
|
||||
> (dior/hermes/prada/chanel/sequencer 각 서비스). 센터별 독립 재생을 위해 원장서비스 groupId를
|
||||
> `CENTER_ID` 접미사로 파라미터화 필요 → SpEL `groupId = "hermes-#{@centerId}"` 또는
|
||||
> `groupId = "\${rtgs.group.hermes}"`(프로퍼티 주입). **순번기만 예외**(접미사 없이 단일 그룹 `sequencer` 유지).
|
||||
|
||||
---
|
||||
|
||||
## 3. B1 — 순번기 이중화 + 무손실 (durable-before-publish)
|
||||
|
||||
> **현 코드 상태(전환 대상)**: `SequencerService.kt`는 인메모리 `AtomicLong` + 부팅 시
|
||||
> `@PostConstruct`로 `MAX(journal_log.global_seq, transfer.global_seq)` 복원. **순번기 자신은
|
||||
> `journal_log`/outbox를 쓰지 않고** 바로 `rtgs.journal` 발행(발행-후 크래시 시 무손실 미보장).
|
||||
> 따라서 아래 SEQUENCE·outbox·재발행은 **전량 신설**이다(현 코드에 없음).
|
||||
|
||||
**리더선출(간결·견고)**: 순번기 3인스턴스를 **같은 컨슈머 그룹 `sequencer`** 로 `rtgs.inbound`(단일 파티션) 구독 →
|
||||
Kafka가 파티션을 **1개 인스턴스에만 배정 = 자동 단일 리더**. 리더 死 시 Kafka 리밸런스로 **자동 승계**(별도 선출 로직 불필요).
|
||||
|
||||
**durable-before-publish (무손실·무결번)**:
|
||||
1. 리더가 접수 수신 → **Postgres SEQUENCE `global_seq_seq`.nextval**(원자적·durable 순번)
|
||||
2. 저널 엔트리를 **outbox 테이블에 저장(commit)** → 그 후 `rtgs.journal` 발행
|
||||
3. 크래시로 발행 전 죽어도, 재기동/승계 리더가 **outbox의 미발행분을 재발행**(at-least-once)
|
||||
4. 소비측은 seq 기준 **멱등**(이미 있음) → 중복 무해
|
||||
|
||||
**펜싱(split-brain)**: nextval이 원자적이라 **중복 순번 불가**. 순간적 이중리더가 있어도 서로 다른 seq만 발급 →
|
||||
저널 오염 없음. (엄격 fencing이 필요하면 outbox에 leader epoch 컬럼 추가—후순위)
|
||||
|
||||
**작업**: 새 서비스/모듈 대신 SequencerService 개편 + `global_seq_seq`·`journal_outbox` + 재발행 스케줄러.
|
||||
- **Flyway 위치**: 마이그레이션은 `backend/louisvuitton/src/main/resources/db/migration/`에 있고
|
||||
현재 `V1__baseline`·`V2__admin_login`·`V3__service_heartbeat`까지 존재 → SEQUENCE+outbox는 **`V4__seq_outbox.sql`** 로 추가.
|
||||
- **재발행**: `journal_outbox`에 `published` 플래그 컬럼 → 발행 성공 시 true. 스케줄러가 미발행분 주기 재발행(at-least-once).
|
||||
|
||||
---
|
||||
|
||||
## 4. B2 — 정족수(2/3) 완결
|
||||
|
||||
**흐름**: 각 센터 Hermes가 seq 적용(선저널 commit) 후 **applied ack** 발행 → `rtgs.applied`(bmi, seq, center).
|
||||
각 센터 Prada가 **bmi별 서로 다른 center 수 집계** → **≥ quorum(=CENTER_COUNT/2+1)** 도달 시 **ACCC 확정**.
|
||||
**결과통보는 origin 센터만** → 과반 도달 후 pacs.002 송부.
|
||||
|
||||
**구현 결정 — `rtgs.result`를 applied-ack으로 재활용(별도 토픽 불필요)**:
|
||||
- 당초 `rtgs.applied` 신설을 검토했으나, `rtgs.result`가 이미 **커밋 후에만**(publish-after-commit)
|
||||
발행되고 `processedCenter`를 담으므로 "그 센터가 durable 적용했다"는 ack와 **정확히 동일**하다.
|
||||
→ 토픽/Hermes 변경·마이그레이션 없이 더 단순·안전. `Topics`에 APPLIED 추가하지 않음.
|
||||
- **구현 완료(2026-07-13)**:
|
||||
- `prada/QuorumAggregator.kt`(신규, 순수로직+단위테스트 6): bmi별 서로 다른 `processedCenter` 집계,
|
||||
과반 **최초 도달** 판정, 완결 후 멱등 가드.
|
||||
- `PradaService.finalize()`: `confirmations=1` 제거 → `aggregator.record()` 기반. 과반 미달=ACSP,
|
||||
도달=ACCC(반려면 RJCT). `@KafkaListener(groupId="prada-${rtgs.center-id}")`로 전 센터분 소비.
|
||||
- 중복 통보 방지: **origin 센터 Prada만** notify 발행(`notice.originCenter == centerId`).
|
||||
`NotificationService`의 origin 필터는 그대로 유지(이중 안전).
|
||||
|
||||
**ack 집계 저장(§9.4 확정)**: **인메모리 맵**(bmi→집계한 center 집합). `finality_ack` 테이블은 **불채택**(간단성 우선).
|
||||
|
||||
**재기동 경계조건(인메모리 손실 대응)**:
|
||||
- 각 센터 Prada는 자기 컨슈머그룹으로 `rtgs.applied`를 소비 → 재기동 시 커밋된 오프셋에서 재개.
|
||||
- 인메모리 집계 맵이 재기동으로 비어도: **이미 ACCC 확정분은 `transfer.status`에 영속**되어 안전(재판정해도 멱등).
|
||||
아직 과반 미달(ACSP)인 건만 이후 도착하는 ack로 재집계 → 최종 수렴. **이중완결/누락 없음**.
|
||||
|
||||
**가용성 매핑**:
|
||||
- 1센터 down → 남은 2센터가 ack 2개 → 과반 충족 → **완결 지속**.
|
||||
- 2센터 down → ack 1개 → 과반 미달 → **완결 보류(안전정지)**. 복구 후 자동 재개.
|
||||
|
||||
---
|
||||
|
||||
## 5. B3 — 순서 결번(gap) 처리
|
||||
|
||||
- B1으로 **영구 결번 소멸**(발급=durable). 지연으로 인한 일시 gap은 Hermes 버퍼가 재정렬(S4 검증됨).
|
||||
- 방어책: `expected` 순번이 **T초 이상 미도착** 시 → **경보 + Kafka에서 해당 seq 오프셋 재읽기(pull)**
|
||||
(durable 저널에서 직접 가져옴). **스킵 금지**(실제 엔트리는 반드시 적용).
|
||||
- 지표: `rtgs.journal.gap`(이미 추가) + 타임아웃 카운터 → Grafana 경보.
|
||||
|
||||
---
|
||||
|
||||
## 6. 재해·복구 시나리오 (검증 항목)
|
||||
| 시나리오 | 기대 | 검증(7/13) |
|
||||
|---|---|---|
|
||||
| 평상시 3센터 | 동일 순서·동일 잔액, 완결 즉시 과반 | 3 DB 대사 일치, 총액 보존 |
|
||||
| 1센터 강제종료 | 서비스 지속(과반 2/3), 이중지급 0 | 나머지 2센터 완결 지속 |
|
||||
| 재해센터 복구 | 밀린 저널 **진도처리(따라잡기)** 후 3센터 수렴 | 오프셋 재생→잔액 일치 |
|
||||
| 2센터 종료 | 완결 보류(안전정지), 오처리 0 | 과반 미달로 ACCC 정지 |
|
||||
| 2센터 복구 | 따라잡기 후 **3센터 정상 재개** | 완결 재개·정합성 |
|
||||
| 순서 역전/gap | 재정렬·pull로 정답 수렴 | S4 확장(3센터) |
|
||||
|
||||
---
|
||||
|
||||
## 7. 설정 공통화 (A안, 삼중화 전제작업)
|
||||
- `common`에 `application-common.yml`(datasource·kafka·management 공통) → 각 서비스 `spring.config.import`.
|
||||
- 센터·포트·DB·그룹접미사는 **env 파라미터**: `CENTER_ID`, `CENTER_COUNT=3`, `POSTGRES_DB=rtgs_dc1`, `PORT_OFFSET`, `GROUP_SUFFIX`.
|
||||
- 실행 스크립트 3종: `run-dc1.cmd`(현행) + **`run-dc2.cmd`·`run-dc3.cmd`**(env만 다름) + 공유 인프라/ELK/Prometheus는 1회 기동.
|
||||
|
||||
---
|
||||
|
||||
## 8. 착수 체크리스트 (순서)
|
||||
|
||||
**A. 현 개발 PC(8코어)에서 가능 — 코드·빌드·경량 검증**
|
||||
2. **설정 공통화(A안)** + env 파라미터화(`CENTER_ID`/`CENTER_COUNT`/`PORT_OFFSET`/`GROUP_SUFFIX`) + `run-dc2/dc3.cmd`.
|
||||
3. **DB 분리**: 단일 인스턴스(5433)에 `rtgs_dc1/dc2/dc3` 생성(Flyway가 각 DB 스키마 생성).
|
||||
4. **B1**: `V4` SEQUENCE+outbox+재발행 스케줄러, 순번기 3인스턴스(그룹 리더선출) → 단일 순번 검증.
|
||||
5. **B2**: `rtgs.applied` 토픽 + Hermes ack 발행 + Prada 과반 집계(인메모리) → 완결 규율.
|
||||
6. **B3**: gap 타임아웃·pull.
|
||||
→ 각 단계마다 **`./gradlew build` + 단위테스트 + 경량 스모크(관자 1~2센터 기동)**로 확인.
|
||||
|
||||
**B. 고사양 PC 이관 후 — 전체 3센터 실측**
|
||||
1. **환경 이관 검증**: C:\ai-dev 복사본으로 빌드·기동 확인(포터블 무설치 철학이라 경로만 동일하면 OK).
|
||||
7. **재해/복구 시나리오**(§6) + **성능 실측**(터미널3, 21 JVM, k6/테스트화면, 순번·지연 병목).
|
||||
8. sim S3(정족수/펜싱)·S5(스파이크) 추가.
|
||||
|
||||
---
|
||||
|
||||
## 9. 결정사항 — **확정 완료** (2026-07-13, 양팀장님)
|
||||
| # | 항목 | 확정 | 비고 |
|
||||
|---|---|---|---|
|
||||
| 1 | PostgreSQL | **단일 인스턴스 · 3 DB**(5433, `rtgs_dc1/dc2/dc3`) | 자원 절약, 8코어 PC 적합. Flyway가 각 DB 스키마 생성 |
|
||||
| 2 | 프론트 콘솔 | **DC1 콘솔 하나로 3센터 조회**(제안) | 간단. 센터별 3콘솔(5174~5176)은 필요 시 후속 |
|
||||
| 3 | 순번기 배치 | **3인스턴스**(센터당 1, 그룹 `sequencer` 리더선출) | Kafka 리밸런스 자동 승계 |
|
||||
| 4 | applied ack 저장 | **인메모리 맵** | `finality_ack` 테이블 불채택. 재기동 경계조건은 §4 참조 |
|
||||
|
||||
---
|
||||
|
||||
## 10. 리스크·주의
|
||||
- 21개 서비스 JVM 동시 구동은 **고사양 PC 전용**(여유). **현 8코어 PC에선 경량 스모크(1~2센터, 일부 서비스)**로
|
||||
코드 정상동작만 확인하고, 전체 동시기동 실측은 이관 후. 개별 명령 기동(bash for-loop 동시기동 실패 이슈).
|
||||
- Kafka 저널은 **반드시 단일 파티션**(전역 순서). 3센터가 같은 토픽 독립 그룹으로 소비.
|
||||
- 정합성 대사: 3 DB의 `transfer`/`account`가 동일해야(총액·상태·순번). 자동 대사 스크립트 준비.
|
||||
- 참조: 전문 보강(BAH/pacs.002 검증)은 `docs/pacs.7z` 기준(B7과 함께).
|
||||
|
||||
*본 설계는 구현 착수 기준 확정본(리뷰·보완 2026-07-13). §9 결정 반영 완료 — 이후 구현 중 세부만 조정.*
|
||||
402
docs/RTGS 아키텍처 설계서.md
Normal file
402
docs/RTGS 아키텍처 설계서.md
Normal file
@@ -0,0 +1,402 @@
|
||||
# RTGS 프로토타입 아키텍처 설계서
|
||||
|
||||
| 항목 | 내용 |
|
||||
|---|---|
|
||||
| 문서명 | 소액 RTGS(실시간총액결제) 프로토타입 아키텍처 설계서 |
|
||||
| 버전 | v1.0 |
|
||||
| 작성일 | 2026-07-10 |
|
||||
| 작성 | 루비(AI) · 검토 양희정 팀장(한국은행 RTGS시스템팀) |
|
||||
| 분류 | SW 산출물 — 아키텍처 설계 |
|
||||
| 대상 시스템 | `C:\ai-dev\workspace\rtgs` (로컬 포터블 프로토타입, 1센터 DC1) |
|
||||
| 관련 문서 | 개발일지 · 워크플로우 · 로컬 테스트 시나리오 · 복원력방안(과제⑧ v2.0) · PoC 1~3차 결과보고 |
|
||||
|
||||
---
|
||||
|
||||
## 1. 개요
|
||||
|
||||
### 1.1 목적
|
||||
클라우드 PoC(3회차, "접수"까지 검증)의 아키텍처를 로컬에서 **업무기능 완결(신청→접수→청산·정산→완결 ACCC)** 로
|
||||
재현하고, 복원력방안(Active-Active-Active)의 미검증 급소를 검증하기 위한 프로토타입의 **소프트웨어/배포 아키텍처**를 정의한다.
|
||||
|
||||
### 1.2 범위
|
||||
- 1센터(DC1) 로컬 구성의 논리·물리(OS-WAS-DB) 아키텍처, SW 스택, 데이터/인터페이스/보안 설계.
|
||||
- 다센터(DC2/DC3) A-A-A 확장 설계는 §11에 방향만 제시(구현은 고사양 PC 이관 후).
|
||||
|
||||
### 1.3 용어
|
||||
| 약어 | 의미 |
|
||||
|---|---|
|
||||
| RTGS | Real-Time Gross Settlement(실시간총액결제) |
|
||||
| A-A-A | Active-Active-Active(3센터 동시 가동) |
|
||||
| BMI | Business Message Identifier(거래식별자 22자리) |
|
||||
| 저널 | 전역순번이 부여된 처리 명단(Kafka 단일 파티션) |
|
||||
| 정족수 | Quorum(N/2+1). 3센터=2 |
|
||||
| 선저널 | Write-ahead journal(잔액연산 전 순번 확정) |
|
||||
| WAS | Web Application Server(본 시스템은 Spring Boot 내장 Tomcat) |
|
||||
|
||||
---
|
||||
|
||||
## 2. 아키텍처 원칙 (복원력방안 반영)
|
||||
|
||||
1. **단일 전역 순번기**로 입구에서 전역순번 부여 → 저널 → N센터 **결정론적 재생**(동일순서·동일업무).
|
||||
2. **원장 = RDB(PostgreSQL) 강한 일관성** — 잔액 확정의 권위 저장소.
|
||||
3. **정족수(2/3) + 펜싱**으로 split-brain 차단(1센터=자기 과반, 구조 선반영).
|
||||
4. **선(先)저널(write-ahead)** 후 잔액연산 → 노드사(死) 무손실 승계.
|
||||
5. **완결 규율** — 과반 확정 후 최종(ACCC).
|
||||
6. **경량 명령문 + 원전문 해시(SHA-256)** — 센터 간엔 수백B 구조체만 복제, 원전문은 별도 저장.
|
||||
7. **순서=Sequencer(상태O) / 분산·관문=Gucci·GSLB(상태X)** 역할 분리.
|
||||
8. **포터블 네이티브** — 모든 런타임을 `C:\ai-dev\apps` 하위에 무설치 배치(회사 PC 가상화 차단으로 Docker 불가).
|
||||
|
||||
---
|
||||
|
||||
## 3. 논리 아키텍처
|
||||
|
||||
> **그림 3-0. 업무 흐름도(기준)** — 3센터 A-A-A 동일 순서·동일 업무 처리
|
||||
>
|
||||
> 
|
||||
|
||||
### 3.1 계층 구조
|
||||
|
||||
**그림 3-1. 컴포넌트·데이터 흐름 (7개 서비스)**
|
||||
```mermaid
|
||||
flowchart TD
|
||||
EXT["참가기관 / k6"] --> G["Gucci 관문 :8095<br/>인증·유량·재전송·콜백"]
|
||||
CON["운영·관리 콘솔<br/>React :5174"] --> C
|
||||
CON --> LV["LouisVuitton :8099<br/>시스템관리"]
|
||||
G --> C["Chanel :8091<br/>접수·검증·통보"]
|
||||
C -->|rtgs.inbound| K
|
||||
K["Kafka :9092<br/>저널/결과/통보"] --> S["Sequencer :8090<br/>전역순번"]
|
||||
S -->|rtgs.journal| K
|
||||
K --> D["Dior :8092<br/>접수동기화"]
|
||||
K --> H["Hermes :8093<br/>정산·원장엔진"]
|
||||
H -->|rtgs.result| K
|
||||
K --> P["Prada :8094<br/>완결"]
|
||||
P -->|rtgs.notify| K
|
||||
D --> PG[("PostgreSQL :5433<br/>원장")]
|
||||
H --> PG
|
||||
P --> PG
|
||||
C --> PG
|
||||
G -->|콜백 송부| EXT
|
||||
```
|
||||
|
||||
**그림 3-2. 계층 구조**
|
||||
```
|
||||
┌── 표현 계층 ─────────────────────────────────────────────┐
|
||||
│ 운영/관리 콘솔 (React + Vite, :5174) │
|
||||
└───────────────┬──────────────────────────────────────────┘
|
||||
┌── 경계/관문 계층 ────────────▼───────────────────────────┐
|
||||
│ Gucci(:8095) — 로그인·API인증·유량제어·재전송차단·결과콜백 │
|
||||
└───────────────┬──────────────────────────────────────────┘
|
||||
┌── 접속/연계 계층 ────────────▼───────────────────────────┐
|
||||
│ Chanel(:8091) — pacs.008 접수·XSD검증·경량화·결과통보 │
|
||||
└───────────────┬──────────────────────────────────────────┘
|
||||
┌── 코어(순번/정산) 계층 ──────▼───────────────────────────┐
|
||||
│ Sequencer(순번) → [저널] → Dior(접수동기화) │
|
||||
│ Hermes(정산·원장엔진) │
|
||||
│ Prada(완결·결과동기화) │
|
||||
└───────────────┬──────────────────────────────────────────┘
|
||||
┌── 데이터/원장 계층 ──────────▼───────────────────────────┐
|
||||
│ PostgreSQL(원장·강한 일관성) · Kafka(입구/저널/결과/통보) │
|
||||
└───────────────┬──────────────────────────────────────────┘
|
||||
┌── 관리/관측 계층 ────────────▼───────────────────────────┐
|
||||
│ LouisVuitton(:8099) 시스템관리 · ELK 로그(9200/5000/5601)│
|
||||
└──────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 3.2 서비스(컴포넌트) 구성 — 7개 (명품 코드네임)
|
||||
| # | 서비스 | 코드네임 의미 | 유형 | 논리포트 | 책임 |
|
||||
|---|---|---|---|---|---|
|
||||
| 1 | **Sequencer** | 전역 순번기 | Headless(JVM) | 8090 | 전역순번·결정론적값 확정 → 저널 발행. **순번 영속화**(재기동 시 MAX(global_seq) 복원) |
|
||||
| 2 | **Chanel** | Contact Hub…Entry Liaison | **Web(Tomcat)** | 8091 | pacs.008 접수·XSD검증·경량화·원전문 저장·입구 발행 / **결과통보**(pacs.002 생성·아웃박스) |
|
||||
| 3 | **Dior** | Data Integration…Relay | Headless(JVM) | 8092 | 저널 소비 → 원장 PDNG 기록(접수 동기화) |
|
||||
| 4 | **Hermes** | …Rapid Memory Execution…Settlement | Headless(JVM) | 8093 | 저널 순차소비(cc=1)·순서교정·선저널·당좌 차/대변(강한 일관성)·ACSP |
|
||||
| 5 | **Prada** | Persistent Repository…Aggregation | Headless(JVM) | 8094 | 결과 소비·완결 규율(과반)·ACCC 확정·조회사본·**결과통보 발행** |
|
||||
| 6 | **Gucci** | Global User Communication Control Interface | **Web(Tomcat)** | 8095 | 외부 경계 관문: 로그인·JWT·API인증·유량제어·재전송차단·리버스프록시·결과 콜백송부 |
|
||||
| 7 | **LouisVuitton** | Leading Operations Unified Info Systems… | **Web(Tomcat)** | 8099 | 시스템관리: DB초기화·코드·사용자권한·대사 대시보드 |
|
||||
|
||||
> **유형 구분**: Web 서비스(Chanel·Gucci·LouisVuitton)만 내장 Tomcat으로 HTTP 리슨. 코어 처리 서비스
|
||||
> (Sequencer·Dior·Hermes·Prada)는 Kafka 컨슈머 JVM. **관측성(B4)** 도입으로 내장 Tomcat을
|
||||
> 활성화해 예약 포트(8090/8092/8093/8094)에 `/actuator`(health·prometheus)만 노출한다(업무 HTTP 아님).
|
||||
|
||||
---
|
||||
|
||||
## 4. 물리(배포) 아키텍처 — OS · WAS · DB 스택 ★
|
||||
|
||||
### 4.1 배포 토폴로지 (로컬 1센터, 단일 호스트)
|
||||
```
|
||||
┌──────────────── 물리 호스트 1대 (개발 PC, Windows 11) ────────────────┐
|
||||
│ 포터블 루트: C:\ai-dev\apps (런타임) · C:\ai-dev\home (데이터/상태) │
|
||||
│ │
|
||||
│ [WAS 계층 — JVM 프로세스] │
|
||||
│ 내장 Tomcat: Chanel:8091 · Gucci:8095 · LouisVuitton:8099 │
|
||||
│ Headless JVM: Sequencer · Dior · Hermes · Prada (전부 JDK 21) │
|
||||
│ │ JDBC(5433) │ Kafka(9092) │
|
||||
│ ▼ ▼ │
|
||||
│ [DB 계층] PostgreSQL 16.4 :5433 [MQ] Apache Kafka 3.8.1(KRaft):9092 │
|
||||
│ data: home\pgsql-data data: home\kafka\kraft-logs │
|
||||
│ │
|
||||
│ [프론트] Node.js 26 + Vite dev server :5174 (개발) │
|
||||
│ [관측] Elasticsearch:9200 · Logstash:5000(TCP) · Kibana:5601 │
|
||||
│ data: home\es-data │
|
||||
└────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
* 프로토타입은 **단일 호스트에 전 구성요소 공존**. 운영/BMT는 계층별 물리 분리 + 3센터 배치(§11).
|
||||
|
||||
**그림 4-1. 배포 스택 (단일 호스트)**
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph HOST["개발 PC · Windows 11 · C:/ai-dev"]
|
||||
subgraph WAS["WAS · JDK21 · Spring Boot 내장 Tomcat"]
|
||||
W1["Chanel :8091"]
|
||||
W2["Gucci :8095"]
|
||||
W3["LouisVuitton :8099"]
|
||||
end
|
||||
subgraph CORE["Headless JVM · Kafka 컨슈머"]
|
||||
c1["Sequencer"]
|
||||
c2["Dior"]
|
||||
c3["Hermes"]
|
||||
c4["Prada"]
|
||||
end
|
||||
DB[("PostgreSQL 16.4 :5433")]
|
||||
MQ["Kafka 3.8.1 KRaft :9092"]
|
||||
OBS[("ELK 8.15.2<br/>9200/5000/5601")]
|
||||
FE["Vite :5174 · Node 26"]
|
||||
end
|
||||
FE --> W1
|
||||
WAS --> DB
|
||||
WAS --> MQ
|
||||
CORE --> DB
|
||||
CORE --> MQ
|
||||
WAS -->|로그| OBS
|
||||
CORE -->|로그| OBS
|
||||
```
|
||||
|
||||
### 4.2 OS-WAS-DB 스택 매트릭스
|
||||
| 구분 | 구성요소 | 제품/버전 | 포트 | 데이터 경로 | 비고 |
|
||||
|---|---|---|---|---|---|
|
||||
| **OS** | 운영체제 | Windows 11 Enterprise 10.0.26100 (x64) | — | — | CPU 가상화 차단 → Docker 미사용, 네이티브 배치 |
|
||||
| **런타임** | JVM | Eclipse Temurin **JDK 21.0.11 LTS** | — | `apps\jdk-21` | 빌드·실행 공통(전역 JAVA_HOME은 jdk-26 별도) |
|
||||
| **WAS** | 앱서버 | Spring Boot **3.3.4** 내장 **Apache Tomcat** | 8091/8095/8099 | — | Web 3종. 코어 4종은 headless JVM |
|
||||
| **언어/빌드** | 언어 | **Kotlin 2.0.20** / Java 21 toolchain | — | — | — |
|
||||
| | 빌드 | **Gradle 8.10.2** (Kotlin DSL, 멀티모듈) | — | `home\gradle` | JDK26 미지원 → JDK21 고정 |
|
||||
| | DB 마이그레이션 | **Flyway**(Spring Boot 관리 버전) | — | `db/migration/V*.sql` | 스키마 형상관리(LouisVuitton 실행) |
|
||||
| **DB(원장)** | RDBMS | **PostgreSQL 16.4** | 5433 | `home\pgsql-data` | 강한 일관성 원장. acs(5432) 회피 |
|
||||
| **MQ/브로커** | 이벤트 | **Apache Kafka 3.8.1** (KRaft, 무 Zookeeper) | 9092 | `home\kafka\kraft-logs` | 입구·저널·결과·통보. 저널은 단일 파티션 |
|
||||
| **로그** | 검색엔진 | **Elasticsearch 8.15.2** | 9200 | `home\es-data` | 인덱스 `rtgs-logs-*` |
|
||||
| | 수집 | **Logstash 8.15.2** | 5000(TCP) | — | json_lines → ES. encoder 7.4 |
|
||||
| | 시각화 | **Kibana 8.15.2** | 5601 | — | Discover "RTGS Logs" |
|
||||
| **메트릭** | 수집 | **Prometheus 3.13.0** | 9090 | `home\prometheus-data` | 7서비스 `/actuator/prometheus` 스크랩(5s), `infra\start-prometheus.cmd` |
|
||||
| | 시각화 | **Grafana 13.1.0** (OSS) | 3000 | `home\grafana-data` | Prometheus 데이터소스+RTGS Overview 대시보드 자동, `infra\start-grafana.cmd` |
|
||||
| **프론트** | 런타임 | **Node.js 26.3.0** | — | — | 개발 서버 |
|
||||
| | 프레임워크 | **React 18.3.1 + Vite 5.4.2 + TS 5.5.4** | 5174 | — | 운영/관리 콘솔 |
|
||||
| **부하** | 테스트 | **k6 0.56.0** | — | — | 성능/스파이크 |
|
||||
| **(미사용)** | 문서DB | MongoDB 7.0.14 | (27017) | — | EDR 파일락 크래시 → PostgreSQL로 대체 |
|
||||
|
||||
### 4.3 프로세스·환경
|
||||
- 기동: `run-dc1.cmd`(인프라 + 7서비스) · `infra\start-elk-native.cmd`(ELK) · `run-frontend.cmd`(콘솔). 종료: `stop-dc1.cmd`.
|
||||
- 환경변수: `C:\ai-dev\scripts\env.cmd`(JDK21_HOME/GRADLE_HOME/PATH). 센터 파라미터 `CENTER_ID=DC1`, `CENTER_COUNT=1`.
|
||||
- 각 서비스는 `java -jar <service>-0.1.0.jar`(Spring Boot fat jar)로 독립 실행.
|
||||
|
||||
---
|
||||
|
||||
## 5. 데이터 아키텍처
|
||||
|
||||
### 5.1 저장소 역할 분리
|
||||
| 저장소 | 역할 | 근거 |
|
||||
|---|---|---|
|
||||
| PostgreSQL | **권위 원장**(잔액·거래상태·선저널·조회사본·원전문·통보·사용자·콜백) | 강한 일관성(이중지급 방지) |
|
||||
| Kafka | 입구 완충 + **전역순서 저널 전파** + 결과/통보 | 순서 공유·비동기·완충 |
|
||||
| (MongoDB) | 원전문/조회사본 (클라우드/BMT) | 로컬은 EDR 이슈로 PostgreSQL 대체 |
|
||||
|
||||
### 5.2 PostgreSQL 테이블 (DB: `rtgs`)
|
||||
| 테이블 | 용도 | 소유(주 기록) |
|
||||
|---|---|---|
|
||||
| `account` | 참가기관 당좌계좌 잔액(19개 기관 시드) | Hermes |
|
||||
| `transfer` | 거래 원장(BMI PK, 상태·순번·금액) | Dior/Hermes/Prada |
|
||||
| `journal_log` | 선저널(global_seq PK) | Hermes |
|
||||
| `raw_message` | 원전문 pacs.008 XML + SHA-256 | Chanel |
|
||||
| `settlement_view` | 조회 전용 사본(완결 확정) | Prada |
|
||||
| `notification` | 결과통보 아웃박스(pacs.002, delivered/attempts) | Chanel(적재)/Gucci(송부확정) |
|
||||
| `app_user` | 사용자·권한(ADMIN/ORG_S/ORG_R) + secret | LouisVuitton/Gucci |
|
||||
| `institution_endpoint` | 기관 콜백 URL 레지스트리 | Gucci |
|
||||
|
||||
* 컬럼 한글명은 `COMMENT ON COLUMN`(데이터 사전)으로 관리 → 화면 라벨의 단일 출처.
|
||||
* **스키마 형상관리 = Flyway**: `backend/louisvuitton/src/main/resources/db/migration/V*.sql`가 유일 출처.
|
||||
LouisVuitton 기동 시 자동 적용(`flyway_schema_history` 기록). 기존 DB는 baseline 처리, 신규 DB는 V1부터 생성.
|
||||
변경은 새 `V+1__*.sql` 추가(수기 ALTER·기적용 마이그레이션 수정 금지).
|
||||
|
||||
### 5.3 Kafka 토픽
|
||||
| 토픽 | 발행 → 소비 | 파티션 | 용도 |
|
||||
|---|---|---|---|
|
||||
| `rtgs.inbound` | Chanel → Sequencer | 1 | 접수 원시 요청 |
|
||||
| `rtgs.journal` | Sequencer → Dior·Hermes | **1(전역순서)** | 전역순번 저널 |
|
||||
| `rtgs.result` | Hermes → Prada | 1 | 결제 결과(ACSP/RJCT) |
|
||||
| `rtgs.notify` | Prada → Chanel | 1 | 완결 결과통보 트리거 |
|
||||
|
||||
### 5.4 상태 전이 (TxSts)
|
||||
`RCVD`(접수) → `ACTC`(순번) → `PDNG`(원장기록) → `ACSP`(정산반영) → `ACCC`(입금처리완료) / 실패 `RJCT`.
|
||||
|
||||
**그림 5-1. 상태 전이도**
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> RCVD: 접수(Chanel)
|
||||
RCVD --> ACTC: 순번(Sequencer)
|
||||
ACTC --> PDNG: 원장기록(Dior)
|
||||
PDNG --> ACSP: 정산(Hermes)
|
||||
ACSP --> ACCC: 완결·과반(Prada)
|
||||
ACCC --> [*]
|
||||
RCVD --> RJCT: 검증실패(XSD/업무규칙)
|
||||
ACSP --> RJCT: 잔액부족/미등록기관
|
||||
RJCT --> [*]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 인터페이스 설계
|
||||
|
||||
### 6.1 전문(ISO 20022, 공식 XSD)
|
||||
| 전문 | 표준/버전 | 방향 | 검증 |
|
||||
|---|---|---|---|
|
||||
| 결제의뢰 | **pacs.008.001.08** (FIToFICstmrCdtTrf) | 참가기관 → RTGS | 공식 XSD(XXE 차단, DOM 파싱) |
|
||||
| 결과통보 | **pacs.002.001.10** (FIToFIPmtStsRpt) | RTGS → 참가기관 | 정식 네임스페이스 생성 |
|
||||
| 식별자 | BMI 22자리 = 영업일(8)+기관(4)+일련(10) | — | 멱등키 |
|
||||
|
||||
### 6.2 주요 API
|
||||
| 계층 | 메서드·경로 | 인증 | 설명 |
|
||||
|---|---|---|---|
|
||||
| 관문(Gucci) | `POST /gucci/auth/login` | — | 로그인 → JWT 발급 |
|
||||
| | `POST /gucci/pay/customer` | Bearer+nonce | 인증 접수(→Chanel 프록시) |
|
||||
| | `GET /gucci/inquiry/{bmi}` · `/accounts` | Bearer | 인증 조회 |
|
||||
| | `GET /gucci/health/centers` · `GET/PUT /gucci/callbacks` | —/ADMIN | 헬스·콜백 레지스트리 |
|
||||
| 코어(Chanel) | `POST /pay/customer`, `GET /inquiry/{bmi}`, `/accounts` | (내부) | 접수·조회 |
|
||||
| | `GET /notifications[/{bmi}]`, `/rawmessage/{bmi}`, `/meta/labels` | (내부) | 통보·원전문·데이터사전 |
|
||||
| 관리(LouisVuitton) | `POST /admin/reset`, `/admin/institutions|users|status-codes|summary|ledger/*` | (관리) | 시스템관리 |
|
||||
|
||||
---
|
||||
|
||||
## 7. 처리 흐름 (요약)
|
||||
```
|
||||
신청 → [Chanel] RCVD·XSD·경량화 → rtgs.inbound
|
||||
→ [Sequencer] 전역순번·ACTC → rtgs.journal
|
||||
├ [Dior] PDNG(접수동기화)
|
||||
└ [Hermes] 순서교정·선저널·잔액 차/대변(강한 일관성)·ACSP → rtgs.result
|
||||
→ [Prada] 과반확정·ACCC·조회사본 → (커밋 후) rtgs.notify
|
||||
→ [Chanel] pacs.002 생성·아웃박스 → [Gucci] 콜백 송부(재시도/ACK)
|
||||
```
|
||||
**그림 7-1. 거래 처리 시퀀스**
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant ORG as 참가기관(ORG_S)
|
||||
participant G as Gucci
|
||||
participant C as Chanel
|
||||
participant S as Sequencer
|
||||
participant H as Hermes
|
||||
participant P as Prada
|
||||
ORG->>G: 로그인 → JWT
|
||||
ORG->>G: pacs.008 (Bearer,nonce)
|
||||
G->>C: 인증·검사 후 프록시
|
||||
C-->>ORG: pacs.002 (RCVD)
|
||||
C->>S: rtgs.inbound
|
||||
S->>H: rtgs.journal (ACTC, Dior도 소비→PDNG)
|
||||
H->>H: 선저널·잔액 차/대변 (ACSP)
|
||||
H->>P: rtgs.result
|
||||
P->>P: 과반 확정 (ACCC)
|
||||
P->>C: rtgs.notify
|
||||
C->>G: 결과통보(pacs.002)
|
||||
G->>ORG: 콜백 송부 (ACK)
|
||||
```
|
||||
|
||||
* 상세는 「RTGS 프로토타입 워크플로우.md」 참조.
|
||||
|
||||
---
|
||||
|
||||
## 8. 비기능 요구 설계
|
||||
|
||||
### 8.1 성능
|
||||
- 목표 10,000~20,000 TPS(개요). 저널 **단일 파티션**이 순서 보장의 대가로 순번기 처리량 상한이 됨 → 성능 급소로 계측(sim).
|
||||
- Gucci **유량제어**(기관별 50/10s 기본)로 코어·순번기 보호(백프레셔).
|
||||
|
||||
### 8.2 가용성 (정족수)
|
||||
- 3센터 정족수=2: **1센터 다운=서비스 지속 / 2센터 다운=안전 정지**(장애). 1센터 로컬은 자기=과반.
|
||||
- Web 서비스는 무상태(세션리스 JWT) → 수평 확장·GSLB 분산 적합.
|
||||
|
||||
### 8.3 복원성 (무손실)
|
||||
- **선저널 + Kafka 오프셋 재생**: 원장엔진(Hermes) 재기동 시 유실 없이 승계.
|
||||
- **순번 영속화**: 순번기 재기동 시 MAX(global_seq)에서 이어서 발번.
|
||||
|
||||
### 8.4 데이터 정합성
|
||||
- 전역순번 직렬화 + 강한 일관성 트랜잭션 → **이중지급 0 / 총액 보존** 불변식. 원장=저널=조회사본 대사.
|
||||
- 멱등성: BMI 기준 `ON CONFLICT` upsert(중복 접수 무해).
|
||||
|
||||
### 8.5 보안
|
||||
| 영역 | 설계 |
|
||||
|---|---|
|
||||
| 인증 | Gucci 로그인 → **JWT(HS256)**. 자격증명=app_user(권한 마스터), 비밀키 **BCrypt+솔트 해시** 저장(기동 시 평문 자동 승격) |
|
||||
| 인가 | 역할(ADMIN/ORG_S/ORG_R) + **기관 바인딩**(토큰 org == 전문 송신기관) |
|
||||
| 관리자 콘솔 | LouisVuitton `/admin/**` 은 **ADMIN JWT 필수**(인터셉터 검증). Gucci 발급 토큰을 동일 서명키로 검증. 초기암호 변경(must_change_password) 지원. 테스트계정 a/1 |
|
||||
| API 보호 | 유량제어 · **재전송 차단**(nonce+timestamp) · 감사로그(ELK) |
|
||||
| 전문 무결성 | 원전문 **SHA-256** 해시, XSD 검증, **XXE 차단**(DTD/외부엔티티 비허용) |
|
||||
| 전달 보장 | 결과 콜백 at-least-once(재시도+ACK) + 수신측 dedup 전제 |
|
||||
| (후속) | mTLS·비밀키 해시/회전·펜싱 토큰 |
|
||||
|
||||
### 8.6 관측성
|
||||
- **로그**: 전 서비스 Logback(JSON, `service`/`center` 필드) → Logstash(:5000) → ES(`rtgs-logs-*`) → Kibana.
|
||||
- **메트릭(B4)**: 전 서비스 Micrometer → `/actuator/prometheus` 노출. 업무 메트릭 `rtgs.settlements`(정산·status),
|
||||
`rtgs.settle`(정산 지연 Timer), `rtgs.finality`(완결·status), `rtgs.journal.gap`(순서 gap), 접수 TPS/지연은
|
||||
`http_server_requests`(Chanel /pay), Kafka consumer lag는 Micrometer 자동. **Prometheus 3.13.0(:9090)** 설치·7타깃 스크랩(5s) + **Grafana 13.1.0(:3000)** "RTGS Overview" 대시보드(접수 TPS·정산율·완결·Kafka lag·JVM) 완료.
|
||||
- **헬스(B5)**: 전 서비스(헤드리스 포함)가 `service_heartbeat`에 주기 하트비트 → LouisVuitton `/admin/health` 및
|
||||
관리자 대시보드에서 UP/STALE 표시(DB 기반 liveness).
|
||||
- 감사 이벤트(AUTH/AUTHZ/RATE/REPLAY/ORGBIND/PAY/DELIVER)와 처리 로그 중앙 수집.
|
||||
|
||||
---
|
||||
|
||||
## 9. 검증 설계 (복원력 급소, sim/)
|
||||
| ID | 축 | 검증 | 상태 |
|
||||
|---|---|---|---|
|
||||
| S1 | 기능성 | 정합성·총액보존·계층대사 | **PASS** |
|
||||
| S2 | 복원성 | Hermes 강제종료·재기동 무손실 승계 | **PASS** |
|
||||
| S4 | 기능성 | 저널 순번 역전 주입 → 재정렬 | **PASS** |
|
||||
| S3/S5 | 가용성/성능 | 정족수·펜싱 / 스파이크 완충 | 다센터(§11) |
|
||||
|
||||
---
|
||||
|
||||
## 10. 형상·배포 운영
|
||||
- 소스: `backend`(Gradle 멀티모듈 7+1 common) · `frontend` · `infra` · `iso20022` · `sim` · `loadtest` · `docs`.
|
||||
- 산출물: 서비스별 Spring Boot fat jar(`*-0.1.0.jar`). 프론트는 Vite 빌드.
|
||||
- 스키마: **Flyway** `db/migration/V1__baseline.sql`(+ 이후 V2…) — LouisVuitton 기동 시 자동 적용. (구 `postgres-init.sql`은 legacy)
|
||||
- 반영: 백엔드=재빌드+해당 서비스 재기동(무관 서비스 무중단), 프론트=Vite HMR.
|
||||
|
||||
---
|
||||
|
||||
## 11. 확장 아키텍처 (다센터 A-A-A, 향후 · 고사양 PC 이후)
|
||||
```
|
||||
┌── DC1 (7서비스 + PostgreSQL rtgs_dc1)
|
||||
[단일 Sequencer(리더선출)]─저널(Kafka 공유)─┼── DC2 (동일 + rtgs_dc2)
|
||||
└── DC3 (동일 + rtgs_dc3)
|
||||
· 컨슈머 그룹 센터별 분리 · 센터별 DB/포트 오프셋 · 정족수 2/3 자동전환 · 펜싱
|
||||
· Gucci 센터별 배치 + 앞단 GSLB(무상태 라우팅) · 3센터 데이터 동일성 대사
|
||||
```
|
||||
- 순번기: active-standby 리더선출(4번째 사이트 불필요) — GSLB로 순서 결정 금지(무상태라 순서 권위 없음).
|
||||
- 헬스체크(Gucci G3): 관측·멤버십 입력용, 실제 페일오버는 정족수+펜싱(단순 ping 페일오버는 split-brain).
|
||||
|
||||
---
|
||||
|
||||
## 12. 제약·전제·미결
|
||||
- **제약**: 단일 호스트(가상화 차단으로 Docker 불가) · 로컬 1센터 · MongoDB 미사용.
|
||||
- **전제**: 사전확인(잔액·계좌)은 참가기관(클라이언트) 책임, RTGS 코어는 기관 당좌계좌 간 이체만.
|
||||
- **미결/후속**: 다센터 S3/S5, 프론트 Gucci 경유 로그인 UI, mTLS·비밀키 해시, Logstash 힙 256m, PC 이관.
|
||||
|
||||
---
|
||||
|
||||
## 부록 A. 포트 일람
|
||||
Sequencer(논리)8090 · Chanel 8091 · Dior(논리)8092 · Hermes(논리)8093 · Prada(논리)8094 ·
|
||||
Gucci 8095 · LouisVuitton 8099 · Frontend 5174 · PostgreSQL 5433 · Kafka 9092 ·
|
||||
ES 9200 · Logstash 5000 · Kibana 5601. (acs와 전면 분리: acs 8080/5173/5432)
|
||||
|
||||
## 부록 B. 디렉터리
|
||||
```
|
||||
C:\ai-dev\apps\ 포터블 런타임(jdk-21, gradle, kafka, postgresql, elk, k6, nodejs)
|
||||
C:\ai-dev\home\ 데이터·상태(pgsql-data, kafka, es-data, gradle 캐시)
|
||||
C:\ai-dev\workspace\rtgs\ backend · frontend · infra · iso20022 · sim · loadtest · docs
|
||||
```
|
||||
|
||||
*본 설계서는 개발 진행에 따라 갱신한다.*
|
||||
BIN
docs/RTGS 프로로타입 시스템 PoC 테스트 3차 결과보고.pdf
Normal file
BIN
docs/RTGS 프로로타입 시스템 PoC 테스트 3차 결과보고.pdf
Normal file
Binary file not shown.
234
docs/RTGS 프로토타입 개발일지.md
Normal file
234
docs/RTGS 프로토타입 개발일지.md
Normal file
@@ -0,0 +1,234 @@
|
||||
# RTGS 프로토타입 개발일지
|
||||
|
||||
> 대상: 한국은행 소액RTGS(실시간총액결제) 프로토타입 — 로컬(포터블 Windows) 자체 개발
|
||||
> 목적: 클라우드 PoC(1~3차)에서 검증한 아키텍처의 **업무기능을 로컬에서 완결 개발·테스트**
|
||||
> 작성: 루비 · 담당: 양팀장님(한국은행 RTGS시스템팀)
|
||||
> 위치: `C:\ai-dev\workspace\rtgs`
|
||||
|
||||
---
|
||||
|
||||
## 0. 한눈에 보기
|
||||
|
||||
| 구분 | 내용 |
|
||||
|---|---|
|
||||
| 기술 스택 | Kotlin 2.0.20 · Spring Boot 3.3.4 · JDK 21(Temurin) · Gradle 8.10.2 |
|
||||
| 아키텍처 | 전역 순번기(Sequencer) + 저널 순차처리 + 강한 일관성 원장(PostgreSQL) · N센터 확장형 |
|
||||
| 서비스(6) | Sequencer · Chanel(전문송수신) · Dior(접수동기화) · Hermes(결제/원장엔진) · Prada(결과동기화) · LouisVuitton(관리자) |
|
||||
| 인프라 | **네이티브 포터블**(Docker 아님): Kafka(KRaft) · PostgreSQL · Elasticsearch/Logstash/Kibana |
|
||||
| 전문 | ISO20022 pacs.008(요청)/pacs.002(응답), BMI 22자리 식별자 |
|
||||
| 상태머신 | RCVD→ACTC→PDNG→ACSP→ACCC (실패 RJCT) |
|
||||
|
||||
---
|
||||
|
||||
## 1. 일자별 상세 내역
|
||||
|
||||
### 📅 2026-07-08 (1일차) — 분석 · 설계 · 개발환경 · 골격 개발
|
||||
|
||||
**분석**
|
||||
- 워크스페이스 현황 파악(acs 기존 프로젝트, rtgs는 docs만 있는 빈 폴더)
|
||||
- 설계 자료 정독: PoC 1·2·3차 결과보고 PDF, "RTGS ActiveActive 복원력방안"(과제⑧ v2.0), 리뷰어/저자 의견, 프로젝트 개요
|
||||
- acs와 **포트/compose 비충돌** 원칙 확인
|
||||
|
||||
**설계 결정**
|
||||
- 언어 Kotlin, 로컬 인프라 실물 경량 스택, 센터는 1센터 시작·N센터 확장 구조
|
||||
- 복원력방안 반영: **전역 순번기 / 원장=RDB(PostgreSQL) 강한 일관성 / 정족수 / 저널 결정론적 재생 / 선저널 / 완결 규율**
|
||||
- 원장은 RDB, 인메모리(Hazelcast)는 코어 연산, 문서형(MongoDB)은 원전문/조회사본으로 역할 분리(설계)
|
||||
|
||||
**설치 (포터블, `C:\ai-dev\apps`)**
|
||||
- **JDK 21 (Temurin 21.0.11)** — Gradle 8.10.2가 JDK26 미지원이라 별도 설치. `env.cmd`에 `JDK21_HOME` 추가(전역 JAVA_HOME은 jdk-26 유지)
|
||||
- **Gradle 8.10.2** — `GRADLE_HOME` 추가, 캐시는 `home\gradle`
|
||||
|
||||
**개발 (backend 멀티모듈)**
|
||||
- Gradle Kotlin DSL 멀티모듈 구성(common/sequencer/chanel/dior/hermes/prada) + Gradle Wrapper 생성
|
||||
- **common 모듈**: `TxSts`(상태머신), `CoreMessage`(경량명령문+SHA-256), `JournalEntry`(전역순번+결정론적값), `BmiGenerator`(22자리), `QuorumContext`(정족수), ISO20022 DTO(Pacs008/Pacs002)+코덱, `XmlValidator`(XSD), 토픽/맵/테이블 상수 → **빌드·단위테스트 통과**
|
||||
- **5개 서비스** 구현(Sequencer 순번부여·저널발행, Chanel 접수·검증·경량화·응답, Dior 접수동기화, Hermes 순차소비·선저널·이체·결과발행, Prada 완결) → **빌드 성공**
|
||||
- infra: `docker-compose.yml`(초안, Kafka/PostgreSQL/MongoDB) + `postgres-init.sql`(참가기관 19개 시드)
|
||||
- loadtest: `pacs008.k6.js`(정상/스파이크), 샘플 전문
|
||||
- 실행 스크립트(run-dc1/stop-dc1), React(Vite) 프론트엔드 스캐폴드
|
||||
- ※ 일 사용한도 초과로 `kotlin("plugin.jpa")` 적용 직후 중단
|
||||
|
||||
### 📅 2026-07-09 (2일차) — 통합 · 인프라 전환 · 검증 · 고도화
|
||||
|
||||
**환경 이슈 해결**
|
||||
- IDE(VS Code Java확장)가 JDK26으로 import 실패 → `gradle.properties`에 `org.gradle.java.home=jdk-21` 고정, `.vscode/settings.json` 지정, wrapper 8.10.2 복원
|
||||
- `kotlin("plugin.jpa")`를 common에 적용(JPA 엔티티 open/no-arg) → **전체 빌드 성공**
|
||||
- 사내 EDR 파일락으로 Gradle 캐시 이동 실패 → **재시도**로 통과(Maven/git과 동일 계열)
|
||||
|
||||
**Docker → 네이티브 전환 (중대 전환점)**
|
||||
- Docker Desktop 실행 시 **"Virtualization support not detected"** — 회사 PC의 CPU 가상화 차단(BIOS/정책). Docker(WSL2) 사용 불가 판정
|
||||
- **Docker 대신 네이티브 포터블 인프라로 전환**(이 PC의 포터블 철학에 오히려 부합):
|
||||
- **Kafka 3.8.1**(KRaft 단일노드, :9092) 설치·기동. Windows bat의 `wmic`(제거됨) 호출 실패 → `KAFKA_HEAP_OPTS` 사전 설정으로 우회
|
||||
- **PostgreSQL 16.4**(바이너리, :5433) initdb·기동, DB `rtgs` + 스키마/시드
|
||||
- **MongoDB 7.0.14** 설치했으나 **WiredTiger 체크포인트가 EDR 파일락에 걸려 반복 크래시(exit 14)** → 원전문/조회사본 저장을 **PostgreSQL로 이전**(raw_message/settlement_view). MongoDB는 미사용
|
||||
|
||||
**E2E 검증 성공**
|
||||
- 비웹 서비스 ObjectMapper 빈 누락(spring-web 부재) 수정 → 5서비스 기동
|
||||
- pacs.008 1건(1001→1002 150원): 접수 RCVD → **최종 ACCC**, 송신 −150/수신 +150, transfer·journal_log·settlement_view·raw_message 전 계층 대사 일치, **19계좌 총액 190억 보존(이중지급 0)**
|
||||
|
||||
**ELK 로그관리 구축**
|
||||
- Elasticsearch 8.15.2 설치·기동 → **EDR 환경 생존 검증 통과**(Lucene은 WiredTiger보다 견고)
|
||||
- Kibana 8.15.2, Logstash 8.15.2 설치. 서비스 공통 `logback-spring.xml`(logstash-logback-encoder, TCP :5000) → Logstash → ES `rtgs-logs-*` → Kibana. 5개 서비스 로그 수집 확인
|
||||
|
||||
**k6 부하테스트 + 버그 2건 발견·수정**
|
||||
- k6 0.56.0 설치, 30 rps×15초 부하
|
||||
- **버그① ACSP 잔류 레이스**: Hermes가 결과를 트랜잭션 커밋 *전* 발행 → Prada가 커밋 전 읽음 → **커밋 후 발행(publish-after-commit)** 으로 수정
|
||||
- **버그② 중복키 ERROR**: Dior/Hermes가 원장 같은 행 동시 INSERT → **원자적 upsert(ON CONFLICT)** 로 수정, 기존 ERROR 로그 정리
|
||||
|
||||
**프론트엔드 개선**
|
||||
- **자금이체 신청 폼**(송신/수신 드롭다운·금액→RCVD→ACCC 자동조회·잔액갱신)
|
||||
- 거래조회 **한글 항목명**(DB 컬럼 코멘트=데이터 사전 기반) · 상태 한글값 · 시각 `YYYYMMDDHH24MISS` · **원문 pacs.008** 표시
|
||||
|
||||
**관리자(LouisVuitton) 서비스 신규** (3차 PoC 미구현분)
|
||||
- `louisvuitton` 서비스(:8099) + 콘솔 "🛠 관리자" 탭
|
||||
- 기능: **DB 초기화**, **코드(참가기관) 관리**, **사용자 권한 관리**(app_user, ADMIN/ORG_S/ORG_R — 로그인 강제는 후속), **대사 대시보드**(서비스별 처리 현황)
|
||||
|
||||
**개인화**
|
||||
- 어시스턴트 이름 "루비"(Louis Vuitton 줄임), 사용자 호칭 "양팀장님" — 메모리 저장
|
||||
|
||||
### 📅 2026-07-10 (3일차) — 세션 재개 · 문서화 · 고도화(순번영속화·정식ISO·검증하니스)
|
||||
|
||||
- 세션 종료 후에도 **네이티브 프로세스 전부 생존** 확인(인프라·5+1 서비스), 데이터(거래·총액 190억) 보존. **프론트엔드(Vite)만 재기동**
|
||||
- 개발 문서 3종 작성: **개발일지 / 워크플로우 / 테스트 시나리오 갱신**
|
||||
|
||||
**고도화 ① Sequencer 전역순번 영속화 (2-1, 정합성 개선)**
|
||||
- 문제: 순번기 카운터가 인메모리라 재기동 시 1로 리셋 → journal_log(global_seq PK) 충돌/스테일
|
||||
- 해결: 기동 시 `@PostConstruct`에서 원장의 `MAX(global_seq)`(journal_log·transfer) 조회 → 그 다음부터 발번(high-water mark 복원). sequencer에 JDBC(:5433) 추가
|
||||
- 검증: 재기동 후 실제 이체가 **globalSeq=943**(복원 max 942의 다음)으로 처리 — 리셋 없음 확인
|
||||
|
||||
**고도화 ② 정식 ISO20022 XSD 적용 (2-2, 실제 전문 검증)**
|
||||
- iso20022.org 공식 XSD 도입: **pacs.008.001.08**(접수)·**pacs.002.001.10**(결과통보) → `common/resources/iso20022/xsd/`
|
||||
- 전문 모델을 실제 구조로 전환: `Document/FIToFICstmrCdtTrf`(GrpHdr: MsgId/CreDtTm/NbOfTxs/SttlmInf=CLRG, CdtTrfTxInf: PmtId/EndToEndId·IntrBkSttlmAmt@Ccy·ChrgBr=SLEV·Dbtr/DbtrAcct/DbtrAgt(ClrSysMmbId/MmbId)·CdtrAgt·Cdtr/CdtrAcct)
|
||||
- 파서를 **DOM 방식**(지역명 기준·네임스페이스 견고·XXE 차단)으로 재작성, pacs.002 응답은 정식 네임스페이스 템플릿 생성
|
||||
- 샘플 5종·프론트 신청폼 XML·공통 테스트 전면 정합. 검증: 공식 XSD로 정상 4건 통과, `badformat`(ChrgBr=ZZZZ) → `cvc-enumeration-valid` 반려
|
||||
- 적용: sequencer·chanel 재빌드·재기동(나머지 4서비스는 CoreMessage/JournalEntry 불변이라 무중단)
|
||||
|
||||
**고도화 ③ 검증 하니스 sim/ (S1/S2/S4)**
|
||||
- `sim/`(Git Bash): `s1_consistency`(정합성)·`s2_failover`(무손실 승계)·`s4_order`(순서 재정렬)·`reconcile.sql`·`lib.sh`·`README.md`
|
||||
- **결과 전건 PASS**: S1(20건 총액보존·원장=사본), S2(30건 투입 중 Hermes 강제종료·재기동, 유실 0), S4(seq 965→966→964 역전주입 → 964,965,966 순서기록·전건 ACCC)
|
||||
**고도화 ④ 결과통보(이체결과 송부) Chanel 이관**
|
||||
- Gucci 후보 기능 검토 결과, "이체신청기관앞 이체결과 송부"는 Chanel 헌장(접수+통보)에 맞아 **Chanel로 이관**
|
||||
- 신규 토픽 `rtgs.notify`: **Prada**가 완결(ACCC/RJCT) 후 발행(publish-after-commit) → **Chanel**이 소비
|
||||
- Chanel: 접수센터(originCenter)만 최종 pacs.002 생성 → **신청기관(송신) 결과통보**(항상)·**수취기관 입금통보**(ACCC만) → `notification` 아웃박스 기록. 조회 API `/notifications`, `/notifications/{bmi}`
|
||||
- 검증: ACCC(1003→1008)=APPLICANT+BENEFICIARY 2건, RJCT(9990 미등록)=APPLICANT 1건(사유 포함) 확인
|
||||
- Gucci 잔여 범위(관문/인증/헬스체크 등)는 요건 정의 후 착수
|
||||
|
||||
**고도화 ⑤ Gucci(외부 경계 관문) 신규 — 로컬 단계 G1·G2**
|
||||
- 6개 후보 기능 검토 후 **관문 계층으로 한정**(순서결정=Sequencer, 통보=Chanel로 이미 분담). 신규 `backend/gucci`(:8095)
|
||||
- **G1 인증 관문**: 로그인(app_user.secret 대조) → **JWT(HS256 자체구현)** 발급 · 인증된 리버스프록시(→Chanel) · 가드[JWT검증·역할·**기관바인딩**(토큰org=전문송신)·**유량제어**(50/10s)·**재전송차단**(nonce+timestamp)] · 감사로그(ELK) · 헬스체크
|
||||
- **G2 결과 콜백송부(전달보장)**: Chanel 아웃박스(delivered=false) → Gucci `DeliveryService`(3s 폴링)가 `institution_endpoint` 콜백으로 pacs.002 POST → 2xx ACK시 확정, 실패시 재시도(max5). 데모 sink + 레지스트리 관리(admin)
|
||||
- 검증: curl E2E(로그인/정상pay/무토큰401/기관불일치403/재전송400/관리자accounts/변조401) + 가드 단위테스트 통과. G2: 등록기관 즉시송부·미등록 재시도→등록→송부성공 확인
|
||||
- app_user.secret·notification.attempts·institution_endpoint 스키마 추가, run-dc1.cmd에 Gucci 기동 추가 → **7개 서비스 아키텍처 완성**
|
||||
- (남은 것) 프론트 Gucci 경유 로그인 UI(선택), G3(센터간 헬스/펜싱/GSLB)=다센터 단계
|
||||
|
||||
**보안 강화 A2 — 비밀키 BCrypt 해시**
|
||||
- `app_user.secret` 평문 → **BCrypt+솔트 해시** 저장. Gucci `AuthService`가 `PasswordEncoder.matches`로 대조
|
||||
- `SecretMigrator`(ApplicationRunner): 기동 시 평문(비 BCrypt) 시크릿을 자동 해시 승격(멱등). spring-security-crypto 도입
|
||||
- 검증: DB secret `$2a$10$…`(60자), 원 시크릿 로그인 200·오답 401
|
||||
|
||||
**A4 — Flyway 스키마 형상관리 도입**
|
||||
- 수기 SQL/ALTER → **Flyway** 단일 출처. 현재 스키마를 `louisvuitton/.../db/migration/V1__baseline.sql`로 이관, LouisVuitton이 실행
|
||||
- `baseline-on-migrate` → **기존 운영 DB는 baseline(무변경)**, 신규 빈 DB는 V1부터 생성. 인프라 스크립트는 빈 DB만 생성(스키마는 Flyway). 구 `postgres-init.sql`은 legacy 표시
|
||||
- 검증: `flyway_schema_history`에 baseline(1)+V2 기록, 운영 DB 무변경 확인
|
||||
|
||||
**A1 — 관리자 로그인(Gucci JWT 재사용, 옵션1)**
|
||||
- LouisVuitton `/admin/**`에 **JWT 인터셉터**(role ADMIN 필수). Gucci 발급 토큰을 동일 서명키로 **검증만** 수행
|
||||
- Gucci: 로그인 응답에 `mustChangePassword` 추가 + **비밀번호 변경 API**(`/gucci/auth/change-password`)
|
||||
- **V2 마이그레이션**: `must_change_password` 컬럼 + **테스트관리자 a/1**(ADMIN)
|
||||
- 프론트: 관리자 탭 **로그인 화면**(a/1) + Bearer 전송 + 초기암호변경 화면 + 로그아웃, vite proxy `/gucci`
|
||||
- 검증(프론트 프록시 경유): a/1→ADMIN 로그인, `/admin/summary` 토큰有 200·無 401, ORG_S 401, 비번변경 왕복(1→12→1)
|
||||
|
||||
**Gucci 요건 검토(설계 방향 합의)**
|
||||
- 양팀장님 제시 6개 후보 기능을 **경계 관문 계층으로 재배치**: ①헬스체크=관측만(페일오버는 정족수+펜싱) ②센터간MQ=Kafka가 이미 담당(Gucci는 외부관문) ③기관로그인=Gucci인증+LouisVuitton권한마스터 ④API인증=JWT+서명/nonce/timestamp ⑤"API순서지정"=라우팅/유량제어(전역순번은 Sequencer 단독) ⑥이체결과송부=**Chanel 이관**
|
||||
- 원칙 합의: **순서=Sequencer(리더선출)/분산=GSLB·Gucci(무상태)**. 로컬 단계 G1(인증관문)/G2(콜백송부)/G3(센터간)
|
||||
|
||||
**작업 분담 (양팀장님 지시)**
|
||||
- **ACS 프로젝트의 개선·서버배포는 Codex에게 일임**, 루비는 **RTGS 전담**. 동일 workspace 병렬작업 → 포트 이미 분리(ACS 8080/5173/5432 ↔ RTGS 8090~8095/5174/5433), 공유 `env.cmd`·Gradle 캐시는 RTGS 관련만 수정, cold 빌드 순차
|
||||
|
||||
**문서 산출물 관리 체계 수립**
|
||||
- **아키텍처 설계서** 신규 작성(`RTGS 아키텍처 설계서.md`) — OS-WAS-DB 스택 매트릭스 포함(Win11/JDK21.0.11/SpringBoot3.3.4 내장Tomcat/PostgreSQL16.4/Kafka3.8.1/ELK8.15.2/React18+Vite5+Node26/k6). **Mermaid 다이어그램**(컴포넌트·배포·상태·시퀀스) + 팀장님 참고 PNG(2센터흐름도·서비스png) 임베드
|
||||
- **문서 운영방침**(양팀장님): 개발 중 `.md` → 완성 시 **PPT/PDF**(HWP 불필요). pandoc 확인(pptx 직접·pdf는 Edge인쇄), `docs/tools/convert.cmd` 작성. 다이어그램=Mermaid+참고PNG, UML.xlsx는 필요 시 추출
|
||||
- **SW 산출물 관리대장**(`SW 산출물 관리대장.md`) 신규 — 과거 PoC의 코드-문서 드리프트 재발 방지. **단일 출처 원칙**(포트=run-dc1/yml·토픽=Constants·스키마=Flyway·데이터사전=DB COMMENT·API=Controller·전문=XSD), 변경유형→갱신문서 체크리스트, 정합성 점검 항목
|
||||
|
||||
**개선점 진단(루비 관점) → 우선순위 논의**
|
||||
- A(즉시): A1 관리자무인증·A2 평문시크릿·A3 관문우회·A4 수기스키마 / B(운영전): B1 순번기SPOF·B2 정족수하드코딩·B3 head-of-line·B4 관측성·B5 헤드리스헬스·B6 테스트자동화·B7 TLS/암호화 / C(다센터·성능)
|
||||
- **A1·A2·A4 완료**(위). A3=합의(부하테스트는 Chanel 직접 유지, 운영 전 차단). B군은 아래 결정대로 진행
|
||||
|
||||
**B6 — 테스트 실행 화면 (완료)**
|
||||
- 관리자 콘솔에 **이체 대량생성** 섹션: BMI 시작번호·건수 입력 → 송/수신기관 랜덤·금액 1,000~1,000,000 랜덤, Chanel 직접 호출(관문 우회), 20건 동시배치, 진행/접수/반려 집계. k6 대체 간이도구
|
||||
- 검증: 프론트 tsc 통과. (프론트 HMR 반영)
|
||||
|
||||
**B1/B2/B3 — 3센터 삼중화 방향 확정(설계 논의 중, 구현 예정)**
|
||||
- 양팀장님 결정: 이전 PoC는 2센터, **이제부터 3센터 기준 삼중화 구조로 전환**
|
||||
- B1 순번기: **durable-before-publish**(저널을 durable 저장 후 발행, 재기동 시 미발행분 재발행=outbox) + 리더선출·펜싱(에폭)
|
||||
- B2 정족수: 결정론적 재생이므로 데이터 동기화는 불필요, 단 **완결(응답) 시 과반(2/3) 확정 대기**로 1센터 손실 안전. `confirmations` 하드코딩→센터별 ack 집계로
|
||||
- B3 head-of-line: 영구 gap의 원인은 B1 유실 → B1 해결 시 소멸. 방어책=gap 타임아웃 시 **journal_log(durable)에서 결번 직접 pull**(skip 아님)
|
||||
**B4 관측성(메트릭) + B5 서비스 헬스 (완료)**
|
||||
- **B4**: 전 서비스 Micrometer + `/actuator/prometheus` 노출. 헤드리스 4종(Sequencer/Dior/Hermes/Prada)에 starter-web 추가→예약포트(8090/8092/8093/8094)에 actuator만 노출. 업무 메트릭: `rtgs.settlements`(status)·`rtgs.settle`(Timer)·`rtgs.journal.gap`(Hermes), `rtgs.finality`(status, Prada), 접수 TPS/지연=`http_server_requests`(Chanel), Kafka lag 자동. (Prometheus 서버/Grafana는 후속)
|
||||
- **B5**: `common.ops.HeartbeatAutoConfiguration`(스프링 자동설정, `@AutoConfigureAfter` DataSource) → 전 서비스가 `service_heartbeat`(Flyway V3)에 주기 기록. LouisVuitton `/admin/health` + 관리자 대시보드 **🩺 서비스 상태**(UP/STALE, 헤드리스 포함). micrometer-registry-prometheus는 common으로 전파
|
||||
- 검증: 7서비스 `/actuator/prometheus` 200, 하트비트 7건, 이체1건 후 settlements/finality/http 메트릭 증가, /admin/health 7 UP
|
||||
- (교훈) 자동설정 `@ConditionalOnSingleCandidate(DataSource)`는 `@AutoConfigureAfter(DataSourceAutoConfiguration)` 없으면 순서상 미적용. 헤드리스→web 전환으로 기동 ~13s(정상)
|
||||
|
||||
**B4 Prometheus 서버 설치 (완료)**
|
||||
- 포터블 **Prometheus 3.13.0**(`apps\prometheus-3.13.0`, 데이터 `home\prometheus-data`, :9090) 설치. `infra\prometheus.yml`(7서비스 스크랩·5s)·`infra\start-prometheus.cmd`
|
||||
- 검증: **7/7 타깃 up**, PromQL `rtgs_finality_total{status="ACCC"}` 조회 성공. README apps 표 등록
|
||||
- (교훈) `start "title" cmd /k ""exe" args"` 중첩따옴표는 배치에서 실패 → `start "title" "exe" args` 형태로
|
||||
|
||||
**B4 Grafana 시각화 (완료)**
|
||||
- 포터블 **Grafana 13.1.0**(`apps\grafana-13.1.0`, 데이터 `home\grafana-data`, :3000, 익명 Admin). `infra\start-grafana.cmd` + 프로비저닝(`infra\grafana\provisioning`: Prometheus 데이터소스 rtgs-prom + "RTGS Overview" 대시보드)
|
||||
- 대시보드 패널: 접수 TPS/지연(p95)·정산 처리율/지연·Kafka consumer lag·JVM heap·완결 누계. 검증: 데이터소스·대시보드 프로비저닝 확인
|
||||
- **관측성(B4/B5) 완료** — 로그(ELK)+메트릭(Prometheus/Grafana)+헬스(하트비트)
|
||||
|
||||
- (다음) **B1/B2/B3 3센터 삼중화**(durable-before-publish·정족수·gap pull) → **B7 암복호화+TLS**. C(다센터 실측)·성능은 **7/13(월) 고사양 PC 이관 후**(터미널 3개로 DC1~DC3 병렬 실측). D: pacs.7z를 전문 보강(BAH/pacs.002 검증) 착수 시 참조
|
||||
|
||||
---
|
||||
|
||||
## 2. 주요 의사결정·전환점 요약
|
||||
|
||||
| 결정/전환 | 이유 |
|
||||
|---|---|
|
||||
| JDK21 별도 설치 | Gradle 8.10.2가 JDK26 미지원 |
|
||||
| **Docker → 네이티브 포터블** | 회사 PC CPU 가상화 차단(스펙 아닌 BIOS/정책). 포터블 철학과 부합 |
|
||||
| **MongoDB → PostgreSQL** | MongoDB WiredTiger가 EDR 파일락에 크래시. 문서저장을 RDB로 대체(클라우드는 문서형 유지) |
|
||||
| 원장 = RDB 강한 일관성 | 복원력방안 원칙(이중지급 방지엔 단일순번+강한일관성) |
|
||||
| publish-after-commit | 결과발행을 커밋 후로 → Prada 완결 레이스 제거 |
|
||||
| 원자적 upsert(ON CONFLICT) | Dior/Hermes 동시삽입 중복키 제거 |
|
||||
| 정식 ISO20022 공식 XSD | 실제 전문 검증(pacs.008.001.08/002.001.10) |
|
||||
| 결과통보 = Chanel | Chanel 헌장(접수+통보)에 부합 |
|
||||
| Gucci = 외부 경계 관문만 | 순서=Sequencer, 분산=Gucci(무상태) 역할 분리 |
|
||||
| **Flyway 스키마 형상관리** | 수기 SQL 드리프트 방지(코드-문서 정합성) |
|
||||
| 관리자 로그인(JWT/BCrypt) | 관리자 무인증·평문시크릿 급소 제거 |
|
||||
| **ACS=Codex / RTGS=루비** | 병렬 개발 분담(양팀장님 지시) |
|
||||
| 문서: md→PPT/PDF, 다이어그램 Mermaid+PNG | 개발중 관리 용이, 완성시 정식 산출물 |
|
||||
| **3센터 삼중화 전환** | 이전 PoC는 2센터, 본 개발은 3센터 A-A-A 기준 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 현재 산출물
|
||||
|
||||
- **backend/**(Gradle 멀티모듈, **7서비스**): common + sequencer/chanel/dior/hermes/prada/**gucci**/louisvuitton
|
||||
- **frontend/**(React+Vite): 운영 콘솔(이체신청·조회·계좌) + 관리자 탭(로그인·대사·초기화·코드·사용자·**테스트 실행**)
|
||||
- **infra/**: 네이티브 기동 스크립트(start-infra-native[빈DB만 생성]·start-elk-native), init/postgres-init.sql(**legacy**), (참고용) docker-compose
|
||||
- **DB 스키마**: **Flyway** `louisvuitton/.../db/migration/`(V1__baseline, V2__admin_login)
|
||||
- **loadtest/**: k6, **sim/**: S1/S2/S4 하니스, **iso20022/**: 공식 XSD·샘플 5종
|
||||
- **docs/**: 개요·보고서·개발일지·워크플로우·테스트시나리오·**아키텍처 설계서**·**SW 산출물 관리대장**·참고(2센터흐름도·서비스png·UML.xlsx)·tools/convert.cmd
|
||||
- 포터블 앱: `C:\ai-dev\apps`(jdk-21, gradle, kafka, postgresql, elk, k6, nodejs 등)
|
||||
|
||||
## 4. 남은 과제 (백로그)
|
||||
|
||||
**완료(2026-07-10)**: ✅순번 영속화 ✅정식 ISO20022 XSD ✅sim S1/S2/S4 ✅결과통보 Chanel이관 ✅Gucci G1/G2(인증관문·콜백) ✅A2 BCrypt ✅A4 Flyway ✅A1 관리자로그인 ✅B6 테스트화면 ✅아키텍처설계서·관리대장 ✅**B4 메트릭(Micrometer/Prometheus 노출)** ✅**B5 서비스 헬스(하트비트 대시보드)** ✅**B4 Prometheus 서버(:9090)**
|
||||
|
||||
**진행/예정**:
|
||||
- **B1/B2/B3 3센터 삼중화** — *설계 확정 + **코드 구현·빌드·단위테스트 완료(2026-07-13, 8코어 PC)***:
|
||||
- **B1**: `V4__seq_outbox.sql`(SEQUENCE `global_seq_seq` + `journal_outbox`), `SequencerService` durable-before-publish + 재발행 스케줄러(@EnableScheduling). 순번기 전역 단일 리더(그룹 `sequencer`).
|
||||
- **B2**: `prada/QuorumAggregator`(신규, 단위테스트 6) — `rtgs.result`=applied-ack 재활용(별도 토픽 불필요), `processedCenter` 집계 과반(2/3) → ACCC. origin Prada만 통보.
|
||||
- **B3**: `hermes/GapBuffer`(신규, 단위테스트 6) + 타임아웃 스케줄러 → 단일파티션 근거 phantom skip, `rtgs.journal.gap.timeout` 지표.
|
||||
- **설정공통화**: 원장/엣지 groupId 센터접미사 파라미터화(`hermes-${center-id}`…), 순번기만 단일그룹.
|
||||
- **이관 산출물**: `run-dc2/dc3.cmd`, `infra/init-3centers-db.cmd`, `run-dc1.cmd`(3센터 모드), `docs/RTGS 3센터 이관·기동 체크리스트.md`.
|
||||
- **남은 것**: 전체 3센터 21 JVM 기동·재해/복구 시나리오·성능 실측 = **고사양 PC 이관 후**(팀장님 테스트).
|
||||
- **B4 관측성**: Micrometer + Prometheus(TPS/지연/consumer lag)
|
||||
- **B5 헤드리스 헬스**: LouisVuitton 대시보드에 서비스 하트비트(DB)
|
||||
- **B7 전문 암복호화 + TLS**: 기관 수신 ISO전문 복호화→처리, 통보 암호화 송부(성능테스트에 암복호화 포함)
|
||||
- **다센터(DC2/DC3) 실행·S3/S5**: 고사양 PC 이관 후
|
||||
- 프론트 Gucci 경유 로그인 UI(선택), Logstash 힙 256m, PC 백업/이관 체크리스트(다음주)
|
||||
|
||||
*본 일지는 개발 진행에 따라 계속 갱신.*
|
||||
341
docs/RTGS 프로토타입 로컬 테스트 시나리오.md
Normal file
341
docs/RTGS 프로토타입 로컬 테스트 시나리오.md
Normal file
@@ -0,0 +1,341 @@
|
||||
# RTGS 프로토타입 로컬 테스트 시나리오
|
||||
|
||||
> 대상: 로컬(이 PC) RTGS 프로토타입 (1센터 DC1)
|
||||
> 작성: 루비 · 사용: 양팀장님
|
||||
> 구성: Docker 없이 **네이티브 포터블**(Kafka·PostgreSQL·ELK) + **6개 서비스** + React 콘솔(운영/관리자 탭)
|
||||
> 갱신(2026-07-10): 자금이체 **신청 폼**, 조회 **한글화·원문**, **관리자(LouisVuitton) 탭** 반영
|
||||
|
||||
---
|
||||
|
||||
## 0. 접속 정보 (브라우저로 여는 것)
|
||||
|
||||
| 화면/엔드포인트 | 주소 | 용도 |
|
||||
|---|---|---|
|
||||
| **RTGS 콘솔(운영)** | http://localhost:5174 | **자금이체 신청 폼** · 거래조회(한글·원문) · 계좌 잔액 |
|
||||
| **RTGS 콘솔(관리자)** | http://localhost:5174 → "🛠 관리자" 탭 | DB초기화 · 코드 · 사용자권한 · 대사 대시보드 |
|
||||
| **Kibana(로그)** | http://localhost:5601 | ☰ → Discover → **RTGS Logs** |
|
||||
| Chanel API(접수·코어) | http://localhost:8091/pay/customer | pacs.008 전문 POST(내부 코어) |
|
||||
| 조회 API | http://localhost:8091/inquiry/{BMI} , /accounts | 상태·잔액 |
|
||||
| 관리자 API | http://localhost:8099/admin/* | reset·institutions·users·summary |
|
||||
| **Gucci 관문 API** | http://localhost:8095/gucci/* | 로그인·인증 접수/조회·콜백(외부 경계) |
|
||||
|
||||
포트 요약: Chanel 8091 · Sequencer 8090 · Dior 8092 · Hermes 8093 · Prada 8094 · **Gucci 8095** · **LouisVuitton 8099** ·
|
||||
Kafka 9092 · PostgreSQL **5433** · Elasticsearch 9200 · Logstash 5000 · Kibana 5601 · Frontend 5174
|
||||
|
||||
> 💡 **가장 쉬운 테스트 경로는 브라우저(:5174)** 입니다. 아래 시나리오는 화면(GUI)과 명령(curl) 두 방법을
|
||||
> 함께 적었으니 편한 쪽을 쓰세요. curl은 자동화/부하테스트에, 화면은 눈으로 확인할 때 좋습니다.
|
||||
|
||||
> 참고: 명령은 **CMD 또는 PowerShell**을 열고 아래 폴더로 이동해 실행하세요.
|
||||
> `cd C:\ai-dev\workspace\rtgs`
|
||||
|
||||
---
|
||||
|
||||
## 1. 사전 확인 (헬스체크)
|
||||
|
||||
시작 전, 인프라·서비스가 떠 있는지 확인합니다.
|
||||
|
||||
```cmd
|
||||
rem 계좌 19개가 나오면 백엔드 정상
|
||||
curl http://localhost:8091/accounts
|
||||
|
||||
rem Elasticsearch 상태(green/yellow면 정상)
|
||||
curl http://localhost:9200/_cluster/health?pretty
|
||||
```
|
||||
|
||||
- 브라우저에서 http://localhost:5174 열어 **계좌 목록**이 보이면 프론트도 정상.
|
||||
- 안 뜨면 → **6. 기동/종료** 참조.
|
||||
|
||||
---
|
||||
|
||||
## 2. 테스트용 샘플 전문 (iso20022\samples\)
|
||||
|
||||
| 파일 | 내용 | 기대 |
|
||||
|---|---|---|
|
||||
| `pacs.008.xml` | 1001→1002, 150원 | 정상 완결(ACCC) |
|
||||
| `pacs.008-normal.xml` | 1005→1010, 5,000원 | 정상 완결(ACCC) |
|
||||
| `pacs.008-selftransfer.xml` | 1007→1007 (자기이체) | **접수 반려(RJCT)** |
|
||||
| `pacs.008-unknownbank.xml` | 9990(미등록)→1002 | **결제단계 반려(RJCT)** |
|
||||
| `pacs.008-badformat.xml` | ChrgBr=ZZZZ (스키마 위반) | **접수 반려(RJCT, XSD)** |
|
||||
|
||||
> 전문은 **정식 ISO20022 pacs.008.001.08**(접수)/**pacs.002.001.10**(응답) 공식 XSD로 검증합니다
|
||||
> (2026-07-10 전환). 응답 전문은 `urn:iso:std:iso:20022:tech:xsd:pacs.002.001.10` 네임스페이스로 옵니다.
|
||||
|
||||
> ⚠️ **BMI(거래식별자)는 유일해야 합니다.** 같은 파일을 두 번 보내면 두 번째는 "이미 처리됨"으로
|
||||
> 멱등 스킵됩니다(정상 동작). **다시 테스트하려면** 파일 안 `<BizMsgIdr>`의 뒷자리 숫자를 바꾸세요.
|
||||
|
||||
---
|
||||
|
||||
## 시나리오 A. 정상 자금이체 (기능·완결) ⭐핵심
|
||||
|
||||
**목적**: 신청→접수→청산·정산→완결(ACCC) 전 과정과 잔액 증감 확인.
|
||||
|
||||
### A-1) 화면(폼)으로 — 권장 ✅
|
||||
1. 브라우저 http://localhost:5174 (운영 탭) → **자금이체 신청** 폼
|
||||
2. **송신기관·수신기관** 드롭다운 선택(서로 다르게), **금액** 입력 → **신청** 클릭
|
||||
3. 화면이 자동으로 접수(RCVD) → 조회를 폴링해 **ACCC**(입금처리완료)로 바뀌는 것을 표시
|
||||
4. 아래 계좌 테이블에서 **송신기관 잔액 −금액 / 수신기관 잔액 +금액** 확인
|
||||
- BMI는 폼이 자동 생성(매번 유일)하므로 중복 걱정 없음
|
||||
|
||||
### A-2) 명령(curl)으로
|
||||
```cmd
|
||||
cd C:\ai-dev\workspace\rtgs
|
||||
curl -X POST http://localhost:8091/pay/customer -H "Content-Type: application/xml" --data-binary @iso20022\samples\pacs.008-normal.xml
|
||||
```
|
||||
**기대 (접수 응답, pacs.002)**: `<TxSts>RCVD</TxSts>` (접수됨)
|
||||
|
||||
**확인 (2~3초 후)**
|
||||
```cmd
|
||||
curl http://localhost:8091/inquiry/2026070911110000000001
|
||||
```
|
||||
- `status` = **ACCC** (입금처리완료)
|
||||
- 그 후 `curl http://localhost:8091/accounts` → **1005 잔액 −5,000**, **1010 잔액 +5,000**
|
||||
- 브라우저(5174)의 "거래 상태 조회"에 BMI `2026070911110000000001` 입력해도 동일 확인.
|
||||
|
||||
---
|
||||
|
||||
## 시나리오 B. 조회 (계좌·거래 · 한글화 · 원문) ⭐개선
|
||||
|
||||
**목적**: 조회 화면의 한글 항목명·상태·시각 포맷·원문 표시 확인.
|
||||
|
||||
### 화면(권장): 거래 상태 조회
|
||||
- 브라우저(5174) "거래 상태 조회"에 BMI 입력 → 결과 확인:
|
||||
- **항목명 한글 표시**(DB 컬럼 코멘트=데이터 사전 기반), **상태 한글값**(예: ACCC → *입금처리완료*)
|
||||
- 시각은 **YYYYMMDDHH24MISS** 포맷(예: 20260710153012)
|
||||
- **원문 pacs.008(ISO20022 XML)** 도 함께 표시 → 접수된 실제 전문 확인
|
||||
- 계좌 테이블 "새로고침" → 19개 기관 당좌계좌 잔액
|
||||
|
||||
### 명령(curl)
|
||||
```cmd
|
||||
curl http://localhost:8091/accounts rem 19개 기관 당좌계좌 잔액
|
||||
curl http://localhost:8091/inquiry/{BMI} rem 특정 거래 상태(JSON)
|
||||
curl http://localhost:8091/rawmessage/{BMI} rem 접수된 원문 pacs.008
|
||||
curl http://localhost:8091/meta/labels rem 항목 한글 데이터사전
|
||||
curl http://localhost:8091/notifications/{BMI} rem 결과통보 내역(신청/수취기관 앞 pacs.002)
|
||||
curl http://localhost:8091/notifications rem 최근 결과통보 100건
|
||||
```
|
||||
|
||||
> **결과통보(이체결과 송부)**: 거래가 완결(ACCC)/반려(RJCT)되면 접수센터의 Chanel이 최종 pacs.002를
|
||||
> 생성해 **신청기관(송신) 앞 결과통보**(항상)와 **수취기관 앞 입금통보**(ACCC만)를 `notification`에 기록합니다.
|
||||
> (당초 Gucci 후보 기능을 Chanel 헌장에 맞춰 이관 — 2026-07-10)
|
||||
|
||||
---
|
||||
|
||||
## 시나리오 C. 반려(RJCT) 3종
|
||||
|
||||
**목적**: 잘못된 요청이 안전하게 거절되는지(이중지급·오처리 방지) 확인.
|
||||
|
||||
**C-1) 자기 이체 (송신=수신)** → 접수 즉시 반려
|
||||
```cmd
|
||||
curl -X POST http://localhost:8091/pay/customer -H "Content-Type: application/xml" --data-binary @iso20022\samples\pacs.008-selftransfer.xml
|
||||
```
|
||||
기대: 응답 `<TxSts>RJCT</TxSts>` (업무규칙 위반, 원장에 기록 안 됨)
|
||||
|
||||
**C-2) 미등록 기관 (9990)** → 접수는 되나 결제단계에서 반려
|
||||
```cmd
|
||||
curl -X POST http://localhost:8091/pay/customer -H "Content-Type: application/xml" --data-binary @iso20022\samples\pacs.008-unknownbank.xml
|
||||
```
|
||||
기대: 응답 `RCVD` → 잠시 후 `curl http://localhost:8091/inquiry/2026070999900000000003` → **status RJCT** (사유: unknown account)
|
||||
|
||||
**C-3) 스키마 위반 (ChrgBr=ZZZZ)** → 공식 XSD 검증 실패로 접수 반려
|
||||
```cmd
|
||||
curl -X POST http://localhost:8091/pay/customer -H "Content-Type: application/xml" --data-binary @iso20022\samples\pacs.008-badformat.xml
|
||||
```
|
||||
기대: 응답 `<TxSts>RJCT</TxSts>` (ClrSysRef에 `cvc-enumeration-valid: 'ZZZZ' ...` XSD 오류 사유)
|
||||
|
||||
> 세 경우 모두 **잔액은 전혀 변하지 않아야** 합니다(시나리오 D로 총액 확인).
|
||||
|
||||
---
|
||||
|
||||
## 시나리오 D. 정합성 — 총액 보존 & 계층 대사 ⭐핵심
|
||||
|
||||
**목적**: 어떤 이체를 해도 **19개 계좌 잔액 합계 = 190억(19,000,000,000)** 이 유지되는지(돈이 생기거나
|
||||
사라지지 않음 = 이중지급 0)와, 원장 각 계층이 일치하는지 확인.
|
||||
|
||||
```cmd
|
||||
"C:\ai-dev\apps\postgresql-16.4\bin\psql.exe" -h localhost -p 5433 -U rtgs -d rtgs -c "SELECT sum(balance) AS 총액, count(*) AS 계좌수 FROM account;"
|
||||
"C:\ai-dev\apps\postgresql-16.4\bin\psql.exe" -h localhost -p 5433 -U rtgs -d rtgs -c "SELECT status, count(*) FROM transfer GROUP BY status ORDER BY status;"
|
||||
```
|
||||
기대: 총액 = **19000000000**, 상태 분포는 대부분 **ACCC**(+반려테스트한 RJCT 일부).
|
||||
|
||||
**계층 대사**(원장 transfer vs 조회사본 settlement_view 일치):
|
||||
```cmd
|
||||
"C:\ai-dev\apps\postgresql-16.4\bin\psql.exe" -h localhost -p 5433 -U rtgs -d rtgs -c "SELECT (SELECT count(*) FROM transfer WHERE status='ACCC') AS 원장ACCC, (SELECT count(*) FROM settlement_view WHERE final_status='ACCC') AS 사본ACCC;"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 시나리오 E. 멱등성 (중복 전문)
|
||||
|
||||
**목적**: 같은 거래(BMI)가 두 번 들어와도 한 번만 반영(이중지급 방지).
|
||||
|
||||
```cmd
|
||||
rem 같은 파일을 연속 2회 전송
|
||||
curl -X POST http://localhost:8091/pay/customer -H "Content-Type: application/xml" --data-binary @iso20022\samples\pacs.008.xml
|
||||
curl -X POST http://localhost:8091/pay/customer -H "Content-Type: application/xml" --data-binary @iso20022\samples\pacs.008.xml
|
||||
```
|
||||
기대: 1001→1002 잔액 변동이 **150원 한 번만** 발생(2회 보내도 총액·잔액 동일). `/accounts`로 확인.
|
||||
|
||||
---
|
||||
|
||||
## 시나리오 F. 부하 테스트 (k6) ⭐성능
|
||||
|
||||
**목적**: 동시 다발 접수 시 처리량·응답시간·정합성 확인.
|
||||
|
||||
**정상 부하 (초당 30건 × 15초 ≈ 450건)**
|
||||
```cmd
|
||||
cd C:\ai-dev\workspace\rtgs
|
||||
"C:\ai-dev\apps\k6-0.56.0\k6.exe" run -e RATE=30 -e DURATION=15s loadtest\pacs008.k6.js
|
||||
```
|
||||
**스파이크(정점) 테스트**
|
||||
```cmd
|
||||
"C:\ai-dev\apps\k6-0.56.0\k6.exe" run -e MODE=spike -e PEAK=500 loadtest\pacs008.k6.js
|
||||
```
|
||||
- 조절 옵션: `-e RATE=`(초당건수) `-e DURATION=`(시간) / spike는 `-e PEAK=`(정점 초당건수).
|
||||
- 확인: k6 요약(`http_reqs`, `http_req_duration`), 그 후 **시나리오 D**로 전건 ACCC·총액 보존 확인.
|
||||
- 주의: 이 PC 메모리(약 16GB)에서 ELK까지 다 켠 상태면 너무 높은 PEAK는 버거울 수 있음(300~1000 권장).
|
||||
|
||||
---
|
||||
|
||||
## 시나리오 G. 로그 확인 (Kibana / ELK) ⭐로그관리
|
||||
|
||||
**목적**: 모든 서비스 로그가 중앙 수집·검색되는지 확인.
|
||||
|
||||
1. 브라우저 http://localhost:5601 → ☰ **Discover** → 데이터뷰 **RTGS Logs**
|
||||
2. 검색창(KQL)에 필터 입력 예:
|
||||
- `service : "hermes"` — 결제처리 로그만
|
||||
- `service : "prada" and message : "ACCC"` — 완결 로그
|
||||
- `level : "ERROR"` — 오류만
|
||||
3. 오른쪽 위 시간범위를 "Last 1 hour"로.
|
||||
- 부하테스트(F) 돌린 직후 보면 로그가 실시간으로 쌓이는 것을 확인 가능.
|
||||
|
||||
---
|
||||
|
||||
## 시나리오 H. 관리자 기능 (LouisVuitton) ⭐시스템관리
|
||||
|
||||
**목적**: 테스트 초기화·코드·사용자권한·대사 화면 동작 확인. 브라우저(5174) → **"🛠 관리자" 탭**.
|
||||
|
||||
> 🔐 **관리자 로그인 필요(A1)**: 관리자 탭 진입 시 로그인 화면 → 테스트계정 **`a` / `1`**(ADMIN)으로 로그인.
|
||||
> (본격 운영은 초기암호 로그인 후 변경 강제 — `must_change_password`.) 비ADMIN·무토큰은 `/admin/*` 401 차단.
|
||||
> 부하테스트(k6)는 관문/인증과 무관하게 **Chanel(:8091) 직접** 호출로 수행.
|
||||
|
||||
**H-1) DB 초기화 (테스트 리셋)**
|
||||
- 관리자 탭 → **DB 초기화** 버튼(확인창) → 거래/저널/조회사본/원전문 비우고 **계좌 잔액 10억으로 리셋**
|
||||
- 초기화 후 시나리오 D로 총액 190억·거래 0건 확인. (curl: `curl -X POST http://localhost:8099/admin/reset`)
|
||||
- 💡 반복 테스트 전에 초기화하면 상태 분포가 깔끔해집니다.
|
||||
|
||||
**H-2) 코드 관리 (참가기관 마스터)**
|
||||
- 참가기관(당좌계좌) 목록 조회·추가·수정. 상태코드(RCVD~ACCC/RJCT) 데이터사전 확인.
|
||||
- (curl: `curl http://localhost:8099/admin/institutions` , `/admin/status-codes`)
|
||||
|
||||
**H-3) 사용자·권한 관리 (관리 마스터)**
|
||||
- app_user 목록·추가·수정 — role **ADMIN/ORG_S/ORG_R**. *로그인 강제는 후속 단계*(지금은 마스터 관리만).
|
||||
- (curl: `curl http://localhost:8099/admin/users`)
|
||||
|
||||
**H-4) 대사 대시보드**
|
||||
- 상태별 건수·총액 보존 체크 + 서비스별 처리 현황(transfer/journal_log/settlement_view 건수 비교).
|
||||
- 시나리오 A·F 후 열어 **원장=저널=사본 건수 일치**를 눈으로 확인.
|
||||
- (curl: `curl http://localhost:8099/admin/summary`)
|
||||
|
||||
---
|
||||
|
||||
## 시나리오 I. 복원력 검증 하니스 (sim/) ⭐자동검증
|
||||
|
||||
**목적**: 복원력방안 부록A 급소(S1 정합·S2 무손실승계·S4 순서교정)를 자동 검증. **Git Bash**에서 실행.
|
||||
|
||||
```bash
|
||||
bash sim/s1_consistency.sh 30 # 정합성: 총액보존·원장=사본·이중지급 0
|
||||
bash sim/s2_failover.sh 30 # 복원성: 부하중 Hermes 강제종료·재기동 → 유실 0
|
||||
bash sim/s4_order.sh # 기능성: 저널 순번 역전주입 → 재정렬 완결
|
||||
# 수동 대사 리포트:
|
||||
"C:\ai-dev\apps\postgresql-16.4\bin\psql.exe" -h localhost -p 5433 -U rtgs -d rtgs -f sim\reconcile.sql
|
||||
```
|
||||
- 각 스크립트가 `PASS/FAIL`을 출력합니다. 자세한 원리는 [sim/README.md](../sim/README.md).
|
||||
- ⚠️ **S2·S4는 서비스(Hermes/Sequencer)를 죽였다 살립니다.** 수동 테스트와 겹치지 않을 때 실행하세요.
|
||||
|
||||
---
|
||||
|
||||
## 시나리오 J. Gucci 관문 (인증·유량·재전송·결과송부) ⭐게이트웨이
|
||||
|
||||
**목적**: 참가기관이 **Gucci(:8095)** 를 통해 인증·접수하고, 결과가 콜백으로 송부되는지 확인. **Git Bash** 권장.
|
||||
|
||||
**dev 계정(시크릿)**: `kookmin_s`/`kookmin-secret`(ORG_S,1001) · `shinhan_r`/`shinhan-secret`(ORG_R,1002) · `admin`/`admin-secret`(ADMIN)
|
||||
|
||||
```bash
|
||||
G=http://localhost:8095
|
||||
# ① 로그인 → 토큰
|
||||
TOKEN=$(curl -s -X POST $G/gucci/auth/login -H "Content-Type: application/json" \
|
||||
-d '{"username":"kookmin_s","secret":"kookmin-secret"}' | python -c "import sys,json;print(json.load(sys.stdin)['token'])")
|
||||
|
||||
# ② 인증 이체신청(토큰+nonce+timestamp). 전문 송신기관(1001)은 토큰 기관과 일치해야 함
|
||||
TS=$(date +%s)
|
||||
curl -s -X POST $G/gucci/pay/customer -H "Authorization: Bearer $TOKEN" \
|
||||
-H "X-Nonce: n-$TS" -H "X-Timestamp: $TS" -H "Content-Type: application/xml" \
|
||||
--data-binary @iso20022/samples/pacs.008-normal.xml # (송신 1005면 403 — 토큰 org와 불일치)
|
||||
|
||||
# ③ 인증 조회
|
||||
curl -s $G/gucci/inquiry/{BMI} -H "Authorization: Bearer $TOKEN"
|
||||
# ④ 헬스체크(무인증)
|
||||
curl -s $G/gucci/health/centers
|
||||
```
|
||||
**기대(거부 케이스)**: 무토큰→401 · 변조토큰→401 · 토큰org≠전문송신→403 · nonce 재사용→400 · ORG_S가 /gucci/accounts→403(ADMIN 전용) · 유량 초과→429
|
||||
|
||||
**결과 콜백송부(G2)**: 완결되면 Chanel이 `notification`에 적재(delivered=false) → Gucci가 3초 폴링으로 기관 콜백에 pacs.002 송부 → ACK시 delivered=true.
|
||||
```bash
|
||||
# 콜백 레지스트리(관리자) / 송부 상태 확인
|
||||
AT=$(curl -s -X POST $G/gucci/auth/login -H "Content-Type: application/json" -d '{"username":"admin","secret":"admin-secret"}' | python -c "import sys,json;print(json.load(sys.stdin)['token'])")
|
||||
curl -s $G/gucci/callbacks -H "Authorization: Bearer $AT"
|
||||
"C:\ai-dev\apps\postgresql-16.4\bin\psql.exe" -h localhost -p 5433 -U rtgs -d rtgs -c "SELECT bmi,to_org,delivered,attempts FROM notification ORDER BY created_at DESC LIMIT 10;"
|
||||
```
|
||||
> 로컬 데모는 기관 수신서버를 Gucci 자체 sink(`/gucci/sink/{org}`)로 모사합니다. 미등록 기관은 재시도(attempts↑)하다가
|
||||
> 관리자가 `PUT /gucci/callbacks/{org}`로 URL 등록하면 다음 폴링에 송부 성공합니다.
|
||||
|
||||
---
|
||||
|
||||
## 6. 기동 / 종료
|
||||
|
||||
**전체 기동** (인프라 + 7서비스: Sequencer/Chanel/Dior/Hermes/Prada/LouisVuitton/Gucci)
|
||||
```cmd
|
||||
C:\ai-dev\workspace\rtgs\run-dc1.cmd
|
||||
```
|
||||
**ELK(로그) 기동** (선택, 메모리 여유 있을 때)
|
||||
```cmd
|
||||
C:\ai-dev\workspace\rtgs\infra\start-elk-native.cmd
|
||||
```
|
||||
**프론트엔드 기동**
|
||||
```cmd
|
||||
C:\ai-dev\workspace\rtgs\run-frontend.cmd
|
||||
```
|
||||
**종료**: 각 서비스/인프라 창을 닫거나 → `C:\ai-dev\workspace\rtgs\stop-dc1.cmd` (인프라 종료, 데이터 보존)
|
||||
|
||||
> 지금은 이미 떠 있는 상태라 바로 시나리오 A부터 하시면 됩니다.
|
||||
|
||||
---
|
||||
|
||||
## 7. 부록
|
||||
|
||||
### 유용한 psql 조회
|
||||
```cmd
|
||||
set PSQL="C:\ai-dev\apps\postgresql-16.4\bin\psql.exe" -h localhost -p 5433 -U rtgs -d rtgs
|
||||
%PSQL% -c "SELECT bmi,global_seq,status,sender_code,receiver_code,amount FROM transfer ORDER BY global_seq DESC LIMIT 10;"
|
||||
%PSQL% -c "SELECT code,name,balance FROM account ORDER BY code;"
|
||||
```
|
||||
|
||||
### 데이터 초기화(계좌 잔액 리셋 등)가 필요하면
|
||||
- **가장 쉬운 방법**: 콘솔(5174) "🛠 관리자" 탭 → **DB 초기화** 버튼 (시나리오 H-1).
|
||||
- 명령으로: `curl -X POST http://localhost:8099/admin/reset`
|
||||
- (거래/저널/사본/원전문 비우고 계좌 잔액을 10억으로 되돌림)
|
||||
|
||||
### BMI(거래식별자) 규칙 — 직접 전문 만들 때
|
||||
- 22자리 = **영업일(8, YYYYMMDD) + 기관코드(4) + 일련번호(10)**
|
||||
- 예: `2026070911110000000009` (2026-07-09 · 기관 1111 · 일련 0000000009)
|
||||
- 매 요청마다 뒷자리를 다르게 하면 유일성 확보.
|
||||
|
||||
### 트러블슈팅
|
||||
- `/accounts`가 안 나옴 → 백엔드 미기동. `run-dc1.cmd` 실행.
|
||||
- 접수는 되는데 계속 IN_FLIGHT → Sequencer/Hermes 창 확인(기동 여부).
|
||||
- Kibana가 안 열림 → 기동에 ~1분 소요, 또는 `start-elk-native.cmd`로 ES/Logstash/Kibana 기동.
|
||||
- 포트 충돌 → acs 등 다른 프로젝트와 포트 분리돼 있으나, 8091/5433/9092 등이 이미 쓰이면 해당 프로세스 확인.
|
||||
|
||||
---
|
||||
|
||||
*문의/이상 발견 시 루비에게 알려주시면 바로 확인하겠습니다.*
|
||||
BIN
docs/RTGS 프로토타입 시스템 PoC 테스트 1차 결과보고.pdf
Normal file
BIN
docs/RTGS 프로토타입 시스템 PoC 테스트 1차 결과보고.pdf
Normal file
Binary file not shown.
BIN
docs/RTGS 프로토타입 시스템 PoC 테스트 2차 결과보고.pdf
Normal file
BIN
docs/RTGS 프로토타입 시스템 PoC 테스트 2차 결과보고.pdf
Normal file
Binary file not shown.
BIN
docs/RTGS 프로토타입 시스템 PoC 테스트 4차 계획.pdf
Normal file
BIN
docs/RTGS 프로토타입 시스템 PoC 테스트 4차 계획.pdf
Normal file
Binary file not shown.
186
docs/RTGS 프로토타입 워크플로우.md
Normal file
186
docs/RTGS 프로토타입 워크플로우.md
Normal file
@@ -0,0 +1,186 @@
|
||||
# RTGS 프로토타입 워크플로우
|
||||
|
||||
> 로컬(1센터 DC1) RTGS 프로토타입의 전문 처리 흐름 · 서비스별 처리 · 데이터 흐름 정리
|
||||
> 작성: 루비 · 담당: 양팀장님
|
||||
|
||||
---
|
||||
|
||||
## 1. 시스템 구성도
|
||||
|
||||
```
|
||||
[참가기관 / 부하생성기(k6) / 조회·신청 콘솔(:5174)]
|
||||
│ pacs.008 (HTTP, XML)
|
||||
▼
|
||||
┌──────────────────────────── 1센터 (CENTER_ID=DC1) ────────────────────────────┐
|
||||
│ │
|
||||
│ ① Chanel(전문송수신) :8091 │
|
||||
│ XSD검증 → 경량명령문(75B)+원전문해시 → 원전문 저장 → 입구 발행 → pacs.002 응답 │
|
||||
│ │ rtgs.inbound └→ raw_message(PostgreSQL) │
|
||||
│ ▼ │
|
||||
│ ③ Sequencer(순번기) :8090 전역순번 부여 + 결정론적값 확정 → 저널 발행 │
|
||||
│ │ rtgs.journal (단일 파티션 = 전역 순서) │
|
||||
│ ├───────────────┬───────────────────────────┐ │
|
||||
│ ▼ ▼ ▼ │
|
||||
│ Dior(접수동기화) Hermes(결제/원장엔진, cc=1) (저널 구독) │
|
||||
│ PDNG 기록 순차소비→선저널→잔액이체→ACSP→결과발행 │
|
||||
│ │ │ rtgs.result │
|
||||
│ │ ▼ │
|
||||
│ │ Prada(결과동기화) 과반확정→ACCC 최종확정 │
|
||||
│ ▼ ▼ ▼ │
|
||||
│ ┌──────────────── PostgreSQL(:5433, 강한 일관성 원장) ────────────────┐ │
|
||||
│ │ account(잔액) · transfer(거래상태) · journal_log(선저널) · │ │
|
||||
│ │ settlement_view(조회사본) · raw_message(원전문) · app_user │ │
|
||||
│ └────────────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ⑥ LouisVuitton(관리자) :8099 초기화·코드·사용자·대사 (← 콘솔 관리자 탭)
|
||||
│ ⑦ Gucci(외부 경계 관문) :8095 로그인·API인증·유량제어·재전송차단·결과콜백송부(→참가기관) │
|
||||
└────────────────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
로그: 각 서비스(Logback) ──TCP:5000──▶ Logstash ──▶ Elasticsearch(:9200) ──▶ Kibana(:5601)
|
||||
메시지: Kafka(:9092, KRaft) 토픽: rtgs.inbound / rtgs.journal / rtgs.result / rtgs.notify
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 자금이체 처리 워크플로우 (핵심)
|
||||
|
||||
### 흐름 요약
|
||||
```
|
||||
[신청] ORG_S가 pacs.008 전송
|
||||
└▶ [접수] Chanel : XSD검증·경량화·원전문저장 → 상태 RCVD → rtgs.inbound
|
||||
└▶ [순번] Sequencer : 전역순번+결정론적값 → 상태 ACTC → rtgs.journal(저널)
|
||||
├▶ [접수동기화] Dior : 저널 소비 → PostgreSQL 원장에 PDNG 기록
|
||||
└▶ [청산·정산] Hermes : 저널 순차소비(cc=1)
|
||||
→ gap/역전 탐지·재정렬 → 선저널(write-ahead)
|
||||
→ 송신기관 당좌 −금액 / 수신기관 당좌 +금액 (강한 일관성 트랜잭션)
|
||||
→ 상태 ACSP → (커밋 후) rtgs.result 발행
|
||||
└▶ [결과동기화] Prada : 결과 소비 → 과반(2/3) 확정
|
||||
→ 상태 ACCC(입금처리완료) 최종확정 + 조회사본 기록
|
||||
→ (커밋 후) rtgs.notify 발행
|
||||
└▶ [결과통보] Chanel(접수센터) : rtgs.notify 소비 → 최종 pacs.002 생성
|
||||
→ 신청기관(송신) 앞 결과통보 + 수취기관 앞 입금통보(ACCC) → notification 아웃박스
|
||||
```
|
||||
|
||||
### 단계별 상세 (각 서비스가 다루는 토픽/DB)
|
||||
|
||||
| 순서 | 서비스 | 입력 | 처리 | 출력 | 상태 |
|
||||
|---|---|---|---|---|---|
|
||||
| ① | **Chanel** | HTTP pacs.008 | XSD검증→경량명령문+원전문해시, 원전문 저장 | `raw_message` 저장, `rtgs.inbound` 발행, pacs.002 응답 | RCVD |
|
||||
| ② | **Sequencer** | `rtgs.inbound` | 전역순번 부여 + 결정론적값(수신시각) 확정 | `rtgs.journal` 발행(저널) | ACTC |
|
||||
| ③ | **Dior** | `rtgs.journal` | BMI 중복체크(upsert) | `transfer` PDNG 기록 | PDNG |
|
||||
| ④ | **Hermes** | `rtgs.journal`(cc=1 순차) | 순서 탐지·교정 → 선저널 → 당좌계좌 차·대변(강한 일관성) | `account`·`journal_log`·`transfer`(ACSP), `rtgs.result` 발행 | ACSP |
|
||||
| ⑤ | **Prada** | `rtgs.result` | 완결 규율(과반 확정) | `transfer`(ACCC)·`settlement_view`, (커밋 후) `rtgs.notify` 발행 | ACCC |
|
||||
| ⑥ | **Chanel**(결과통보) | `rtgs.notify` | 최종 pacs.002 생성, 접수센터만 송부 | `notification`(신청기관+수취기관) | — |
|
||||
|
||||
> **핵심 원칙**: 어느 센터로 접수돼도 **단일 순번기**가 하나의 전역순번으로 줄 세우고, 저널(단일 파티션)을
|
||||
> N센터가 **같은 순서로 결정론적 재생** → 세 센터 데이터가 항상 동일. 잔액 변경이 하나의 순번 줄로
|
||||
> 직렬화되므로 "동시에 같은 계좌"가 없어 **이중지급 원천 차단**.
|
||||
|
||||
---
|
||||
|
||||
## 2.5 서비스별 처리 개념도 (참조)
|
||||
|
||||
| 서비스 | 개념도 |
|
||||
|---|---|
|
||||
| Chanel(전문송수신) |  |
|
||||
| Dior(접수동기화) |  |
|
||||
| Hermes(결제/원장엔진) |  |
|
||||
| Prada(결과동기화) |  |
|
||||
|
||||
> 전체 업무 흐름은  참조.
|
||||
|
||||
---
|
||||
|
||||
## 3. 상태 전이도 (TxSts)
|
||||
|
||||
```
|
||||
(접수) (승인) (대기) (예약) (완결)
|
||||
RCVD ──▶ ACTC ──▶ [PDNG] ──▶ ACSP ──────────▶ ACCC
|
||||
│Chanel │Sequencer │Dior │Hermes(이체) │Prada(과반확정)
|
||||
│
|
||||
└─(검증실패/잔액부족/미등록기관)─▶ RJCT (반려)
|
||||
```
|
||||
- **RCVD** 접수 / **ACTC** 승인(순번부여) / **PDNG** 대기(원장 기록) / **ACSP** 예약(결제 반영, 결과대기) / **ACCC** 입금처리완료(최종) / **RJCT** 반려
|
||||
|
||||
---
|
||||
|
||||
## 4. 데이터 흐름
|
||||
|
||||
**Kafka 토픽** (전 센터 공유 단일 클러스터 = 순서 공유)
|
||||
- `rtgs.inbound` : Chanel → Sequencer (접수 원시)
|
||||
- `rtgs.journal` : Sequencer → 전 센터(Dior/Hermes) (전역순번 저널, **단일 파티션**)
|
||||
- `rtgs.result` : Hermes → Prada (결제결과)
|
||||
- `rtgs.notify` : Prada → Chanel (완결 결과통보 트리거; 접수센터가 신청/수취기관 앞 pacs.002 송부)
|
||||
|
||||
**PostgreSQL 테이블** (센터별 원장, 강한 일관성)
|
||||
- `account` 참가기관 당좌계좌 잔액 | `transfer` 거래 원장·상태 | `journal_log` 선저널
|
||||
- `settlement_view` 조회 사본 | `raw_message` 원전문(pacs.008) | `notification` 결과통보 아웃박스 | `app_user` 사용자·권한
|
||||
|
||||
**로그(ELK)**: 서비스 Logback(JSON, service/center 필드) → Logstash(:5000) → ES `rtgs-logs-*` → Kibana
|
||||
|
||||
---
|
||||
|
||||
## 5. 관리자(LouisVuitton) 워크플로우
|
||||
|
||||
```
|
||||
콘솔(:5174) "🛠 관리자" 탭 ──▶ louisvuitton(:8099) /admin/*
|
||||
· DB 초기화 : transfer/journal_log/settlement_view/raw_message TRUNCATE + 계좌 잔액 리셋
|
||||
· 코드 관리 : 참가기관(account) 마스터 CRUD + 상태코드 데이터사전
|
||||
· 사용자 권한 : app_user CRUD (ADMIN/ORG_S/ORG_R) — 로그인 강제는 후속
|
||||
· 대사 대시보드 : 상태별 건수·총액 보존 체크 + 서비스별 처리 현황(transfer/journal_log/settlement_view)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 개발 · 실행 워크플로우
|
||||
|
||||
**빌드** (JDK21로 — Gradle 8.10.2는 JDK26 미지원; `gradle.properties`에 고정)
|
||||
```
|
||||
cd C:\ai-dev\workspace\rtgs\backend
|
||||
gradlew build (EDR 파일락 시 재시도)
|
||||
```
|
||||
**기동/종료**
|
||||
```
|
||||
run-dc1.cmd : 네이티브 인프라(Postgres/Kafka) + 6개 서비스
|
||||
infra\start-elk-native.cmd : ES/Logstash/Kibana (로그)
|
||||
run-frontend.cmd : 콘솔(:5174)
|
||||
stop-dc1.cmd : 인프라 종료(데이터 보존)
|
||||
```
|
||||
**접속**: 콘솔 http://localhost:5174 · Kibana http://localhost:5601
|
||||
|
||||
**변경→반영**: 백엔드 = 재빌드(bootJar)+해당 서비스 재기동 / 프론트 = Vite HMR(자동)
|
||||
|
||||
---
|
||||
|
||||
## 6.5 Gucci(외부 경계 관문, :8095) 워크플로우
|
||||
|
||||
참가기관은 코어(Chanel:8091)에 직접 붙지 않고 **Gucci 관문**을 통한다(외부↔RTGS 북-남 경계).
|
||||
전역 결제순서는 **Sequencer 단독**이며 Gucci는 관문/인증/유량제어/전달보장만 담당(순서 결정 아님).
|
||||
|
||||
```
|
||||
참가기관 ──① 로그인(POST /gucci/auth/login, secret) ──▶ Gucci ── app_user.secret 대조 ──▶ JWT 발급
|
||||
──② 이체신청(POST /gucci/pay/customer, Bearer+X-Nonce+X-Timestamp) ──▶ Gucci 가드:
|
||||
[JWT검증] → [역할 ORG_S/ADMIN] → [유량제어 50/10s] → [재전송 nonce/timestamp]
|
||||
→ [기관바인딩: 토큰 org == 전문 DbtrAgt MmbId] → (통과) Chanel /pay/customer 프록시
|
||||
◀── pacs.002(RCVD) ──
|
||||
|
||||
[완결 후 결과 콜백송부 — G2]
|
||||
Prada 완결 → Chanel notification 아웃박스(delivered=false)
|
||||
→ Gucci DeliveryService(3s 폴링) → institution_endpoint 콜백 URL로 pacs.002 POST
|
||||
→ 2xx ACK: delivered=true / 실패: attempts++ 재시도(max 5)
|
||||
```
|
||||
- 감사로그(인증·허용/거부·송부)는 ELK(service=gucci)로 수집.
|
||||
- 헬스체크 `GET /gucci/health/centers`(관측). 다센터 정족수/펜싱·GSLB 라우팅은 G3(고사양 PC 이후).
|
||||
|
||||
---
|
||||
|
||||
## 7. 확장 워크플로우 (다센터 A-A-A, 향후)
|
||||
|
||||
```
|
||||
┌── DC1 (Chanel/Dior/Hermes/Prada + PostgreSQL rtgs_dc1)
|
||||
[단일 Sequencer] ──저널(Kafka 공유)──┼── DC2 (동일 서비스 + rtgs_dc2)
|
||||
└── DC3 (동일 서비스 + rtgs_dc3)
|
||||
· 컨슈머 그룹 센터별 분리(hermes-DC1/DC2/DC3) · 센터별 DB · 포트 오프셋
|
||||
· 정족수 2/3 자동전환 · 3센터 데이터 동일성 대사로 검증(개요 "3센터 동일순서 동일업무")
|
||||
```
|
||||
*필요 작업·자원은 개발일지 백로그 참조. 고사양 PC 이관 후 수월.*
|
||||
75
docs/SW 산출물 관리대장.md
Normal file
75
docs/SW 산출물 관리대장.md
Normal file
@@ -0,0 +1,75 @@
|
||||
# RTGS SW 산출물 관리대장
|
||||
|
||||
> 목적: **코드와 문서의 정합성 유지**(과거 PoC 시 문서 미갱신으로 코드와 설계서가 어긋난 문제 재발 방지).
|
||||
> 원칙: 개발·변경이 있을 때 **관련 산출물을 같은 작업에서 즉시 갱신**하고, 개발일지에 이력을 남긴다.
|
||||
> 관리: 루비(AI) · 검토: 양팀장님 · 최초 2026-07-10
|
||||
|
||||
---
|
||||
|
||||
## 1. 정합성 원칙 (Single Source of Truth)
|
||||
|
||||
| 정보 | 진실의 출처(코드/설정) | 문서는 이를 "반영"만 |
|
||||
|---|---|---|
|
||||
| 포트·서비스 구성 | `run-dc1.cmd`, 각 `application.yml` | 아키텍처 설계서 §3·§4, 부록 A |
|
||||
| Kafka 토픽 | `common/Constants.kt (Topics)` | 아키텍처 §5.3, 워크플로우 §4 |
|
||||
| DB 스키마 | **Flyway** `backend/louisvuitton/.../db/migration/V*.sql` (수기 ALTER 금지, 신규는 V+1 추가) + JPA 엔티티 | 아키텍처 §5.2 |
|
||||
| 인증/권한(관리자) | Gucci JWT 발급 + LouisVuitton 검증(role ADMIN), 비밀키 BCrypt | 아키텍처 §8.5 |
|
||||
| 항목 한글명(데이터 사전) | **DB `COMMENT ON COLUMN`** (→ `/meta/labels`) | 화면 라벨·문서 표 |
|
||||
| 전문(ISO20022) | `common/resources/iso20022/xsd/*` + 샘플 | 아키텍처 §6.1, 테스트 시나리오 |
|
||||
| API 목록 | 각 `*Controller.kt` | 아키텍처 §6.2, 테스트 시나리오 |
|
||||
| 상태머신 | `common/TxSts.kt` + 서비스 로직 | 아키텍처 §5.4, 워크플로우 §3 |
|
||||
|
||||
> 문서가 코드와 다르면 **코드가 맞다고 간주**하고 문서를 고친다(반대 아님). 의도적 설계 변경이면 코드·문서를 함께 바꾼다.
|
||||
|
||||
---
|
||||
|
||||
## 2. 필수 산출물 목록
|
||||
|
||||
| 산출물 | 목적 | 갱신 트리거 | 최신 갱신 |
|
||||
|---|---|---|---|
|
||||
| **RTGS 아키텍처 설계서.md** | 구조·스택(OS-WAS-DB)·데이터·인터페이스·보안 | 서비스/포트/스택/스키마/API/전문/보안 변경 | 2026-07-10 |
|
||||
| **RTGS 프로토타입 워크플로우.md** | 처리 흐름·토픽·서비스 책임 | 처리흐름/토픽/서비스 역할 변경 | 2026-07-10 |
|
||||
| **RTGS 프로토타입 로컬 테스트 시나리오.md** | 기능·엔드포인트·샘플 시험 절차 | 기능/엔드포인트/샘플/포트 변경 | 2026-07-10 |
|
||||
| **RTGS 프로토타입 개발일지.md** | 일자별 개발·변경 이력(감사 추적) | **모든 작업·변경 시(必)** | 2026-07-10 |
|
||||
| **sim/README.md** | 복원력 검증(S1/S2/S4) 절차·결과 | 검증 수행·시나리오 변경 | 2026-07-10 |
|
||||
| SW 산출물 관리대장.md (본 문서) | 산출물·정합성 관리 규칙 | 산출물/규칙 변경 | 2026-07-10 |
|
||||
| (메모리) rtgs-project 등 | 세션 간 지식 유지 | 아키텍처·결정 변경 | 2026-07-10 |
|
||||
|
||||
**참고 자료(입력물, 갱신 대상 아님)**: `2센터흐름도.png`, `chanel/dior/hermes/prada.png`, `UML.xlsx`, `pacs.7z`,
|
||||
복원력방안 PDF, PoC 1~3차 결과보고, `rtgs 프로젝트 개요.txt`(양팀장님 관리).
|
||||
|
||||
---
|
||||
|
||||
## 3. 변경 유형 → 갱신 대상 매핑 (체크리스트)
|
||||
|
||||
작업 시 해당 행의 문서를 **모두** 갱신한다.
|
||||
|
||||
| 변경 유형 | 갱신할 산출물 |
|
||||
|---|---|
|
||||
| 서비스 추가/포트 변경 | 아키텍처(§3·§4·부록A) · `run-dc1.cmd` · 테스트 시나리오(포트) · 개발일지 · 메모리 |
|
||||
| 처리 흐름/토픽 변경 | 워크플로우 · 아키텍처(§5.3·§7) · 개발일지 |
|
||||
| DB 스키마 변경 | **Flyway 새 마이그레이션 `V+1__*.sql` 추가**(+ `COMMENT`) · 엔티티 · 아키텍처(§5.2) · (초기화 대상이면 admin reset) · 개발일지 |
|
||||
| API 추가/변경 | 컨트롤러 · 아키텍처(§6.2) · 테스트 시나리오 · 개발일지 |
|
||||
| 전문(ISO) 변경 | XSD/샘플 · 아키텍처(§6.1) · 테스트 시나리오 · 개발일지 |
|
||||
| 보안/인증 변경 | 아키텍처(§8.5) · 워크플로우(Gucci) · 개발일지 |
|
||||
| 검증(sim/k6) 수행 | sim/README(결과) · 개발일지 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 정합성 점검 (주기·릴리스 전)
|
||||
|
||||
아래가 **문서와 실제가 일치**하는지 확인한다(불일치 시 문서 수정):
|
||||
- 포트: `run-dc1.cmd`/`application.yml` ↔ 아키텍처 부록 A
|
||||
- 토픽: `Constants.kt` ↔ 아키텍처 §5.3
|
||||
- 테이블: Flyway 마이그레이션(V*) ↔ 아키텍처 §5.2 (실DB `flyway_schema_history`로 적용본 확인)
|
||||
- API: `*Controller.kt` ↔ 아키텍처 §6.2 / 테스트 시나리오
|
||||
- 전문 버전: XSD 파일명 ↔ 아키텍처 §6.1
|
||||
- 서비스 수: 빌드 모듈 수 ↔ 문서의 "N개 서비스"
|
||||
|
||||
> (자동화 여지) 위 항목은 grep 기반 점검 스크립트로 만들 수 있음 — 필요 시 `docs/tools/`에 추가.
|
||||
|
||||
---
|
||||
|
||||
## 5. 최종 산출물 변환
|
||||
- 개발 중: `.md` 유지. 완성 시: `docs/tools/convert.cmd`로 **PPT/PDF** 생성(→ `docs/_dist/`).
|
||||
- 다이어그램: 참고 PNG 임베드 + 신규는 Mermaid. PPT/PDF 변환 전 Mermaid는 PNG로 선렌더.
|
||||
BIN
docs/UML.xlsx
Normal file
BIN
docs/UML.xlsx
Normal file
Binary file not shown.
BIN
docs/chanel.png
Normal file
BIN
docs/chanel.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 218 KiB |
BIN
docs/dior.png
Normal file
BIN
docs/dior.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 214 KiB |
BIN
docs/hermes.png
Normal file
BIN
docs/hermes.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 238 KiB |
BIN
docs/pacs.7z
Normal file
BIN
docs/pacs.7z
Normal file
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user