포스트

TreeMap으로 CLOB 호가창 구현하기 — 가격/시간 우선 매칭과 슬리피지

CLOB이란

거래소는 주문을 어떻게 체결하는가?

실제 거래소의 체결 방식은 CLOB(Central Limit Order Book) 이다. 모든 매수/매도 지정가 주문을 하나의 장부에 모아두고, 가격과 시간 순서에 따라 매칭한다.

1
2
3
4
매도호가 (asks)                   매수호가 (bids)
  73,300 × 500주                   72,800 × 1,200주
  73,200 × 800주  ← 최우선 매도     73,000 × 300주
                                   73,100 × 400주  ← 최우선 매수

최우선 매도가(73,200) ≤ 최우선 매수가(73,100)가 되면 체결이 발생한다. monticker의 모의투자는 이 구조를 코드로 구현한다.


자료구조 선택: TreeMap

호가창은 두 가지 연산이 핵심이다.

  1. 최우선 호가 조회: 매도는 가장 낮은 가격, 매수는 가장 높은 가격
  2. 가격 레벨 탐색: 체결 시 여러 가격 레벨을 순서대로 순회

이 두 연산을 O(log n)에 처리하는 것이 TreeMap이다.

1
2
3
4
5
6
7
class OrderBook(val stockId: Long) {
    // 매도호가: 낮은 가격이 앞 (자연 정렬)
    val asks: TreeMap<BigDecimal, ArrayDeque<Order>> = TreeMap()

    // 매수호가: 높은 가격이 앞 (역순 정렬)
    val bids: TreeMap<BigDecimal, ArrayDeque<Order>> = TreeMap(reverseOrder())
}

가격 레벨마다 ArrayDeque<Order>를 두는 이유는 시간 우선 원칙 때문이다. 같은 가격에 먼저 들어온 주문이 먼저 체결된다. Deque는 앞에서 꺼내고(체결) 뒤에 추가하는(신규 주문) 연산이 O(1)이다.


주의: firstKey()의 함정

1
2
3
4
5
6
7
// BAD: bids가 비어있으면 NoSuchElementException
fun getBestBid(): BigDecimal? = bids.firstKey().takeIf { bids.isNotEmpty() }
// takeIf 평가 전에 firstKey()가 먼저 실행된다!

// GOOD: isEmpty를 먼저 확인
fun getBestBid(): BigDecimal? = if (bids.isEmpty()) null else bids.firstKey()
fun getBestAsk(): BigDecimal? = if (asks.isEmpty()) null else asks.firstKey()

이 버그는 주문장이 비어있을 때(새벽 시간, 초기 상태) NoSuchElementException을 발생시킨다. Kotlin의 takeIf가 lazy하지 않다는 점에서 나오는 실수다.


주문 매칭 알고리즘

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
fun match(incomingOrder: Order): List<Fill> {
    val fills = mutableListOf<Fill>()
    val oppositeBook = if (incomingOrder.side == Side.BUY) asks else bids
    var remainingQty = incomingOrder.quantity

    while (remainingQty > 0 && oppositeBook.isNotEmpty()) {
        val bestPrice = oppositeBook.firstKey()

        // 가격 조건 확인 (LIMIT 주문만)
        val priceMatch = when (incomingOrder.orderType) {
            OrderType.MARKET -> true  // 시장가는 항상 체결
            OrderType.LIMIT  -> when (incomingOrder.side) {
                Side.BUY  -> incomingOrder.price!! >= bestPrice  // 매수 지정가 ≥ 매도 최우선
                Side.SELL -> incomingOrder.price!! <= bestPrice  // 매도 지정가 ≤ 매수 최우선
            }
        }

        if (!priceMatch) break

        val priceLevel = oppositeBook[bestPrice]!!
        while (priceLevel.isNotEmpty() && remainingQty > 0) {
            val resting = priceLevel.first()
            val fillQty = minOf(remainingQty, resting.remainingQuantity)

            fills.add(Fill(
                aggressorOrderId = incomingOrder.id,
                restingOrderId   = resting.id,
                price            = bestPrice,
                quantity         = fillQty,
                filledAt         = Instant.now(),
            ))

            resting.fill(fillQty)
            remainingQty -= fillQty

            if (resting.remainingQuantity == 0) {
                priceLevel.removeFirst()  // 완전 체결된 주문 제거
            }
        }
        if (priceLevel.isEmpty()) oppositeBook.remove(bestPrice)
    }

    return fills
}

시장가 주문(MARKET)은 가격 조건 없이 최우선 반대 호가부터 순서대로 체결한다. 지정가 주문(LIMIT)은 체결 조건이 맞을 때까지만 체결하고 나머지는 호가창에 등록 대기한다.


슬리피지 시뮬레이션

대량 시장가 주문은 여러 가격 레벨에 걸쳐 체결된다. 이를 슬리피지(slippage) 라고 한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
시장가 매수 주문: 1,000주

매도호가:
  50,000원 × 300주  ← 여기서 300주 체결
  50,100원 × 400주  ← 여기서 400주 체결
  50,200원 × 500주  ← 여기서 300주 체결 (1,000주 완료)

평균 체결가:
  (50,000×300 + 50,100×400 + 50,200×300) / 1,000
  = (15,000,000 + 20,040,000 + 15,060,000) / 1,000
  = 50,100원

단순 최우선가(50,000원)보다 100원 불리 — 이것이 슬리피지

monticker에서는 이 슬리피지가 자동으로 시뮬레이션된다. 사용자가 대량 주문을 넣으면 여러 가격 레벨에서 부분 체결이 일어나고, 평균 체결가가 최우선 호가보다 불리해진다.


부분 체결과 주문 상태 전이

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
enum class OrderStatus {
    PENDING,            // 접수됨
    PARTIALLY_FILLED,   // 부분 체결
    FILLED,             // 전량 체결
    CANCELLED           // 취소
}

data class Order(
    val id: Long,
    val stockId: Long,
    val side: Side,
    val orderType: OrderType,
    val price: BigDecimal?,
    val quantity: Int,
    var filledQuantity: Int = 0,
    var status: OrderStatus = OrderStatus.PENDING,
) {
    val remainingQuantity: Int get() = quantity - filledQuantity

    fun fill(qty: Int) {
        filledQuantity += qty
        status = if (remainingQuantity == 0) OrderStatus.FILLED
                 else OrderStatus.PARTIALLY_FILLED
    }
}

RiskChecker와의 연동

주문이 OrderBook에 도달하기 전에 RiskChecker를 거친다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@Service
class MatchingService(
    private val orderBookService: MatchingOrderBookService,
    private val riskChecker: RiskChecker,
    private val ledgerService: LedgerService,
) {
    fun submitOrder(userId: Long, request: OrderRequest): OrderResponse {
        // 1. 리스크 사전 체크 (동기, 통과 못하면 REJECTED 즉시 반환)
        val riskResult = riskChecker.preCheck(userId, request)
        if (!riskResult.approved) {
            return OrderResponse.rejected(riskResult.blockedBy)
        }

        // 2. 주문 생성 및 호가창 제출
        val order = orderBookService.submitOrder(userId, request)

        // 3. 체결 결과 원장 기록
        order.fills.forEach { fill ->
            ledgerService.recordFill(fill)
        }

        return OrderResponse.from(order)
    }
}

Spring Bean 이름 충돌을 주의해야 한다. matching 모듈의 OrderBookServicemarketdata 모듈의 OrderBookService가 이름이 같아서 충돌이 발생한다. @Service("matchingOrderBookService")로 명시적으로 이름을 지정해 해결한다.


정리

  • TreeMap<BigDecimal, ArrayDeque<Order>>로 O(log n) 가격 탐색과 O(1) 시간 우선 체결을 구현한다.
  • bids.firstKey().takeIf { bids.isNotEmpty() } 패턴은 버그다. if (bids.isEmpty()) null else bids.firstKey()를 사용한다.
  • 시장가 주문은 여러 가격 레벨에 걸쳐 체결되어 슬리피지가 자동으로 시뮬레이션된다.

다음 편에서는 주문이 체결 엔진에 도달하기 전에 동기적으로 실행되는 리스크 한도 시스템을 다룬다.

  1. 1 가격이 아니라 이벤트를 팔자 — monticker 설계 철학
  2. 2 모듈식 모놀리스를 선택한 이유 — MSA의 유혹을 거부하기
  3. 3 TimescaleDB를 시계열 DB로 고른 이유 — Hypertable과 연속 집계
  4. 4 Go goroutine으로 202개 종목 동시 수집하기 — Market Gateway 설계
  5. 5 Kafka로 시세 파이프라인 분리하기 — 토픽 설계와 at-least-once
  6. 6 Netty로 수만 연결에 시세 브로드캐스트하기 — NioEventLoopGroup 리액터 패턴
  7. 7 EMA 기반 이상 탐지 — 가격 급등과 거래량 서지 실시간 감지
  8. 8 TreeMap으로 CLOB 호가창 구현하기 — 가격/시간 우선 매칭과 슬리피지
  9. 9 주문 전 동기 리스크 게이트 설계 — VaR, 집중도, 일일손실 5가지 규칙
  10. 10 잔고를 저장하지 말고 재구성하라 — 이벤트 소싱 원장 설계
  11. 11 감정 태그 × 수익률 — 투자 습관을 데이터로 기록하기
  12. 12 룰 엔진: RSI·MACD 조건식을 JSON DSL로 — Quant Lab 설계
  13. 13 백테스트 엔진: look-ahead 없는 시뮬레이션 — Sharpe·MDD·PF 계산
  14. 14 전략 지문(SHA-256)으로 룰셋 보호하기 — 서버 사이드 실행과 역공학 방어
  15. 15 Markowitz 최적화를 솔버 없이 구현하기 — 프로젝션 경사하강법
  16. 16 Kelly Criterion: 수학적 파산 방지 베팅 비율 — Half Kelly와 백테스트 연동
  17. 17 ZigZag + 패턴 템플릿 매칭으로 차트 패턴 감지 — 헤드앤숄더·이중바닥
  18. 18 ADX로 시장 국면 분류하기 — BULL·BEAR·SIDEWAYS·HIGH_VOL
  19. 19 손익통산으로 세금 줄이기 — Tax-Loss Harvesting 시뮬레이션
  20. 20 MockK로 JdbcTemplate 목킹하기 — 311개 테스트 작성 경험
  21. 21 OpenTelemetry + Jaeger로 분산 추적 — 시세 파이프라인 지연 측정
  22. 22 Circuit Breaker로 외부 API 장애 격리 — Resilience4j + KIS·Yahoo 폴백 체인
이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.

댓글

아직 댓글이 없습니다