자동매매 장애 복구 설계

자동매매 봇은 왜 멈추는가

자동매매 시스템을 운영하다 보면 예상치 못한 장애로 봇이 멈추는 상황을 반드시 경험합니다. 거래소 API 타임아웃, 네트워크 단절, 서버 재부팅, 메모리 누수 등 원인은 다양합니다. 문제는 장애 자체가 아니라 복구 전략의 부재입니다. 이 글에서는 자동매매 봇의 장애 복구(Fault Recovery) 설계를 체계적으로 다룹니다.

장애 유형 분류

자동매매 시스템에서 발생하는 장애는 크게 세 가지로 나뉩니다.

  • 일시적 장애(Transient): API Rate Limit, 네트워크 지연, 일시적 서버 오류(HTTP 5xx). 재시도하면 해결됩니다.
  • 반복적 장애(Persistent): 거래소 점검, DNS 장애, 인증 토큰 만료. 단순 재시도로는 해결되지 않으며 별도 로직이 필요합니다.
  • 치명적 장애(Fatal): 서버 크래시, 디스크 풀, OOM Kill. 프로세스 자체가 종료되므로 외부 감시가 필수입니다.

재시도 전략: 지수 백오프 구현

일시적 장애에 가장 효과적인 패턴은 지수 백오프(Exponential Backoff)입니다. 단순 반복 재시도는 거래소 측에서 IP 차단을 유발할 수 있으므로, 재시도 간격을 점진적으로 늘려야 합니다.

import time
import random

def retry_with_backoff(func, max_retries=5, base_delay=1.0):
    for attempt in range(max_retries):
        try:
            return func()
        except Exception as e:
            if attempt == max_retries - 1:
                raise
            delay = base_delay * (2 ** attempt) + random.uniform(0, 1)
            print(f"재시도 {attempt+1}/{max_retries}, {delay:.1f}초 대기: {e}")
            time.sleep(delay)

핵심 포인트는 jitter(무작위 지연)를 추가하는 것입니다. 여러 봇이 동시에 재시도하면 거래소에 부하가 집중되는 thundering herd 문제가 발생합니다. random.uniform(0, 1)로 분산시킵니다.

상태 저장과 복원: 체크포인트 패턴

봇이 재시작되면 마지막 상태를 복원할 수 있어야 합니다. 이를 체크포인트(Checkpoint) 패턴이라 합니다.

import json
from pathlib import Path

CHECKPOINT_FILE = "bot_state.json"

def save_checkpoint(state: dict):
    """봇 상태를 디스크에 저장"""
    tmp = Path(CHECKPOINT_FILE + ".tmp")
    tmp.write_text(json.dumps(state, default=str))
    tmp.rename(CHECKPOINT_FILE)  # 원자적 쓰기

def load_checkpoint() -> dict:
    """저장된 상태 복원"""
    path = Path(CHECKPOINT_FILE)
    if path.exists():
        return json.loads(path.read_text())
    return {"positions": [], "last_order_id": None, "balance_snapshot": 0}

반드시 원자적 쓰기(atomic write)를 사용하세요. 파일에 직접 쓰다가 크래시하면 상태 파일이 손상됩니다. 임시 파일에 쓴 후 rename하는 것이 안전합니다.

체크포인트에 저장해야 할 항목

  • 현재 보유 포지션 (심볼, 수량, 진입가)
  • 마지막 주문 ID와 상태
  • 잔고 스냅샷
  • 마지막 처리한 캔들 타임스탬프
  • 전략별 내부 상태 (이동평균 값, 시그널 등)

프로세스 감시: systemd와 헬스체크

치명적 장애에 대비하려면 봇 외부에서 프로세스를 감시하고 자동 재시작해야 합니다. Linux 환경이라면 systemd가 가장 실용적입니다.

# /etc/systemd/system/trading-bot.service
[Unit]
Description=Auto Trading Bot
After=network.target

[Service]
Type=simple
ExecStart=/usr/bin/python3 /opt/bot/main.py
Restart=on-failure
RestartSec=30
WatchdogSec=120
Environment=PYTHONUNBUFFERED=1

[Install]
WantedBy=multi-user.target

WatchdogSec=120은 봇이 120초 내에 heartbeat를 보내지 않으면 systemd가 강제 재시작합니다. 봇 코드에서 주기적으로 watchdog 알림을 보내야 합니다.

import sdnotify

notifier = sdnotify.SystemdNotifier()
notifier.notify("READY=1")

# 메인 루프에서 주기적으로
while True:
    run_strategy()
    notifier.notify("WATCHDOG=1")

포지션 동기화: 거래소 실제 상태 확인

장애 복구에서 가장 위험한 부분은 포지션 불일치입니다. 봇이 주문을 보냈지만 응답을 받기 전에 크래시하면, 로컬 상태와 거래소 실제 포지션이 달라집니다.

def reconcile_positions(local_state, exchange_client):
    """로컬 상태와 거래소 실제 포지션 동기화"""
    exchange_positions = exchange_client.get_positions()
    exchange_map = {p['symbol']: p for p in exchange_positions}
    
    for local_pos in local_state['positions']:
        symbol = local_pos['symbol']
        if symbol in exchange_map:
            actual = exchange_map[symbol]
            if abs(local_pos['qty'] - actual['qty']) > 0.0001:
                print(f"⚠️ 포지션 불일치: {symbol}")
                print(f"  로컬: {local_pos['qty']}, 실제: {actual['qty']}")
                local_pos['qty'] = actual['qty']  # 거래소 기준으로 보정
        else:
            print(f"⚠️ 거래소에 없는 포지션: {symbol}")
            local_pos['qty'] = 0
    
    return local_state

핵심 원칙: 거래소 상태가 항상 진실(Source of Truth)입니다. 로컬 상태와 불일치하면 거래소 기준으로 보정합니다.

알림 체계: 장애를 빠르게 인지하기

자동 복구가 아무리 잘 설계되어도, 운영자가 장애 상황을 인지하는 것이 중요합니다. 텔레그램 알림이 가장 보편적입니다.

import requests

def send_alert(message: str, level: str = "WARNING"):
    emoji = {"INFO": "ℹ️", "WARNING": "⚠️", "CRITICAL": "🚨"}
    text = f"{emoji.get(level, '📢')} [{level}] {message}"
    requests.post(
        f"https://api.telegram.org/bot{BOT_TOKEN}/sendMessage",
        json={"chat_id": CHAT_ID, "text": text}
    )

알림 레벨을 구분하세요. 재시도 성공은 INFO, 3회 이상 재시도는 WARNING, 프로세스 재시작은 CRITICAL로 분류하면 알림 피로(alert fatigue)를 줄일 수 있습니다.

서킷 브레이커: 연쇄 장애 방지

장애가 반복되면 무한 재시도 대신 서킷 브레이커(Circuit Breaker)로 봇을 안전하게 정지시켜야 합니다. 관련 내용은 자동매매 주문 큐 설계 글에서도 다룬 바 있습니다.

class CircuitBreaker:
    def __init__(self, failure_threshold=5, reset_timeout=300):
        self.failures = 0
        self.threshold = failure_threshold
        self.reset_timeout = reset_timeout
        self.last_failure_time = 0
        self.state = "CLOSED"  # CLOSED → OPEN → HALF_OPEN
    
    def record_failure(self):
        self.failures += 1
        self.last_failure_time = time.time()
        if self.failures >= self.threshold:
            self.state = "OPEN"
            send_alert("서킷 브레이커 OPEN — 매매 중단", "CRITICAL")
    
    def can_execute(self):
        if self.state == "CLOSED":
            return True
        if self.state == "OPEN":
            if time.time() - self.last_failure_time > self.reset_timeout:
                self.state = "HALF_OPEN"
                return True
            return False
        return True  # HALF_OPEN: 시험적 실행

실전 복구 플로우 정리

자동매매 봇의 장애 복구 전체 플로우를 정리하면 다음과 같습니다.

  1. 프로세스 시작 → 체크포인트 파일 로드
  2. 거래소 포지션 조회 → 로컬 상태와 비교·동기화
  3. 미체결 주문 확인 → 오래된 주문 취소 또는 상태 갱신
  4. 서킷 브레이커 상태 확인 → OPEN이면 대기, CLOSED면 전략 실행
  5. 전략 루프 진입 → 주기적으로 체크포인트 저장 + watchdog 알림
  6. 장애 발생 시 → 지수 백오프 재시도 → 실패 누적 시 서킷 브레이커 작동

이 플로우를 체계적으로 구현하면 대부분의 장애 상황에서 데이터 손실 없이 자동 복구가 가능합니다. 더 자세한 리스크 관리 방법은 자동매매 로그 모니터링 구축 글도 참고하세요.

마무리

자동매매 봇의 수익률만큼 중요한 것이 안정성입니다. 아무리 좋은 전략도 봇이 멈추면 소용없습니다. 체크포인트 저장, 포지션 동기화, 지수 백오프, 서킷 브레이커, 프로세스 감시 — 이 다섯 가지가 장애 복구의 핵심입니다. 처음부터 완벽하게 구현할 필요는 없지만, 체크포인트와 포지션 동기화만큼은 반드시 구현하고 운영하시길 권합니다.

위로 스크롤
WordPress Appliance - Powered by TurnKey Linux