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