들어가는 글: "성능보다 무서운 것은 단 1원의 정합성 오류다"
금융 트레이딩 시스템에서 아무리 화면이 빠르고 부드러워져도, 표시되는 현재가나 호가 잔량이 실제 시장 상태와 단 1원, 단 1주라도 어긋나면 그 엔진은 실패한 것입니다.
특히 우리가 마주한 환경에는 세 가지 까다로운 정합성 과제가 있었습니다:
- 복수 거래소(Primary Exchange + Alternative Exchange + Consolidated) 체제:
사용자는 한 화면 안에서정규 거래소(Primary),대체거래소(ATS),통합(Consolidated)시세를 언제든 탭 하나로 전환하거나 동시에 비교합니다. - 예상체결(동시호가) ↔ 실제체결(정규장) 도메인 간 완료 순서 역전:
동시호가가 끝나고 오전 9시 정각 첫 정규장 체결 패킷(Trade)이 들어올 때, 직전에 도착한 마지막 예상체결 패킷(Expected)을 처리하던 워커가 미세하게 늦게 끝나면 화면에 종일 "예상체결" 마크가 남는 치명적 역전 현상이 발생할 수 있습니다. - 초기 스냅샷 조회(Request/Reply)와 실시간 구독(Pub/Sub) 구간의 경합:
종목을 처음 클릭해 서버에 초기 조회(RQ)를 요청하고 응답을 기다리는 수십 ms 동안에도 실시간 호가·체결 패킷(SB)은 계속 쏟아집니다. 이때 뒤늦게 도착한 초기 조회 응답이 그사이 도착한 최신 실시간 패킷을 덮어쓰면 과거 가격으로 회귀(Stale Overwrite)하게 됩니다.
이번 [3부]에서는 이 세 가지 정합성 난제를 락 없이 결정론적(Deterministic)으로 해결한 상태 투영(State Projection) 아키텍처를 다룹니다.
1. 종목별 3-슬롯 거래소 격리와 실시간 최우선호가 합성
각 종목 컨텍스트(SymbolContext)는 내부에 3개의 독립된 RCU 스냅샷 슬롯을 보유합니다.
flowchart LR
subgraph Context["SymbolContext (종목당 1개)"]
S0["Slot 0: Consolidated (통합 시세)"]
S1["Slot 1: Primary Exchange (정규 거래소)"]
S2["Slot 2: Alternative Exchange (대체거래소)"]
end
P1["정규 거래소 패킷"] --> S1
P1 -->|"최우선호가 교차 비교"| S0
P2["대체거래소 패킷"] --> S2
P2 -->|"최우선호가 교차 비교"| S0
P0["통합 피드 패킷"] --> S0
최우선호가(Best Bid / Best Ask) 합성 규칙
개별 거래소 패킷이 도착했을 때 통합 슬롯(Consolidated)의 최우선 매도1호가와 매수1호가는 단순 덮어쓰기가 아니라 시장 미시구조(Market Microstructure) 규칙에 따라 합성됩니다:
- 최우선 매도호가(Best Ask): 두 거래소의 유효한(
> 0) 매도1호가 중 더 낮은 가격($\min(\text{Ask}_{\text{Primary}}, \text{Ask}_{\text{ATS}})$)이 통합 최우선 매도호가가 됩니다. 만약 두 거래소의 매도1호가가 동일하다면 잔량을 합산합니다. - 최우선 매수호가(Best Bid): 두 거래소의 유효한(
> 0) 매수1호가 중 더 높은 가격($\max(\text{Bid}_{\text{Primary}}, \text{Bid}_{\text{ATS}})$)이 통합 최우선 매수호가가 됩니다. 마찬가지로 가격이 같으면 잔량을 합산합니다.
이 합성을 백그라운드 샤드 워커가 CoW(Copy-on-Write) 단계에서 즉시 계산해 두기 때문에, UI 화면은 어떤 거래소 탭을 선택하든 GetSnapshot(symbol, exchangeType) 호출 한 번으로 완성된 데이터를 즉시 얻습니다.
2. 원본 상태 분리 보관과 Ingress Order 기반 투영 (StockRealtimeState)
[2부]에서 우리는 체결(Trade), 예상체결(Expected), 호가(Depth) 패킷을 높은 처리량을 위해 서로 다른 샤드 워커 그룹에서 병렬로 처리한다고 설명했습니다.
문제는 동시호가에서 정규장으로 넘어가는 09:00:00 경계나 장 마감 동시호가 진입(15:20:00) 경계에서 발생합니다.
- 패킷 #100: 마지막 예상체결 패킷 (
Expected, 가격 71,000원) - 패킷 #101: 정규장 첫 실제체결 패킷 (
Trade, 가격 71,200원)
만약 Trade 워커가 패킷 #101을 먼저 처리해 화면 상태를 '실제체결(71,200원)'로 바꾸었는데, 0.1ms 뒤에 Expected 워커가 패킷 #100 처리를 마치면서 공통 필드를 덮어쓰면 어떻게 될까요? 화면은 실제 체결이 일어난 뒤에도 계속 '예상체결(71,000원)'을 가리키게 됩니다!
해결책: "공통 필드를 직접 덮어쓰지 말고, 도메인 원본(Raw Image)에서 투영(Project)하라"
우리는 스냅샷 내부에 화면 표시용 공통 필드와 별개로, 각 도메인의 최신 원본 상태(TradeImage, ExpectedImage, DepthImage)와 해당 패킷이 엔진에 인입된 순서 번호(ingressOrder)를 분리하여 저장했습니다.
struct RealtimeDomainState {
// 1. 각 도메인별 독립 원본 이미지 (서로 다른 도메인이 절대 침범하지 않음)
uint64_t lastTradeOrder = 0; // 체결 패킷의 인입 시퀀스
uint64_t lastExpectedOrder = 0; // 예상체결 패킷의 인입 시퀀스
uint64_t lastDepthOrder = 0; // 호가 패킷의 인입 시퀀스
TradeRawImage tradeImage; // 마지막 실제 체결 원본
ExpectedRawImage expectedImage; // 마지막 예상 체결 원본
DepthRawImage depthImage; // 마지막 10단계 호가 원본
// 2. 결정론적 상태 투영 (Deterministic Projection)
void ProjectToDisplay(SymbolSnapshot& out) const {
// 어떤 워커가 먼저 끝났는지가 아니라,
// '누가 더 나중에 네트워크로 인입되었는가(ingressOrder)'가 화면 상태를 결정한다!
if (lastExpectedOrder > lastTradeOrder) {
ApplyExpectedToDisplay(expectedImage, out);
} else if (lastTradeOrder > 0) {
ApplyTradeToDisplay(tradeImage, out);
}
// 순수 10단계 호가는 체결/예상체결과 독립적으로 투영
if (lastDepthOrder > 0) {
ApplyDepthToDisplay(depthImage, out);
}
}
};
이 설계를 통해:
- 패킷 #101(
Trade)이 먼저 반영된 후 패킷 #100(Expected)이 뒤늦게 도착하더라도,lastTradeOrder(101) > lastExpectedOrder(100)이므로ProjectToDisplay()결과는 흔들림 없이 패킷 #101(실제체결)로 수렴(Converge)합니다. - 또한 전일종가와 구분되는 프리장/동시호가 기준가격(Reference Price) 역시 상승(
현재가 - 대비) 및 하락(현재가 + 대비) 부호 의미론(PriceSemantics)을 단일 함수로 통일하여, 수신 파서와 모든 UI 화면이 100% 동일한 기준가를 계산하도록 보장했습니다.
3. 초기 조회(RQ)와 실시간 스트림(SB)의 무손실 병합 (Start-Order Overlay)
사용자가 화면에서 새로운 종목을 선택하면, 클라이언트는 두 가지 작업을 수행합니다:
- 서버에 초기 마스터 및 10호가 스냅샷 조회(Request/Reply, RQ) 전송
- 해당 종목의 실시간 체결·호가 스트림(Publish/Subscribe, SB) 구독 등록
만약 초기 조회 응답이 네트워크를 타고 돌아오는 30ms 사이에 실시간 체결이 3건 발생했다면 어떻게 처리해야 할까요?
- 초기 조회 응답을 그대로 화면 구조체에
memcpy해버리면, 그 30ms 사이에 도착한 최신 실시간 체결 3건이 날아가 버립니다. - 반대로 실시간 패킷을 초기 조회 완료 전부터 그냥 적용하면, 초기 조회에만 들어 있는 기초 필드(상하한가, 전일거래량, 액면가 등 RQ-Only 필드)가 비어 있는 상태에서 화면이 깨집니다.
우리는 이를 LookupSession의 startOrder 경계선과 선택적 오버레이(Selective Overlay)로 해결했습니다.
sequenceDiagram
participant UI as "화면 (View)"
participant Engine as "MarketDataEngine"
participant Server as "시세 서버"
UI->>Engine: 1. BeginLookupSession(종목) → startOrder = #500 기록
UI->>Server: 2. 실시간 구독(SB) 등록 & 초기 조회(RQ) 요청 전송
Server-->>Engine: 3. (RQ 대기 중) 실시간 호가/체결 패킷 #501, #502 도착 → RCU 스냅샷에 누적 보관
Server-->>UI: 4. 초기 조회(RQ) 응답 도착 (시점: #500 이전 상태)
UI->>Engine: 5. RQ 응답을 백그라운드 워커에서 엔디안 변환 후 베이스 구조체에 적재
UI->>Engine: 6. OverlayRealtimeSince(startOrder = #500) 호출!
Note over UI,Engine: order > #500 인 최신 도메인(#501, #502)만 RQ 베이스 위에 정확히 덮어씀 (RQ 전용 필드는 보존)
추가 실전 트러블슈팅: 가변 길이 호가 응답 패킷 처리
실제 운영망에서 초기 호가 조회 응답을 검증하던 중, 유동성 공급자(LP)가 있는 종목은 1,194바이트 전체 구조체가 내려오지만, LP가 없는 일반 종목은 마지막 LP 잔량 블록(210바이트)이 생략된 984바이트 패킷으로 내려오는 프로토콜 특성을 발견했습니다.
단순히 reply.size == sizeof(FullDepthResponse)로만 검증하면 정상적인 일반 종목 응답을 길이 오류로 거절하게 됩니다. 우리는 컴파일 타임 static_assert로 두 유효 길이(WithoutLP = 984, WithLP = 1194)를 명시적으로 검증하는 와이어 어댑터를 도입했습니다:
template <typename WireDepthStruct>
struct DepthWireValidator {
static constexpr size_t SizeWithoutLp =
offsetof(WireDepthStruct, rsqty) + 3 * sizeof(decltype(WireDepthStruct{}.rsqty[0]));
static constexpr size_t SizeWithLp = sizeof(WireDepthStruct);
static_assert(SizeWithoutLp == 984 && SizeWithLp == 1194, "Wire depth layout mismatch");
static bool IsValidSize(int bytes) noexcept {
return bytes == static_cast<int>(SizeWithoutLp) ||
bytes == static_cast<int>(SizeWithLp);
}
static void CopySafe(WireDepthStruct& dst, const void* src, int bytes) noexcept {
std::memset(&dst, 0, sizeof(dst)); // 생략된 LP 블록은 자동으로 0 초기화
if (IsValidSize(bytes)) {
std::memcpy(&dst, src, static_cast<size_t>(bytes));
}
}
};
이처럼 컴파일 타임 레이아웃 검증(static_assert) + Zero-Init 후 가변 블록 안전 복사를 통해 프로토콜의 미묘한 변형까지 완벽히 흡수했습니다.
다음 이야기 예고
중앙 엔진(MarketDataEngine)이 정확한 스냅샷을 무잠금으로 제공하고 있지만, 여전히 수백 종목을 한꺼번에 표시하는 관심종목 그리드와 초당 수천 건이 쌓이는 실시간 체결 스트림 화면에서는 UI 스레드가 해야 할 일이 많았습니다.
다음 [4부]에서는 "UI 스레드는 오직 화면에 그리기만 하라"는 철학 아래 구현한 화면 전용 워커(ScreenWorker)의 28개 필드 백그라운드 사전 포맷팅, 더티 코얼레싱(Dirty Coalescing), 그리고 64MB 고정 메모리 링버퍼 기반 가상 그리드(VirtualGrid)를 소개합니다.