# 거래소 시세 데이터 플레인 구축

> 거래소 열한 곳의 호가를 Rust 수집기, Redpanda, RisingWave로 모아 USD 기준 프리미엄을 계산하고 웹 터미널로 서빙하는 구조와 설계 결정을 정리합니다.

- Canonical URL: https://jerrylog.dev/ko/blog/stratops-dataplane
- Date: 2026-10-19
- Author: Jerry
- Language: ko
- Topic: 소프트웨어 엔지니어링 / 데이터 엔지니어링
- Tags: stratops, risingwave, redpanda, stream-processing, kimchi-premium, rust

---

같은 비트코인도 거래소마다 가격이 다릅니다. Upbit의 원화 가격을 Binance의 달러 가격과 환율로 맞춰 보면 김치 프리미엄이 나오고, 해외 거래소끼리도 현물과 무기한 선물 사이에 작은 가격 차이가 생깁니다. 이 차이를 실시간으로 보려면 여러 거래소의 호가를 한곳에 모으고, 통화와 자산 이름을 같은 기준으로 맞추고, 수수료와 호가 깊이까지 반영해야 합니다.

그렇다면 초당 수백 건씩 바뀌는 호가를 어떤 구조로 모으고 계산해야 할까요? 이 글에서는 트레이딩 시스템 stratops의 데이터 플레인이 어떤 데이터를 어떻게 수집하고, 어떻게 가공해서, 누구에게 전달하는지 살펴보겠습니다. 처음 선택이 바뀐 부분도 그 이유와 함께 적었습니다.

## 데이터 플레인 개요

### 세 계층 구조

데이터 플레인은 수집, 계산, 소비의 세 계층으로 나뉩니다. 수집기(collector)는 거래소와 외부 API에서 받은 값을 공통 레코드 형식으로 바꿔 Redpanda 토픽에 씁니다. RisingWave는 이 토픽을 읽어 최신 상태, 환산 가격, 프리미엄, 1분 bar를 스트리밍으로 계산합니다. 계산 결과는 다시 Kafka 토픽으로 나가고, 트레이딩 봇과 웹 터미널이 이 토픽을 받아 씁니다.

> **Diagram:** 수집, 전송, 계산, 소비로 나뉜 데이터 플레인 전체 구조
> 
> Rust collector-market과 Python collector가 수집한 레코드를 Redpanda 토픽에 쓰고, RisingWave가 dbt로 정의한 스트리밍 MV로 계산한다. 결과는 Kafka 토픽으로만 나가며, 트레이딩 봇은 signal 토픽을 구독하고 웹 터미널은 serving-db를 읽는 API를 쓴다.

| 계층 | 하는 일 | 하지 않는 일 |
| --- | --- | --- |
| **수집** | 원천 데이터를 공통 레코드로 바꿔 Redpanda에 발행 | SQL 계산, 봇 로직 |
| **전송·계산** | 레코드를 재생 가능한 로그로 보관하고, 스트리밍 MV로 지표 계산 | 외부 API 호출, 주문 |
| **소비** | 신호 토픽을 구독하고, 신선도 검사를 통과한 값만 사용 | 원천 API 직접 조회, 값 보정 |

계층을 나눈 이유는 장애가 번지는 범위를 줄이기 위해서입니다. 수집기가 재시작해도 RisingWave는 멈추지 않고, RisingWave를 다시 만들어도 보존 기간 안의 원본은 Redpanda에 남아 있어 다시 읽을 수 있습니다.

### 구독 원칙과 DEGRADE

라이브 봇은 이 플레인의 토픽을 구독만 합니다. 데이터가 늦을 때 봇이 거래소 API를 직접 조회하기 시작하면, 봇이 본 값과 플레인이 계산한 값이 달라지기 때문입니다. 그래서, 값이 오래됐거나 상태가 정상이 아니면 봇은 마지막 값을 재사용하지 않고 그 신호를 버립니다. 이 동작을 DEGRADE라고 부릅니다. 봇 쪽 gate는 다음 세 가지를 검사합니다.

```python
def gate(tick, now, max_age_s, min_conf):
    if tick.get("book_state", "stale") != "ok":
        return DEGRADE
    age_s = now - float(tick.get("ts_collector", 0.0))
    if not -max_age_s <= age_s <= max_age_s:
        return DEGRADE
    # confidence를 요구하는데 필드가 없으면 신뢰하지 않음
    if min_conf > 0 and (tick.get("confidence") is None or tick["confidence"] < min_conf):
        return DEGRADE
    return tick
```

틀린 값으로 주문하는 것보다 값이 없는 편이 손실이 적다고 보았습니다. 예외를 던지지 않고 DEGRADE를 돌려주는 것도 같은 이유입니다. 플레인이 멈추면 봇은 해당 신호를 쓰는 동작만 멈추고, 나머지 기능은 계속 돌아갑니다.

## 데이터 수집

### 수집 대상 데이터

*이 글은 아직 작성중입니다. 완성되면 이어서 공개합니다.*
