[DB-02] 자동매매 동시성·원장 스키마

작업 내용 (설계 의도)

변경 사항

codex 교차 리뷰가 낸 p0 24건·p1 39건 중 스키마가 없어서 코드로 고칠 수 없는 4가지를 한 마이그레이션으로 묶습니다. 근거: _후속-DAG-codex-재검수 wave A, TDD “ERD”.

하나로 묶는 이유는 후행 4티켓(BE-23·BE-24·BE-25·BE-26)이 전부 이 스키마를 참조하기 때문입니다. 쪼개면 wave 가 1→1→1 직렬 사슬이 되고, 4개 마이그레이션 파일의 적용 순서까지 관리해야 합니다. 한 파일로 끝내고 wave C 4폭을 동시에 엽니다.

#변경없으면 생기는 일
1auto_trading_policies·trading_days·order_proposalsversion BIGINT NOT NULL DEFAULT 0낙관적 락을 걸 자리가 없어 손익 유실·킬 스위치 되살아남을 코드로 막을 수 없습니다
2trading_days.execution_mode VARCHAR(10) NOT NULL + UNIQUE 를 (trade_date, execution_mode) 복합으로 교체같은 날 PAPER·LIVE 정산이 서로를 덮어써 FR-23(paper/live 집계 분리)이 깨집니다
3risk_tier_transitions 신규 (append-only)FR-38 “단계 전환 이력을 기록한다” 를 충족할 저장소가 없습니다. tier_changed_at 한 컬럼은 최종 시각만 남깁니다
4execution_failures 신규TierOperationMetrics.executionFailureCount 의 데이터 소스가 없어 승급 게이트 4항목 중 2항목이 상수 0 으로 무의미합니다

trading_days.execution_mode 를 NOT NULL 로 즉시 추가하는 근거

값을 채워야 하는 컬럼이므로 백필 여부를 먼저 판정했습니다. trading_days 의 쓰기 경로가 origin/main 에 0곳입니다.

  • git grep tradingDayRepository origin/main -- backend/src/main/kotlin 결과가 인터페이스 정의(TradingDayRepository.kt:5)와 구현체(TradingDayRepositoryImpl.kt:9) 두 곳뿐입니다.
  • save() 를 호출하는 정산 서비스(BE-12)·사이클 UseCase(BE-13)가 미머지이고, autotrading/application 패키지 자체가 origin/main 에 없습니다.
  • 따라서 운영 DB 의 trading_days 는 0건이고, 채울 기존 행이 없습니다. Flyway 인라인 백필 DML 을 쓰지 않습니다.

적용 전 게이트: 마이그레이션 실행 직전 SELECT COUNT(*) FROM trading_days 로 0건을 확인하고 그 출력을 검증 아티팩트로 남깁니다. 0건이 아니면(로컬 dev DB 에서 사이클을 돌린 이력이 있으면) 즉시 중단하고 아래 3단계로 전환합니다.

단계내용롤백 지점
1execution_mode 를 nullable 로 추가 (DDL)컬럼 DROP
2Spring Batch 로 기존 행을 PAPER 로 백필 (PK 범위 청크, 재실행 가능)아무도 읽지 않으므로 코드 되돌리기
3NULL 잔존 0건 확인 후 NOT NULL 강화 (별도 마이그레이션)역방향 DDL 로 nullable 복귀

DEFAULT 를 남기지 않습니다. 실행 모드가 누락된 INSERT 가 조용히 PAPER 로 저장되면 지금 고치는 FR-23 위반이 그대로 재발합니다. version 은 반대로 DEFAULT 0 을 둡니다 — 상수 기본값이라 기존 행(정책 시드 1건)이 DDL 만으로 채워지고, 백필 DML 이 필요 없습니다.

파괴적 변경 — UNIQUE 키 교체

uk_trading_days_trade_date (단독) 를 DROP 하고 uk_trading_days_date_mode (trade_date, execution_mode) 를 ADD 합니다. 인덱스 조작에는 ALGORITHM=INPLACE, LOCK=NONE 을 명시합니다.

롤백: 역방향 DDL 로 복합 UNIQUE 를 제거하고 trade_date 단독 UNIQUE 를 재생성한 뒤 execution_mode·version 컬럼과 신규 2테이블을 DROP 합니다 (모드별 2행이 남아 있으면 LIVE 행을 먼저 삭제해야 단독 UNIQUE 가 다시 걸립니다).

무중단 배포 순서

스키마 먼저입니다. 이 시점에는 신규 컬럼·테이블을 읽는 코드가 없습니다. execution_mode NOT NULL 추가는 컬럼을 채우지 않는 구 INSERT 를 실패시키지만, 위에서 확인했듯 trading_days INSERT 경로가 0곳이라 배포 간극에 영향이 없습니다.

인덱스 근거

인덱스대상 쿼리컬럼 순서 근거
uk_trading_days_date_modefindBy(tradeDate, mode) (BE-24)유일성 제약 겸 조회 인덱스
idx_risk_tier_transitions_at전환 이력 시간 역순 조회 (BE-25)단일 컬럼 범위·정렬
idx_execution_failures_mode_occurred기간·모드별 실패 집계 (BE-26)등치 조건(execution_mode, 카디널리티 2)을 앞, 범위 조건(occurred_at)을 뒤
마이그레이션 DDL 골자 (`V202608281800__autotrading_concurrency_and_ledger.sql`)
-- 롤백: 아래 4묶음의 역방향 DDL. 상세는 티켓 "파괴적 변경" 절 참조.
ALTER TABLE auto_trading_policies
    ADD COLUMN version BIGINT NOT NULL DEFAULT 0 COMMENT '낙관적 락 버전. 정책 갱신 경합 시 나중 스냅샷의 덮어쓰기를 막는다';
ALTER TABLE order_proposals
    ADD COLUMN version BIGINT NOT NULL DEFAULT 0 COMMENT '낙관적 락 버전. 같은 제안의 이중 집행을 막는다';
ALTER TABLE trading_days
    ADD COLUMN version BIGINT NOT NULL DEFAULT 0 COMMENT '낙관적 락 버전. 실현손익 누계의 갱신 유실을 막는다',
    ADD COLUMN execution_mode VARCHAR(10) NOT NULL COMMENT '실행 모드 — PAPER / LIVE. 거래일 집계를 모드별로 분리한다';
ALTER TABLE trading_days
    DROP INDEX uk_trading_days_trade_date,
    ADD UNIQUE KEY uk_trading_days_date_mode (trade_date, execution_mode), ALGORITHM=INPLACE, LOCK=NONE;
 
CREATE TABLE risk_tier_transitions (
    id                              BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '기본키',
    previous_risk_tier              VARCHAR(10) NOT NULL COMMENT '전환 이전 단계 — T1/T2/T3',
    next_risk_tier                  VARCHAR(10) NOT NULL COMMENT '전환 이후 단계 — T1/T2/T3',
    transition_reason               VARCHAR(30) NOT NULL COMMENT '전환 사유 — PROMOTION_CONFIRMED / DEMOTION_DRAWDOWN',
    uninterrupted_trading_days      INT NOT NULL COMMENT '판정 시점 무중단 거래일 수',
    execution_failure_count         INT NOT NULL COMMENT '판정 시점 집행 실패 건수',
    risk_limit_violation_count      INT NOT NULL COMMENT '판정 시점 리스크 한도 위반 건수',
    realized_slippage_gap_point     DECIMAL(7,4) NOT NULL COMMENT '판정 시점 실현 슬리피지 갭(%p)',
    cumulative_realized_pnl         DECIMAL(18,4) NOT NULL COMMENT '판정 시점 누적 실현손익(원). 강등 기준선이 된다',
    edge_t_statistic                DECIMAL(7,4) NULL COMMENT '판정 시점 엣지 t 값. 미측정이면 NULL',
    transitioned_at                 DATETIME(6) NOT NULL COMMENT '전환 시각',
    created_at                      DATETIME(6) NOT NULL COMMENT '생성일시',
    KEY idx_risk_tier_transitions_at (transitioned_at)
) COMMENT = '리스크 사다리 단계 전환 이력 — append-only. 판정 시점 지표를 함께 남겨 사다리 기준 자체를 사후 평가한다';
 
CREATE TABLE execution_failures (
    id              BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '기본키',
    trade_date      DATE NOT NULL COMMENT '실패가 발생한 거래일',
    execution_mode  VARCHAR(10) NOT NULL COMMENT '실행 모드 — PAPER / LIVE',
    symbol          VARCHAR(20) NOT NULL COMMENT '종목 코드',
    proposal_id     BIGINT NULL COMMENT '근거 제안 id (order_proposals.id). FK 아님. 제안 없는 청산 실패는 NULL',
    failure_type    VARCHAR(40) NOT NULL COMMENT '실패 유형 — ORDER_SUBMIT_FAILED/ACCEPTANCE_UNCONFIRMED/PROTECTION_REGISTRATION_FAILED/LIQUIDATION_FAILED/RISK_LIMIT_VIOLATION',
    failure_message VARCHAR(500) NOT NULL COMMENT '실패 사유 원문. 사후 원인 분석용',
    toss_order_id   VARCHAR(50) NULL COMMENT '토스 주문 id. 제출 이전 실패는 NULL',
    occurred_at     DATETIME(6) NOT NULL COMMENT '실패 발생 시각',
    created_at      DATETIME(6) NOT NULL COMMENT '생성일시',
    KEY idx_execution_failures_mode_occurred (execution_mode, occurred_at)
) COMMENT = '자동매매 집행 실패 원장 — 승급 게이트의 집행 실패·한도 위반 건수를 이 원장으로 집계한다';

의존

  • 없음 (wave A 선행 병목)

다이어그램

처리 흐름

sequenceDiagram
    participant Op as 배포 담당
    participant DB as MySQL
    participant Fly as Flyway
    Op->>DB: SELECT COUNT(*) FROM trading_days
    DB-->>Op: 0건
    Op->>Fly: 마이그레이션 실행
    Fly->>DB: version 3개 + execution_mode NOT NULL
    Fly->>DB: UNIQUE 교체 (INPLACE, LOCK=NONE)
    Fly->>DB: risk_tier_transitions / execution_failures 생성

클래스 의존

flowchart LR
    Mig[V__autotrading_concurrency_and_ledger] --> Policy[(auto_trading_policies.version)]
    Mig --> Proposals[(order_proposals.version)]
    Mig --> Days[(trading_days.version)]
    Mig --> Mode[(trading_days.execution_mode)]
    Mig --> Uk[(uk_trading_days_date_mode)]
    Mig --> Trans[(risk_tier_transitions)]
    Mig --> Fails[(execution_failures)]

테스트 케이스

  • 마이그레이션 적용 후 3개 테이블에 version 컬럼이 BIGINT NOT NULL DEFAULT 0 으로 존재한다.
  • 정책 시드 1행의 version 이 백필 DML 없이 0으로 채워진다.
  • 같은 trade_date 로 PAPER·LIVE 거래일을 각각 저장하면 두 행이 모두 남는다.
  • 같은 trade_date·같은 execution_mode 를 두 번 저장하면 uk_trading_days_date_mode 위반으로 거부된다(제약명까지 확인).
  • execution_mode 없이 trading_days 에 INSERT 하면 NOT NULL 위반으로 거부된다(DEFAULT 로 조용히 채워지지 않는다).
  • risk_tier_transitions 에 같은 전환을 두 번 INSERT 하면 두 행이 남는다(append-only).
  • execution_failures 를 모드·기간으로 조회할 때 idx_execution_failures_mode_occurred 를 탄다(EXPLAIN 확인).
  • 역방향 DDL 실행 후 trade_date 단독 UNIQUE 가 복구되고 신규 2테이블이 제거된다.