사고 개요
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 모델 필드 동시 수정 필수 |