본문으로 건너뛰기
Life Saver Wiki

[분산 시스템 + 금융공학] 리더-팔로워 복제와 금융 시장 데이터의 일관성 보장

Raft 리더-팔로워 복제 알고리즘을 금융 거래소 호가 데이터 배포 시스템에 적용하는 설계 원칙과 장애 복구 전략을 코드와 함께 정리합니다.

운영자
Life Saver Wiki

리얼타임 시세 피드 시스템을 설계하던 중, 리더 노드 장애 후 팔로워가 새 리더로 승격되기까지 약 150ms의 공백이 발생했습니다. 이 짧은 순간 동안 일부 클라이언트는 구 리더의 마지막 스냅샷을 계속 사용하는 반면, 새 리더와 이미 동기화된 클라이언트는 갱신된 호가를 받는 분기가 생겼습니다. 두 클라이언트 집합이 서로 다른 최선의 호가(Best Bid/Offer)를 보는 상황은 아비트라지 기회처럼 보이지만 실제로는 시스템 결함이었습니다. 그 사건이 계기가 되어 Raft 기반 리더-팔로워 복제 설계를 처음부터 다시 들여다봤습니다.

분산 시스템에서 리더-팔로워 복제 구조와 금융 시장 호가 데이터 배포 아키텍처

배경

금융 시장 데이터 배포 시스템에서는 일관성 요구사항이 일반 웹 서비스와 차원이 다릅니다. NBBO(National Best Bid and Offer)는 미국 증권 규제 상 거래소 전체에서 가장 유리한 매수 및 매도 호가를 의미합니다. 이 값이 클라이언트마다 달라진다면 규제 위반과 직결됩니다.

Eventual Consistency가 위험한 이유

일반적인 분산 시스템에서는 최종 일관성(Eventual Consistency)으로도 충분합니다. 소셜 미디어 피드나 상품 재고 조회가 몇 초 지연된다고 해서 직접적인 금전 손실이 발생하지는 않습니다. 그러나 금융 시스템에서는 다음 문제가 생깁니다.

  • 클라이언트 A가 AAPL의 최선 매수호가를 150.00으로읽는사이,클라이언트B는이미갱신된150.00으로 읽는 사이, 클라이언트 B는 이미 갱신된 150.05를 읽습니다.
  • 이 두 클라이언트가 각각 매도 주문을 제출하면 다른 기대 체결가로 주문이 진입합니다.
  • 결국 체결 이후 감사 추적에서 불일치가 드러나고 컴플라이언스 이슈가 됩니다.

따라서 시세 피드 시스템은 Strong Consistency 혹은 최소한 Monotonic Read Consistency를 보장해야 합니다.

리더-팔로워 복제와 Raft 알고리즘

Raft는 분산 합의 알고리즘 중 이해하기 쉬운 구현으로 설계된 프로토콜입니다. 세 가지 핵심 단계로 구성됩니다.

1단계: 리더 선출 (Leader Election)

모든 노드는 Follower 상태로 시작합니다. 일정 시간(election timeout) 안에 리더로부터 Heartbeat를 받지 못하면 Candidate로 전환되어 투표를 요청합니다. 과반수(쿼럼) 투표를 확보하면 Leader가 됩니다.

쿼럼은 (N / 2) + 1입니다. 5개 노드 클러스터라면 3개 노드의 동의가 필요합니다. 이 조건이 분할 시나리오에서 두 파티션이 동시에 리더를 선출하는 스플릿 브레인(split-brain)을 방지합니다.

2단계: 로그 복제 (Log Replication)

리더는 클라이언트 요청을 자신의 로그에 먼저 기록한 뒤 팔로워들에게 AppendEntries RPC를 전송합니다. 팔로워 과반수가 ACK를 반환하면 해당 엔트리를 커밋 처리합니다.

3단계: 커밋과 적용 (Commit and Apply)

커밋된 엔트리만 상태 머신에 적용됩니다. 아직 과반수 ACK를 받지 못한 엔트리는 uncommitted 상태로 남으며, 리더 장애 시 롤백될 수 있습니다.

코드 예시: Python으로 단순화한 Raft 상태 전이

아래는 핵심 로직만 추출한 의사코드 수준의 Python 구현입니다. 실제 프로덕션 구현은 gRPC와 타임아웃 처리, 영속 로그 스토리지가 추가됩니다.

import time
import random
from enum import Enum
from dataclasses import dataclass, field
from typing import List, Optional

class NodeState(Enum):
    FOLLOWER = "follower"
    CANDIDATE = "candidate"
    LEADER = "leader"

@dataclass
class LogEntry:
    term: int
    index: int
    command: dict  # 예: {"symbol": "AAPL", "bid": 150.05, "ask": 150.07}

class RaftNode:
    def __init__(self, node_id: str, peers: List[str]):
        self.node_id = node_id
        self.peers = peers
        self.state = NodeState.FOLLOWER
        self.current_term = 0
        self.voted_for: Optional[str] = None
        self.log: List[LogEntry] = []
        self.commit_index = -1
        self.last_applied = -1
        self.election_timeout = random.uniform(150, 300)  # ms
        self.last_heartbeat = time.time()

    def append_entries(
        self,
        term: int,
        leader_id: str,
        prev_log_index: int,
        prev_log_term: int,
        entries: List[LogEntry],
        leader_commit: int,
    ) -> dict:
        """팔로워가 리더로부터 엔트리를 수신하는 RPC 핸들러."""
        # 1. 구 term 리더 요청 거부
        if term < self.current_term:
            return {"success": False, "term": self.current_term}

        # 2. 유효한 리더로부터의 요청이므로 팔로워 상태 유지
        self.state = NodeState.FOLLOWER
        self.current_term = term
        self.last_heartbeat = time.time()

        # 3. 로그 일관성 검사: prev_log_index의 term이 일치해야 함
        if prev_log_index >= 0:
            if prev_log_index >= len(self.log):
                return {"success": False, "term": self.current_term}
            if self.log[prev_log_index].term != prev_log_term:
                # 충돌 지점 이후 로그 삭제
                self.log = self.log[:prev_log_index]
                return {"success": False, "term": self.current_term}

        # 4. 새 엔트리 추가
        for entry in entries:
            if entry.index < len(self.log):
                if self.log[entry.index].term != entry.term:
                    self.log = self.log[:entry.index]
            if entry.index >= len(self.log):
                self.log.append(entry)

        # 5. 커밋 인덱스 갱신
        if leader_commit > self.commit_index:
            self.commit_index = min(leader_commit, len(self.log) - 1)
            self._apply_committed_entries()

        return {"success": True, "term": self.current_term}

    def request_vote(
        self,
        term: int,
        candidate_id: str,
        last_log_index: int,
        last_log_term: int,
    ) -> dict:
        """후보자의 투표 요청 RPC 핸들러."""
        if term < self.current_term:
            return {"vote_granted": False, "term": self.current_term}

        if term > self.current_term:
            self.current_term = term
            self.state = NodeState.FOLLOWER
            self.voted_for = None

        # 이미 다른 후보에게 투표했으면 거부
        if self.voted_for is not None and self.voted_for != candidate_id:
            return {"vote_granted": False, "term": self.current_term}

        # 후보의 로그가 자신보다 최신이어야 투표
        my_last_index = len(self.log) - 1
        my_last_term = self.log[-1].term if self.log else -1

        log_is_up_to_date = (
            last_log_term > my_last_term
            or (last_log_term == my_last_term and last_log_index >= my_last_index)
        )

        if log_is_up_to_date:
            self.voted_for = candidate_id
            return {"vote_granted": True, "term": self.current_term}

        return {"vote_granted": False, "term": self.current_term}

    def _apply_committed_entries(self):
        """커밋된 엔트리를 상태 머신(시세 테이블)에 적용합니다."""
        while self.last_applied < self.commit_index:
            self.last_applied += 1
            entry = self.log[self.last_applied]
            self._apply_to_state_machine(entry.command)

    def _apply_to_state_machine(self, command: dict):
        """시세 피드 상태 갱신 (실제 구현은 인메모리 테이블 또는 Redis)."""
        symbol = command.get("symbol")
        bid = command.get("bid")
        ask = command.get("ask")
        print(f"[{self.node_id}] 시세 갱신: {symbol} Bid={bid} Ask={ask}")

금융공학 관점

팔로워 읽기 허용 시 Stale Read 리스크

Raft는 기본적으로 리더에서만 읽기를 허용합니다. 팔로워 읽기(Follower Read)를 허용하면 지연을 낮출 수 있지만, 아직 커밋되지 않은 이전 값을 반환할 위험이 있습니다.

금융 시세 피드에서 팔로워 읽기를 허용할 경우 다음 시나리오가 발생합니다.

  • 리더가 AAPL bid=$150.05를 커밋했지만 팔로워 F2가 아직 동기화되지 않은 상태
  • 클라이언트가 F2에서 읽으면 $150.00(구 값)을 받습니다
  • 이 시간 차이가 수십~수백 ms라도 알고리즘 트레이딩 전략에는 치명적입니다

NBBO 위반 가능성

미국 증권법 Regulation NMS 하에서 브로커는 고객에게 NBBO 이상의 가격으로 거래를 체결할 수 없습니다. 시세 피드가 stale 데이터를 제공하면 브로커가 실제 NBBO보다 낮은 가격으로 주문을 라우팅하는 규제 위반으로 이어질 수 있습니다.

장애 복구 시 공백 처리 전략

앞서 언급한 150ms 공백 문제의 해결책은 두 가지입니다.

방법 1: 클라이언트 사이드 버퍼링 클라이언트가 리더 전환 감지 시 읽기를 일시 보류하고, 새 리더가 쿼럼 과반수로부터 Heartbeat ACK를 받아 권한을 확인한 뒤에만 읽기를 재개합니다.

방법 2: 리더 확인 읽기 (Leader Lease) 리더가 현재 임기 동안 팔로워들로부터 Heartbeat ACK를 받은 시점을 기준으로 일정 시간(lease 기간) 동안은 자신이 유일한 리더임을 보장합니다. 이 기간 내에 처리된 읽기는 stale read 없이 안전합니다.

트레이드오프

항목Strong Consistency (Raft 리더 읽기)Eventual Consistency (팔로워 읽기)
읽기 지연높음 (항상 리더 경유)낮음 (가까운 팔로워 선택 가능)
쓰기 지연쿼럼 ACK 대기 (수십 ms)비동기 복제 (거의 즉시)
가용성리더 장애 시 선출 완료까지 잠시 불가팔로워 개별 장애에 강함
일관성선형화 가능 (Linearizable)읽기-쓰기 순서 보장 없음
금융 시스템 적합성높음낮음 (NBBO 위반 리스크)
구현 복잡도높음 (리더 병목, 선출 관리 필요)낮음

표에서 보이듯 금융 시세 피드 시스템은 읽기 지연과 구현 복잡도를 희생하더라도 Strong Consistency를 선택해야 합니다.

결론

리더-팔로워 복제는 금융 시스템에서 다음 조건을 모두 만족할 때 적합한 선택입니다.

  • 일관성이 가용성보다 우선: 짧은 장애 공백(수백 ms)을 허용할 수 있으나 데이터 불일치는 절대 허용 불가
  • 쓰기 처리량이 단일 리더로 수용 가능: 시세 피드처럼 단일 거래소 채널에서 발생하는 이벤트는 수만 TPS 이내인 경우 대부분 리더 한 대로 소화 가능
  • 감사 추적이 요구됨: Raft 로그 자체가 순서 보장된 이벤트 기록이므로 사후 재현이 용이합니다

반면 글로벌 다중 거래소 환경에서 지역별 낮은 지연이 필수라면 Multi-Raft 또는 지역별 리더를 두는 확장 설계를 고려해야 합니다. 그러나 그 경우에도 단일 거래소 내 호가 데이터는 단일 리더 아래서 강한 일관성을 유지하는 것이 규제 준수와 시스템 신뢰성 양쪽을 동시에 달성하는 현실적인 방법입니다.