에러 복구 안내 문구가 이중 실행을 부르는 결함 — 머니패스 사가 재점검
해피 패스는 수백 개 테스트를 통과했는데, 진짜 치명 결함은 "실패했을 때 뭘 하라"는 안내 문구 안에 있었다
돈이 실제로 움직이는 경로를 사가(saga) 패턴으로 다시 짰습니다. 입출금 같은 자본 이벤트가 계좌 장부에 반영되는 과정을 여러 단계로 쪼개고, 중간에 실패하면 보상(compensation)으로 되돌리는 구조입니다.
구현을 끝내고 코드리뷰를 돌렸습니다. 그런데 가장 치명적인 결함은 제가 가장 공들여 짠 실행 로직이 아니라, 전혀 다른 곳에 있었습니다.
이 글은 그 "다른 곳"에 관한 이야기입니다. 이 경로는 종목을 고르는 판단 로직이 아니라 주문 실행·안전장치 계층에 속하는데, 그 전체 설계는 실전 계좌 적용편(새 창)에 따로 정리해 뒀습니다.
무슨 일이 있었나
자본 이벤트 하나가 장부에 반영되려면 대략 이런 단계를 거칩니다. 이벤트를 선언하고, 실행하고, 장부에 기록하고, 그날의 기준 자산가치(앵커)를 다시 잡습니다.
문제는 이 중간 어딘가에서 기록 단계가 실패할 때였습니다. 이벤트는 선언됐는데 장부에는 행이 안 남은, 어정쩡한 상태가 생깁니다.
그럴 때를 대비해 "이렇게 하라"는 복구 안내 문구를 여러 곳에 박아뒀습니다. 코드리뷰는 바로 그 안내 문구를 치명 결함으로 짚었습니다.
안내 문구 네 곳이 하나같이 "다시 실행하라"는 쪽으로 사람을 몰고 있었던 겁니다. 그런데 그 재실행 경로는 멱등(idempotent)하지 않았습니다. 같은 입금이 두 번 반영될 수 있었습니다.
처음엔 뭐라고 생각했나
솔직히 저는 위험이 실행 로직 쪽에 있을 거라고 생각했습니다. 해피 패스에는 새 테스트를 20개 넘게 붙였고, 관련 스위트 수백 개가 통과한 상태였거든요.
그래서 코드리뷰에 "드물게 터지는 실행 실패 케이스를 집중해서 봐 달라"고 부탁했습니다. 레이스 컨디션이나 부분 실패 같은, 제가 놓쳤을 법한 가장자리를 기대한 거죠.
AI에게 맡긴 코드리뷰는 그 가장자리 대신 전혀 다른 지점을 짚었습니다. "실패한 뒤 사람이 읽는 안내 문구"가 가장 위험하다는 거였습니다.
처음엔 좀 억울했습니다. 그건 코드가 아니라 문자열 아닌가? 그런데 곱씹을수록 그 지적이 맞았습니다.
안내 문구는 평소엔 아무도 안 읽습니다. 오직 뭔가 이미 잘못됐을 때만 읽힙니다. 그리고 그 순간은 사람이 가장 긴장한 채로, 문구를 글자 그대로 따르는 순간입니다.
즉 복구 안내는 "문서"가 아니라 사실상 실행 경로의 일부였습니다. 그 경로가 비멱등한 재시도를 가리키고 있었으니, 이중 실행은 코드에 심어둔 잠복 버그였던 셈입니다.
진짜 원인
곱씹어 보니 결함은 하나가 아니라 세 겹이었습니다.
첫째, 재실행 경로 자체가 멱등하지 않았습니다. "이 자본 이벤트가 이미 장부에 반영됐는가"를 판정할 키가 없어서, 두 번째 실행이 그냥 한 번 더 반영해 버렸습니다.
둘째, 안내 문구가 성격이 정반대인 두 실패 상황을 하나로 뭉뚱그렸습니다. 자동 정리(sweep)가 만든 이벤트는 "닫기만" 해야 하고 절대 다시 입금하면 안 되는데, 진짜로 기록이 안 된 이벤트는 작업을 끝까지 완료해야 합니다. 둘에 같은 문구가 걸려 있으니, 한쪽에는 반드시 틀린 행동을 지시하게 됩니다.
셋째, 순서가 잘못돼 있었습니다. 장부에 행이 없는지 먼저 확인하기도 전에 재실행부터 할 수 있는 흐름이었습니다.
세 겹이 겹쳐서, "불안하니까 다시 한 번 돌려보자"는 지극히 자연스러운 반응이 곧바로 이중 입금으로 이어지는 구조였습니다.
어떻게 고쳤나
핵심 원칙은 하나였습니다. 애매하면 추측하지 말고 닫아라(fail-closed).
자본 이벤트가 애매한 부분 실패 상태(선언됐고, 장부 행은 없고, 앵커도 다시 안 잡힌)에 있으면, 라운드 게이트가 아무것도 기록하지 않습니다. 그리고 그 이벤트가 명시적으로 해소될 때까지 해당 라운드의 거래 슬리브를 통째로 막습니다.
막힌 라운드는 나중에 되살릴 수 있지만, 이중 입금은 되돌리기 어렵습니다. 그래서 의심스러울 땐 멈추는 쪽을 택했습니다.
다음으로 기록 단계에 멱등성 키를 붙였습니다. 작업마다 고유한 식별자를 넘기게 해서, 같은 이벤트를 다시 실행해도 두 번째는 아무 일도 안 하는 no-op이 되게 했습니다. 사람이 올바르게 행동해 주기를 바라는 대신, 재시도가 구조적으로 안전하도록 만든 겁니다.
안내 문구도 실패 유형별로 쪼갰습니다. 자동 정리 이벤트는 "해소만 하고 수동 입금 금지", 진짜 미기록 이벤트는 "해소 → 장부에 행 없음 재확인 → 그 다음에야 재실행"으로 순서를 강제했습니다.
마지막으로, 특정 자본 이벤트의 현재 상태를 직접 들여다보는 조회 명령을 하나 만들었습니다. 사람이 안내 문구의 말을 믿는 대신, 실제 상태(ground truth)를 눈으로 확인하고 움직이게요.
재리뷰에서는 치명 결함이 0건으로 떨어졌습니다. 배포할 때 한 가지 조건이 더 붙긴 했습니다 — 이 경로를 임포트하는 상주 프로세스를 전부 같이 재시작할 것. 구세대 코드가 하나라도 남아 있으면 바로 그 이중 반영이 재발할 수 있어서였습니다.
일반화하면
이 사건에서 건진 교훈은 트레이딩과 무관하게 어디에나 적용됩니다.
에러 메시지와 복구 런북은 안전 표면(safety surface)의 일부다. 나중에 덧붙이는 문서가 아닙니다. 가장 나쁜 순간(이미 뭔가 실패한 순간)에 읽히고, 글자 그대로 실행됩니다. 비멱등한 경로에 걸린 "다시 시도하세요"는 잠복한 이중 실행 버그입니다.
부수효과가 있는 경로는 멱등성 키로 보호해라. 돈이든 외부 API 호출이든, 작업 식별자를 받아 재시도가 no-op이 되게 만들면 됩니다. "사람이 실수 안 할 것"에 안전을 의존하지 마세요.
상태가 애매하면 추측하지 말고 닫아라. 보상 로직으로 똑똑하게 되돌리려다 틀리느니, 막고 사람을 부르는 게 낫습니다. 막힌 작업은 복구 가능하지만, 잘못 실행된 부수효과는 그렇지 않을 때가 많습니다.
해피 패스 테스트가 많다고 안심하면 안 된다. 이 코드는 수백 개 테스트를 통과하고도 치명 결함을 안고 있었습니다. 버그가 테스트가 잘 안 닿는 실패·복구 계층, 그중에서도 "안내 문구"에 숨어 있었기 때문입니다. 복구 경로와 그 안내까지 테스트 대상에 넣어야 합니다.
비슷해 보이지만 정반대 대응이 필요한 실패를 구분해라. 둘 다 들어맞는 하나의 범용 메시지는, 사실 둘 중 한쪽에겐 버그입니다.
제가 가장 공들인 건 실행 로직이었는데, 정작 시스템을 지켜준 건 "실패했을 때 뭘 하라"를 다시 설계한 쪽이었습니다. 다음부터는 기능을 다 짠 뒤에 복구 안내를 "그 기능의 일부"로 같이 리뷰하려고 합니다.