Quant Trading Bot Devlog

Read in English

테스트 정리 코드가 운영 서버를 죽인 사고

이름이 겹치는 프로세스를 패턴으로 죽이는 정리 코드와, 에러 메시지를 문자열로만 분류하는 로직이 겹쳐서 만든 헛다리 진단

밤사이 자동 처리 파이프라인이 100개 중 77개 작업을 건너뛰는 일이 있었습니다.

로그를 열어보니 그럴듯한 용의자가 바로 보였습니다. 외부 데이터 벤더 호출 노이즈가 사고 시각 근처에 잔뜩 찍혀 있었거든요.

그래서 처음엔 "로컬 네트워크가 잠깐 끊겼는데 하필 외부 벤더 쪽 요청 제한(429)까지 겹쳐서 그렇다"고 결론 내렸습니다. 실제 원인은 완전히 다른 곳에 있었습니다.

처음 진단이 그럴듯했던 이유

매일 밤 같은 파이프라인이 외부 데이터 벤더를 호출하는데, 이 호출은 원래도 매일 밤 로그에 흔적을 남기는 무해한 루틴이었습니다.

사고 당일에도 이 흔적이 사고 시각 부근에 찍혀 있었고, 마침 그 시점에 재시도 소진 후 스킵된 종목 수도 컸습니다.

시간이 겹친다는 이유만으로 "이게 원인이구나" 하고 넘어갔습니다. 로그가 그럴듯하게 맞아떨어졌기 때문에 더 깊이 파지 않았습니다.

진짜 원인

그 시각에 실제로 벌어진 일은 따로 있었습니다. 코드리뷰를 위해 실행 중이던 한 테스트 스위트가, 테스트 정리(cleanup) 단계에서 pkill -f "프로세스이름.*포트번호" 같은 패턴 매칭 명령을 실행하고 있었습니다.

이 테스트는 자기가 테스트용으로 띄운 프로세스를 정리하려던 것이었습니다. 그런데 그 패턴 매칭 명령을 실제 시스템 콜로 실행하는 부분이 모킹(mocking)되어 있지 않았습니다.

그리고 하필 실제 운영 중인 LLM 서버가 같은 이름·같은 포트 번호 규칙을 쓰고 있었습니다. 테스트와 운영이 이름 규칙을 공유한다는 우연 때문에, 정리 명령이 테스트 프로세스가 아니라 운영 프로세스를 그대로 죽여버렸습니다.

운영 서버가 죽자 34분 동안 그 서버를 호출하던 상위 파이프라인이 전부 응답을 받지 못했습니다.

이 실패를 분류하는 로직이 문제를 더 키웠습니다. 에러 메시지 안에 "ConnectionError"라는 문자열이 들어있으면 무조건 "외부망 장애"로 분류하는 방식이었습니다.

운영 서버가 죽어서 생긴 연결 실패든, 진짜 인터넷이 끊겨서 생긴 연결 실패든, 둘 다 같은 문자열을 남깁니다. 분류기는 둘을 구분하지 못했고, 외부망 장애로 오분류된 요청은 정해진 횟수만큼 재시도만 하다가 결국 스킵으로 처리됐습니다.

그렇게 스킵이 77건까지 쌓였습니다.

어떻게 고쳤는지

먼저 정리 코드에 안전장치를 넣었습니다. 프로세스를 죽이기 전에 "이게 정말 운영 중인 프로세스인지" 확인하는 절차를 하나 추가해서, 그 확인을 통과하지 못하면 죽이지 않게 했습니다.

테스트 쪽도 고쳤습니다. 정리 단계에서 실제 시스템 콜을 실행하는 부분을 모킹하도록 바꿔서, 테스트를 몇 번을 돌려도 실제 프로세스에는 손을 대지 못하게 막았습니다.

그리고 이 안전장치 자체가 제대로 작동하는지 검증하는 회귀 테스트를 별도로 추가했습니다.

에러 분류 로직 쪽 개선은 아직 남아 있습니다. 로컬에서 서버 자체가 죽어서 생기는 실패와, 진짜 외부망 장애를 구분해서 서로 다른 버킷으로 보내는 작업은 다음 과제로 남겨뒀습니다.

일반화하면

테스트의 정리(teardown/cleanup) 단계는 본문 로직보다 더 방치되기 쉬운데, 오히려 더 위험할 수 있습니다. 본문 로직은 뭘 검증하는지가 명확해서 리뷰할 때 눈이 가지만, 정리 코드는 "그냥 뒷정리"로 취급돼서 시스템 콜을 그대로 남겨두기 쉽습니다. 테스트 코드 리뷰 체크리스트에 "이 테스트가 끝나거나 실패했을 때 실행되는 정리 코드도 모킹돼 있는가"를 넣어둘 만합니다.

이름이나 포트 같은 패턴으로 프로세스를 찾아 죽이는 명령은, 그 패턴을 테스트와 운영이 우연히 공유하는 순간 위험해집니다. pkill -f처럼 문자열 패턴에 기대는 킬 명령은 "내가 띄운 것만 걸리겠지"라는 암묵적 가정 위에 서 있습니다. 이 가정은 이름 규칙을 공유하는 다른 환경이 등장하는 순간 깨집니다. 프로세스를 종료하기 전엔 자신이 직접 띄운 PID인지, 혹은 지금 이게 운영 환경인지를 먼저 확인하는 편이 안전합니다.

에러를 메시지 문자열로 분류하면, 원인이 전혀 다른 두 장애가 같은 버킷에 섞입니다. "ConnectionError"라는 문자열은 로컬 프로세스가 죽어서 생긴 연결 실패와 진짜 네트워크 장애를 구분하지 못합니다. 이렇게 얕게 분류된 결과를 근거로 재시도·폴백 전략을 세우면, 서로 다른 대응이 필요한 두 상황에 같은 대응을 적용하게 됩니다. 가능하면 예외 타입이나 발생 지점처럼 더 구조적인 신호로 분류하는 편이 낫습니다.

증상과 동시에 관측된 그럴듯한 로그가 진짜 원인이라는 보장은 없습니다. 매일 밤 찍히는 무해한 노이즈가 하필 사고 시각과 겹쳐서 최초 진단을 완전히 다른 방향으로 이끌었습니다. 시간적으로 겹친다는 이유만으로 원인을 확정하기 전에, "이 로그가 없었어도 이 사고가 똑같이 일어났을까"를 스스로 물어보는 습관이 이런 헛다리 진단을 줄여줍니다.