금융/증권 HTS 시스템이나 초고속 메시징 애플리케이션처럼 단 한 번의 예외 상황도 허용되지 않는 견고한 C++ 시스템을 구축할 때 가장 중요한 요소 중 하나는 프로세스 간 통신(IPC, Inter-Process Communication)과 OS 레벨의 메모리 수명 관리 원리입니다.

본 포스트에서는 Win32 메시지 기반의 WM_COPYDATA에서 흔히 저지르는 메모리 실수부터, 대용량 Zero-Copy 공유 메모리(Shared Memory)의 오해, 그리고 나노초(ns) 지연을 지향하는 Lock-Free (SPSC/MPMC) 링 버퍼 및 C++11 std::atomic 메모리 오더링까지 핵심 아키텍처를 체계적으로 정리합니다.


1. WM_COPYDATA의 진실과 수신 측 delete 금지 원칙

C++ 개발 시 프로세스 A에서 메모리를 할당해 WM_COPYDATA로 전송한 후, "받는 쪽(프로세스 B)에서 쓰임을 마쳤으니 delete를 해주면 되겠지?"라고 생각하기 쉽습니다. 그러나 이는 단순 메모리 누수(Memory Leak)를 넘어 Access Violation (메모리 액세스 위반) 및 비정상 종료(Crash)를 유발합니다.

🚨 왜 수신 프로세스 B에서 delete를 호출하면 크래시가 발생하는가?
  • 독립적인 가상 주소 공간: Windows의 모든 프로세스는 격리된 가상 메모리 공간을 가집니다. A 프로세스의 힙(Heap)과 B 프로세스의 힙은 완전히 별개입니다.
  • 메모리 관리자의 충돌: A 프로세스에서 new로 할당한 메모리는 A 프로세스 힙 관리자만 기록하고 있습니다. B 프로세스에서 이 주소에 대해 delete를 호출하면, B의 메모리 관리자는 "내가 할당한 적 없는 알 수 없는 주소"로 판단하여 프로세스를 즉시 강제 종료시킵니다.

WM_COPYDATA의 커널 동작 라이프사이클

  1. SendMessage 전송: A 프로세스가 COPYDATASTRUCT에 데이터 주소를 담아 SendMessage로 호출합니다. (주의: 비동기 PostMessage는 OS가 메모리 생명주기를 보장할 수 없어 절대 사용 불가)
  2. 커널 메모리 매핑: Windows 커널이 개입하여 A의 메모리 영역을 B 프로세스의 가상 주소 공간에 임시로 매핑(복사)합니다.
  3. 핸들러 반환과 자동 해제: B 프로세스의 WM_COPYDATA 메시지 핸들러 함수가 완료되어 리턴(Return)되는 즉시, Windows 커널은 B 프로세스에 임시 매핑했던 메모리를 완전히 삭제(Invalidate)합니다.
💡 수신 측(B)의 올바른 패스 패턴 (Deep Copy):
수신 측에서 전달받은 데이터를 큐나 타 스레드에 넣어 나중에 처리해야 한다면, 메시지 핸들러가 리턴되기 전에 B 프로세스 자체 힙 메모리를 새롭게 할당하고 깊은 복사(Deep Copy)를 수행해야 합니다.

2. WM_COPYDATA vs Shared Memory (File Mapping)

실시간 틱 데이터나 대용량 체결 데이터를 처리할 때는 복사 오버헤드가 발생하는 WM_COPYDATA 대신 공유 메모리(Shared Memory) 방식을 사용해야 합니다.

📊 IPC 패러다임 비교

  • WM_COPYDATA: OS 커널 메모리 복사 발생, 상대적으로 느림, SendMessage 자동 동기화, 핸들러 리턴 시 데이터 소멸
  • Shared Memory: Zero-Copy (복사 없이 동일 물리 메모리 공유), 극도로 빠름, 직접 동기화(Mutex/Event/Lock-Free) 필요, 참조 카운트 0까지 유지

공유 메모리에 대한 흔한 오해: "디스크에 저장되나요?"

Windows API인 CreateFileMapping에 첫 번째 파라미터로 INVALID_HANDLE_VALUE를 전달하면, 실제 하드디스크 파일이 아닌 OS의 페이징 파일(Pagefile)을 백업으로 삼아 전적으로 초고속 '물리적 RAM' 위에서만 동작합니다. RAM이 부족하여 Swap-out이 일어나지 않는 한 디스크 I/O는 0Byte입니다.

⚠️ 커널 참조 카운트(Reference Count)와 메모리 해제:
공유 메모리는 new/delete가 아닌 OS 커널 오브젝트입니다. A 프로세스가 CloseHandle을 호출해도, B 프로세스가 참조 중이라면 메모리는 소멸하지 않습니다. 모든 프로세스가 UnmapViewOfFile 후 CloseHandle을 불러 참조 카운트가 0이 되는 순간 OS가 물리 메모리를 회수합니다.

3. Lock-Free SPSC (Single-Producer Single-Consumer) 링 버퍼

Mutex나 Event 같은 커널 동기화 객체는 안전하지만, 호출 시마다 유저 모드 ↔ 커널 모드 전환 비용(Context Switch Overhead)이 발생합니다. 나노초 단위 지연을 다투는 HTS 통신에서는 오직 CPU 원자적 명령어(Atomic)로만 동기화하는 락프리 링 버퍼를 사용합니다.

SPSC 링 버퍼 구현 및 메모리 오더링 (C++11)

쓰는 프로세스 1개(Producer), 읽는 프로세스 1개(Consumer) 환경에서는 Head와 Tail 인덱스를 분리하여 락 없이 안전하게 통신할 수 있습니다.

// 공유 메모리 내 제어 블록 구조체
struct ControlBlock {
    std::atomic<size_t> head{0}; // Consumer가 업데이트
    uint8_t padding1[56];        // False Sharing 방지를 위한 패딩 (64바이트 캐시라인 정렬)
    
    std::atomic<size_t> tail{0}; // Producer가 업데이트
    uint8_t padding2[56];
};

// 1. Producer (송신) 측 로직
void Produce(const DataPacket& packet) {
    size_t current_tail = control->tail.load(std::memory_order_relaxed);
    
    // 버퍼 슬롯에 데이터 기록
    ring_buffer[current_tail] = packet;
    
    // memory_order_release: 이전의 모든 메모리 쓰기(버퍼 기록)가 완료된 후 Tail 갱신을 보장
    size_t next_tail = (current_tail + 1) % RING_BUFFER_SIZE;
    control->tail.store(next_tail, std::memory_order_release);
}

// 2. Consumer (수신) 측 로직
bool Consume(DataPacket& outPacket) {
    // memory_order_acquire: Tail 읽기 이후의 메모리 읽기가 전으로 재배치되는 것을 방지
    size_t current_tail = control->tail.load(std::memory_order_acquire);
    size_t current_head = control->head.load(std::memory_order_relaxed);
    
    if (current_head == current_tail) {
        return false; // 데이터 없음
    }
    
    outPacket = ring_buffer[current_head];
    
    size_t next_head = (current_head + 1) % RING_BUFFER_SIZE;
    control->head.store(next_head, std::memory_order_release);
    return true;
}
🔥 꿀팁: False Sharing (거짓 공유) 방지 패딩:
Head와 Tail 변수가 동일한 CPU 캐시 라인(64 Byte)에 인접해 있으면, 각 코어가 서로의 캐시를 무효화(Invalidate)하여 심각한 병목이 생깁니다. 변수 사이에 uint8_t padding[56]을 추가하여 캐시 라인을 철저히 분리해야 극강의 퍼포먼스가 나옵니다.

4. MPMC Lock-Free와 CAS (Compare-And-Swap) 및 ABA 문제

다중 송신자/다중 수신자(MPMC) 환경에서는 여러 프로세스가 인덱스를 동시 선점하려 하므로 CAS (Compare-And-Swap) 기법이 필수적입니다.

// Producer의 Enqueue CAS 루프 (c++11)
void MPMC_Enqueue(const DataPacket& data) {
    size_t currentTail = tail.load(std::memory_order_relaxed);
    size_t nextTail;

    while (true) {
        nextTail = (currentTail + 1) % QUEUE_SIZE;

        // CAS 연산 시도
        if (tail.compare_exchange_weak(currentTail, nextTail,
                                       std::memory_order_release,
                                       std::memory_order_relaxed)) {
            // 성공: currentTail 슬롯 선점 완료!
            break;
        }
        // 실패: 타 프로세스가 tail을 변경함. currentTail은 최신값으로 자동 갱신되며 재시도 (Spin)
    }

    // 슬롯에 안전하게 데이터 기록 후 Slot Status를 READY로 변경
    slots[currentTail].data = data;
    slots[currentTail].status.store(SLOT_READY, std::memory_order_release);
}

ABA 문제 (The ABA Problem)와 해결책

프로세스 1이 상태값 A를 읽고 잠시 Preempted 된 사이, 타 프로세스가 A → B → A 로 변경하면 CAS가 성공해버려 데이터 구조가 엉키는 현상입니다.

🔑 ABA 문제 해결책 (Sequence Numbering):
인덱스 값만 비교하지 않고, 인덱스가 변경될 때마다 1씩 무한히 증가하는 **시퀀스 번호(Sequence Number)**를 64비트 정수로 함께 묶어 CAS 연산을 수행합니다. A로 값이 돌아왔더라도 시퀀스 번호가 달라지므로 CAS가 실패를 정확히 감지해 냅니다.

5. 결론 및 요약

  • WM_COPYDATA 사용 시 수신 프로세스는 메모리를 해제하지 말고 핸들러 내에서 Deep Copy해야 합니다.
  • 대용량 IPC는 CreateFileMapping을 통한 Zero-Copy 공유 메모리를 활용하며, 커널 참조 카운트 기반으로 관리합니다.
  • 초저지연 시스템에서는 Mutex 대신 SPSC/MPMC 락프리 링 버퍼, std::atomic 메모리 오더링, CAS 및 캐시 라인 패딩을 조합하여 최고의 성능을 달성할 수 있습니다.