Quant Trading Bot Devlog

Read in English

테스트가 목킹하지 않은 함수 하나가 운영 DB를 오염시켰다

이틀 연속 재발하고서야 잡은 근본원인 - 로컬 임포트와 파이썬 기본인자 바인딩

며칠 전, 모의투자 계좌 하나가 이틀 연속 같은 이유로 거래를 멈췄습니다.

원인을 찾다가 처음엔 "테스트 스크립트가 실수로 실 DB에 뭔가 썼나 보다"로 결론 내고 넘어갔습니다. 다음날 똑같은 일이 또 일어나고 나서야 진짜 원인을 잡았습니다.

무슨 일이 있었는지

집행 워치독이 경보를 보냈습니다. 모의투자 계좌 하나가 예정된 라운드마다 안전장치(보유수량 초과 감지 게이트)에 걸려 거래를 멈추고 있었습니다.

크래시는 아니었습니다. 안전장치가 "장부상 보유수량이 브로커 실제 보유수량보다 많다"를 정확히 감지하고, 설계된 대로 거래를 막은 것이었습니다.

문제는 장부가 왜 브로커 실보유보다 많아졌느냐였습니다.

거래 원장을 열어보니 이상한 행 몇 개가 보였습니다. 정상 체결이라면 주문 접수·체결 저널이 같이 남아야 하는데, 이 행들에는 그게 하나도 없었습니다.

라운드 식별자도 비어 있었고, 단가는 실제 체결가의 정확히 1/1000이었습니다. 누군가 혹은 무언가가 장부 기록 함수를 직접 호출한 흔적이었습니다.

처음엔 뭐라고 생각했는지

시간대를 보니 그 며칠 전 스케일 가드(체결가 이상치를 걸러내는 안전장치)를 추가하던 작업 시점과 겹쳤습니다.

그래서 "그 작업 중에 돌리던 테스트나 디버그 스크립트가 실수로 진짜 DB에 썼을 것"이라고 결론지었습니다. 정확히 어떤 스크립트였는지는 특정하지 못했습니다.

이상 행을 백업해두고 삭제해서 장부를 브로커 실보유와 다시 맞췄습니다. 재발 방지책도 마땅히 안 세우고 "그때 그 일회성 사고"로 종결 처리했습니다.

이 판단이 틀렸다는 게 밝혀지는 데는 하루가 걸렸습니다.

진짜 원인

다음날 같은 패턴이 또 나타났습니다. 이번엔 원인을 끝까지 추적했습니다.

범인은 최근에 추가한 회귀 테스트 파일이었습니다. 이 테스트는 체결가 스케일 가드가 정상 동작하는지 확인하는 목적으로, 브로커와 저널 디렉터리는 전부 더미로 격리해뒀습니다.

그런데 딱 하나, "체결이 정상 승인되면 장부에 반영한다"는 성공 경로의 마지막 함수 하나를 격리하지 않았습니다. 이 함수를 부르는 상위 함수가 그 함수를 모듈 최상단이 아니라 함수 내부에서 지역 임포트하고 있었기 때문입니다.

지역 임포트는 호출되는 그 순간 다시 조회를 합니다. 그래서 테스트 안에서 상위 모듈의 "DB 경로 상수"를 패치해도, 이 함수는 그 패치를 보지 못하고 늘 원래 실제 경로로 기록했습니다.

게다가 이 함수의 DB 경로는 파이썬 함수 정의 시점(임포트 시점)에 이미 실제 경로 문자열로 바인딩돼 있었습니다. 나중에 모듈 상수를 바꿔치기해도 이미 굳어진 기본값에는 영향을 주지 못하는, 파이썬의 흔한 기본인자 함정이었습니다.

결과적으로 이 테스트의 "성공 부기 경로"를 검증하는 테스트 케이스들은 실행될 때마다 실제 운영 DB에 가짜 체결 행을 그대로 남겼습니다. pytest 전체 스위트를 돌릴 때마다요.

이틀 전 사고와 이번 사고 둘 다 이 테스트가 원인이었습니다. 다만 이틀 전엔 이 사실을 몰랐을 뿐입니다.

어떻게 고쳤는지

수선은 두 갈래로 했습니다.

첫째, 놓쳤던 그 함수를 명시적으로 목킹 대상에 추가했습니다. 지역 임포트라 호출 시점에 이름을 다시 찾는다는 걸 이용해서, 모듈 경로를 통째로 패치하는 방식으로 바꿨습니다. 회귀 테스트를 돌려서 DB가 실제로 전혀 변하지 않는 것까지 확인했습니다.

둘째, 이 DB 파일을 "테스트 도중 바뀌면 안 되는 프로덕션 관측 파일" 감시 목록에 새로 등재했습니다. 이 목록은 테스트 스위트 종료 시점에 지정된 파일들의 변경 여부를 검사해서, 격리가 새는 순간 즉시 실패시키는 안전장치입니다.

이 목록이 애초에 있었다면 이 파일이 처음부터 거기 있었어야 했습니다. 사고가 나고서야 등재됐다는 게, 뒤집어보면 "격리 누락을 잡는 장치 자체도 완전할 수 없다"는 뜻이기도 합니다.

일반화하면

목(mock)은 "정의된 곳"이 아니라 "조회되는 곳"에서 패치해야 합니다. 어떤 함수가 다른 모듈의 상수나 함수를 지역 임포트로 매번 새로 불러온다면, 그 상위 모듈에서 상수를 바꿔치기해봐야 소용없습니다. 지역 임포트가 있다는 건 그 자리에서 호출 시점 조회가 일어난다는 뜻이고, 패치도 그 호출 시점 이름 공간을 정확히 겨냥해야 합니다.

파이썬 기본인자는 함수가 정의되는 순간(모듈 임포트 시점)에 평가되어 굳어집니다. 나중에 참조하던 모듈 변수를 바꿔도, 이미 굳어진 기본값 자체는 바뀌지 않습니다. 이 두 특성이 겹치면 "패치했다고 생각했는데 안 먹힌" 상황이 아주 조용히 만들어집니다.

회귀 테스트를 쓸 때는 테스트 대상 함수가 내부에서 호출하는 모든 부수효과 경로를 전수 확인해야 합니다. 이번 경우 브로커 호출과 텔레그램 발송은 목킹했지만, DB 부기라는 세 번째 부수효과 하나를 놓쳤습니다. "이 함수가 실제로 손대는 것" 목록을 코드를 읽으며 하나씩 세어보지 않으면 이런 구멍이 남습니다.

첫 발생에서 원인을 특정하지 못했다면, 그 사실 자체를 다음 발생과 이어붙일 장치를 남겨둬야 합니다. 처음엔 "일회성 사고"로 정리하고 넘어갔는데, 재발하고 나서야 두 사고가 같은 원인이라는 게 드러났습니다. 이례적인 데이터 흔적(호출 패턴, 타임스탬프 간격, 값의 배율 오차 등)은 삭제하기 전에 기록해두면, 다음 재발 때 훨씬 빨리 연결할 수 있습니다.

성공 경로도 실패 경로만큼 철저히 격리해야 합니다. 테스트를 짤 때 "이 함수가 실패하면 어떻게 되나"는 신경 쓰기 쉬운데, "이 함수가 성공하면 무엇을 건드리나"는 상대적으로 덜 의심하게 됩니다. 이번 사고의 진원지도 정확히 그 성공 경로였습니다.