Initialize RTGS prototype repository

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

View 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")
}

View File

@@ -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)}")
}

View File

@@ -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/**")
}
}

View File

@@ -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),
)
}

View File

@@ -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
}

View File

@@ -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)
}

View 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

View File

@@ -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로그인비밀키';

View File

@@ -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;

View File

@@ -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';

View File

@@ -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 '발행완료여부';

View 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 =
`총잔액 &nbsp; 전 <b>${fmt(before.totalBalance)}</b> &nbsp;→&nbsp; 후 <b>${fmt(after.totalBalance)}</b> &nbsp; `+
(same ? `<span class="good">✔ 보존됨 (0-합)</span>` : `<span class="broke">✘ 불일치! 차액 ${fmt(after.totalBalance-before.totalBalance)}</span>`) +
`<br>transfer 건수 &nbsp; 전 <b>${fmt(before.transfers)}</b> &nbsp;→&nbsp; 후 <b>${fmt(after.transfers)}</b> &nbsp; (증가 <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>