사고 개요

2026년 6월 18일, 자동매매 엔진의 체결 기록이 9거래일째 0건이었다는 사실이 드러났다.

운영자 신고 내용은 “어제부터 매매 불가"였다. 실제로는 어제가 아니었다. DB 포렌식 결과 마지막 정상 체결은 2026-06-05 15:10이었고, 그 이후 57건의 주문이 발행됐으나 전부 cancelled 또는 failed였다. status=completed(체결)가 단 한 건도 없었다.

백엔드 /health는 9거래일 내내 200 OK를 반환했다. KIS API는 정상이었고 주문 접수(order_no 발급)도 이루어졌다. “정상으로 보이는데 왜 체결이 없나"가 첫 의문이었다.

원인은 DB 마이그레이션 직후 발생한 코드 회귀였다. SQLModel 모델 필드에 timezone=True 한 줄이 빠진 것이, asyncpg로 하여금 매도 체결 저장 트랜잭션을 거부하게 만들었다.


배경: Phase 124 timestamptz 마이그레이션

2026-06-06, Phase 124 배포로 DB의 datetime 컬럼들이 TIMESTAMP WITHOUT TIME ZONE에서 TIMESTAMP WITH TIME ZONE(timestamptz)으로 전환됐다.

동기는 KST 정책 강화였다. 코드 전반이 datetime.now(KST)(timezone-aware)를 쓰도록 바뀌었고, DB도 그에 맞춰 timestamptz로 올렸다.

마이그레이션은 두 단계를 동시에 처리해야 한다. DB Alembic 마이그레이션과 SQLModel 모델 정의 갱신이다. DB 컬럼 타입을 바꿨어도 모델이 naive로 남아 있으면 SQLAlchemy/asyncpg는 naive 코덱을 선택한다.

Phase 124 당시 대부분의 필드는 올바르게 전환됐다. created_at이 대표적인 예였다.

# models/trade.py — 올바른 필드
created_at: datetime = Field(
    default_factory=now_kst_aware,
    sa_column=Column(DateTime(timezone=True), index=True, nullable=False),
)

그런데 filled_at 필드는 빠졌다.


문제 코드

# models/trade.py — 결함 있는 필드 (수정 전)
class TradeHistory(SQLModel, table=True):
    created_at: datetime = Field(
        default_factory=now_kst_aware,
        sa_column=Column(DateTime(timezone=True), ...),  # 정상
    )
    filled_at: datetime | None = Field(default=None)     # timezone=True 누락

DB 컬럼은 Phase 124 마이그레이션으로 이미 TIMESTAMP WITH TIME ZONE으로 바뀌어 있었다. 그런데 모델 필드에는 sa_column과 timezone=True가 없었다. SQLModel/SQLAlchemy는 이 필드를 naive TIMESTAMP로 해석하고, naive 코덱을 사용한다.

매도가 체결되면 엔진은 trade.filled_at = now_kst_aware() — timezone-aware KST datetime — 를 저장한다. naive 코덱이 timezone-aware 값을 처리하려 할 때 asyncpg가 거부한다.

asyncpg.exceptions.DataError: invalid input for query argument $7:
datetime.datetime(2026, 6, 18, 15, 26, 5, tzinfo=<ZoneInfo key='Asia/Seoul'>)
(can't subtract offset-naive and offset-aware datetimes)

이 예외로 매도 체결 트랜잭션 전체가 롤백된다. 주문은 failed로 기록되고, filled_qty와 filled_price는 0으로 남는다. 체결이 단 한 건도 쌓이지 않는다.


왜 9거래일간 발견되지 않았나

세 가지 이유가 겹쳤다.

첫째, /health가 정상이었다. 엔진 프로세스는 살아 있었고, KIS 연결도 정상이었다. 체결 저장 실패는 개별 트랜잭션 레벨에서 발생했다. 헬스체크는 이를 감지하지 않았다.

둘째, 주문 접수는 이루어졌다. KIS API가 주문을 받고 order_no를 발급했다. 로그만 보면 엔진이 정상 가동 중인 것처럼 보였다. “체결이 없는 것"이 아니라 “체결 기록이 없는 것"이 장애였는데, 이 구분이 처음에는 명확하지 않았다.

셋째, 체결 현황 모니터링이 없었다. filled_qty > 0 체결 건수를 별도로 추적하는 알림이나 대시보드 지표가 존재하지 않았다. 운영자가 능동적으로 거래 내역을 확인하지 않는 한, 체결이 9거래일째 0건인 상황을 알 방법이 없었다.


DB 포렌식이 먼저였어야 했다

초기 진단에서 두 번의 오진이 있었다.

첫 번째는 “KIS API 서버 장애"였다. 무인증 curl로 KIS 응답속도를 측정했는데, KIS는 무인증 요청을 tarpit 처리한다. 응답 지연을 서버 장애로 판단한 것이었다. 측정 자체가 무의미했다.

두 번째는 “거래는 정상, 대시보드 표시만 문제"였다. trade_history에 행이 쌓이는 것을 보고 “거래 중"으로 판단했다. status 분포를 보지 않았다. 실제로는 전부 cancelled/failed였다.

정확한 원인은 DB 포렌식으로 확인됐다. trade_history의 status 컬럼 분포, raw_response 필드의 에러 메시지, 마지막 filled_qty > 0 체결 시각을 교차검증했다.

docker 컨테이너 로그는 재시작 시 소실된다. “어제부터"의 이력을 볼 수 없다. PostgreSQL의 trade_history는 재시작에도 살아남는다. 매매 이상 시 DB 포렌식이 첫 번째 수순이어야 했다.


수정

# models/trade.py — 수정 후
class TradeHistory(SQLModel, table=True):
    created_at: datetime = Field(
        default_factory=now_kst_aware,
        sa_column=Column(DateTime(timezone=True), index=True, nullable=False),
    )
    filled_at: datetime | None = Field(
        default=None,
        sa_column=Column(DateTime(timezone=True), nullable=True),  # timezone=True 추가
    )

DB 컬럼은 이미 TIMESTAMP WITH TIME ZONE이었으므로 Alembic 마이그레이션은 불필요했다. 모델 정의를 DB 실제 타입에 맞게 정렬한 것이 전부다.

함께 발견된 다른 필드 2개도 동시에 수정했다. circuit_breaker_log.resolved_at과 thesis_log.gate_checked_at이다. 두 필드는 현재 null 값이라 에러가 발현되지 않았지만, 같은 타입 불일치를 갖고 있었다.


회귀 가드 테스트

수정 후 모든 SQLModel table 모델의 DateTime 컬럼이 timezone-aware인지 자동으로 단언하는 테스트를 추가했다.

# tests/test_model_timestamptz_guard.py
import pytest
from sqlmodel import SQLModel
from sqlalchemy import DateTime

# timezone-naive가 허용된 컬럼 목록 (의도적 예외)
NAIVE_ALLOWLIST = {
    ("kis_token", "expires_at"),  # DB가 plain timestamp인 외부 연동 컬럼
}

def test_all_datetime_columns_are_timezone_aware():
    """모든 table 모델의 DateTime 컬럼이 timezone=True인지 검증한다."""
    for model_class in SQLModel.__subclasses__():
        if not getattr(model_class, "__tablename__", None):
            continue
        for column in model_class.__table__.columns:
            if not isinstance(column.type, DateTime):
                continue
            key = (model_class.__tablename__, column.name)
            if key in NAIVE_ALLOWLIST:
                continue
            assert column.type.timezone, (
                f"{model_class.__tablename__}.{column.name}: "
                f"DateTime(timezone=True) 필요"
            )

이 테스트가 있었으면 Phase 124 배포 시점에 CI에서 잡혔을 것이다.


DB 마이그레이션 체크리스트

TIMESTAMP → TIMESTAMPTZ 변경은 두 파일을 항상 함께 수정해야 한다.

□ Alembic 마이그레이션: ALTER COLUMN ... TYPE TIMESTAMP WITH TIME ZONE
□ SQLModel 모델 필드: sa_column=Column(DateTime(timezone=True), ...)
□ 기본값/팩토리가 timezone-aware datetime을 반환하는지 확인
□ 모델 guard 테스트 통과 확인

Alembic 파일에 op.alter_column(..., type_=DateTime(timezone=True))을 작성하는 순간, 대응하는 SQLModel 필드도 PR에서 함께 변경됐는지 확인해야 한다. 한쪽만 수정하면 마이그레이션은 DB와 코드 사이에 타입 불일치를 만든다.


정리

항목내용
발생 기간2026-06-06 ~ 2026-06-18 (약 9거래일)
원인trade_history.filled_at SQLModel 필드 timezone=True 누락
에러asyncpg.exceptions.DataError — timezone-aware datetime을 naive 코덱이 거부
증상매도 체결 저장 실패 → 전 주문 failed → 체결 0건
발견 지연/health 200, 주문 접수는 정상, 체결 건수 알림 없음
수정filled_at에 sa_column=Column(DateTime(timezone=True)) 추가
교훈DB 마이그레이션 시 Alembic + SQLModel 모델 필드 동시 수정 필수