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

View File

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

View File

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

View 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