← FinanceManage_Agent — AI 주식 자동매매 시스템 개요로 돌아가기

Troubleshooting

매매 실행 신뢰성 개선: Kafka 메시지 큐 도입

ai-agent(분석 파이프라인)와 api-server(매매 실행) 사이에 있던 두 가지 신뢰성 문제를 Kafka로 해결한 기록.

ai-agent(분석 파이프라인)와 api-server(매매 실행) 사이에 있던 두 가지 신뢰성 문제를 Kafka로 해결한 기록. 결정 근거, 핵심 구현, 실측 결과를 남긴다.

문제

이슈 1 — 매매 주문 유실. ai-agent Stage 6이 api-server를 동기 HTTP로 호출하는데 실패 시 재시도가 없었다. api-server가 다운되거나 KIS가 타임아웃되면 주문이 영구히 사라지고 파이프라인은 “성공”으로 마감됐다. trade_history에도 기록이 남지 않아 매매 감사 로그가 시스템 어디에도 없었다.

이슈 2 — 파이프라인 동시 실행 방지 부재. 스케줄 트리거와 수동 트리거가 동시 실행을 막는 락 없이 각각 파이프라인을 실행할 수 있어, 겹치면 동일 유저에게 중복 매수 주문이 나갈 수 있었다.

왜 Kafka인가

두 이슈 모두 “요청과 실행을 분리하고, 실행이 실패해도 요청이 사라지지 않게 하는 것”이 본질이라 Kafka의 Producer/Consumer 모델과 맞아떨어진다. RabbitMQ·Redis Streams·SQS 대신 Kafka를 고른 이유:

  • 파티션 기반 순서 보장: 동일 키(userId:stockCode:tradeDate:side) 메시지가 같은 파티션에 순서대로 쌓여 멱등성 로직이 단순해진다.
  • 향후 확장 여지: Gemini 결정 큐잉 등 이벤트 스트림을 계속 늘려나갈 걸 감안하면 생태계가 넓은 Kafka가 유리하다.
  • 낮은 도입 비용: 이미 다뤄본 기술이라 학습 곡선 없이 바로 프로덕션 수준으로 붙일 수 있었다.

조사 후보 5개(주문 유실, 파이프라인 동시성, Gemini 큐잉, 종목 단위 재시도, Stage 체크포인트) 중 설계가 확정된 이슈 1·2만 이번에 구현했다 — 나머지는 처리량 이득이 없거나(종목 재시도), 선행 리팩터링이 필요하거나(Stage 체크포인트), 정책이 미확정(Gemini 큐잉)이라 제외했다. 이슈 1의 멱등키는 이슈 2의 잔여 리스크(중복 실행 시 중복 주문)까지 막아주는 안전망 역할도 한다(검증 단계에서 실측 확인).

메시지 계약 & 토폴로지

api-server·ai-agent를 독립적으로 작업시키기 위해 스펙을 먼저 고정했다: 메시지 8개 필드, 멱등키는 record key와 body 양쪽에 채워 컨슈머가 어느 쪽을 읽어도 안전하게 했다.

graph LR
    Stage6["ai-agent<br/>Stage 6"] -->|발행| TOR["trade.order.requested"]
    TOR -->|소비| Consumer["api-server<br/>TradeOrderConsumer"]
    Consumer -->|발행| TORes["trade.order.result"]
    Consumer -.->|재시도 소진| DLQ["trade.order.dlq"]
    TORes -->|소비| ResultHandler["ai-agent<br/>결과 반영"]

    Sched["스케줄(08:50)"] -->|발행| PRR["pipeline.run.requested"]
    Manual["수동 트리거"] -->|발행| PRR
    PRR -->|"단일 컨슈머<br/>(순차 소비)"| Orchestrator["run_complete_pipeline()"]

핵심 구현

PENDING 선(先) claim + PHASE 기반 재시도. KIS 호출 전에 PENDING 행을 선점 INSERT해, 프로세스가 죽어도 재실행 흔적이 남게 했다. 재시도 여부는 “KIS를 호출했는가”로 가른다 — 이미 건드린 실패는 도달 여부가 불확실해 즉시 DLQ, 아직 안 건드린 인프라 오류만 재시도한다.

flowchart TD
    P0{"PHASE 0<br/>메시지 파싱"} -->|실패| DLQ["즉시 FAILED<br/>+ DLQ 격리"]
    P0 -->|성공| PENDING["PENDING 선점 INSERT"]
    PENDING --> P2{"PHASE 2<br/>KIS 호출"}
    P2 -->|"타임아웃/4xx<br/>(도달 불확실·명시적 거부)"| DLQ
    P1["PHASE 1<br/>KIS 미접촉 인프라 오류"] -->|"지수 백오프<br/>최대 3회"| P2
    P1 -.->|소진| DLQ
    P2 -->|성공| SUCCESS["EXECUTED 기록<br/>+ result 발행"]

파이프라인 직렬화: 스케줄·수동 트리거 모두 pipeline.run.requested에 발행하고 단일 컨슈머가 순차 소비하도록 바꿔, 수동 트리거는 발행 직후 즉시 202를 반환한다(이전엔 전체가 끝날 때까지 동기 대기).

발행-INSERT 순서 레이스 발견·수정: KIS를 호출하지 않고 즉시 실패를 반환하는 초고속 경로에서, 결과가 배치 INSERT보다 먼저 도착해 이미 확정된 실패가 QUEUED로 덮이는 레이스가 있었다.

graph LR
    subgraph 수정전["수정 전 레이스 발생"]
        A1["① Kafka 발행"] --> A2["② 배치 INSERT(QUEUED)"]
    end
    subgraph 수정후["수정 후"]
        B1["① 초기 레코드<br/>INSERT(QUEUED)"] --> B2["② 발행"] --> B3["③ 발행 자체 실패 시만<br/>단건 UPDATE"]
    end

결과가 도착할 수 있는 시점보다 행이 항상 먼저 존재하도록 순서를 뒤집었고, 이 레이스를 재현하는 테스트 5개로 수정 전 전부 실패·수정 후 전부 통과를 확인했다.

검증 및 실측 결과

Testcontainers로 실제 Kafka+PostgreSQL을 띄워 프로덕션 코드 경로를 검증했다: 동일 키 3회 발행에도 KIS 호출 1회(멱등성), KIS 타임아웃/4xx는 재시도 없이 즉시 DLQ, 인프라 오류만 재시도 후 DLQ. 메시지 계약을 독립 구현한 두 서비스가 실제로 같은 메시지를 주고받는지도 직렬화 바이트 단위로 정적 대조해 7개 계약 항목 전부 일치를 확인했다. 신규 테스트 포함 기존 테스트 전부 통과, 회귀 없음.

항목 실측 결과
파이프라인 트리거 응답 시간 동기 대기(수십 분) → 발행 후 즉시 응답(수 ms~30ms)
동시 트리거 시 최대 동시 실행 수 1 (반복 실측에서 한 번도 2를 넘지 않음)
멱등성의 중복 실행 방지 효과 동일 거래일 2회 실행 → 메시지 2건 발행, 실제 KIS 호출은 1건

마지막 행이 설계 의도(“이슈 1의 멱등키가 이슈 2의 잔여 리스크도 막아준다”)가 실측으로 성립함을 보여준다.

최종 결과

Kafka Producer/Consumer 모델로 주문 요청·파이프라인 트리거를 큐에 발행하는 구조로 바꿔, 두 신뢰성 문제를 브로커 레벨의 재시도·DLQ·직렬 소비로 해소했다.

  • 주문 유실 해소: PENDING 선점 + DB UNIQUE 이중 방어로 동일 메시지 3회 발행에도 KIS 호출 1회만 발생.
  • 파이프라인 동시 실행 방지: 실측상 항상 순차 처리, 요청 유실 없음.
  • 응답성 개선: 트리거 응답이 수십 분 동기 대기에서 수 ms~30ms로.
  • 회귀 없이 반영: api-server·ai-agent 전체 테스트 통과 + 독립 구현된 메시지 계약 7개 항목 전부 일치.

향후 과제

  • outbox 패턴 부재: 결과 발행이 trade_history 원장 기록과 원자적이지 않다. 지금은 실용적 타협이지만 트래픽이 늘면 재검토 대상.
  • 운영 관측성 부재: PENDING/QUEUED 잔여 행이나 DLQ 격리 메시지를 사람이 확인할 알람·도구가 아직 없다.
  • 실 인프라 end-to-end 미검증: 두 서비스를 하나의 브로커에 동시에 붙여 실행하는 end-to-end 테스트, 실 KIS 모의계좌 크레덴셜 동작은 아직 검증하지 않았다.