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:
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"))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user