개요
암호화폐 거래소에서 실시간 시장 데이터를 안정적으로 수집하는 것은 고빈도 거래 시스템, 봇 개발, 데이터 분석에 필수적이다. 이 튜토리얼에서는 Tardis.dev(캡처 및 리플레이 서비스)와 OKX API를 활용한 **이중 데이터소스 실시간 시장 데이터 집계 아키텍처**를 구축한다.
┌─────────────────────────────────────────────────────────────┐
│ 실시간 시장 데이터 집계 아키텍처 │
├─────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ OKX API │ │ Tardis.dev │ │
│ │ (WebSocket) │ │ (Captured) │ │
│ └──────┬───────┘ └──────┬───────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ Primary Feed │ │ Backup Feed │ │
│ │ (실시간) │ │ (리플레이) │ │
│ └──────┬───────┘ └──────┬───────┘ │
│ │ ┌─────────────┘ │
│ ▼ ▼ │
│ ┌─────────────────────────────────────┐ │
│ │ Data Aggregator │ │
│ │ (중복 제거 + 정렬 + 병합) │ │
│ └──────────────────┬──────────────────┘ │
│ ▼ │
│ ┌─────────────────────────────────────┐ │
│ │ Unified Market Data Stream │ │
│ └─────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
필수 사전 준비
Node.js 프로젝트 초기화
npm init -y
필요한 패키지 설치
npm install @tardis-dev/client
npm install @okx-api/api
npm install ws # WebSocket 클라이언트
npm install axios # HTTP 요청
npm install dotenv # 환경변수 관리
.env 파일 설정
OKX_API_KEY=your_okx_api_key
OKX_SECRET_KEY=your_okx_secret_key
OKX_PASSPHRASE=your_okx_passphrase
OKX_FLAG=0 # 실거래: 0, 테스트넷: 1
TARDIS_API_KEY=your_tardis_api_key
Tardis.dev 기본 설정
Tardis.dev는 주요 거래소의 시장 데이터를 캡처하여 API와 SDK로 제공하는 서비스이다.
// tardis-client.js
const { createClient } = require('@tardis-dev/client');
class TardisDataSource {
constructor(apiKey) {
this.client = createClient({ apiKey });
this.subscriptions = new Map();
}
// 특정 거래소 데이터订阅
async subscribeExchange(exchange, symbol, messageHandler) {
const subscription = this.client.subscribe({
exchange,
channel: 'trades',
symbol
});
subscription.on('message', (message) => {
const normalizedData = this.normalizeTardisTrade(message);
messageHandler(normalizedData);
});
subscription.on('error', (error) => {
console.error([Tardis] ${exchange} 오류:, error.message);
});
this.subscriptions.set(${exchange}:${symbol}, subscription);
console.log([Tardis] ${exchange} ${symbol} 구독 시작);
return subscription;
}
// Tardis 메시지를 표준 형식으로 변환
normalizeTardisTrade(message) {
return {
source: 'tardis',
exchange: message.exchange,
symbol: message.symbol,
price: parseFloat(message.price),
quantity: parseFloat(message.side === 'buy' ? message.amount : -message.amount),
side: message.side,
timestamp: new Date(message.timestamp).getTime(),
tradeId: message.id,
localTimestamp: Date.now()
};
}
// 특정 시간 범위 데이터 조회 (리플레이)
async replayRange(exchange, symbol, startTime, endTime) {
const messages = [];
for await (const message of this.client.replay({
exchange,
channel: 'trades',
symbol,
from: startTime,
to: endTime
})) {
messages.push(this.normalizeTardisTrade(message));
}
return messages;
}
disconnect() {
for (const sub of this.subscriptions.values()) {
sub.destroy();
}
this.subscriptions.clear();
console.log('[Tardis] 모든 연결 해제됨');
}
}
module.exports = TardisDataSource;
OKX API 실시간 데이터 수집
// okx-websocket-client.js
const WebSocket = require('ws');
const crypto = require('crypto');
class OKXWebSocketClient {
constructor(apiKey, secretKey, passphrase, flag = '0') {
this.apiKey = apiKey;
this.secretKey = secretKey;
this.passphrase = passphrase;
this.flag = flag;
this.ws = null;
this.subscriptions = new Map();
this.reconnectAttempts = 0;
this.maxReconnectAttempts = 10;
this.pingInterval = null;
}
// WebSocket 연결 URL 생성 (서명 포함)
generateWsUrl() {
const timestamp = new Date().toISOString();
const sign = crypto
.createHmac('sha256', this.secretKey)
.update(timestamp + 'GET' + '/users/self/verify')
.digest('base64');
const params = new URLSearchParams({
apiKey: this.apiKey,
timestamp,
sign,
passphrase: this.passphrase,
flag: this.flag
});
return wss://ws.okx.com:8443/ws/v5/public?${params.toString()};
}
connect() {
return new Promise((resolve, reject) => {
const wsUrl = this.generateWsUrl();
console.log('[OKX] WebSocket 연결 시도:', wsUrl.replace(this.apiKey, '***'));
this.ws = new WebSocket(wsUrl);
this.ws.on('open', () => {
console.log('[OKX] WebSocket 연결 성공');
this.reconnectAttempts = 0;
this.startPing();
resolve();
});
this.ws.on('message', (data) => {
try {
const message = JSON.parse(data.toString());
this.handleMessage(message);
} catch (e) {
console.error('[OKX] 메시지 파싱 오류:', e.message);
}
});
this.ws.on('error', (error) => {
console.error('[OKX] WebSocket 오류:', error.message);
reject(error);
});
this.ws.on('close', () => {
console.log('[OKX] WebSocket 연결 종료');
this.stopPing();
this.scheduleReconnect();
});
});
}
// 거래 데이터 구독
subscribeTrades(instId, handler) {
const subscribeMsg = {
op: 'subscribe',
args: [{
channel: 'trades',
instId: instId // 예: 'BTC-USDT'
}]
};
this.ws.send(JSON.stringify(subscribeMsg));
this.subscriptions.set(instId, handler);
console.log([OKX] ${instId} 거래 구독 요청됨);
}
handleMessage(message) {
// 구독 확인 메시지
if (message.event === 'subscribe') {
console.log([OKX] 구독 성공: ${message.arg?.instId});
return;
}
// 데이터 메시지
if (message.data && Array.isArray(message.data)) {
for (const trade of message.data) {
const normalized = this.normalizeTrade(trade);
const handler = this.subscriptions.get(trade.instId);
if (handler) {
handler(normalized);
}
}
}
}
normalizeTrade(trade) {
return {
source: 'okx',
exchange: 'okx',
symbol: trade.instId,
price: parseFloat(trade.px),
quantity: parseFloat(trade.sz) * (trade.side === 'buy' ? 1 : -1),
side: trade.side,
timestamp: parseInt(trade.ts),
tradeId: trade.tradeId,
localTimestamp: Date.now()
};
}
startPing() {
this.pingInterval = setInterval(() => {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.ws.ping();
}
}, 20000);
}
stopPing() {
if (this.pingInterval) {
clearInterval(this.pingInterval);
this.pingInterval = null;
}
}
scheduleReconnect() {
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
console.error('[OKX] 최대 재연결 횟수 초과');
return;
}
const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempts), 30000);
this.reconnectAttempts++;
console.log([OKX] ${delay}ms 후 재연결 시도 (${this.reconnectAttempts}/${this.maxReconnectAttempts}));
setTimeout(async () => {
try {
await this.connect();
// 기존 구독 재설정
for (const instId of this.subscriptions.keys()) {
this.subscribeTrades(instId, this.subscriptions.get(instId));
}
} catch (e) {
console.error('[OKX] 재연결 실패:', e.message);
}
}, delay);
}
disconnect() {
this.stopPing();
if (this.ws) {
this.ws.close();
this.ws = null;
}
this.subscriptions.clear();
console.log('[OKX] 연결 해제됨');
}
}
module.exports = OKXWebSocketClient;
이중 데이터소스 집계기 구현
// market-data-aggregator.js
const TardisDataSource = require('./tardis-client');
const OKXWebSocketClient = require('./okx-websocket-client');
class MarketDataAggregator {
constructor(config) {
this.okxClient = new OKXWebSocketClient(
config.okxApiKey,
config.okxSecretKey,
config.okxPassphrase,
config.okxFlag
);
this.tardisClient = new TardisDataSource(config.tardisApiKey);
this.primaryBuffer = new Map(); // OKX 실시간 데이터
this.secondaryBuffer = new Map(); // Tardis 백업 데이터
this.unifiedStream = new Map(); // 집계된 데이터
this.messageHandlers = [];
this.lagThreshold = 5000; // 5초 이상 지연 시 알림
this.lastOkxTimestamp = 0;
this.lastTardisTimestamp = 0;
}
async start(symbols) {
console.log('='.repeat(50));
console.log('이중 데이터소스 집계기 시작');
console.log('='.repeat(50));
// 1단계: OKX 실시간 스트림 연결
try {
await this.okxClient.connect();
for (const symbol of symbols) {
const instId = symbol.replace('-', '-'); // OKX 형식
this.okxClient.subscribeTrades(instId, (data) => {
this.handlePrimaryData(data);
});
}
} catch (error) {
console.error('[ Aggregator ] OKX 연결 실패, Tardis 활성화:', error.message);
await this.activateTardisBackup(symbols);
}
// 2단계: Tardis 백업/보조 스트림
await this.activateTardisBackup(symbols);
// 3단계: 지연 감시 시작
this.startLagMonitoring();
console.log('모든 데이터소스 활성화 완료');
}
async activateTardisBackup(symbols) {
for (const symbol of symbols) {
await this.tardisClient.subscribeExchange('okx', symbol, (data) => {
this.handleSecondaryData(data);
});
}
}
handlePrimaryData(data) {
const key = ${data.exchange}:${data.symbol};
this.primaryBuffer.set(key, data);
this.lastOkxTimestamp = data.timestamp;
// 지연 감시
const delay = Date.now() - data.timestamp;
if (delay > this.lagThreshold) {
console.warn([ Aggregator ] ${data.symbol} 지연 감지: ${delay}ms);
}
this.emitUnifiedData(key, data);
}
handleSecondaryData(data) {
const key = ${data.exchange}:${data.symbol};
this.secondaryBuffer.set(key, data);
this.lastTardisTimestamp = data.timestamp;
// primary가 없거나 오래된 경우 secondary 사용
const primary = this.primaryBuffer.get(key);
if (!primary || data.timestamp > primary.timestamp) {
this.emitUnifiedData(key, data);
}
}
emitUnifiedData(key, data) {
this.unifiedStream.set(key, data);
for (const handler of this.messageHandlers) {
handler({
...data,
primary: this.primaryBuffer.has(key) ? 'okx' : 'tardis',
timestamp: Date.now()
});
}
}
startLagMonitoring() {
setInterval(() => {
const now = Date.now();
if (this.lastOkxTimestamp > 0) {
const okxLag = now - this.lastOkxTimestamp;
if (okxLag > this.lagThreshold) {
console.warn([ Monitor ] OKX 데이터 지연: ${okxLag}ms);
}
}
}, 10000);
}
onMessage(handler) {
this.messageHandlers.push(handler);
}
getUnifiedData(symbol) {
return this.unifiedStream.get(symbol) || null;
}
getStats() {
return {
primaryBufferSize: this.primaryBuffer.size,
secondaryBufferSize: this.secondaryBuffer.size,
unifiedStreamSize: this.unifiedStream.size,
lastOkxTimestamp: this.lastOkxTimestamp,
lastTardisTimestamp: this.lastTardisTimestamp,
timestampDiff: this.lastOkxTimestamp - this.lastTardisTimestamp
};
}
stop() {
console.log('집계기 종료 중...');
this.okxClient.disconnect();
this.tardisClient.disconnect();
this.primaryBuffer.clear();
this.secondaryBuffer.clear();
this.unifiedStream.clear();
console.log('집계기 종료 완료');
}
}
module.exports = MarketDataAggregator;
메인 실행 파일
// index.js
require('dotenv').config();
const MarketDataAggregator = require('./market-data-aggregator');
async function main() {
const symbols = ['BTC-USDT', 'ETH-USDT', 'SOL-USDT'];
const aggregator = new MarketDataAggregator({
okxApiKey: process.env.OKX_API_KEY,
okxSecretKey: process.env.OKX_SECRET_KEY,
okxPassphrase: process.env.OKX_PASSPHRASE,
okxFlag: process.env.OKX_FLAG || '0',
tardisApiKey: process.env.TARDIS_API_KEY
});
// 실시간 데이터 처리 핸들러
aggregator.onMessage((data) => {
const time = new Date(data.timestamp).toISOString();
console.log([${time}] ${data.source.padEnd(5)} | ${data.symbol.padEnd(10)} | ${data.price} | Qty: ${Math.abs(data.quantity)} | ${data.side.toUpperCase()});
});
// 통계 출력 (10초마다)
setInterval(() => {
const stats = aggregator.getStats();
console.log('\n--- 현재 통계 ---');
console.log(Primary 버퍼: ${stats.primaryBufferSize});
console.log(Secondary 버퍼: ${stats.secondaryBufferSize});
console.log(`타이밍 차이: ${stats.timestampDiff}ms');
console.log('------------------\n');
}, 10000);
try {
await aggregator.start(symbols);
console.log('데이터 수집 중... (Ctrl+C로 종료)\n');
} catch (error) {
console.error('시작 실패:', error);
process.exit(1);
}
// Graceful shutdown
process.on('SIGINT', () => {
console.log('\n\n종료 시그널 수신...');
aggregator.stop();
process.exit(0);
});
}
main();
성능 최적화 팁
// 고성능 집계기 (배치 처리 최적화)
class OptimizedAggregator {
constructor(options = {}) {
this.batchSize = options.batchSize || 100;
this.flushInterval = options.flushInterval || 100;
this.batchBuffer = [];
this.lastFlush = Date.now();
}
// 배치 버퍼링으로 처리량 향상
addToBatch(data) {
this.batchBuffer.push(data);
if (this.batchBuffer.length >= this.batchSize ||
Date.now() - this.lastFlush >= this.flushInterval) {
this.flushBatch();
}
}
flushBatch() {
if (this.batchBuffer.length === 0) return;
// 배치 처리 (예: DB 벌크 인서트, 분석 시스템 전달)
const batch = this.batchBuffer.splice(0, this.batchBuffer.length);
this.lastFlush = Date.now();
return batch;
}
// 압축 처리 (고빈도 데이터용)
compressTrades(trades) {
const priceMap = new Map();
for (const trade of trades) {
const key = trade.symbol;
const existing = priceMap.get(key);
if (existing) {
// 같은 심볼 내 마지막 거래만 유지 (가격만 업데이트)
existing.price = trade.price;
existing.quantity += Math.abs(trade.quantity);
existing.timestamp = trade.timestamp;
} else {
priceMap.set(key, { ...trade });
}
}
return Array.from(priceMap.values());
}
}
자주 발생하는 오류와 해결책
| 오류 유형 |
원인 |
해결 방법 |
WebSocket connection failed |
OKX 서버 연결 거부, 방화벽, 네트워크 문제 |
재연결 로직 구현, 프록시 사용, 연결 타임아웃 설정 |
Tardis API rate limit exceeded |
API 요청 과다, 구독 제한 초과 |
요청 간 딜레이 추가, 프리미엄 플랜 업그레이드, 캐싱 활용 |
Data timestamp out of order |
네트워크 지연으로 인한 순서 역전 |
타임스탬프 기반 정렬 버퍼 구현, 지연 데이터 필터링 |
Invalid signature for OKX API |
비밀키 불일치, 타임스탬프 오차 |
서명 알고리즘 검증, 서버 시간 동기화 (NTP) |
Memory leak in WebSocket |
버퍼 미청소, 이벤트 리스너 누수 |
주기적 버퍼 플러시, 명시적 리스너 제거, maxBufferSize 설정 |
추가 최적화 및 모니터링
// 헬스체크 및 메트릭스 수집
class MetricsCollector {
constructor() {
this.metrics = {
messagesReceived: 0,
messagesBySource: { okx: 0, tardis: 0 },
errors: [],
latencies: [],
lastMessageTime: null
};
}
recordMessage(source, latency) {
this.metrics.messagesReceived++;
this.metrics.messagesBySource[source]++;
this.metrics.latencies.push(latency);
this.metrics.lastMessageTime = Date.now();
// 1000개 샘플만 유지
if (this.metrics.latencies.length > 1000) {
this.metrics.latencies.shift();
}
}
recordError(error) {
this.metrics.errors.push({
timestamp: Date.now(),
message: error.message,
stack: error.stack
});
// 최근 100개만 유지
if (this.metrics.errors.length > 100) {
this.metrics.errors.shift();
}
}
getReport() {
const avgLatency = this.metrics.latencies.reduce((a, b) => a + b, 0) /
this.metrics.latencies.length || 0;
return {
totalMessages: this.metrics.messagesReceived,
bySource: this.metrics.messagesBySource,
avgLatency: avgLatency.toFixed(2) + 'ms',
errorCount: this.metrics.errors.length,
uptime: this.metrics.lastMessageTime ?
Date.now() - this.metrics.lastMessageTime : 'N/A'
};
}
}
결론
Tardis.dev와 OKX API를 결합한 이중 데이터소스 아키텍처는 단일 소스 의존도를 낮추고 데이터 가용성을 극대화한다. OKX를 primary 실시간 스트림으로 사용하고 Tardis를 backup 및 historical 데이터 소스로 활용하면 네트워크 단절이나 API 장애 시에도 안정적인 시장 데이터 수집이 가능하다.
실제 거래 시스템에서는 이 집계기에 **_FAILOVER 메커니즘_, 중복 데이터 제거, 배치 처리 최적화**를 반드시 구현해야 하며, 메모리 관리와 연결 상태 모니터링도 필수적이다.