최근 사내 HTS 프로그램의 아키텍처를 Direct2D와 멀티코어 병렬 처리 기반으로 모더나이징하는 작업을 구상하면서, 가장 깊게 고민했던 부분은 '데이터 파이프라인의 병목 현상'이었습니다.

실시간으로 수신되는 방대한 양의 시세 데이터를 여러 화면 컴포넌트들이 동시에 받아가서 화면에 렌더링해야 합니다. 기존처럼 각각의 화면이 데이터 구조체 메모리를 따로 구성해서 복사해 사용한다면, CPU 점유율이 치솟고 렌더링 속도에 악영향을 미칠 수밖에 없습니다.

이에 대한 해결책으로 데이터 매니저(서브 스레드)가 중앙에서 정적 메모리를 관리하고, 각 화면(UI 스레드)들은 그 메모리 주소만 참조하는 Zero-copy 방식을 구상했습니다. 하지만 멀티스레딩 환경에서 쓰기와 읽기가 동시에 발생할 때의 동기화(Synchronization)와 안전성 문제가 발목을 잡았습니다.

이 글에서는 이 문제를 해결하기 위해 더블 버퍼링과 트리플 버퍼링을 거쳐, 최종적으로 'std::shared_ptr + 커스텀 디리터 + 오브젝트 풀'이라는 완벽한 아키텍처에 도달하기까지의 과정을 정리해 둡니다.


1. 락(Lock) 경합과 기존 동기화 방식의 한계

가장 단순한 접근은 Mutex나 Critical Section을 사용하는 것입니다. 하지만 1초에도 수천 번씩 시세가 업데이트되는 환경에서, 화면이 데이터를 읽을 때마다 락을 걸면 쓰기 스레드(Data Manager)가 대기(Block)하게 됩니다. 반대로 쓰기 스레드가 데이터를 업데이트할 때는 화면 렌더링이 멈춥니다.

읽기 작업이 많은 환경을 위해 SRWLock을 사용할 수도 있지만, 근본적인 락 오버헤드를 없애기 위해서는 Lock-free 구조가 필요했습니다.


2. 더블 버퍼링(Double Buffering)의 명암

락을 없애기 위해 가장 먼저 떠올린 것은 그래픽 렌더링에서 주로 쓰이는 더블 버퍼링 기법이었습니다.

  • 구조체 A(읽기용)와 구조체 B(쓰기용)를 둡니다.
  • Data Manager는 대기 중인 B에 데이터를 쓰고, 완료되면 포인터를 A에서 B로 원자적(Atomic)으로 스왑(Swap)합니다.

매우 빠르고 훌륭한 방법이지만, Tearing(데이터 찢어짐) 현상이 발생할 위험이 컸습니다. UI 스레드가 데이터를 읽어서 그리는 도중에(예: 5ms 소요), Data Manager가 새로운 시세를 두 번 이상 수신하여 UI가 읽고 있는 메모리까지 다시 쓰기 모드로 전환해 덮어써 버릴 수 있기 때문입니다.


3. 트리플 버퍼링(Triple Buffering)과 다중 화면 환경의 충돌

더블 버퍼링의 덮어쓰기 문제를 해결하기 위해 트리플 버퍼링을 고려했습니다. 버퍼를 3개(Read, Write, Ready)로 나누어 읽기와 쓰기 스레드의 대기 시간을 완전히 비동기화하는 방식입니다.

std::atomic의 exchange 연산을 활용해 3개의 인덱스를 교환하면 완벽한 Lock-free가 구현됩니다. Data Manager는 UI를 기다리지 않고 최신 데이터를 'Ready' 버퍼에 계속 밀어 넣고, UI는 화면 갱신 주기마다 'Ready' 버퍼의 완성본만 쏙 가져가면 됩니다.

🚨 하지만, 치명적인 문제점이 있었습니다:
트리플 버퍼링은 1:1 (1 Producer : 1 Consumer) 구조에서는 완벽하지만, 우리의 HTS 환경처럼 여러 개의 화면(UI)이 데이터를 동시에 받아가는 환경에서는 사용할 수 없었습니다. 화면 A와 화면 B가 각각 다른 과거 버퍼를 물고 있으면, Data Manager가 다음 데이터를 기록할 여유 버퍼가 부족해져 구조가 완전히 깨져버립니다.

4. 궁극의 아키텍처: std::shared_ptr + 커스텀 디리터 + 오브젝트 풀

이 모든 딜레마(락 오버헤드, 다중 화면 참조 유지, 메모리 파편화 방지)를 해결하기 위해 채택한 최종 아키텍처는 C++ 최신 표준의 스마트 포인터와 메모리 풀을 결합하는 방식입니다.

📌 핵심 동작 원리:
1. Object Pool (정적 메모리 풀): 프로그램 시작 시 시세 데이터를 담을 구조체 수천 개를 메모리에 미리 띄워둡니다. OS 힙 매니저를 거치지 않아 할당 오버헤드가 제로(0)입니다.
2. 가상 더블 버퍼링 (Snapshot): Data Manager는 새 시세가 오면 풀에서 빈 구조체를 하나 꺼내 데이터를 씁니다. 그리고 전역 포인터를 원자적으로 변경합니다.
3. 참조 카운팅: UI 화면은 렌더링 직전 이 포인터를 shared_ptr로 복사(Load)합니다. 참조 카운트가 1 증가하므로, 서브 스레드가 백 번 천 번 데이터를 업데이트해도 UI가 쥐고 있는 메모리는 절대 덮어씌워지지 않습니다.
4. Custom Deleter (자동 반납): UI 화면이 렌더링을 마치고 shared_ptr이 소멸할 때, 메모리를 delete하는 대신 Object Pool로 자동 반납하도록 람다 함수를 주입합니다.
#include <iostream>
#include <memory>
#include <atomic>
#include <vector>
#include <mutex>
#include <string>

// 1. 시세 데이터 구조체
struct MarketData {
    std::string code;
    int price;
    long volume;

    void Reset() { code.clear(); price = 0; volume = 0; }
};

// 2. 스레드 안전한 오브젝트 풀 (Object Pool)
class MarketDataPool {
private:
    std::vector<MarketData*> m_pool;
    std::mutex m_mutex;

public:
    MarketDataPool(size_t initialSize) {
        for (size_t i = 0; i < initialSize; ++i) {
            m_pool.push_back(new MarketData());
        }
    }
    ~MarketDataPool() { for (auto ptr : m_pool) delete ptr; }

    MarketData* Acquire() {
        std::lock_guard<std::mutex> lock(m_mutex);
        if (m_pool.empty()) return new MarketData();
        MarketData* ptr = m_pool.back();
        m_pool.pop_back();
        return ptr;
    }

    void Release(MarketData* ptr) {
        if (!ptr) return;
        ptr->Reset();
        std::lock_guard<std::mutex> lock(m_mutex);
        m_pool.push_back(ptr);
    }
};

// 3. 데이터를 중앙 관리하는 매니저 클래스
class DataManager {
private:
    MarketDataPool m_pool; 
    std::atomic<std::shared_ptr<MarketData>> m_latestData;

public:
    DataManager() : m_pool(1000) {
        MarketData* rawPtr = m_pool.Acquire();
        auto initData = std::shared_ptr<MarketData>(rawPtr, [this](MarketData* p) {
            m_pool.Release(p);
        });
        m_latestData.store(initData);
    }

    // [서브 스레드 영역] 시세 수신 및 기록
    void OnReceiveNewData(const std::string& code, int price, long volume) {
        MarketData* rawPtr = m_pool.Acquire();
        rawPtr->code = code;
        rawPtr->price = price;
        rawPtr->volume = volume;

        // 커스텀 디리터 주입: 참조 카운트가 0이 되면 풀로 반납
        auto newData = std::shared_ptr<MarketData>(rawPtr, [this](MarketData* p) {
            m_pool.Release(p); 
        });

        // 전역 포인터 원자적 교체 (Lock-free)
        m_latestData.store(newData);
    }

    // [UI 스레드 영역] 화면용 스냅샷 조회
    std::shared_ptr<MarketData> GetSnapshot() {
        return m_latestData.load();
    }
};

5. 커스텀 디리터 (Custom Deleter) 상세 해설

auto newData = std::shared_ptr<MarketData>(rawPtr, [this](MarketData* p) {
    m_pool.Release(p); 
});

std::shared_ptr를 생성할 때 괄호 ( ) 안에 두 개의 인자(Parameter)를 넘겨주고 있습니다.

  • 첫 번째 인자: rawPtr
    • 의미: 방금 오브젝트 풀(Object Pool)에서 빌려온 '진짜 메모리 주소(Raw Pointer)'입니다.
    • 역할: "이 shared_ptr가 앞으로 이 주소를 감싸서(Wrapping) 관리해라"라고 지정해 주는 것입니다.
  • 두 번째 인자: [this](MarketData* p) { m_pool.Release(p); }
    이 부분이 바로 '커스텀 디리터(Custom Deleter)' 역할을 하는 람다 함수(익명 함수)입니다.
    원래 std::shared_ptr는 참조 카운트(화면들이 읽고 있는 횟수)가 0이 되면 내부적으로 무조건 delete rawPtr;를 실행하여 메모리를 시스템에 반환(파괴)해 버립니다. 하지만 두 번째 인자로 함수를 넘겨주면, "참조 카운트가 0이 되었을 때 delete를 하지 말고, 내가 넘겨준 이 함수를 대신 실행해라"라고 기본 동작을 가로채게(Intercept) 됩니다.

이 람다 함수를 조금 더 잘게 쪼개보겠습니다.

  • [this] (캡처 블록): 이 코드가 실행되고 있는 현재 클래스(DataManager)의 멤버 변수와 함수에 접근할 수 있도록 this 포인터를 람다 함수 내부로 가져옵니다. 이것이 있어야 클래스 멤버인 m_pool에 접근할 수 있습니다.
  • (MarketData* p) (매개변수): shared_ptr가 수명을 다할 때, 자기가 관리하던 진짜 메모리 주소(rawPtr)를 이 변수 p에 담아서 던져줍니다.
  • { m_pool.Release(p); } (실행부): 던져받은 메모리 주소 p를 delete로 소멸시키는 대신, 우리의 오브젝트 풀인 m_pool의 Release() 함수로 넘겨줍니다.
💡 전체 흐름 요약:
즉, 저 한 줄의 코드는 C++ 컴파일러에게 다음과 같은 계약서를 쓰는 것과 같습니다.

"새로운 스마트 포인터 newData를 만들 건데, 얘가 관리할 알맹이는 rawPtr야. 그리고 나중에 화면(UI)들이 이 데이터를 다 써서 참조 카운트가 0이 되면, 절대 delete로 메모리를 날려버리지 말고, rawPtr를 다시 m_pool로 반납(Release)해 줘."

이 한 줄 덕분에 우리는 포인터의 생명 주기는 shared_ptr의 편리한 자동 관리에 맡기면서도, 실제 메모리는 매번 할당/해제되지 않고 뱅글뱅글 도는 완벽한 Zero-Allocation(무할당) 시스템을 완성할 수 있게 된 것입니다.

📌 다음 심화 기술 포스트:
본 기본 아키텍처를 바탕으로 다중 PB 서비스 수신과 수천 개 종목 병렬 처리까지 확장한 심화 포스팅입니다.
👉 [C++] HTS 실시간 시세 처리 심화: 다중 PB 서비스 수신과 종목별 병렬 처리 (CAS vs Shared Mutex)

6. 마무리하며

이번에 설계한 이 아키텍처는 다중 코어 환경과 Direct2D 렌더링 파이프라인에서 발생하는 속도 저하를 원천적으로 차단할 수 있는 구조입니다.

"메모리 풀의 정적 효율성"과 "스마트 포인터의 스레드 안전성"을 완벽하게 결합함으로써 락(Lock) 대기를 없애고 댕글링 포인터나 메모리 릭의 위험성까지 제거했습니다. 앞으로 구축해 나갈 차세대 HTS 데이터 파이프라인의 든든한 뼈대가 되어줄 것이라 기대합니다.