테스트는 전부 통과했는데, 봇은 예산의 4배를 샀습니다

•4 min read•Jeongwoo Ahn

테스트는 전부 통과했는데, 봇은 예산의 4배를 샀습니다

thumbnail

들어가며

사이드 프로젝트로 주식 자동매매 봇을 만들고 있습니다.

돈이 실제로 나가는 시스템이라 평소보다 조심스럽게 만들었습니다. 테스트도 많이 썼습니다. 실거래로 전환하던 시점에 1,100개가 넘었고, 전부 초록이었습니다.

그리고 실거래 첫날 아침, 봇이 예산의 4배를 샀습니다.

이 글은 그날의 버그 이야기가 아닙니다. 버그는 한 줄짜리였고 고치는 데 오래 걸리지도 않았습니다.

제가 오래 붙들고 있었던 건 다른 질문이었습니다. 테스트가 1,100개 있었는데, 왜 이걸 잡는 테스트는 한 개도 없었을까.

봇이 어떻게 움직이는지부터

봇은 3분마다 깨어납니다. 그리고 두 가지 일을 순서대로 합니다.

  1. 아직 정산되지 않은 주문을 증권사에서 조회해서 장부에 반영합니다.
  2. 그 장부를 보고 살지 팔지를 정합니다.

여기서 중요한 건 2번이 1번의 결과 위에 서 있다는 점입니다. 장부가 사실과 같다는 전제가 깔려 있습니다.

현금이 얼마 남았는지, 지금 몇 종목을 들고 있는지. 전부 장부에서 읽습니다. 증권사에 매번 물어보지 않습니다.

문제의 한 줄

미체결 주문을 가져오는 코드는 이랬습니다.

// 사고 당시
const res = await fetch(`${BASE}/api/v1/orders?status=OPEN`);

"미체결 주문을 가져온다"는 의도였습니다. 함수 이름도 listOpenOrders였습니다.

문제는 시장가 주문이 OPEN 상태에 잠시도 머물지 않는다는 것이었습니다. 접수되는 순간 체결되어 곧장 CLOSED로 갑니다.

사고가 난 뒤에 직접 찍어봤습니다.

GET /api/v1/orders?status=OPEN    → 0건
GET /api/v1/orders?status=CLOSED  → 14건, 전부 status: "FILLED"
GET /api/v1/orders                → HTTP 400

정산은 텅 빈 목록을 받았습니다. 에러가 아니라 빈 배열이었습니다.

그래서 아무것도 반영하지 않았습니다. 그리고 그건 코드 입장에서 정상 동작이었습니다.

빈 목록이 증폭기가 되기까지

여기서부터가 진짜 문제였습니다.

장부에는 주문이 PENDING으로 남고, 현금은 줄어들지 않고, 보유 종목은 0개로 남았습니다.

3분 뒤에 봇이 다시 깨어납니다. 그리고 그 장부를 봅니다. 현금은 가득하고 보유는 없습니다.

그러니 또 삽니다.

09:00  매수 → 체결됨 → 장부: 현금 그대로, 보유 0
09:03  "현금 있네, 보유 없네" → 또 매수 → 체결 → 장부: 그대로
09:06  "현금 있네, 보유 없네" → 또 매수 → ...

증권사의 실제 매수가능금액이 바닥날 때까지 반복됐습니다. 그날 주문 테이블에 142행이 쌓였고 그중 4행만 접수됐습니다. 나머지는 전부 잔고 부족으로 거절됐습니다.

브레이크를 밟은 게 제 코드가 아니라 증권사의 잔고였던 셈입니다.

단일 버그가 아니라 되먹임 고리였다는 게 무서웠습니다. 장부가 틀리면 그 장부를 보는 판단이 틀리고, 틀린 판단의 결과가 다시 장부에 반영되지 않습니다.

스펙은 이미 그 자리를 보고 있었습니다

더 뼈아픈 게 하나 있습니다.

스펙 문서를 뒤져보니 이런 항목이 적혀 있었습니다.

'OPEN'은 조회 필터 값일 뿐인 죽은 항목이다

사실 관계는 맞았습니다. OPEN이라는 문자열은 코드 어디에서도 상태를 비교하는 데 쓰이지 않았습니다. 그냥 쿼리스트링에 들어가기만 했습니다.

틀린 건 결론이었습니다. 죽은 항목이 아니라 정산 전체를 통과시키거나 막는 자리였습니다.

"이 값은 아무 데도 안 쓰인다"와 "이 값은 중요하지 않다"는 다른 문장입니다. 저는 앞 문장을 써놓고 뒤 문장으로 읽었습니다.

그런데 테스트는 다 초록이었습니다

사고를 수습하면서 재발 방지 항목을 적다가 한 줄을 쓰고 한참 멈춰 있었습니다.

사고 이전에는 /orders의 쿼리를 단언하는 케이스가 한 건도 없었다

조회 로직을 다루는 테스트는 많았습니다. 응답을 제대로 파싱하는지, 페이지네이션을 제대로 도는지, 실패하면 올바른 에러를 내는지. 전부 있었습니다.

없던 건 하나였습니다. “우리가 증권사에 무엇을 물어봤는가” 를 보는 단언이요.

왜 못 봤을까

스텁 구조 때문이었습니다.

// 순서 기반 스텁 — 무엇을 물어봤는지 볼 수 없습니다
const fetchStub = createScriptedFetch([
  { json: { orders: [...], nextCursor: 'c1' } },  // 1번째 호출
  { json: { orders: [...], nextCursor: null } },  // 2번째 호출
]);

호출 순서대로 응답을 돌려주는 스텁입니다. URL은 보지 않습니다.

그러니 코드가 ?status=OPEN을 묻든 ?status=CLOSED를 묻든, 심지어 ?banana=1을 묻든 똑같이 1번째 응답을 줍니다.

테스트는 영원히 초록입니다.

이런 스텁을 쓴 이유도 분명했습니다. 편하거든요. 페이지네이션 테스트를 쓸 때 응답 두 개를 배열에 넣기만 하면 됩니다.

편한 대신 질문을 못 보는 도구라는 걸 그때는 생각하지 못했습니다.

어떻게 고쳤나

스텁이 질문에 따라 다르게 답하도록 바꿨습니다.

// 라우팅 스텁 — 무엇을 물어봤는지에 따라 답이 달라집니다
const fetchStub = makeOrdersRoutedFetch({
  OPEN:   [{ orders: [] }],
  CLOSED: [{ orders: [filledOrder] }],
});

그리고 보낸 URL 자체를 통째로 고정했습니다.

expect(fetchStub.calls.map(c => c.url)).toEqual([
  `${BASE}/api/v1/orders?status=OPEN`,
  `${BASE}/api/v1/orders?status=CLOSED`,
]);

조회 메서드 7개에 전부 이렇게 달았습니다.

솔직히 지루한 테스트입니다. 쿼리 파라미터 하나만 바꿔도 깨질 겁니다. 리팩터링할 때 귀찮을 거고요.

그런데 이 지루한 단언 하나가 그날 아침을 막을 수 있었습니다.

함께 추가한 게 하나 더 있습니다. 기존 통합 테스트는 페이지 데이터를 손으로 먹여서 정산 로직만 돌리고 있었습니다. 조회 단계를 통째로 건너뛰고 있었던 거죠.

HTTP 요청에서 장부까지 관통하는 케이스를 그제야 만들었습니다.

진짜 대가는 다른 데 있었습니다

사고를 정리하면서 가장 오래 남은 문장은 이겁니다.

진짜 대가는 「정산이 틀렸다」가 아니라 「아무도 안 알려줬다」 였다.

봇이 한 시간 넘게 같은 실수를 반복하는 동안 자동 신호가 0건이었습니다. 제가 우연히 들여다봐서 알았습니다.

조금 늦게 봤다면 더 갔을 겁니다.

버그는 언젠가 납니다. 돈이 걸린 시스템에서 그보다 중요한 질문은 “틀렸을 때 몇 분 안에 알게 되는가” 였습니다.

그날 이후 제일 먼저 만든 건 정산 로직 보강이 아니라 메일 알림이었습니다.


이 글의 사례는 개인 사이드 프로젝트에서 실제로 겪은 일입니다. 금액과 종목 등은 비율이나 일반화된 표현으로 바꿨습니다.