📌 고성능 C++ 실시간 시세 엔진 아키텍처 (6부작 시리즈):
👉 [1부 (현재 글)] 레거시 Win32/MFC 트레이딩 시스템은 왜 장 시작 9시에 멈추는가?
👉 [2부] 뮤텍스 경합 없는 초저지연 시세 엔진 설계 — 종목 해시 샤딩과 Lock-Free RCU/CoW
👉 [3부] 복수 거래소(Primary + ATS) 실시간 10호가 합성과 비동기 순서 역전(Out-of-Order) 정복기
👉 [4부] UI 스레드는 그리기만 하라 — 백그라운드 사전 포맷팅, 더티 코얼레싱, 64MB 가상 그리드
👉 [5부] 1%의 CPU와 1초의 지연을 잡는 극한의 트러블슈팅 (메시지 스톰, ActiveX 배칭, 60분할 클리핑)
👉 [6부] 장 시작 실전 성적표(UI 프리징 1,531ms → 15ms)와 무중단 Dual-Path 롤아웃 전략

프롤로그: 오전 9시 00분 00초, 얼어붙는 화면

주식 시장이 열리는 오전 9시 정각, 트레이더의 모니터에는 관심종목 수백 개가 담긴 그리드, 실시간 10호가 창 여러 개, 60개 종목의 호가를 한눈에 보여주는 멀티 시세판, 그리고 전 종목 체결 내역이 폭포수처럼 쏟아지는 실시간 체결창이 동시에 떠 있습니다.

평소 장중에는 아무런 문제 없이 부드럽게 돌아가던 수십 년 역사의 Win32/MFC 기반 데스크톱 트레이딩 플랫폼(HTS)이, 유독 09:00:00 ~ 09:05:00 개장 피크 구간만 되면 마우스 클릭이 먹히지 않거나 창 전환이 1~2초씩 멈칫거리는 현상(UI Freezing)을 겪곤 합니다. 더욱이 단일 정규 거래소뿐 아니라 대체거래소(ATS)까지 동시에 시세를 쏟아내는 복수 거래소 환경이 도래하면서 실시간 패킷 유입량은 기존 대비 2배 이상 급증했습니다.

이 시리즈에서는 수백만 줄 규모의 거대한 레거시 C++/MFC 코드베이스를 전면 재작성(Rewrite)하지 않고도, Lock-Free RCU/CoW 기반의 고성능 인메모리 시세 엔진(MarketDataEngine)과 화면 렌더링 디커플링 파이프라인을 이식하여 개장 피크 구간 최대 UI 지연 시간을 1,531ms에서 15ms로 99% 단축하고 실시간 시세 처리량을 3배 이상 끌어올린 전 과정을 6부작에 걸쳐 상세히 공유합니다.

그 첫 번째 이야기로, 왜 전통적인 Win32 이벤트 드리븐 아키텍처가 초고빈도 실시간 스트림 앞에서 무너지는지 그 구조적 병목을 해부해 보겠습니다.

레거시 Win32/MFC 트레이딩 시스템 개장 09:00 UI 프리징 4대 구조적 병목
▲ 개장 09:00 피크 구간에 발생하는 레거시 Win32/MFC 시세 파이프라인의 핵심 병목 요약

1. 전통적인 레거시 시세 수신 파이프라인(AS-IS)의 구조

수십 년간 안정적으로 운영되어 온 대부분의 Win32/MFC 클라이언트 아키텍처는 윈도우 메시지 큐(PostMessage / SendMessage)를 중심으로 설계되어 있습니다.

flowchart LR
    subgraph NetThread["네트워크 수신 레이어"]
        A["소켓 수신 버퍼"] --> B["프레임 디코더 / 압축 해제"]
    end
    subgraph Router["구독 라우터 (SubscriptionRouter)"]
        B --> C["종목별 구독 화면(HWND) 탐색"]
        C -->|"패킷마다 PostMessage"| D["Win32 UI 메시지 큐 (한도: 10,000개)"]
    end
    subgraph UIThread["단일 메인 UI 스레드 (병목 집중)"]
        D --> E["OnRealtimePacket(WM_USER + N)"]
        E --> F["1. Big-Endian → Little-Endian 바이트 스왑"]
        F --> G["2. 등락률·대비·호가합계 계산 및 천단위 포맷팅"]
        G --> H["3. GDI / ActiveX 그리드 셀 즉시 갱신 (Invalidate / Redraw)"]
    end

얼핏 보면 매우 직관적이고 단순한 이벤트 기반 구조입니다. 하지만 초당 수만 건의 시세 패킷이 쏟아지고 사용자가 여러 개의 무거운 화면을 동시에 열어두는 순간, 이 파이프라인은 네 가지 치명적인 병목을 드러냅니다.


2. 장 시작 프리징을 유발하는 4대 구조적 병목

병목 ①: Win32 메시지 큐 포화와 PostMessage 오버헤드

Windows OS에서 각 GUI 스레드의 메시지 큐 기본 한도는 10,000개(USERPostMessageLimit)입니다.

  • 개장 직후 초당 수천~수만 건의 체결·호가 패킷이 유입될 때, 같은 종목을 구독 중인 화면(HWND)이 5개라면 라우터는 패킷 1개당 5번의 PostMessage 커널 전환을 일으킵니다.
  • 큐에 쌓인 수천 개의 사용자 정의 시세 메시지(WM_USER + α)가 앞줄을 가득 메우면, 정작 사용자가 클릭한 마우스 이벤트(WM_LBUTTONDOWN)나 키보드 입력, 혹은 타이머 메시지(WM_TIMER)는 큐 맨 뒤로 밀려나 수백 ms에서 수 초 동안 처리되지 못합니다.

병목 ②: 동일 패킷에 대한 $N$번의 중복 역직렬화 및 엔디안 변환

네트워크 회선을 타고 들어오는 금융 원시 패킷(Wire Packet)은 대개 네트워크 바이트 오더(Big-Endian)와 고정 길이 ASCII 문자열로 압축되어 있습니다.

  • 기존 구조에서는 각 화면(WatchlistView, OrderBookView, MultiQuoteCellWnd 등)이 패킷 포인터나 복사본을 전달받은 뒤, 각 화면의 UI 핸들러 내부에서 독자적으로 4바이트/8바이트 엔디안 스왑(_byteswap_ulong, _byteswap_uint64)과 구조체 파싱을 반복했습니다.
  • 삼성전자 한 종목의 호가 패킷 1개가 들어왔을 때, 이를 보고 있는 4개의 화면이 메인 UI 스레드 위에서 똑같은 바이트 스왑과 가격 역산 로직을 4번 중복 실행하고 있었던 셈입니다.

병목 ③: 메인 UI 스레드에서의 문자열 포맷팅과 메모리 할당

트레이딩 화면의 그리드 셀 하나를 채우기 위해서는 생각보다 많은 연산이 필요합니다.

  • 부호 판별(상한/상승/보합/하락/하한), 전일대비 등락률($\frac{\text{현재가} - \text{기준가}}{\text{기준가}} \times 100$) 부동소수점 연산, 천 단위 콤마(,) 삽입(sprintf / CString::Format), 거래대금 단위 축약(백만/억) 등이 모두 메인 UI 스레드에서 수행됩니다.
  • 200종목이 등록된 관심종목 창에서 초당 1,000번의 체결이 발생하면, UI 스레드는 오직 문자열을 조립하고 CString 힙 메모리를 할당·해제하는 데만 CPU 시간을 낭비합니다.

병목 ④: 시세 수신 속도와 화면 주사율(FPS)의 강결합

인간의 눈은 초당 20~30프레임(33~50ms 간격) 이상의 숫자 변화를 개별적으로 인지하기 어렵습니다.

  • 그러나 레거시 파이프라인은 패킷이 도착할 때마다(Event-Driven) 곧바로 그리드 셀에 값을 밀어 넣고(SetCell / put_Cell) GDI InvalidateRect를 호출했습니다.
  • 특히 COM 기반 ActiveX 그리드 컨트롤(VSFlexGrid 계열)이나 수십 개의 자식 윈도우를 가진 화면에서 1ms 간격으로 셀 값을 갱신하면, 내부 레이아웃 재계산과 동기식 GDI 리페인트가 연쇄적으로 일어나면서 단일 프레임 렌더링이 30ms~100ms씩 블로킹됩니다.

3. "추측하지 말고 계측하라" — 비침투형 마이크로 프로파일러 설계

아키텍처를 뜯어고치기 전에 가장 먼저 한 일은 "어디서 몇 마이크로초($\mu s$)를 쓰고 있는가?"를 운영 환경과 동일한 조건에서 정밀하게 측정하는 경량 프로파일러(EngineProfiler)를 심는 것이었습니다.

프로파일러 자체가 성능을 떨어뜨리지 않도록 다음과 같은 원칙으로 설계했습니다:

  1. QPC(QueryPerformanceCounter) 기반 마이크로초 계측: GetTickCount()(15.6ms 해상도)로는 잡아낼 수 없는 수십 $\mu s$ 단위의 파싱·바인딩·페인팅 지연을 측정합니다.
  2. 스레드 로컬 / 원자적 카운터 누적 + 주기적 CSV Flush: 매 틱마다 파일 I/O를 일으키지 않고, std::atomic<uint64_t> 카운터에 누적한 뒤 1초~5초 주기로만 로그 파일에 기록합니다.
  3. INI 설정 스위치(Profiler.Enable=0/1): 평소 운영 시에는 분기 예측 비용조차 거의 없는 0 상태로 두고, 개장 피크 테스트 시에만 1로 켜서 AS-IS와 TO-BE를 동일 시간대·동일 종목군에서 비교합니다.
// RAII 기반 마이크로초 구간 계측 스코프 예시
struct ScopedTimerUs {
    std::atomic<uint64_t>& totalUs_;
    std::atomic<uint64_t>& maxUs_;
    LARGE_INTEGER start_;

    ScopedTimerUs(std::atomic<uint64_t>& totalUs, std::atomic<uint64_t>& maxUs)
        : totalUs_(totalUs), maxUs_(maxUs) {
        ::QueryPerformanceCounter(&start_);
    }

    ~ScopedTimerUs() {
        LARGE_INTEGER end, freq;
        ::QueryPerformanceCounter(&end);
        ::QueryPerformanceFrequency(&freq);
        const uint64_t elapsedUs =
            static_cast<uint64_t>(end.QuadPart - start_.QuadPart) * 1'000'000ULL /
            static_cast<uint64_t>(freq.QuadPart);

        totalUs_.fetch_add(elapsedUs, std::memory_order_relaxed);
        uint64_t prevMax = maxUs_.load(std::memory_order_relaxed);
        while (elapsedUs > prevMax &&
               !maxUs_.compare_exchange_weak(prevMax, elapsedUs, std::memory_order_relaxed)) {}
    }
};

이 프로파일러를 통해 개장 직후 9분(540초) 동안 측정한 AS-IS의 실상은 충격적이었습니다:

  • 단일 관심종목 화면에서만 최대 틱 처리 시간이 1,531ms (1.53초 동안 UI 완전 정지)까지 치솟았고,
  • 100ms를 초과하는 체감 멈춤(>100ms Freeze)이 74회, 33ms(30fps 기준) 초과 지연이 257회 발생했으며,
  • UI 스레드가 앞선 패킷을 그리느라 바쁜 사이 후속 시세 알림이 밀리면서, 동일 시간 동안 실제 화면에 반영된 시세 건수가 기대치의 1/3 수준으로 병목되고 있었습니다.

4. 아키텍처 재설계의 4대 핵심 목표

문제가 명확해졌으니 해법의 방향도 명확해졌습니다. 기존 수백 개의 화면 코드를 깨뜨리지 않으면서 성능을 근본적으로 뒤바꾸기 위해 다음 4가지 설계 원칙을 세웠습니다.

  1. 통신-캐시-UI의 완전한 디커플링 (Decoupling):
    네트워크 패킷 수신 즉시 백그라운드 워커가 엔디안 변환과 정규화를 딱 1번만 수행하여 중앙 인메모리 스냅샷(SymbolSnapshot)을 갱신한다.
  2. 뮤텍스 대기 없는 읽기 (Lock-Free RCU & Copy-on-Write):
    백그라운드 워커가 시세를 쓰는 동안에도 UI 스레드는 단 1나노초의 뮤텍스 블로킹 없이 최신 불변(Immutable) 스냅샷을 읽어간다.
  3. 생산자 속도와 소비자(화면) 주사율의 분리 (Conflation & Frame Budget):
    시세가 초당 10,000번 변하더라도 화면은 인간의 눈과 모니터 주사율(30~50ms)에 맞춰 최종 상태(Latest State)만 묶어서(Coalescing) 한 번만 그린다.
  4. 100% 안전한 Dual-Path 아키텍처:
    설정 파일(UseDataManager=0/1) 하나로 언제든 기존 레거시 경로와 신규 엔진 경로를 전환할 수 있도록 설계하여, 운영 리스크를 제로(0)로 통제한다.

다음 [2부]에서는 이 아키텍처의 심장부인 종목 해시 샤딩(Sharding)과 Lock-Free RCU(RcuSnapshotSlots) + Copy-on-Write 인메모리 캐시 엔진을 C++17로 어떻게 구현했는지 코드 레벨에서 살펴보겠습니다.

⏭️ 다음 글 이어 읽기:
👉 [고성능 C++ 시세 엔진 #2] 뮤텍스 경합 없는 초저지연 시세 엔진 설계 — 종목 해시 샤딩과 Lock-Free RCU/CoW