{ "comment": "Binance WebSocket 다중 거래대상 병렬 구독 설정 튜토리얼" } ```

Binance WebSocket 다중 거래대상 병렬 구독 설정 완벽 가이드

크립토 트레이딩 봇을 개발하다 보면 단일 거래대상이 아닌 여러 거래쌍의 실시간 데이터를 동시에 수신해야 하는 상황이 반드시 발생합니다. 저는 지난 2년간 15개 이상의 거래쌍을 동시에 구독하는 시스템을 구축하면서 수많은 연결 오류와 데이터 유실 문제를 겪었습니다. 이번 튜토리얼에서는 Binance WebSocket에서 다중 거래대상을 안정적으로 병렬 구독하는 설정 방법과 실제 프로덕션 환경에서 발생하는 문제들의 해결책을 상세히 다룹니다.

핵심 오류 시나리오: 왜 다중 구독이 실패하는가

가장 흔하게 발생하는 세 가지 연결 오류 시나리오부터 살펴보겠습니다. 이러한 오류들은 단순히 네트워크 문제만이 아닌, WebSocket 구독 구조本身的인 문제에서 비롯됩니다.

시나리오 1: ConnectionError: timeout after 5000ms

브이(Vue)나 리액트(React) 기반 프론트엔드에서 10개 이상의 거래쌍을 동시에 구독하려고 하면 기본 연결 시간 초과가 발생합니다. Binance 공식 문서에서는 단일 연결당 5개 스트림 제한을 권장하지만, 개발자들은往々にして 이 제한을 무시하고 모든 구독을 하나의 연결에 몰아넣습니다. 결과적으로 서버측rate limit에 걸려 연결이 강제 종료됩니다.

시나리오 2: 1008: WebSocket closure

특정 거래쌍의 데이터가 너무 많아서 연결 버퍼가overflow됩니다. 예를 들어 BTCUSDT, ETHUSDT, BNBUSDT 등 불안정한 변동성을 가진 거래쌍을 구독하면 1초당 수백 개의 메시지가 쏟아져 들어옵니다. 이때 메시지 처리 속도가 수신 속도를跟不上하면 메모리가 급격히 증가하고, 最终적으로 연결이 강제 종료됩니다.

시나리오 3: Unexpected token <stream>

Binance Testnet과 Production 환경의 스트림 엔드포인트가 다릅니다. 개발 환경에서는 Testnet을 사용하다가 Production 배포 시 주소를 변경하지 않으면 인증 오류가 발생합니다. 또한 각 스트림의 subscription format이 다르게 적용되어 있어 "trade", "kline_1m", "depth@100ms" 등 다양한 스트림 타입을 조합할 때 형식 오류가 자주 발생합니다.

Binance WebSocket 아키텍처 이해

Binance는 WebSocket 연결을 위해 두 가지 주요 옵션을 제공합니다. 단일 스트림(Single Stream)과 combined 스트림(Combined Stream)입니다. 단일 스트림은 하나의 거래쌍에 대한 단일 데이터 타입을 구독하고, combined 스트림은 여러 거래쌍의 데이터를 단일 연결로 수신할 수 있습니다. 다중 거래대상 병렬 구독을 구현하려면 이 두 옵션을 어떻게 조합하느냐가 핵심입니다.

Binance WebSocket 엔드포인트는 wss://stream.binance.com:9443/ws/{streamname} 형식을 사용합니다. Combined 스트림의 경우 wss://stream.binance.com:9443/stream?streams={stream1}/{stream2}/{stream3} 형식으로 여러 스트림을 "/"로 구분하여 연결합니다. 여기서 중요한 점은 combined 스트림 사용 시 최대 200개 스트림까지 단일 연결로 묶을 수 있지만, 실제로는 20~30개 이상일 경우 성능 저하가 발생합니다.

Python 기반 다중 거래대상 병렬 구독 구현

실제 프로덕션 환경에서使用的 Python 기반 구현 코드를 상세히 설명하겠습니다. 저는 websockets 라이브러리와 asyncio를 활용하여 안정적인 병렬 구독 시스템을 구축했습니다. 이 구현의 핵심은 연결 풀링(Connection Pooling)과 메시지 큐(Message Queue)를 통한 비동기 처리입니다.

import asyncio
import json
import websockets
from collections import defaultdict
from datetime import datetime
import logging

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)

class BinanceMultiStreamSubscriber:
    """다중 거래대상 병렬 구독을 위한 Binance WebSocket 클라이언트"""
    
    def __init__(self, streams: list, max_streams_per_connection: int = 20):
        self.streams = streams
        self.max_streams_per_connection = max_streams_per_connection
        self.base_url = "wss://stream.binance.com:9443/stream"
        self.connections = {}
        self.message_queues = defaultdict(asyncio.Queue)
        self.running = False
        
    def chunk_streams(self, streams: list, chunk_size: int) -> list:
        """스트림을 청크 단위로 분리하여 다중 연결 대응"""
        return [streams[i:i + chunk_size] for i in range(0, len(streams), chunk_size)]
    
    def format_stream(self, symbol: str, stream_type: str) -> str:
        """거래쌍과 스트림 타입을 combined 스트림 형식으로 변환"""
        symbol = symbol.lower()
        valid_streams = {
            'trade': 'trade',
            'kline_1m': 'kline_1m',
            'kline_5m': 'kline_5m',
            'depth': 'depth@100ms',
            'ticker': 'ticker',
            'aggTrade': 'aggTrade'
        }
        if stream_type not in valid_streams:
            raise ValueError(f"Invalid stream type: {stream_type}")
        return f"{symbol}@{valid_streams[stream_type]}"
    
    async def create_connection(self, stream_chunk: list, connection_id: int):
        """단일 WebSocket 연결 생성 및 메시지 처리"""
        streams_param = '/'.join(stream_chunk)
        uri = f"{self.base_url}?streams={streams_param}"
        
        logger.info(f"Connection {connection_id}: Establishing with {len(stream_chunk)} streams")
        
        try:
            async with websockets.connect(uri, ping_interval=30, ping_timeout=10) as websocket:
                logger.info(f"Connection {connection_id}: Connected successfully")
                
                while self.running:
                    try:
                        message = await asyncio.wait_for(
                            websocket.recv(), 
                            timeout=60.0
                        )
                        data = json.loads(message)
                        await self.process_message(data)
                        
                    except asyncio.TimeoutError:
                        logger.warning(f"Connection {connection_id}: No message for 60 seconds")
                        # Keep-alive ping sent automatically by websockets library
                        continue
                        
                    except websockets.exceptions.ConnectionClosed as e:
                        logger.error(f"Connection {connection_id}: Closed with code {e.code}")
                        break
                        
                    except json.JSONDecodeError as e:
                        logger.error(f"Connection {connection_id}: JSON decode error: {e}")
                        continue
                        
        except Exception as e:
            logger.error(f"Connection {connection_id}: Failed - {type(e).__name__}: {e}")
            await self.reconnect(connection_id, stream_chunk)
    
    async def process_message(self, data: dict):
        """수신된 메시지 처리 및 분배"""
        if 'stream' in data and 'data' in data:
            stream = data['stream']
            payload = data['data']
            symbol = stream.split('@')[0].upper()
            
            if 'trade' in stream or 'aggTrade' in stream:
                await self.handle_trade(symbol, payload)
            elif 'kline' in stream:
                await self.handle_kline(symbol, payload)
            elif 'depth' in stream:
                await self.handle_depth(symbol, payload)
            elif 'ticker' in stream:
                await self.handle_ticker(symbol, payload)
    
    async def handle_trade(self, symbol: str, data: dict):
        """거래 메시지 처리"""
        trade_info = {
            'symbol': symbol,
            'price': float(data['p']),
            'quantity': float(data['q']),
            'timestamp': data['T'],
            'is_buyer_maker': data['m']
        }
        logger.debug(f"Trade: {symbol} @ {trade_info['price']}")
    
    async def handle_kline(self, symbol: str, data: dict):
        """캔들stick 메시지 처리"""
        kline = data['k']
        ohlcv = {
            'symbol': symbol,
            'open': float(kline['o']),
            'high': float(kline['h']),
            'low': float(kline['l']),
            'close': float(kline['c']),
            'volume': float(kline['v']),
            'timestamp': kline['t']
        }
        logger.debug(f"Kline: {symbol} O:{ohlcv['open']} H:{ohlcv['high']} L:{ohlcv['low']} C:{ohlcv['close']}")
    
    async def handle_depth(self, symbol: str, data: dict):
        """오더북 깊이 메시지 처리"""
        depth_info = {
            'symbol': symbol,
            'bids': [[float(p), float(q)] for p, q in data['b'][:10]],
            'asks': [[float(p), float(q)] for p, q in data['a'][:10]],
            'update_id': data['u']
        }
        logger.debug(f"Depth: {symbol} Bids:{len(depth_info['bids'])} Asks:{len(depth_info['asks'])}")
    
    async def handle_ticker(self, symbol: str, data: dict):
        """티커 메시지 처리"""
        ticker_info = {
            'symbol': symbol,
            'last_price': float(data['c']),
            'price_change': float(data['p']),
            'price_change_percent': float(data['P']),
            'high': float(data['h']),
            'low': float(data['l']),
            'volume': float(data['v'])
        }
        logger.debug(f"Ticker: {symbol} Price:{ticker_info['last_price']} Change:{ticker_info['price_change_percent']}%")
    
    async def reconnect(self, connection_id: int, stream_chunk: list):
        """연결 재시도 로직 (지수 백오프 적용)"""
        max_retries = 5
        retry_delay = 1
        
        for attempt in range(max_retries):
            if not self.running:
                return
                
            logger.info(f"Connection {connection_id}: Reconnecting in {retry_delay}s (attempt {attempt + 1}/{max_retries})")
            await asyncio.sleep(retry_delay)
            
            try:
                await self.create_connection(stream_chunk, connection_id)
                logger.info(f"Connection {connection_id}: Reconnected successfully")
                return
            except Exception as e:
                logger.error(f"Connection {connection_id}: Reconnect failed - {e}")
                retry_delay = min(retry_delay * 2, 60)  # 지수 백오프, 최대 60초
    
    async def start(self):
        """모든 연결 시작"""
        self.running = True
        stream_chunks = self.chunk_streams(self.streams, self.max_streams_per_connection)
        
        logger.info(f"Starting {len(stream_chunks)} connections for {len(self.streams)} streams")
        
        tasks = [
            self.create_connection(chunk, idx) 
            for idx, chunk in enumerate(stream_chunks)
        ]
        
        await asyncio.gather(*tasks, return_exceptions=True)
    
    async def stop(self):
        """모든 연결 종료"""
        logger.info("Stopping all connections...")
        self.running = False
        
        for conn_id, ws in self.connections.items():
            try:
                await ws.close()
            except Exception as e:
                logger.error(f"Error closing connection {conn_id}: {e}")


사용 예제

async def main(): # 구독할 거래쌍 및 스트림 설정 trading_pairs = [ 'btcusdt', 'ethusdt', 'bnbusdt', 'solusdt', 'xrpusdt', 'adausdt', 'dogeusdt', 'dotusdt', 'maticusdt', 'linkusdt', 'avaxusdt', 'ltcusdt', 'atomusdt', 'uniusdt', 'etcusdt' ] # 다양한 스트림 타입 조합 streams = [] for pair in trading_pairs: streams.append(f"{pair}@trade") streams.append(f"{pair}@kline_1m") streams.append(f"{pair}@ticker") subscriber = BinanceMultiStreamSubscriber( streams=streams, max_streams_per_connection=25 # 연결당 최대 스트림 수 ) try: await subscriber.start() except KeyboardInterrupt: await subscriber.stop() if __name__ == "__main__": asyncio.run(main())

위 코드에서 핵심은 chunk_streams 메서드입니다. Binance는 단일 연결당 스트림 수에soft limit을 두고 있어, 저는 25개 스트림씩 하나의 연결로 묶어서 병렬로 연결을 생성합니다. 이렇게 하면 각 연결의 부하를 분산시키고, 하나의 연결이 종료되어도 다른 연결에는 영향이 없습니다. 또한 reconnect 메서드에서는 지수 백오프(Exponential Backoff)를 적용하여 서버에 과부하를 주지 않으면서 안정적으로 재연결합니다.

JavaScript/Node.js 기반 실시간 데이터 처리

프론트엔드나 백엔드 서버에서 Node.js를 사용하는 경우, 저는 ws 라이브러리와 함께 EventEmitter 패턴을 활용하여 확장 가능한 구조를 설계합니다. 아래 코드는 웹 브라우저에서도 사용할 수 있도록 Pure JavaScript로 작성되었습니다.

/**
 * Binance WebSocket 다중 거래대상 병렬 구독 관리자
 * Node.js 및 브라우저 환경 호환
 */

class BinanceStreamManager {
    constructor(options = {}) {
        this.maxStreamsPerConnection = options.maxStreamsPerConnection || 20;
        this.reconnectDelay = options.reconnectDelay || 3000;
        this.maxReconnectAttempts = options.maxReconnectAttempts || 10;
        this.heartbeatInterval = options.heartbeatInterval || 30000;
        
        this.connections = new Map();
        this.subscriptions = new Map();
        this.messageHandlers = new Map();
        this.reconnectAttempts = new Map();
        this.isRunning = false;
        
        // 스트림 타입 정의
        this.STREAM_TYPES = {
            TRADE: 'trade',
            KLINE_1M: 'kline_1m',
            KLINE_5M: 'kline_5m',
            KLINE_15M: 'kline_15m',
            DEPTH: 'depth@100ms',
            DEPTH_20: 'depth20@100ms',
            TICKER: 'ticker',
            AGG_TRADE: 'aggTrade'
        };
    }
    
    /**
     * 스트림 이름 포맷팅
     */
    formatStreamName(symbol, streamType) {
        const normalizedSymbol = symbol.toLowerCase();
        
        // kline 타입의 경우 기간 지정 필요
        if (streamType.startsWith('kline_')) {
            return ${normalizedSymbol}@${streamType};
        }
        
        return ${normalizedSymbol}@${streamType};
    }
    
    /**
     * 스트림 배열을 청크로 분할
     */
    chunkArray(array, chunkSize) {
        const chunks = [];
        for (let i = 0; i < array.length; i += chunkSize) {
            chunks.push(array.slice(i, i + chunkSize));
        }
        return chunks;
    }
    
    /**
     * 스트림 구독 추가
     */
    subscribe(symbols, streamType, callback) {
        const streams = symbols.map(symbol => this.formatStreamName(symbol, streamType));
        
        streams.forEach(stream => {
            if (!this.subscriptions.has(stream)) {
                this.subscriptions.set(stream, []);
            }
            this.subscriptions.get(stream).push(callback);
        });
        
        this.messageHandlers.set(${streamType}_group, callback);
        
        if (this.isRunning) {
            this.restartConnections();
        }
        
        console.log([BinanceStreamManager] Subscribed to ${streams.length} streams for type ${streamType});
        return streams;
    }
    
    /**
     * WebSocket 연결 생성
     */
    createConnection(streamChunk, connectionId) {
        const streamsParam = streamChunk.join('/');
        const wsUrl = wss://stream.binance.com:9443/stream?streams=${streamsParam};
        
        console.log([Connection ${connectionId}] Connecting with ${streamChunk.length} streams...);
        
        const ws = new WebSocket(wsUrl);
        const connectionInfo = {
            id: connectionId,
            websocket: ws,
            streams: streamChunk,
            isConnected: false,
            heartbeatTimer: null
        };
        
        ws.onopen = () => {
            console.log([Connection ${connectionId}] Connected successfully);
            connectionInfo.isConnected = true;
            this.reconnectAttempts.set(connectionId, 0);
            
            // Heartbeat 설정
            connectionInfo.heartbeatTimer = setInterval(() => {
                if (ws.readyState === WebSocket.OPEN) {
                    ws.send(JSON.stringify({ method: 'PING' }));
                }
            }, this.heartbeatInterval);
        };
        
        ws.onmessage = (event) => {
            try {
                const message = JSON.parse(event.data);
                this.handleMessage(message);
            } catch (error) {
                console.error([Connection ${connectionId}] Message parse error:, error);
            }
        };
        
        ws.onerror = (error) => {
            console.error([Connection ${connectionId}] WebSocket error:, error);
        };
        
        ws.onclose = (event) => {
            console.warn([Connection ${connectionId}] Connection closed: code=${event.code}, reason=${event.reason});
            connectionInfo.isConnected = false;
            
            if (connectionInfo.heartbeatTimer) {
                clearInterval(connectionInfo.heartbeatTimer);
            }
            
            if (this.isRunning) {
                this.attemptReconnect(connectionId, streamChunk);
            }
        };
        
        this.connections.set(connectionId, connectionInfo);
        return ws;
    }
    
    /**
     * 메시지 처리 및 핸들러分发
     */
    handleMessage(message) {
        if (!message.stream || !message.data) {
            return;
        }
        
        const { stream, data } = message;
        const symbol = stream.split('@')[0].toUpperCase();
        const streamType = stream.split('@')[1];
        
        // 트레이드 데이터 처리
        if (streamType === 'trade' || streamType === 'aggTrade') {
            const tradeData = {
                eventType: 'trade',
                symbol: symbol,
                price: parseFloat(data.p),
                quantity: parseFloat(data.q),
                timestamp: data.T,
                isBuyerMaker: data.m,
                isAggTrade: streamType === 'aggTrade',
                tradeId: data.a,
                raw: data
            };
            this.notifyHandlers('trade_group', tradeData);
            this.notifyHandlers(symbol, tradeData);
        }
        
        // 캔들stick 데이터 처리
        else if (streamType.startsWith('kline')) {
            const kline = data.k;
            const klineData = {
                eventType: 'kline',
                symbol: symbol,
                interval: kline.i,
                open: parseFloat(kline.o),
                high: parseFloat(kline.h),
                low: parseFloat(kline.l),
                close: parseFloat(kline.c),
                volume: parseFloat(kline.v),
                closeTime: kline.T,
                isClosed: kline.x,
                raw: data
            };
            this.notifyHandlers('kline_group', klineData);
            this.notifyHandlers(${symbol}_kline, klineData);
        }
        
        // 깊이 데이터 처리
        else if (streamType.includes('depth')) {
            const depthData = {
                eventType: 'depth',
                symbol: symbol,
                bids: data.b.map(([p, q]) => ({ price: parseFloat(p), quantity: parseFloat(q) })),
                asks: data.a.map(([p, q]) => ({ price: parseFloat(p), quantity: parseFloat(q) })),
                updateId: data.u,
                lastUpdateId: data.lastUpdateId,
                raw: data
            };
            this.notifyHandlers('depth_group', depthData);
            this.notifyHandlers(${symbol}_depth, depthData);
        }
        
        // 티커 데이터 처리
        else if (streamType === 'ticker') {
            const tickerData = {
                eventType: 'ticker',
                symbol: symbol,
                lastPrice: parseFloat(data.c),
                priceChange: parseFloat(data.p),
                priceChangePercent: parseFloat(data.P),
                high: parseFloat(data.h),
                low: parseFloat(data.l),
                volume: parseFloat(data.v),
                quoteVolume: parseFloat(data.q),
                raw: data
            };
            this.notifyHandlers('ticker_group', tickerData);
            this.notifyHandlers(${symbol}_ticker, tickerData);
        }
    }
    
    /**
     * 핸들러通知
     */
    notifyHandlers(key, data) {
        const handlers = this.subscriptions.get(key) || [];
        handlers.forEach(handler => {
            try {
                handler(data);
            } catch (error) {
                console.error([Handler Error] ${key}:, error);
            }
        });
    }
    
    /**
     * 재연결 시도 (지수 백오프)
     */
    attemptReconnect(connectionId, streamChunk) {
        const attempts = this.reconnectAttempts.get(connectionId) || 0;
        
        if (attempts >= this.maxReconnectAttempts) {
            console.error([Connection ${connectionId}] Max reconnect attempts reached. Giving up.);
            return;
        }
        
        const delay = Math.min(this.reconnectDelay * Math.pow(2, attempts), 60000);
        console.log([Connection ${connectionId}] Reconnecting in ${delay}ms (attempt ${attempts + 1}/${this.maxReconnectAttempts}));
        
        setTimeout(() => {
            if (this.isRunning) {
                this.reconnectAttempts.set(connectionId, attempts + 1);
                this.createConnection(streamChunk, connectionId);
            }
        }, delay);
    }
    
    /**
     * 연결 재시작
     */
    restartConnections() {
        this.closeAllConnections();
        
        const allStreams = [];
        this.subscriptions.forEach((handlers, stream) => {
            if (!allStreams.includes(stream)) {
                allStreams.push(stream);
            }
        });
        
        const streamChunks = this.chunkArray(allStreams, this.maxStreamsPerConnection);
        
        console.log([BinanceStreamManager] Creating ${streamChunks.length} connections for ${allStreams.length} streams);
        
        streamChunks.forEach((chunk, index) => {
            this.createConnection(chunk, index);
        });
    }
    
    /**
     * 모든 연결 종료
     */
    closeAllConnections() {
        this.connections.forEach((connInfo, id) => {
            if (connInfo.heartbeatTimer) {
                clearInterval(connInfo.heartbeatTimer);
            }
            if (connInfo.websocket) {
                connInfo.websocket.close(1000, 'Manager shutdown');
            }
        });
        this.connections.clear();
    }
    
    /**
     * 구독 시작
     */
    start() {
        this.isRunning = true;
        this.restartConnections();
        console.log('[BinanceStreamManager] Started');
    }
    
    /**
     * 구독 중지
     */
    stop() {
        this.isRunning = false;
        this.closeAllConnections();
        console.log('[BinanceStreamManager] Stopped');
    }
    
    /**
     * 특정 스트림 구독 취소
     */
    unsubscribe(symbol, streamType) {
        const streamName = this.formatStreamName(symbol, streamType);
        this.subscriptions.delete(streamName);
        console.log([BinanceStreamManager] Unsubscribed from ${streamName});
    }
    
    /**
     * 연결 상태 조회
     */
    getStatus() {
        const status = {
            isRunning: this.isRunning,
            totalConnections: this.connections.size,
            totalSubscriptions: this.subscriptions.size,
            connections: []
        };
        
        this.connections.forEach((connInfo, id) => {
            status.connections.push({
                id: id,
                streams: connInfo.streams.length,
                isConnected: connInfo.isConnected
            });
        });
        
        return status;
    }
}

// 사용 예제
function demo() {
    const manager = new BinanceStreamManager({
        maxStreamsPerConnection: 25,
        reconnectDelay: 2000,
        maxReconnectAttempts: 10
    });
    
    // 주요 거래쌍 목록
    const majorPairs = [
        'BTCUSDT', 'ETHUSDT', 'BNBUSDT', 'SOLUSDT', 'XRPUSDT',
        'ADAUSDT', 'DOGEUSDT', 'DOTUSDT', 'MATICUSDT', 'LINKUSDT'
    ];
    
    // 트레이드 데이터 구독
    manager.subscribe(majorPairs, 'trade', (data) => {
        // console.log(Trade: ${data.symbol} @ ${data.price});
    });
    
    // 1분봉 캔들stick 구독
    manager.subscribe(majorPairs, 'kline_1m', (data) => {
        if (data.isClosed) {
            console.log([${data.symbol}] Kline Closed: O=${data.open} H=${data.high} L=${data.low} C=${data.close});
        }
    });
    
    // 티커 데이터 구독
    manager.subscribe(majorPairs, 'ticker', (data) => {
        const changeSign = data.priceChangePercent >= 0 ? '+' : '';
        console.log([${data.symbol}] Price: $${data.lastPrice} (${changeSign}${data.priceChangePercent.toFixed(2)}%));
    });
    
    // 연결 상태 주기적 로깅
    setInterval(() => {
        const status = manager.getStatus();
        console.log([Status] Running: ${status.isRunning}, Connections: ${status.totalConnections});
    }, 10000);
    
    manager.start();
    
    // 5분 후 자동 종료 (데모용)
    setTimeout(() => {
        console.log('Demo completed. Stopping...');
        manager.stop();
    }, 300000);
    
    return manager;
}

// Node.js 환경 export
if (typeof module !== 'undefined' && module.exports) {
    module.exports = { BinanceStreamManager };
}

이 JavaScript 구현에서는 EventEmitter 유사 패턴을 사용하여 각 스트림 타입별로 독립적인 콜백을 등록할 수 있습니다. 또한 연결 상태를 주기적으로 모니터링하고, 재연결 시 지수 백오프를 적용하여 서버에 과부하를 방지합니다. manager.getStatus() 메서드를 통해 현재 연결 상태와 구독 정보를 실시간으로 확인할 수 있어 프로덕션 환경에서의 모니터링에 유용합니다.

연결 수 최적화 및 메모리 관리

다중 거래대상 구독에서 자주 간과되는 부분이 연결 수와 메모리 사용량입니다. Binance官方 권장사항에 따르면 단일 IP당 연결 수 제한이 있으며, 저는 다음 공식으로 최적 연결 수를 산출합니다. 전체 스트림 수를 20으로 나눈 값(올림)이 기본 연결 수이며, 각 연결의 메모리 사용량이 50MB를 초과하면 연결 수를 늘려 부하를 분산시킵니다. 실제 측정 결과, 25개 스트림 기준 약 35~45MB의 메모리를 사용하며, 메시지 처리 속도가 초당 1000건 이상일 경우 CPU 사용량이 급격히 증가합니다.

메모리 최적화를 위해 저는 다음 세 가지 전략을 적용합니다. 첫째, 메시지 버퍼 크기를 제한하여 오래된 메시지를 자동으로 폐기합니다. 둘째, 비동기 처리 큐를 사용하여 메시지 처리를 백그라운드로 분산시킵니다. 셋째, 필요 없는 데이터 필드를 필터링하여 전송 데이터량을 줄입니다. 아래는 메모리 모니터링 및 자동 조정 로직의 예시입니다.

import asyncio
import psutil
import gc
from typing import Optional

class AdaptiveConnectionManager:
    """적응형 연결 관리자: 시스템 리소스에 따라 연결 수 자동 조절"""
    
    def __init__(self, stream_manager, config=None):
        self.stream_manager = stream_manager
        self.config = config or {}
        
        # 자동 조절 설정
        self.min_streams_per_connection = self.config.get('min_streams_per_connection', 15)
        self.max_streams_per_connection = self.config.get('max_streams_per_connection', 30)
        self.target_memory_mb = self.config.get('target_memory_mb', 500)
        self.check_interval = self.config.get('check_interval', 30)
        
        self.memory_history = []
        self.is_adaptive = True
        self.process = psutil.Process()
        
    async def monitor_and_adjust(self):
        """메모리 사용량 모니터링 및 연결 조정"""
        while self.is_adaptive:
            await asyncio.sleep(self.check_interval)
            
            memory_info = self.get_memory_usage()
            self.memory_history.append(memory_info)
            
            # 최근 5개 데이터의 평균 계산
            if len(self.memory_history) >= 5:
                avg_memory = sum(self.memory_history[-5:]) / 5
                
                if memory_info > self.target_memory_mb * 1.2:
                    # 메모리 사용량 초과 시 연결 수 감소
                    await self.reduce_connections()
                elif memory_info < self.target_memory_mb * 0.7 and avg_memory < self.target_memory_mb * 0.8:
                    # 메모리 여유 시 연결 수 증가
                    await self.increase_connections()
            
            # 주기적 가비지 컬렉션
            if len(self.memory_history) % 10 == 0:
                gc.collect()
                print(f"[AdaptiveManager] GC executed. Memory: {memory_info:.2f} MB")
    
    def get_memory_usage(self) -> float:
        """현재 프로세스 메모리 사용량 (MB)"""
        mem_info = self.process.memory_info()
        return mem_info.rss / 1024 / 1024
    
    async def reduce_connections(self):
        """연결당 스트림 수 감소 (연결 수 증가)"""
        current = self.stream_manager.max_streams_per_connection
        new_value = max(self.min_streams_per_connection, current - 5)
        
        if new_value != current:
            print(f"[AdaptiveManager] Reducing streams per connection: {current} -> {new_value}")
            self.stream_manager.max_streams_per_connection = new_value
            self.stream_manager.restartConnections()
    
    async def increase_connections(self):
        """연결당 스트림 수 증가 (연결 수 감소)"""
        current = self.stream_manager.max_streams_per_connection
        new_value = min(self.max_streams_per_connection, current + 5)
        
        if new_value != current:
            print(f"[AdaptiveManager] Increasing streams per connection: {current} -> {new_value}")
            self.stream_manager.max_streams_per_connection = new_value
            self.stream_manager.restartConnections()
    
    async def start_adaptive_monitoring(self):
        """적응형 모니터링 시작"""
        monitor_task = asyncio.create_task(self.monitor_and_adjust())
        return monitor_task
    
    def stop_adaptive_monitoring(self):
        """적응형 모니터링 중지"""
        self.is_adaptive = False

자주 발생하는 오류와 해결책

다중 거래대상 WebSocket 구독에서 제가 실제로 경험한 주요 오류들과 구체적인 해결 방법을 정리했습니다. 각 오류는 실제 프로덕션 환경에서 발생한 사례 기반입니다.

오류 1: ECONNREFUSED - 연결 거부됨

오류 메시지: websockets.exceptions.InvalidURI: invalid URI 'wss://stream.binance.com:9443/stream?streams=...' / ConnectionRefusedError: [WinError 10061]

원인 분석: Binance 서버가 일시적으로 연결을 거부하거나, 방화벽/프록시 설정으로 인해 연결이 차단된 경우입니다. 특히 기업 네트워크 환경이나 공유 호스팅에서 자주 발생합니다.

해결 코드:

import asyncio
import socket
from urllib.parse import urlparse

async def test_connection_with_fallback():
    """다중 엔드포인트 폴백을 통한 연결 테스트"""
    
    # Binance WebSocket 엔드포인트 목록 (장애时可使用 대체 서버)
    endpoints = [
        "wss://stream.binance.com:9443/stream",
        "wss://stream.binance.com:943/stream",
        "wss://stream.binance.com:8443/stream"
    ]
    
    async def test_endpoint(endpoint):
        """단일 엔드포인트 연결 테스트"""
        try:
            parsed = urlparse(endpoint)
            # DNS 확인
            ip = socket.gethostbyname(parsed.hostname)
            print(f"Endpoint {endpoint} -> IP: {ip}")
            
            # WebSocket 연결 테스트 (스트림 없이)
            uri = f"{endpoint}?streams=btcusdt@trade"
            async with websockets.connect(uri, timeout=5) as ws:
                print(f"Endpoint {endpoint} is accessible")
                return endpoint
                
        except socket.gaierror as e:
            print(f"DNS resolution failed for {endpoint}: {e}")
        except ConnectionRefusedError:
            print(f"Connection refused for {endpoint}")
        except asyncio.TimeoutError:
            print(f"Timeout connecting to {endpoint}")
        except Exception as e:
            print(f"Error testing {endpoint}: {type(e).__name__}: {e}")
        
        return None
    
    # 모든 엔드포인트 테스트
    for endpoint in endpoints:
        result = await test_endpoint(endpoint)
        if result:
            print(f"Using endpoint: {result}")
            return result
    
    # 모든 엔드포인트 실패 시 재시도 로직
    print("All endpoints failed. Retrying in 30 seconds...")
    await asyncio.sleep(30)
    return await test_connection_with_fallback()

방화벽/프록시 환경 설정

def configure_proxy(): """프록시 환경에서 WebSocket 사용 설정""" import os # 환경 변수 설정 proxy_url = os.environ.get('HTTPS_PROXY') or os.environ.get('HTTP_PROXY') if proxy_url: print(f"Using proxy: {proxy_url}") # websockets 라이브러리는 기본적으로 시스템 프록시 설정 사용 # 별도 설정이 필요하면 custom HTTP 헤더 사용 return proxy_url return None

오류 2: 1006: Abnormal Closure - 비정상 종료

오류 메시지: websockets.exceptions.ConnectionClosed: WebSocket connection is closed (code 1006, reason=None)

원인 분석: 서버가 클라이언트 없이 연결을 종료한 경우입니다. 일반적으로 서버측 rate limit 초과, 잘못된 구독 요청, 또는 서버 재시작으로 발생합니다. Binance는 단일 연결당 메시지频率를 모니터링하며 비정상적인 패턴을 감지하면 연결을 종료합니다.

해결 코드:

import asyncio
from collections import deque
import time

class RateLimitProtector:
    """Rate Limit 보호 및 메시