정렬 기준값이 통째로 사라지면, 정렬 함수는 조용히 원래 순서를 돌려준다
자동매매 시스템은 매일 아침 국내 두 시장에서 시가총액 상위 종목들을 골라 그날의 매매 후보군(유니버스)으로 삼습니다.
지난주 어느 날, 이 후보군이 하루 사이에 거의 통째로 바뀌는 일이 있었습니다. 그리고 그 목록에 있어서는 안 될 종목이 하나 섞여 들어가, 실제로 매수까지 실행됐습니다.
무슨 일이 있었는지
발견은 우연이었습니다. "이 종목을 왜 샀냐"는 질문을 받고서야 뭔가 잘못됐다는 걸 알았습니다.
로그를 열어보니 그날 아침 유니버스 갱신에서 두 시장 모두 상위 종목의 대다수가 교체돼 있었습니다. 시가총액 순위가 하루 사이에 이렇게 급격히 바뀔 이유가 없었습니다.
에러 로그는 없었습니다. 갱신 작업은 평소처럼 "정상 완료"로 끝나 있었습니다.
처음엔 뭐라고 생각했는지
제일 먼저 의심한 건 시장 자체의 이상 변동, 그다음은 유니버스 산출 로직의 버그였습니다.
둘 다 틀렸습니다. 데이터를 받아오는 시점까지 거슬러 올라가서야 진짜 문제를 찾았습니다.
진짜 원인
그날 아침, 시세 데이터를 가져오는 외부 오픈소스 라이브러리가 시가총액 컬럼을 전부 결측치(NaN)로 돌려줬습니다.
시가총액 기준으로 상위 N개를 뽑는 로직은 이 컬럼으로 정렬을 시도합니다. 그런데 정렬 기준값이 통째로 결측치이면, 정렬 함수는 에러를 던지지 않습니다.
대신 원본 데이터가 원래 갖고 있던 순서(이 경우는 알파벳/코드순에 가까운 배열)를 그대로 돌려줍니다. 시스템은 이걸 "시가총액 상위 N개"라고 믿고 그대로 받아들였습니다.
결과적으로 실제 시가총액과는 무관한 종목들이 대량으로 유니버스에 들어왔고, 그중 하나가 매매 신호까지 이어져 실제 계좌에서 매수가 실행됐습니다. 다행히 방향성 손실은 아니었고, 신호 없는 매매에 따른 작은 거래비용 정도로 끝났습니다.
어떻게 고쳤는지
같은 사고가 다시 조용히 통과하지 않도록 검증 계층을 여러 겹으로 나눠 넣었습니다.
첫 번째는 입력 단계 검증입니다. 시세 데이터를 받아올 때 시가총액 값이 유효한 종목의 비율을 확인하고, 일정 비율 이상이 결측이면 그 소스 전체를 거부하도록 했습니다. 실시간 조회가 거부되면 최근 며칠치 캐시본 중 정상적인 스냅샷으로 대체합니다.
두 번째는 이상 탐지 트립와이어입니다. 하루 사이에 유니버스 구성 종목이 비정상적으로 많이 바뀌면, 그 자체를 의심 신호로 보고 갱신을 보류한 채 직전 상태를 유지하도록 했습니다. 이건 첫 번째 검증을 어떤 이유로든 통과해버린 오염을 두 번째 겹에서 잡기 위한 장치입니다.
세 번째는 최종 사용 직전 재검증입니다. 위 두 겹을 통과한 뒤에도, 실제로 매매 로직에 넘기기 전에 결과 목록이 말이 되는지 한 번 더 확인합니다.
복구 자체는 오염 이전의 정상 상태로 되돌리는 작업이었습니다. 다만 그 과정에서 아직 재시작하지 않은 다른 프로세스가 낡은 로직으로 유니버스를 한 번 더 오염시키는 일이 있었고, 그래서 복구 순서를 "프로세스 재시작 → 상태 복원"으로 바꿔야 했습니다.
일반화하면 무슨 교훈인가
정렬 기준 컬럼이 전부 결측치가 되면, 정렬은 실패가 아니라 "그럴듯한 가짜 결과"를 내놓습니다. 에러가 안 나기 때문에 로그만 보면 아무 문제도 없어 보입니다. 정렬·순위 로직에 외부 데이터를 쓴다면, "정렬이 끝났다"와 "정렬이 의미 있게 끝났다"는 서로 다른 문장이라는 걸 기억해야 합니다.
외부 데이터 소스는 스키마가 맞아도 내용이 텅 비어 있을 수 있습니다. 컬럼이 존재하고 타입도 맞는데 값이 전부 결측인 경우는, 흔한 스키마 검증만으로는 걸리지 않습니다. 값의 분포나 유효 비율을 보는 검증이 따로 필요합니다.
입력 검증 한 겹만으로는 부족할 때가 있습니다. 이번엔 입력 검증을 통과한 이후에도 재발했기 때문에, "결과가 갑자기 너무 많이 바뀌었다"는 걸 잡는 이상 탐지 계층을 별도로 뒀습니다. 원인을 특정하기 어려운 오염일수록, 원인이 아니라 증상(과도한 변화량)을 감시하는 장치가 더 안정적으로 작동합니다.
복구 순서도 검증 대상입니다. 오염된 상태를 되돌리는 도중에도, 아직 새 로직을 반영하지 않은 다른 프로세스가 낡은 경로로 다시 오염시킬 수 있습니다. 여러 프로세스가 같은 상태를 공유한다면, 복구는 "상태 되돌리기"와 "모든 소비자를 새 코드로 맞추기"를 한 세트로 묶어야 합니다.
Top comments (0)