AlgoLab Blog · 운영 실무 · 트러블슈팅 · 2026

자동매매 봇 메모리 누수 — pd.concat이 173배 느려진다

운영 · 트러블슈팅 2026-10-19 · 약 7분 읽기 · 알고랩 AlgoLab
한 줄 요약

며칠 돌리면 느려지다 꺼지는 파이썬 자동매매 봇의 원인은 대부분 "틱·체결을 메모리에 끝없이 쌓는 코드"입니다. 직접 재 보니 체결 틱을 dict로 리스트에 쌓으면 건당 약 352바이트(100만 건 336MB)가 늘고, pandas DataFrame에 pd.concat으로 한 줄씩 붙이면 한 번 붙이는 데 1천 행일 때 0.142ms → 100만 행일 때 24.559ms(173배)가 걸립니다. 확인은 psutil로 RSS(실제 점유 메모리)가 오르기만 하는지 기록하고, 범인 줄은 tracemalloc 스냅샷 두 장을 compare_to로 비교해 찾습니다. 고치는 법은 deque(maxlen=…)로 상한 두기, 리스트에 모았다가 한 번에 DataFrame 만들기, 캐시 크기 제한, 재연결 때 핸들러 재등록 금지 네 가지입니다. (측정: Windows 11 · Python 3.10.4 · pandas 2.2.3 · psutil 7.2.2, 2026-10-19)

목차

  1. 느려지는 봇의 네 가지 모양
  2. 실측 — 얼마나 쌓이고 얼마나 느려지나
  3. 범인 찾기 — psutil로 확인, tracemalloc으로 지목
  4. 고치는 코드 4가지
  5. 운영에서 지키는 세 줄
  6. 자주 묻는 질문

1. 느려지는 봇의 네 가지 모양

"첫날은 멀쩡했는데 금요일쯤 되면 주문이 늦게 나간다", "주말 지나고 보니 프로그램이 꺼져 있다" — 납품 후 받는 문의 중 꽤 많은 것이 이 모양입니다. 파이썬은 쓰레기 수집(GC)이 있어서 C처럼 메모리를 잃어버리는 진짜 누수는 드뭅니다. 대신 참조가 살아 있는 채로 계속 커지는 자료구조가 있으면 GC도 손댈 수 없습니다. 자동매매 코드에서 반복해서 보는 건 아래 네 가지입니다.

모양흔한 코드증상
① 틱 무한 적재웹소켓 콜백에서 ticks.append(msg)메모리가 장중 내내 직선으로 증가
② DataFrame 누적틱마다 df = pd.concat([df, new])메모리보다 처리 지연이 먼저 옴
③ 끝없는 캐시시각·주문번호를 키로 하는 dict 캐시, lru_cache(maxsize=None)지표 계산은 빨라 보이는데 메모리만 증가
④ 재연결마다 재등록재연결 함수 안의 addHandler·콜백 등록로그·주문 로직이 N번 실행

①~③은 한국투자증권(KIS) 웹소켓의 H0STCNT0(국내주식 실시간체결가)든 키움 REST 웹소켓의 0B(주식체결)든, 업비트 websocket이든 똑같이 생깁니다. 실시간 데이터는 하루 종일 들어오는데 버리는 코드가 없기 때문입니다. 실시간 구독 자체는 KIS 웹소켓 글과 키움 0B·0D 글에 정리했습니다.

2. 실측 — 얼마나 쌓이고 얼마나 느려지나

체결 틱 하나를 {"stk_cd": "005930", "cur_prc": 70123, "trde_qty": 57, "tm": "093015"} 모양의 dict로 만들고, 패턴별로 쌓아 가며 psutil.Process().memory_info().rss를 찍었습니다. Windows에서 psutil의 rss는 작업 집합(Working Set)입니다.

① 리스트에 쌓기 vs deque(maxlen=2000)

list    200000 RSS +   67.9 MB  (356 B/tick)
list    400000 RSS +  134.9 MB  (354 B/tick)
list    600000 RSS +  201.9 MB  (353 B/tick)
list    800000 RSS +  269.0 MB  (353 B/tick)
list   1000000 RSS +  336.0 MB  (352 B/tick)
after del RSS +5.4 MB

틱 한 건에 약 352바이트, 정확히 직선입니다. 마지막 줄이 중요합니다 — 리스트를 del하자 5.4MB만 남고 돌아왔습니다. 파이썬이 메모리를 잃어버린 게 아니라, 코드가 붙들고 있었던 것입니다. 같은 루프를 deque(maxlen=2000)로 바꾸면 20만 건 내내 +1.7MB에서 움직이지 않았습니다.

② pd.concat으로 한 줄씩 붙이기

행 수가 n인 DataFrame에 한 줄짜리 DataFrame을 pd.concat으로 붙이는 데 걸린 시간(50회 평균)입니다.

concat onto      1000 rows:    0.142 ms/append  df mem     0.1 MB
concat onto     10000 rows:    0.209 ms/append  df mem     1.4 MB
concat onto    100000 rows:    1.737 ms/append  df mem    13.5 MB
concat onto    500000 rows:   11.814 ms/append  df mem    67.7 MB
concat onto   1000000 rows:   24.559 ms/append  df mem    135.4 MB
pd.concat 1회 비용 (ms) — DataFrame 행 수별 1천 행 0.142 1만 행 0.209 10만 행 1.737 50만 행 11.814 100만 행 24.559
같은 한 줄을 붙여도 표가 커질수록 비용이 비례해서 커진다 — 1천 행 대비 100만 행 173배

pd.concat은 붙일 때마다 표 전체를 새로 복사합니다(pandas 공식 문서도 반복 호출 대신 리스트에 모아 한 번에 합치라고 안내합니다). 그래서 이 패턴은 메모리보다 시간이 먼저 문제가 됩니다. 하루 10만 건씩 쌓인다고 가정하면 5거래일째 50만 행, 이때 한 줄 붙이는 데 11.8ms — 초당 85건 이상 들어오면 처리가 들어오는 속도를 못 따라갑니다. 웹소켓 수신 큐가 밀리고, 주문 판단이 수 초씩 늦어지는 게 "금요일쯤 느려진다"의 정체입니다. 참고로 DataFrame.append는 pandas 2.0에서 삭제돼서, 옛 코드를 고치며 pd.concat으로 기계적으로 바꾼 곳에서 이 패턴이 자주 생깁니다.

③ 시각이 들어간 키로 캐시하기

   50000 calls  RSS    24.2 MB  len(cache)=50000
  100000 calls  RSS    48.3 MB  len(cache)=100000
  200000 calls  RSS    96.6 MB  len(cache)=200000

지표 계산 결과를 cache[("005930", 시각)]에 저장하는 코드입니다. 시각이 매번 바뀌니 캐시에 다시 맞는 일이 없고, 저장만 계속됩니다. 이 측정은 tracemalloc을 켠 상태라 수치가 부풀려져 있지만(아래 3장), 직선 증가라는 모양은 같습니다.

3. 범인 찾기 — psutil로 확인, tracemalloc으로 지목

1단계: 정말 계속 오르는지 기록한다

작업 관리자를 가끔 보는 걸로는 판단이 안 됩니다. 봇 안에서 10분마다 RSS를 로그에 남기십시오. 장 시작 후 오르다가 평평해지면 정상이고, 장중 내내 직선으로 오르거나 다음 날 더 높은 곳에서 시작하면 쌓이는 곳이 있다는 뜻입니다.

import os, logging, threading, psutil

log = logging.getLogger("bot")
proc = psutil.Process(os.getpid())

def log_memory(every_sec=600):
    mb = proc.memory_info().rss / 1024 / 1024
    log.info("RSS %.1f MB  threads=%d", mb, proc.num_threads())
    threading.Timer(every_sec, log_memory, [every_sec]).start()

스레드 수도 같이 찍는 이유는, 메모리가 아니라 재연결 때마다 스레드가 늘어나는 경우도 같은 "느려짐"으로 보이기 때문입니다. 로그를 어떻게 남기고 보관할지는 자동매매 로그·매매일지 글을 참고하십시오.

2단계: 어느 줄이 붙들고 있는지 찍는다

파이썬 표준 라이브러리 tracemalloc은 메모리를 할당한 파일·줄 번호를 기록합니다. 스냅샷을 두 번 찍고 차이를 보면 그 사이 가장 많이 늘어난 줄이 맨 위에 옵니다.

import tracemalloc
tracemalloc.start()
snap0 = tracemalloc.take_snapshot()
# ... 봇을 30분~몇 시간 돌린다 ...
snap1 = tracemalloc.take_snapshot()
for stat in snap1.compare_to(snap0, "lineno")[:3]:
    print(stat)

①번 리스트 실험에서 실제로 나온 결과입니다(경로만 줄였습니다).

\leak_lab.py:10: size=49.6 MiB (+49.6 MiB), count=600000 (+600000), average=87 B
\leak_lab.py:11: size=10.5 MiB (+10.5 MiB), count=200001 (+200001), average=55 B
\leak_lab.py:25: size=1586 KiB (+1586 KiB), count=2 (+2), average=793 KiB

10·11번 줄은 틱 dict(와 그 안의 시각 문자열)를 만드는 줄, 25번 줄은 ticks.append()가 있는 줄입니다. "틱을 만들고 버리지 않는다"가 숫자로 지목됩니다. pd.concat 실험에서는 맨 위 줄이 pandas\core\internals\concat.py:177로 나왔습니다 — 라이브러리 내부 줄이 나오면 tracemalloc.start(25)처럼 프레임 수를 늘리고 statistics("traceback")로 내 코드의 호출 위치까지 거슬러 올라가면 됩니다.

운영 계좌에서 tracemalloc을 상시 켜 두지 마십시오. 할당마다 위치를 저장하므로 그 자체가 메모리를 씁니다. 같은 20만 건 실험에서 RSS 증가가 tracemalloc 없이 67.9MB, 켜고 나서 147.7MB였습니다. 모의투자 계좌나 녹화해 둔 틱 재생 환경에서 몇 시간만 켜십시오. 시작부터 추적하려면 환경변수 PYTHONTRACEMALLOC=1 또는 python -X tracemalloc을 씁니다.

4. 고치는 코드 4가지

① 보관 개수에 상한을 둔다 — deque(maxlen)

from collections import deque
ticks = deque(maxlen=2000)        # 최근 2,000건만. 넘치면 가장 오래된 것이 자동으로 빠짐

def on_tick(msg):                 # KIS H0STCNT0 / 키움 0B 콜백
    ticks.append(msg)

이동평균·거래량 급증 판단에 필요한 건 대개 최근 수백~수천 건입니다. 전부 필요하면 메모리가 아니라 파일·SQLite로 보내십시오.

② 모았다가 한 번에 만든다

rows = []
def on_tick(msg):
    rows.append(msg)

def every_minute():               # 1분 봉을 만들 때만 DataFrame 생성
    global rows
    batch, rows = rows, []
    df = pd.DataFrame(batch)      # 10만 행을 한 번에 만드는 데 0.272초 (실측)
    ...

같은 10만 행을 pd.concat으로 한 줄씩 붙였다면 위 표대로 행이 늘수록 한 번에 1ms 넘게 걸립니다. 한 번에 만들면 0.272초면 끝납니다.

③ 캐시는 크기를 정한다

from functools import lru_cache

@lru_cache(maxsize=512)           # maxsize=None 이면 무한히 큼
def sma(stk_cd, bar_time, period):
    ...

키에 시각·주문번호처럼 계속 새로 생기는 값이 들어가면 캐시가 아니라 기록입니다. 날짜가 바뀔 때 sma.cache_clear()도 같이 부르십시오.

④ 재연결 함수에서는 연결만 다시 한다

웹소켓이 끊길 때마다 부르는 함수 안에 로거 설정이 들어 있으면 이렇게 됩니다(최초 연결 + 재연결 3번 후 실제 출력).

08:10:01 매수 주문 접수 005930 10주
08:10:01 매수 주문 접수 005930 10주
08:10:01 매수 주문 접수 005930 10주
08:10:01 매수 주문 접수 005930 10주
handlers: 4

로그만 네 번 찍히면 다행이고, 같은 자리에서 체결 콜백을 등록하고 있었다면 주문 판단이 네 번 실행됩니다. 핸들러·콜백 등록은 프로그램 시작 시 한 번만 하고, 재연결 함수에는 접속·인증(KIS approval_key, 키움 LOGIN)·구독 등록만 둡니다. 프로그램 자체가 두 개 떠서 주문이 두 번 나가는 건 다른 문제라 중복 실행 방지 글에서 따로 다뤘습니다.

5. 운영에서 지키는 세 줄

이 글의 수치는 한 PC에서 합성 틱으로 잰 값이라 실제 봇의 메모리 사용량은 데이터 구조·종목 수·라이브러리 버전에 따라 다릅니다. 측정 방법을 그대로 따라 자기 봇에서 직접 재 보는 것이 정확합니다. 납품 후 이런 문제를 누가 어떻게 챙기는지는 유지보수 비용 글에 정리했습니다.

6. 자주 묻는 질문

자동매매 봇이 며칠 지나면 느려지는데 메모리 누수인가요?

대부분은 엄밀한 의미의 누수가 아니라 데이터를 끝없이 쌓는 코드입니다. 틱을 리스트에 계속 append하거나 pandas DataFrame에 pd.concat으로 한 줄씩 붙이면 메모리와 처리 시간이 함께 늘어납니다. 알고랩 실측에서 pd.concat 한 번의 비용은 1천 행일 때 0.142ms, 100만 행일 때 24.559ms였습니다. psutil로 RSS를 기록해 계속 오르기만 하는지 먼저 확인하십시오.

tracemalloc을 운영 중인 봇에 계속 켜 둬도 되나요?

권하지 않습니다. tracemalloc은 할당마다 위치 정보를 저장하므로 그 자체가 메모리를 씁니다. 알고랩 실측에서 틱 20만 건 적재 시 RSS 증가가 tracemalloc 없이 67.9MB, 켠 상태에서 147.7MB였습니다. 모의투자나 재현 환경에서 몇 시간만 켜고 take_snapshot 두 개를 compare_to로 비교하는 용도로 쓰십시오.

매일 봇을 재시작하면 메모리 문제는 해결되나요?

증상은 가려지지만 원인은 남습니다. 장 마감 후 재시작은 좋은 운영 습관이지만, 하루 안에 쌓이는 양이 크면 장중에 이미 느려집니다. 틱 보관은 deque(maxlen=...)로 상한을 두고, 기록이 필요하면 파일이나 SQLite로 내보낸 뒤 메모리에서는 버리는 구조가 근본 해결입니다.

웹소켓 재연결 뒤 로그가 여러 번 찍히는 건 왜인가요?

재연결 함수 안에서 logging 핸들러나 콜백을 다시 등록하기 때문입니다. 최초 연결과 재연결 3번을 거치면 핸들러가 4개가 되어 같은 줄이 4번 찍힙니다. 핸들러 등록은 프로그램 시작 시 한 번만 하고, 재연결 함수에서는 연결과 구독(KIS H0STCNT0, 키움 0B 등)만 다시 하십시오.

며칠 돌려도 느려지지 않는 봇, 만들어 드립니다

지금 쓰는 봇이 점점 느려진다면 코드를 보고 쌓이는 곳부터 찾아 드립니다. 24시간 빠른 답변 가능합니다.

무료 상담 시작하기