지난 글에서는 HTS 데이터 파이프라인의 병목 현상을 해결하기 위해 std::shared_ptr의 커스텀 디리터와 오브젝트 풀(Object Pool)을 결합한 Lock-free 아키텍처의 뼈대를 설계했습니다.
하지만 실제 증권사 실무 환경에서는 이보다 훨씬 복잡한 예외 상황들을 마주하게 됩니다. 대표적인 것이 '여러 PB(Push) 서비스가 하나의 데이터 구조체를 조각조각 나누어 업데이트하는 상황'과 '수천 개의 종목 데이터를 동시에 병렬로 관리해야 하는 상황'입니다.
오늘은 이전 아키텍처를 한 단계 더 고도화하여, std::atomic의 CAS(Compare-And-Swap) 루프와 std::shared_mutex를 활용해 완벽한 다중 종목 실시간 병렬 처리 시스템을 구축하는 과정을 정리해 보았습니다.
1. 다중 PB 서비스 수신과 부분 갱신(Partial Update)의 딜레마
HTS 실시간 시세는 단일 서버에서 모든 데이터가 한 번에 묶여서 오지 않습니다. 예를 들어 A 서비스는 '현재가'를, B 서비스는 '거래량'을 쏘아주는 식으로 분산되어 수신됩니다.
이때 두 서비스가 0.001초 차이로 동일한 종목의 데이터를 업데이트하려고 하면 어떻게 될까요? 단순 덮어쓰기(store) 방식을 사용하면, 먼저 수신된 A의 업데이트 내용을 B가 과거 데이터로 덮어써서 날려버리는 데이터 유실(Lost Update) 문제가 발생합니다.
2. 해결책: CAS(Compare-And-Swap) 루프와 RCU 패턴
이 딜레마를 락(Lock) 없이 해결하는 궁극의 기법이 바로 CAS(Compare-And-Swap) 연산입니다. C++의 std::atomic::compare_exchange_strong 함수를 활용하여 Read-Copy-Update (RCU) 패턴을 구현합니다.
"내가 복사해 둔 과거 상태와 현재 전역 상태가 같으면 새 데이터로 교체하고, 누군가(다른 서비스) 먼저 덮어써서 값이 달라졌다면 갱신을 취소한 뒤 최신값을 다시 복사해 와서 재시도(Loop)하라."
void OnReceiveServiceA(const std::string& code, int p1, long v1) {
std::shared_ptr<MarketData> currentSnapshot = m_latestData.load();
while (true) { // 성공할 때까지 무한 재시도 (CAS Loop)
MarketData* rawPtr = m_pool.Acquire();
if (currentSnapshot) {
*rawPtr = *currentSnapshot; // 1. Read-Copy (최신 상태 유지)
}
rawPtr->code = code;
rawPtr->price1 = p1; // 2. Update (A 서비스 담당 필드만 갱신)
rawPtr->volume1 = v1;
auto newData = std::shared_ptr<MarketData>(rawPtr, [this](MarketData* p) {
m_pool.Release(p); // 커스텀 디리터 (자동 반납)
});
// 3. Swap 시도
// 찰나의 순간에 B 서비스가 끼어들었다면 실패하고 루프 재진입
if (m_latestData.compare_exchange_strong(currentSnapshot, newData)) {
break;
}
}
}
이 구조를 통해 여러 스레드가 동시에 갱신을 시도해도 Lock 대기 없이 안전하게 데이터가 병합되며, 갱신에 실패한 임시 데이터는 커스텀 디리터에 의해 즉각 풀(Pool)로 반환되어 메모리 누수도 발생하지 않습니다.
3. 다중 종목 확장을 위한 아키텍처: std::unordered_map + std::shared_mutex
모든 종목의 시세가 하나의 구조체로 들어오는 것이 아니므로, 종목코드(Code)를 키(Key)로 삼아 이 Lock-free 아키텍처가 수천 개씩 돌아가도록 확장해야 합니다.
이를 위해 std::unordered_map을 도입하되, 맵의 구조적 훼손을 막기 위해 C++14의 std::shared_mutex (읽기/쓰기 락)를 결합한 Double-Checked Locking(이중 검사 잠금) 패턴을 적용합니다.
struct StockContext {
std::atomic<std::shared_ptr<MarketData>> latestData;
StockContext(std::shared_ptr<MarketData> initData) {
latestData.store(initData);
}
};
class DataManager {
private:
MarketDataPool m_pool;
std::unordered_map<std::string, std::unique_ptr<StockContext>> m_stockMap;
std::shared_mutex m_mapMutex;
StockContext* GetOrCreateContext(const std::string& code) {
// 1. 공유 락(Read Lock): 수십 개의 스레드가 동시에 맵을 고속 조회
{
std::shared_lock<std::shared_mutex> readLock(m_mapMutex);
auto it = m_stockMap.find(code);
if (it != m_stockMap.end()) return it->second.get();
}
// 2. 독점 락(Write Lock): 맵에 없는 새로운 종목일 때만 맵 구조 변경
std::unique_lock<std::shared_mutex> writeLock(m_mapMutex);
auto it = m_stockMap.find(code);
if (it == m_stockMap.end()) {
MarketData* rawPtr = m_pool.Acquire();
auto initData = std::shared_ptr<MarketData>(rawPtr, [this](MarketData* p){ m_pool.Release(p); });
auto context = std::make_unique<StockContext>(initData);
// 메모리 소유권을 맵으로 완전히 이동(move)시키며 in-place 생성
auto result = m_stockMap.emplace(code, std::move(context));
return result.first->second.get();
}
return it->second.get();
}
};
이 케이스의 실시간 데이터 수신 시 로직은 2번의 코드 블럭 OnReceiveServiceA 함수 코드에서 m_latestData만 StockContext 내부의 latestData로 치환하여 생각해보면 쉽게 알 수 있을 것이다.
무거운 Write Lock이 걸리는 시점은 '새로운 종목이 처음 수신되었을 때' 단 한 번뿐입니다. 이후 수십만 번 쏟아지는 시세 갱신은 가벼운 Read Lock을 통과한 뒤, 종목별로 독립된 메모리 주소에서 CAS 루프를 돌게 되므로 스레드 간 간섭이 완벽히 차단됩니다.
4. 마무리하며
이번 심화 과정까지 설계하면서 실시간 대용량 데이터 처리의 복잡성과 매력을 동시에 느낄 수 있었습니다. "Map 구조 보호를 위한 최소한의 Read-Write Lock"과 "종목 내부 데이터 갱신을 위한 100% Lock-free CAS 루프"의 조합은, 현대적인 멀티 코어 환경에서 극한의 렌더링 성능과 데이터 정합성을 동시에 달성해야 하는 HTS 시스템에 가장 이상적인 아키텍처가 될 것입니다.