# RTGS 3센터 삼중화 설계 (B1·B2·B3) — 구현 착수 확정본

| 항목 | 내용 |
|---|---|
| 목적 | 2센터 PoC → **3센터 Active-Active-Active** 전환. 코드 구현은 현 PC, 전체 3센터 실측은 고사양 PC 이관 후 |
| 범위 | B1 순번기 이중화·무손실, B2 정족수(2/3) 완결, B3 순서결번 처리 + 멀티센터 런타임·설정 공통화 |
| 전제 | 코드는 이미 `CENTER_ID`/`CENTER_COUNT` 파라미터화. **§9 결정사항 확정 완료**(단일 인스턴스 3DB·인메모리 ack·순번기 3인스턴스) |
| 실행 환경 | 실측 전제 = 고사양 PC(40코어/128GB) 3터미널. **단, 현 개발 PC는 8코어** → 이 PC는 코드/빌드/단위테스트/경량 스모크(1~2센터)까지, 전체 3센터 21 JVM 실측은 이관 후 |
| 상태 | **설계 확정 + 코드 구현·빌드·단위테스트 완료(2026-07-13, 8코어 PC) · 3센터 실측은 고사양 PC** |
| 구현 산출물 | B1/B2/B3 코드 + `V4__seq_outbox.sql` + 단위테스트(QuorumAggregator·GapBuffer) + `run-dc2/dc3.cmd`·`init-3centers-db.cmd`. 기동·검증은 **`docs/RTGS 3센터 이관·기동 체크리스트.md`** 참조 |

---

## 0. 핵심 원리 (재확인)
- **결정론적 재생**: 3센터가 **같은 저널을 같은 순서로** 재생하면 각자 동일 상태 도달 → **원장 데이터 동기화 불필요**(양팀장님 이론 맞음).
- 센터 간 조율이 필요한 건 딱 둘: **(a) 하나의 전역 순서 생성**(순번기) + **(b) 완결 선언 시 과반 확인**(정족수).
- 재해 규율: **1센터 down = 서비스 지속**(복구 후 밀린 저널 **진도처리=따라잡기**) / **2센터 down = 서비스 중단(안전정지)** / 복구 후 **3센터 정상 재개**.

---

## 1. 현재 상태 (전환 대상)
| 구분 | 현재(1센터) | 삼중화 전환 |
|---|---|---|
| Sequencer | 단일 인스턴스, seq=인메모리→DB max복원 | **durable-before-publish + 리더선출(3인스턴스) + 펜싱** |
| 완결(Prada) | `confirmations=1` **하드코딩** → 즉시 ACCC | **센터별 applied ack 집계 → 과반(2/3) 후 ACCC** |
| 순서(Hermes) | gap 버퍼링(무한대기 가능) | **gap 타임아웃 시 durable(Kafka/journal_log)에서 결번 pull** |
| 원장 DB | 단일 `rtgs` | **센터별 `rtgs_dc1/dc2/dc3`** |
| 서비스 | 1세트(7) | **3세트(센터별) + 단일 순번기 그룹** |
| 설정 | 서비스별 yml 중복 | **공통 yml + env 파라미터화**(A안) |

---

## 2. 멀티센터 런타임 구성 (단일 호스트, 3터미널)

```mermaid
flowchart TB
  subgraph SHARED["공유 인프라 (단일)"]
    K["Kafka :9092<br/>rtgs.inbound / rtgs.journal(단일파티션) / rtgs.result(=applied-ack) / rtgs.notify"]
    PG[("PostgreSQL :5433<br/>rtgs_dc1 · rtgs_dc2 · rtgs_dc3")]
    OBS["Prometheus:9090 · Grafana:3000 · ELK"]
  end
  subgraph SEQ["전역 순번기 (그룹=sequencer, 1 active=리더)"]
    S1["seq@DC1"]:::a --- S2["seq@DC2"]:::s --- S3["seq@DC3"]:::s
  end
  DC1["DC1 스택 809x<br/>Chanel/Dior/Hermes/Prada/Gucci/LV"]
  DC2["DC2 스택 819x<br/>동일"]
  DC3["DC3 스택 829x<br/>동일"]
  K --- SEQ
  K --- DC1 & DC2 & DC3
  DC1 --> PG
  DC2 --> PG
  DC3 --> PG
  classDef a fill:#cfe8cf; classDef s fill:#eee
```

**포트 오프셋** (센터별 +100): DC1 8090~8099·5174 / DC2 8190~8199·5175 / DC3 8290~8299·5176.
**공유**: Kafka 9092 · PostgreSQL 5433(3 DB) · Prometheus 9090 · Grafana 3000 · ELK 9200/5000/5601.
**컨슈머 그룹**: 원장서비스는 **센터별 접미사**(예: `hermes-DC1/DC2/DC3`) → 각 센터가 저널 전량 독립 재생.
순번기는 **단일 그룹 `sequencer`**(3인스턴스, 1파티션 → 1개만 활성 = 리더).

> **구현지점(현 코드 상태)**: `@KafkaListener(groupId="hermes")` 등 groupId가 **하드코딩**되어 있음
> (dior/hermes/prada/chanel/sequencer 각 서비스). 센터별 독립 재생을 위해 원장서비스 groupId를
> `CENTER_ID` 접미사로 파라미터화 필요 → SpEL `groupId = "hermes-#{@centerId}"` 또는
> `groupId = "\${rtgs.group.hermes}"`(프로퍼티 주입). **순번기만 예외**(접미사 없이 단일 그룹 `sequencer` 유지).

---

## 3. B1 — 순번기 이중화 + 무손실 (durable-before-publish)

> **현 코드 상태(전환 대상)**: `SequencerService.kt`는 인메모리 `AtomicLong` + 부팅 시
> `@PostConstruct`로 `MAX(journal_log.global_seq, transfer.global_seq)` 복원. **순번기 자신은
> `journal_log`/outbox를 쓰지 않고** 바로 `rtgs.journal` 발행(발행-후 크래시 시 무손실 미보장).
> 따라서 아래 SEQUENCE·outbox·재발행은 **전량 신설**이다(현 코드에 없음).

**리더선출(간결·견고)**: 순번기 3인스턴스를 **같은 컨슈머 그룹 `sequencer`** 로 `rtgs.inbound`(단일 파티션) 구독 →
Kafka가 파티션을 **1개 인스턴스에만 배정 = 자동 단일 리더**. 리더 死 시 Kafka 리밸런스로 **자동 승계**(별도 선출 로직 불필요).

**durable-before-publish (무손실·무결번)**:
1. 리더가 접수 수신 → **Postgres SEQUENCE `global_seq_seq`.nextval**(원자적·durable 순번)
2. 저널 엔트리를 **outbox 테이블에 저장(commit)** → 그 후 `rtgs.journal` 발행
3. 크래시로 발행 전 죽어도, 재기동/승계 리더가 **outbox의 미발행분을 재발행**(at-least-once)
4. 소비측은 seq 기준 **멱등**(이미 있음) → 중복 무해

**펜싱(split-brain)**: nextval이 원자적이라 **중복 순번 불가**. 순간적 이중리더가 있어도 서로 다른 seq만 발급 →
저널 오염 없음. (엄격 fencing이 필요하면 outbox에 leader epoch 컬럼 추가—후순위)

**작업**: 새 서비스/모듈 대신 SequencerService 개편 + `global_seq_seq`·`journal_outbox` + 재발행 스케줄러.
- **Flyway 위치**: 마이그레이션은 `backend/louisvuitton/src/main/resources/db/migration/`에 있고
  현재 `V1__baseline`·`V2__admin_login`·`V3__service_heartbeat`까지 존재 → SEQUENCE+outbox는 **`V4__seq_outbox.sql`** 로 추가.
- **재발행**: `journal_outbox`에 `published` 플래그 컬럼 → 발행 성공 시 true. 스케줄러가 미발행분 주기 재발행(at-least-once).

---

## 4. B2 — 정족수(2/3) 완결

**흐름**: 각 센터 Hermes가 seq 적용(선저널 commit) 후 **applied ack** 발행 → `rtgs.applied`(bmi, seq, center).
각 센터 Prada가 **bmi별 서로 다른 center 수 집계** → **≥ quorum(=CENTER_COUNT/2+1)** 도달 시 **ACCC 확정**.
**결과통보는 origin 센터만** → 과반 도달 후 pacs.002 송부.

**구현 결정 — `rtgs.result`를 applied-ack으로 재활용(별도 토픽 불필요)**:
- 당초 `rtgs.applied` 신설을 검토했으나, `rtgs.result`가 이미 **커밋 후에만**(publish-after-commit)
  발행되고 `processedCenter`를 담으므로 "그 센터가 durable 적용했다"는 ack와 **정확히 동일**하다.
  → 토픽/Hermes 변경·마이그레이션 없이 더 단순·안전. `Topics`에 APPLIED 추가하지 않음.
- **구현 완료(2026-07-13)**:
  - `prada/QuorumAggregator.kt`(신규, 순수로직+단위테스트 6): bmi별 서로 다른 `processedCenter` 집계,
    과반 **최초 도달** 판정, 완결 후 멱등 가드.
  - `PradaService.finalize()`: `confirmations=1` 제거 → `aggregator.record()` 기반. 과반 미달=ACSP,
    도달=ACCC(반려면 RJCT). `@KafkaListener(groupId="prada-${rtgs.center-id}")`로 전 센터분 소비.
  - 중복 통보 방지: **origin 센터 Prada만** notify 발행(`notice.originCenter == centerId`).
    `NotificationService`의 origin 필터는 그대로 유지(이중 안전).

**ack 집계 저장(§9.4 확정)**: **인메모리 맵**(bmi→집계한 center 집합). `finality_ack` 테이블은 **불채택**(간단성 우선).

**재기동 경계조건(인메모리 손실 대응)**:
- 각 센터 Prada는 자기 컨슈머그룹으로 `rtgs.applied`를 소비 → 재기동 시 커밋된 오프셋에서 재개.
- 인메모리 집계 맵이 재기동으로 비어도: **이미 ACCC 확정분은 `transfer.status`에 영속**되어 안전(재판정해도 멱등).
  아직 과반 미달(ACSP)인 건만 이후 도착하는 ack로 재집계 → 최종 수렴. **이중완결/누락 없음**.

**가용성 매핑**:
- 1센터 down → 남은 2센터가 ack 2개 → 과반 충족 → **완결 지속**.
- 2센터 down → ack 1개 → 과반 미달 → **완결 보류(안전정지)**. 복구 후 자동 재개.

---

## 5. B3 — 순서 결번(gap) 처리

- B1으로 **영구 결번 소멸**(발급=durable). 지연으로 인한 일시 gap은 Hermes 버퍼가 재정렬(S4 검증됨).
- 방어책: `expected` 순번이 **T초 이상 미도착** 시 → **경보 + Kafka에서 해당 seq 오프셋 재읽기(pull)**
  (durable 저널에서 직접 가져옴). **스킵 금지**(실제 엔트리는 반드시 적용). 
- 지표: `rtgs.journal.gap`(이미 추가) + 타임아웃 카운터 → Grafana 경보.

---

## 6. 재해·복구 시나리오 (검증 항목)
| 시나리오 | 기대 | 검증(7/13) |
|---|---|---|
| 평상시 3센터 | 동일 순서·동일 잔액, 완결 즉시 과반 | 3 DB 대사 일치, 총액 보존 |
| 1센터 강제종료 | 서비스 지속(과반 2/3), 이중지급 0 | 나머지 2센터 완결 지속 |
| 재해센터 복구 | 밀린 저널 **진도처리(따라잡기)** 후 3센터 수렴 | 오프셋 재생→잔액 일치 |
| 2센터 종료 | 완결 보류(안전정지), 오처리 0 | 과반 미달로 ACCC 정지 |
| 2센터 복구 | 따라잡기 후 **3센터 정상 재개** | 완결 재개·정합성 |
| 순서 역전/gap | 재정렬·pull로 정답 수렴 | S4 확장(3센터) |

---

## 7. 설정 공통화 (A안, 삼중화 전제작업)
- `common`에 `application-common.yml`(datasource·kafka·management 공통) → 각 서비스 `spring.config.import`.
- 센터·포트·DB·그룹접미사는 **env 파라미터**: `CENTER_ID`, `CENTER_COUNT=3`, `POSTGRES_DB=rtgs_dc1`, `PORT_OFFSET`, `GROUP_SUFFIX`.
- 실행 스크립트 3종: `run-dc1.cmd`(현행) + **`run-dc2.cmd`·`run-dc3.cmd`**(env만 다름) + 공유 인프라/ELK/Prometheus는 1회 기동.

---

## 8. 착수 체크리스트 (순서)

**A. 현 개발 PC(8코어)에서 가능 — 코드·빌드·경량 검증**
2. **설정 공통화(A안)** + env 파라미터화(`CENTER_ID`/`CENTER_COUNT`/`PORT_OFFSET`/`GROUP_SUFFIX`) + `run-dc2/dc3.cmd`.
3. **DB 분리**: 단일 인스턴스(5433)에 `rtgs_dc1/dc2/dc3` 생성(Flyway가 각 DB 스키마 생성).
4. **B1**: `V4` SEQUENCE+outbox+재발행 스케줄러, 순번기 3인스턴스(그룹 리더선출) → 단일 순번 검증.
5. **B2**: `rtgs.applied` 토픽 + Hermes ack 발행 + Prada 과반 집계(인메모리) → 완결 규율.
6. **B3**: gap 타임아웃·pull.
   → 각 단계마다 **`./gradlew build` + 단위테스트 + 경량 스모크(관자 1~2센터 기동)**로 확인.

**B. 고사양 PC 이관 후 — 전체 3센터 실측**
1. **환경 이관 검증**: C:\ai-dev 복사본으로 빌드·기동 확인(포터블 무설치 철학이라 경로만 동일하면 OK).
7. **재해/복구 시나리오**(§6) + **성능 실측**(터미널3, 21 JVM, k6/테스트화면, 순번·지연 병목).
8. sim S3(정족수/펜싱)·S5(스파이크) 추가.

---

## 9. 결정사항 — **확정 완료** (2026-07-13, 양팀장님)
| # | 항목 | 확정 | 비고 |
|---|---|---|---|
| 1 | PostgreSQL | **단일 인스턴스 · 3 DB**(5433, `rtgs_dc1/dc2/dc3`) | 자원 절약, 8코어 PC 적합. Flyway가 각 DB 스키마 생성 |
| 2 | 프론트 콘솔 | **DC1 콘솔 하나로 3센터 조회**(제안) | 간단. 센터별 3콘솔(5174~5176)은 필요 시 후속 |
| 3 | 순번기 배치 | **3인스턴스**(센터당 1, 그룹 `sequencer` 리더선출) | Kafka 리밸런스 자동 승계 |
| 4 | applied ack 저장 | **인메모리 맵** | `finality_ack` 테이블 불채택. 재기동 경계조건은 §4 참조 |

---

## 10. 리스크·주의
- 21개 서비스 JVM 동시 구동은 **고사양 PC 전용**(여유). **현 8코어 PC에선 경량 스모크(1~2센터, 일부 서비스)**로
  코드 정상동작만 확인하고, 전체 동시기동 실측은 이관 후. 개별 명령 기동(bash for-loop 동시기동 실패 이슈).
- Kafka 저널은 **반드시 단일 파티션**(전역 순서). 3센터가 같은 토픽 독립 그룹으로 소비.
- 정합성 대사: 3 DB의 `transfer`/`account`가 동일해야(총액·상태·순번). 자동 대사 스크립트 준비.
- 참조: 전문 보강(BAH/pacs.002 검증)은 `docs/pacs.7z` 기준(B7과 함께).

*본 설계는 구현 착수 기준 확정본(리뷰·보완 2026-07-13). §9 결정 반영 완료 — 이후 구현 중 세부만 조정.*
