돌아가기
← about로 돌아가기
#오르자#오더북#FE

[오르자] 오더북은 어떻게 수백만건의 처리를 할까?

거래소의 호가 데이터는 어떻게 빠르게 정렬되고 갱신되는가

2026-09

오더북은 어떻게 수많은 주문을 처리할까?

거래소의 오더북을 보고 있으면 가격과 수량이 끊임없이 움직입니다.

매수와 매도 가격이 계속 바뀌고, 특정 가격의 수량이 갑자기 사라지기도 합니다.

이 글에서는 오더북이 무엇인지부터 시작해서, 거래소의 데이터가 어떤 방식으로 프론트엔드에 도착하고, 브라우저에서 어떻게 빠르게 정렬되고 렌더링되는지 살펴보겠습니다.

1. 오더북의 구조

오더북은 특정 가격에 어떤 수량의 매수·매도 주문이 쌓여 있는지를 보여주는 데이터 구조입니다.

text
        매도(Ask)
        가격       수량
        101.5      12.8
        101.4       4.2
        101.3       8.7

        매수(Bid)
        가격       수량
        101.2       5.1
        101.1      14.3
        101.0      20.0

매도는 낮은 가격부터, 매수는 높은 가격부터 체결 우선순위를 확인할 수 있도록 정렬합니다.

매수와 매도가 만나는 가격이 생기면 매칭 엔진은 주문을 체결시키고 오더북의 상태를 변경합니다.

빠르게 들어오는 주문 이벤트를 순서대로 처리하고, 그 결과를 현재 상태로 유지하는 것입니다.

2. 주문은 어떤 과정을 거쳐 화면에 보일까

거래소의 전체 흐름을 단순화하면 다음과 같습니다.

text
        사용자 주문
          ↓
        주문 검증
          ↓
        매칭 엔진
          ↓
        오더북 상태 변경
          ↓
        시장 데이터 이벤트 생성
          ↓
        시장 데이터 피드 전송
        (UDP Multicast / WebSocket)
          ↓
        프론트엔드 로컬 오더북 갱신
          ↓
        화면 렌더링

대형 거래소의 내부 매칭 엔진 구현은 대부분 공개되지 않습니다.

대신 거래소들은 클라이언트가 오더북을 안정적으로 동기화할 수 있도록 스냅샷, 변경 이벤트, 업데이트 번호 같은 시장 데이터 프로토콜을 공개합니다.

여기서 WebSocket과 UDP Multicast를 같은 계층의 기술처럼 이해하면 안 됩니다.

거래소와 기관 투자자 사이의 초저지연 시장 데이터 배포에서는 UDP Multicast가 사용됩니다.

하나의 데이터 스트림을 여러 수신자에게 동시에 전달할 수 있고, TCP처럼 모든 수신자와 개별 연결을 유지하지 않아 대규모 시장 데이터 배포에 적합합니다.
Nasdaq TotalView-ITCH는 UDP/IP 기반의 직접 데이터 피드를 제공하고, CME의 MDP 3.0도 UDP·Multicast 시장 데이터 채널을 제공합니다.

반면 우리가 개인 개발자로서 Binance나 Coinbase의 공개 API를 사용할 때는 WebSocket을 주로 사용합니다.

브라우저와 서버에서 연결하기 쉽고, 인증·구독·재연결·ping/pong 같은 기능을 구현하기 편하기 때문입니다.

따라서 글에서 말하는 데이터 흐름은 다음처럼 구분하는 것이 정확합니다.

text
        거래소 내부·기관용
        매칭 엔진 → 바이너리 시장 데이터 → UDP Multicast

        공개 API·웹 애플리케이션용
        거래소 피드 → API 게이트웨이 → WebSocket

        초기 상태·복구용
        REST API → Snapshot

ORZA처럼 웹 브라우저에서 오더북을 보여주는 서비스라면 직접 UDP Multicast를 연결하기보다, 서버가 거래소의 전문 피드를 수신하고 필요한 데이터만 WebSocket이나 SSE로 브라우저에 전달하는 구조를 고려하게 됩니다.

3. Snapshot과 Delta

오더북을 처음 연결할 때는 현재 전체 상태가 필요합니다.
이것을 Snapshot이라고 합니다.

json
        {
          "bids": [["101.2", "5.1"]],
          "asks": [["101.3", "8.7"]]
        }

이후에는 변경된 가격만 전달합니다.
이것을 Delta 또는 Diff Update라고 합니다.

json
        {
          "bids": [["101.2", "3.0"]],
          "asks": [["101.3", "0"]]
        }

수량이 0이면 해당 가격 레벨을 삭제하고, 새로운 수량이 오면 추가하거나 갱신합니다.

Binance는 REST API로 깊이 스냅샷을 받은 뒤 WebSocket diff depth 이벤트를 적용하는 방식을 안내하고 있습니다.

이벤트에는 첫 업데이트 ID와 마지막 업데이트 ID가 포함되며, 클라이언트는 이를 사용해 로컬 오더북이 올바른 순서로 갱신되는지 확인할 수 있습니다.

Bybit 역시 최초 snapshot 이후 delta 메시지를 보내며, 수량이 0인 가격 레벨은 삭제하고 기존 가격은 갱신합니다.

3.5. 실제 ORZA에서는 받은 데이터를 어떻게 화면에 출력할까

ORZA의 실제 거래 화면(orza.kr/trade)도 같은 원리로 동작합니다.

text
        Perpl WebSocket
          ↓
        ORZA 서버의 perpl-stream-hub
          ↓  (서버 WebSocket 1개를 여러 브라우저에 fan-out)
        /api/dex/perpl/stream  (SSE)
          ↓
        브라우저 EventSource
          ↓
        mt:15 snapshot + mt:16 delta를 Map에 병합
          ↓
        가격순 정렬·수량 스케일링·누적 수량 계산
          ↓
        호가창 React 상태 갱신

서버는 같은 종목을 보고 있는 브라우저가 여러 개여도 upstream 연결을 하나만 유지합니다.

서버에서 받은 원본 프레임은 종목별 스트림 ID와 함께 SSE로 전달합니다.

ts
        // ORZA browser: lib/dex/perpl-ws.ts
        const source = new EventSource(
          `/api/dex/perpl/stream?streams=${encodeURIComponent(stream)}`,
        )

        source.onmessage = (event) => {
          const { s: streamId, f: rawFrame } = JSON.parse(event.data)
          if (streamId === stream) onFrame(rawFrame)
        }

서버의 upstream 연결은 mt:5로 구독하고, 30초마다 mt:1 ping을 보냅니다.

ts
        // ORZA server: lib/dex/perpl-stream-hub.ts
        const ws = new WebSocket(
          `${PERPL_WS_URL}/ws/v1/market-data`,
          { headers: { Origin: "https://app.perpl.xyz" } },
        )

        ws.on("open", () => {
          ws.send(JSON.stringify({
            mt: 5,
            subs: [{ stream: `order-book@${marketId}`, subscribe: true }],
          }))
        })

핵심은 매 프레임을 기존 배열에 통째로 덮어쓰지 않고, 가격을 키로 한 Map을 유지하는 것입니다.

mt:15는 전체 스냅샷이므로 Map을 비우고 시작하고, mt:16은 변경된 가격 레벨만 반영합니다.

수량이나 주문 수가 0이면 해당 가격을 삭제합니다.

ts
        // ORZA: snapshot + delta 병합
        function applyDelta(side, levels) {
          for (const level of levels ?? []) {
            const price = Number(level.p)
            const size = Number(level.s) || 0
            const orders = Number(level.o) || 0

            if (size <= 0 || orders <= 0) side.delete(price)
            else side.set(price, { size, orders })
          }
        }

        function applyPerplBookFrame(state, meta, raw) {
          const message = JSON.parse(raw)
          if (message.mt !== 15 && message.mt !== 16) return null

          if (message.mt === 15) {
            state.bids.clear()
            state.asks.clear()
          }
          applyDelta(state.bids, message.bid)
          applyDelta(state.asks, message.ask)

          return {
            bids: ladder(state.bids, meta, "desc"),
            asks: ladder(state.asks, meta, "asc"),
            timestamp: message.at?.t ?? Date.now(),
          }
        }

병합된 결과는 다시 화면에 필요한 형태로 변환됩니다.

내부 프로토콜은 정수 스케일 값이므로 종목별 소수점 자릿수로 나누고, 매수는 높은 가격순, 매도는 낮은 가격순으로 정렬합니다.

ts
        // ORZA: WebSocket frame → local book → React
        const book = createPerplBookState()
        const stream = openPerplStream(
          perplOrderBookStream(marketId),
          (raw) => {
            const next = applyPerplBookFrame(book, perplMeta, raw)
            if (next) applyBook(next, "ws")
          },
        )

        function applyBook(nextBook, source) {
          const grouped = normalizeBookSnapshot(
            groupBook(nextBook, bookGroup),
          )
          pendingBookRef.current = grouped
          scheduleBookFlush() // 너무 잦은 setState를 한 프레임으로 묶음
          bookSourceRef.current = source
        }

결국 화면에는 이 로컬 상태에서 현재가에 가까운 상위 호가만 잘라서 출력합니다.

WebSocket 이벤트의 수신 속도와 React의 렌더링 속도를 분리했기 때문에, 데이터가 빠르게 들어와도 매 프레임마다 전체 UI를 다시 그리지 않습니다.

4. 데이터가 유실되면 어떻게 할까

WebSocket이나 UDP 기반 시장 데이터 피드 모두 네트워크 문제로 이벤트가 누락될 수 있습니다.

UDP는 전송 자체가 전달을 보장하지 않기 때문에, 시퀀스 번호·갭 감지·복구 채널이 특히 중요합니다.

text
        현재 로컬 업데이트 ID: 100

        다음 이벤트: 101 → 정상
        다음 이벤트: 103 → 102가 유실되었을 가능성

그래서 거래소는 이벤트에 sequence number 또는 update ID를 함께 보냅니다.

일반적인 동기화 순서는 다음과 같습니다.

1. WebSocket 또는 UDP 피드의 이벤트를 임시 버퍼에 저장합니다.

2. REST API로 최신 스냅샷을 받습니다.

3. 스냅샷 이후에 발생한 이벤트만 적용합니다.

4. 업데이트 번호가 끊겼다면 현재 로컬 상태를 버리고 다시 동기화합니다.

오더북에서 데이터가 조금 오래된 것보다 더 위험한 것은, 일부 이벤트가 누락된 상태를 정상 데이터처럼 보여주는 것입니다.

5. 오더북을 어떤 자료구조로 관리할까

단순한 배열에 모든 가격을 넣고 이벤트가 올 때마다 전체 배열을 정렬하면 구현은 쉽지만 업데이트가 많아질수록 비효율적입니다.

text
        가격 → 수량
        101.2 → 5.1
        101.1 → 14.3
        101.0 → 20.0

일반적으로는 다음과 같은 역할을 나눠 생각할 수 있습니다.

  • Hash Map: 특정 가격의 수량을 빠르게 갱신
  • 정렬 자료구조: 가격순으로 가격 레벨 유지
  • Heap: 가장 좋은 매수·매도 가격 확인
  • Queue: 같은 가격에서 주문의 시간 순서 관리

프론트엔드에서는 먼저 가격을 Map에 저장하고, 화면에 표시할 상위 N개만 정렬하는 방식으로 단순화할 수 있습니다.

ts
        if (quantity === 0) {
          book.delete(price)
        } else {
          book.set(price, quantity)
        }

중요한 것은 내부 데이터 처리와 화면 렌더링을 같은 속도로 실행하지 않는 것입니다.

6. 프론트엔드에서는 왜 모든 이벤트를 바로 렌더링하면 안 될까

오더북 이벤트가 초당 수백 번 들어온다고 해서 React가 매번 전체 화면을 다시 그리도록 만들면 브라우저가 쉽게 바빠집니다.

text
        WebSocket 수신
          ↓
        로컬 오더북 상태 갱신
          ↓
        이벤트 batching
          ↓
        requestAnimationFrame
          ↓
        화면 렌더링

데이터는 가능한 한 빠르게 내부 상태에 반영하되, 화면은 브라우저 프레임에 맞춰 갱신하는 방법을 사용할 수 있습니다.

또한 매번 전체 오더북을 정렬하는 대신 다음과 같이 처리할 수 있습니다.

text
        이벤트 수신
          → 특정 가격만 수정
          → 상위 10개 가격 계산
          → 필요한 행만 렌더링

사용자가 보고 있는 화면에는 보통 모든 가격이 필요하지 않습니다.
현재가에 가까운 상위 호가만 보여준다면 데이터 처리 비용과 DOM 렌더링 비용을 함께 줄일 수 있습니다.