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

View File

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

View File

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

View File

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

View 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

View File

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