A18 을 받는 DB 스키마
마이그레이션 한 장

V1~V3 위에 V4__a18_seat_forecast.sql 한 장을 얹는다. 열 추가 6 · 신설 표 2 · 인덱스 3 · 제약 1 이고, 기존 열의 타입은 하나도 바뀌지 않는다. 아래 SQL 은 PostgreSQL 16 에서 실제로 적용하고 제약까지 시험한 파일 그대로다 — 복사해서 그대로 쓰면 된다.

2026-08-19 작성 · 설계 개요는 v4-be 도메인 · 열의 정본은 파이프라인 계약 v4 · 화면 쪽 짝은 Client v4 확정본(2.0.0)

한 장 요약 30초

규모부터 적는다. 각 항목의 SQL 은 2절에 절 단위로 있다.

재는 것따라붙는 말
기존 열의 타입 변경0건남는 표에서 열 삭제도 NOT NULL 변경도 없다. 계약 대조 결과 설명문 외 변경 0건이다
기존 표에 붙는 열+6route_reference_version +4(방향별 첫차·막차) · location_poll +1(forecast_completed_at) · vehicle_observation +1(vehicle_trip_key)
신설 표2개demand_profile 열 11 · vehicle_stop_prediction 열 14 — 3절
인덱스+3ix_poll_forecast_ready · ix_obs_trip · ix_obs_stop_recent. 신설 표에 딸린 ix_vsp_pending 은 그 표와 함께 생긴다
제약+1ux_obs_context(UNIQUE). 이게 없으면 마이그레이션이 실패한다4절
백필0건더하는 여섯 열이 전부 NULL 허용이다. 기존 행을 건드리는 UPDATE 가 없다
DROP없다forecast_publication · stop_prediction 은 이 마이그레이션이 건드리지 않는다 — 6절
마이그레이션 파일V4 한 장V1~V3 은 다시 쓰지 않는다 — 1절
검증실측 완료PostgreSQL 16 에서 V1→V2→V3→V4 적용, 8표 111열이 계약과 일치, 제약 시험 10건 전부 거절 — 5절
가장 중요한 한 줄

기존 열의 타입은 하나도 바뀌지 않는다. 더하기만 있고, 더하는 열이 전부 NULL 허용이라 기존 행을 다시 쓰는 일도 없다. 표를 잠그고 백필하는 마이그레이션이 아니다.

요약의 제약 +1 은 열에 딸리지 않은 제약 하나, 곧 ux_obs_context 를 센 것이다. 새 열에 딸린 CHECK 둘(ck_ref_departure_format · ck_poll_forecast)은 그 열과 함께 오고, 신설 표 둘의 제약은 CREATE TABLE 안에 있다.

1. 적용 방법 V4 한 장

Flyway 파일명 규약은 V<n>__<이름>.sql 이고 v3 는 셋까지 와 있다.

파일담긴 표이 페이지에서
V1V1__collector.sqlcall_ledger · route_reference_version · route_stop · location_poll · vehicle_observation다시 쓰지 않는다
V2V2__model.sqlmodel_deployment다시 쓰지 않는다
V3V3__forecast.sqlforecast_publication · stop_prediction다시 쓰지 않고 DROP 도 하지 않는다 — 6절
V4V4__a18_seat_forecast.sql아래 2절 전문파일 내려받기

V1~V3 의 DDL 은 이 페이지에 옮겨 적지 않는다. 원본은 컨플루언스 「0817 BE DB 스키마」(pageId 9994292, space HEM52VQZUW) 다. V4 이후의 열 정의·제약의 정본은 파이프라인 계약 v4 이고, 아래 SQL 은 그 계약을 PostgreSQL 문법으로 옮긴 것이다.

적용 순서가 강제된다

노선정보 수집 한 번을 마이그레이션과 api 기동 사이에 끼워야 한다

① 마이그레이션② 노선정보 수집 1회③ api 기동 순서다. ②가 방향별 첫차·막차 4열을 채운다. 그 열은 NULL 허용으로 생기므로 마이그레이션 직후에는 비어 있다.

②를 건너뛰면 /board 를 낼 수 없다 — 확정 계약의 DirectionInfofirstDepartureTimelastDepartureTime 을 필수 필드로 두었다. 빈 값을 담을 자리가 없어서 응답 조립이 그 자리에서 막힌다. 마이그레이션이 실패하는 것이 아니라 api 가 정상 응답을 만들지 못한다 — 증상이 다르니 헷갈리지 마라.

②는 노선당 한 번이면 된다. 갱신은 그 뒤로 노선당 하루 1회(KST 03:30)와 판본 신설 시점에 돈다. 호출은 call_ledger 에서 예약하는데 합쳐 하루 2회라 한도 10,000 회 옆에서 무시할 수 있다.

2. SQL 전문 복사해서 그대로 쓴다

파일을 절 단위로 나눠 싣고 각 절 앞에 왜 필요한지 적었다. 순서를 바꾸지 마라 — 아래 SQL 3번 절ux_obs_contextSQL 5번 절의 복합 FK 보다 먼저 와야 한다.

파일 머리

이 파일이 무엇을 하고 무엇을 하지 않는지, 그리고 적용 순서가 머리에 적혀 있다. 파일만 받아서 읽는 사람이 이 페이지를 안 봐도 되게 하려고 넣었다.

-- ============================================================================
--  V4__a18_seat_forecast.sql
--  연어(Salmonbus) — 1차 운영 모델을 A18(차량 상태 조건부 좌석 분포)로 교체
--
--  V1__collector.sql · V2__model.sql · V3__forecast.sql 위에 얹는다.
--  **기존 열의 타입은 하나도 바꾸지 않는다.** 계약 대조 결과 설명문 외 변경 0건이다.
--
--  하는 일 — 열 추가 6 · 신설 표 2 · 인덱스 3 · 제약 1
--  하지 않는 일 — forecast_publication 과 stop_prediction 의 DROP (아래 6장)
--
--  적용 순서가 강제된다:
--    1) 이 마이그레이션
--    2) **노선정보 수집을 한 번 돌려** 방향별 첫차·막차 4열을 채운다
--    3) 그 다음에 api 를 올린다
--  2 를 건너뛰면 /board 를 낼 수 없다 — directions[].firstDepartureTime 이 필수 필드다.
-- ============================================================================

1. route_reference_version — 방향별 첫차·막차 4열

확정된 화면 계약이 directions[].firstDepartureTimelastDepartureTime 을 필수로 올렸다. 값은 상류 노선정보에 이미 있고 방향마다 다르다 — 1650 은 상행 막차 22:35 · 하행 막차 23:55 로 80분 어긋난다. 노선 하나를 통째로 "운행 종료"라고 하면 그 80분 동안 틀린다. 네 열 다 NULL 허용이라 백필이 없고, 형식 CHECK 가 25:00 같은 값을 적재 자리에서 막는다.

-- ============================================================
-- 1. route_reference_version — 방향별 첫차·막차 4열
--    상류 getBusRouteInfoItemv2 의 up/down First/Last Time 에서 온다.
--    NULL 허용이라 백필이 없다. 첫 노선정보 수집이 채운다.
--
--    방향마다 다르다 — 1650 은 상행 22:35 · 하행 23:55 로 80분 차이가 난다.
--    노선 하나를 통째로 "운행 종료"라고 하면 그 80분 동안 틀린다.
-- ============================================================
ALTER TABLE route_reference_version
    ADD COLUMN up_first_departure_time   char(5),   -- KST HH:MM. 상행 첫차
    ADD COLUMN up_last_departure_time    char(5),   -- KST HH:MM. 상행 막차
    ADD COLUMN down_first_departure_time char(5),   -- KST HH:MM. 하행 첫차
    ADD COLUMN down_last_departure_time  char(5);   -- KST HH:MM. 하행 막차

ALTER TABLE route_reference_version
    ADD CONSTRAINT ck_ref_departure_format CHECK (
        (up_first_departure_time   IS NULL OR up_first_departure_time   ~ '^([01][0-9]|2[0-3]):[0-5][0-9]$') AND
        (up_last_departure_time    IS NULL OR up_last_departure_time    ~ '^([01][0-9]|2[0-3]):[0-5][0-9]$') AND
        (down_first_departure_time IS NULL OR down_first_departure_time ~ '^([01][0-9]|2[0-3]):[0-5][0-9]$') AND
        (down_last_departure_time  IS NULL OR down_last_departure_time  ~ '^([01][0-9]|2[0-3]):[0-5][0-9]$'));

이 넷은 content_digest 대상이 아니다. 시간표만 바뀌면 판본을 새로 끊지 않고 같은 행을 UPDATE 한다 — 판본은 여전히 "정류장 목록의 판본"이라는 뜻을 지킨다.

2. location_poll — 예보 완료 표시와 스냅샷 인덱스

/board 는 poll 한 판을 통째로 스냅샷으로 쓴다. 예보가 아직 안 붙은 판을 고르면 그 판의 차량이 통째로 빠지고, 그 응답은 "지금 오는 버스가 없다"와 구별되지 않는다. 이 열은 "예보 행이 있다"가 아니라 "예보 단계를 지났다"를 뜻한다 — 대상이 0건이어서 아무 행도 못 쓴 판에도 찍는다. 부분 인덱스는 그 조건을 그대로 담아 스냅샷 한 판을 인덱스 스캔 한 번으로 고르게 한다.

-- ============================================================
-- 2. location_poll — 예보까지 끝났음을 찍는 열
--    /board 스냅샷 선택의 정렬 축이자 Board.observedAt 의 값이다.
--    관측 행의 observed_at 과 같은 값이지만 poll 쪽을 권위로 삼는다 —
--    차량이 0대인 판(SUCCESS_EMPTY)에는 관측 행이 없는데
--    Board.observedAt 은 그때도 필수 필드다.
--
--    NULL 허용이라 백필이 없다. 마이그레이션 이전 poll 은 전부 스냅샷 후보에서
--    빠지는데, 어차피 그 poll 들에는 예보 행이 없으므로 고르면 안 되는 것이 맞다.
-- ============================================================
ALTER TABLE location_poll
    ADD COLUMN forecast_completed_at timestamptz;  -- 한 판의 예보를 다 쓴 시각

ALTER TABLE location_poll
    ADD CONSTRAINT ck_poll_forecast CHECK (
        forecast_completed_at IS NULL
        OR (response_received_at IS NOT NULL
            AND forecast_completed_at >= response_received_at));

-- 예보까지 끝난 마지막 poll — /board 스냅샷 선택.
-- 부분 조건이 있어야 한 번의 인덱스 스캔으로 끝난다.
CREATE INDEX ix_poll_forecast_ready
    ON location_poll (route_reference_version_id, response_received_at DESC)
    WHERE outcome IN ('SUCCESS_ROWS', 'SUCCESS_EMPTY')
      AND forecast_completed_at IS NOT NULL;

3. vehicle_observation — 여정 키 · 인덱스 둘 · 유니크 하나

A18 이 쓰는 상류 좌석 기울기는 같은 여정 안에서만 뜻이 있다. 여정을 나누지 않으면 이번 여정의 40번 정류장이 지난 여정의 40번과 짝지어진다. 인덱스 둘은 각각 같은 여정의 앞선 관측과 그 정류장에 직전 도착한 차량을 집는 경로다. 마지막 ux_obs_context 는 다음 절의 복합 FK 가 상대편으로 삼는 유니크다 — 이 줄을 빼면 마이그레이션이 SQL 5번 절에서 실패한다(4절).

-- ============================================================
-- 3. vehicle_observation — 여정 키
--    같은 차량이라도 여정이 바뀌면 다른 계열이다. 상류 좌석 기울기를
--    한 여정 안에서만 잇기 위한 키다.
--
--    NULL 허용이라 백필이 필요 없다. 다만 마이그레이션 이전 관측은 여정이
--    비어 있어 **라벨** 대상에서 빠진다(서빙은 결측 지시자로 받는다).
--    소급 계산은 정규화 판을 새로 매기는 일이라 별도 작업이다.
-- ============================================================
ALTER TABLE vehicle_observation
    ADD COLUMN vehicle_trip_key varchar(120);  -- 한 여정을 잇는 키. NULL = 여정 미상

-- 같은 여정의 앞선 관측 — 상류 좌석 기울기
CREATE INDEX ix_obs_trip
    ON vehicle_observation (route_reference_version_id, vehicle_trip_key, stop_order)
    WHERE vehicle_trip_key IS NOT NULL;

-- 그 정류장에 직전 도착한 차량과 직전 연속 만석 대수(90분 창)
CREATE INDEX ix_obs_stop_recent
    ON vehicle_observation (route_reference_version_id, stop_order, observed_at DESC);

-- 아래 vehicle_stop_prediction 의 복합 FK 가 이 유니크를 상대편으로 삼는다.
-- **이것이 없으면 5장의 fk_vsp_obs 생성이 실패한다** — PostgreSQL 은 참조 대상에
-- unique 제약을 요구한다. V1 의 vehicle_observation 에는 (id) PK 만 있었다.
ALTER TABLE vehicle_observation
    ADD CONSTRAINT ux_obs_context UNIQUE (id, route_reference_version_id);

4. demand_profile — 셀 통계

A18 의 셀 재료가 읽는 표다. (노선 판본, 정류장, 시간대) 한 칸의 경험 통계를 담는다. 저장하는 값은 원값이고 z화는 같은 세대의 행들에서 유도한다 — 표준화 상수를 따로 저장하면 표가 갱신될 때 상수가 뒤처져 z 가 어긋난다. 재계산은 같은 키를 덮어쓴다(UPSERT). 열별 설명은 3절에 있다.

-- ============================================================
-- 4. demand_profile — (노선 판본, 정류장, 시간대) 셀의 경험 통계
--    A18 의 셀 재료가 읽고, 통과 구간의 행들을 합쳐 읽는다.
--
--    저장하는 값은 **원값**이다. z화((노선, 시간대) 안에서)는 같은 세대의
--    행들에서 유도한다 — 표준화 상수를 따로 저장하면 표가 갱신될 때 상수가
--    뒤처져 z 가 어긋난다.
--
--    재계산은 같은 키를 덮어쓴다(UPSERT). 빈 채로 만들어지고, 비는 동안은
--    이웃 폴백만 돈다(개편 직후 새 판본에도 같은 일이 일어난다 — 콜드스타트, 미결).
-- ============================================================
CREATE TABLE demand_profile (
    route_reference_version_id bigint       NOT NULL,
    stop_order                 int          NOT NULL,
    time_cell_id               varchar(40)  NOT NULL,  -- morning | evening | other
    feature_contract_version   varchar(40)  NOT NULL,  -- 셀 정의의 소유자
    revision                   int          NOT NULL,  -- 재계산 세대. 배치마다 단조 증가

    occupancy_mean             double precision NOT NULL,  -- 도착 시 평균 점유율
    net_demand_mean            double precision NOT NULL,  -- 평균 순수요. 음수가 정상
    sample_count               int          NOT NULL,  -- 이 셀을 만든 유효 라벨 수
    day_count                  int          NOT NULL,  -- 기여 날짜 수. 날짜 균등가중이라 함께 둔다
    trained_through            timestamptz  NOT NULL,  -- 이 시각까지의 도착 라벨만 들어갔다
    computed_at                timestamptz  NOT NULL,

    PRIMARY KEY (route_reference_version_id, stop_order, time_cell_id, feature_contract_version),
    CONSTRAINT fk_prof_stop FOREIGN KEY (route_reference_version_id, stop_order)
        REFERENCES route_stop (route_reference_version_id, stop_order),

    CONSTRAINT ck_prof_revision  CHECK (revision >= 1),
    CONSTRAINT ck_prof_occupancy CHECK (occupancy_mean >= 0 AND occupancy_mean <= 1
                                        AND occupancy_mean = occupancy_mean),
    CONSTRAINT ck_prof_demand    CHECK (net_demand_mean >= -1 AND net_demand_mean <= 1
                                        AND net_demand_mean = net_demand_mean),
    CONSTRAINT ck_prof_counts    CHECK (sample_count >= 0 AND day_count >= 0
                                        AND day_count <= sample_count)
);

5. vehicle_stop_prediction/board 의 본체

한 관측에서 낸, 앞으로 지날 정류장 하나의 좌석 예보다. 한 행이 곧 /board 응답의 한 항목이라 행이 없으면 그 차량은 그 정류장에서 조용히 빠진다. 판 단위 봉인이 없고, 한 판의 원자성은 봉인 절차가 아니라 location_poll.forecast_completed_at 이 낸다. 제약이 많은 것은 회수 상태 하나가 세 열(도착 관측 · 실제 좌석 · 회수 시각)의 있고 없음을 결정하기 때문이고, 그 짝을 코드가 아니라 DB 가 강제한다.

-- ============================================================
-- 5. vehicle_stop_prediction — 한 관측에서 낸, 앞으로 지날 정류장 하나의 좌석 예보
--    **이 표가 /board 의 본체이고, 한 행이 곧 응답의 한 항목이다.**
--    행이 없으면 그 차량은 그 정류장에 대해 조용히 빠진다.
--
--    판 단위 봉인이 없다. 한 행은 정확히 한 vehicle_observation 행에 매달리는
--    파생이고, 한 판의 단위는 poll 하나다. 판의 원자성은 봉인 절차가 아니라
--    location_poll.forecast_completed_at 이 낸다.
--
--    행을 만들지 않는 경우 — 미정차 정류장 · 여정 끝 너머 · 좌석 결측 관측.
-- ============================================================
CREATE TABLE vehicle_stop_prediction (
    vehicle_observation_id     bigint       NOT NULL,
    route_reference_version_id bigint       NOT NULL,
    target_stop_order          int          NOT NULL,
    horizon_stops              int          NOT NULL,  -- 몇 정류장 전에서 예측했나. 모델 키다
    model_deployment_id        bigint       NOT NULL,  -- 읽기 필터가 아니라 출처 표시
    cell_profile_revision      int          NOT NULL,  -- 읽은 demand_profile 세대

    p_full_raw                 double precision NOT NULL,  -- 사전확률 이동 적용 **전**
    p_full                     double precision NOT NULL,  -- 응답에 쓰는 값. 좌석 분포의 0 지점
    expected_seats             double precision,           -- 기댓값. NULL 이면 api 가 필드를 뺀다
    generated_at               timestamptz  NOT NULL,      -- 한 poll 의 행은 전부 같은 값

    settlement                 varchar(16)  NOT NULL,      -- 라벨 회수 상태
    outcome_observation_id     bigint,                     -- 대상 정류장 도착 관측
    arrived_seats              int,                        -- 도착 시 실제 잔여석. 0 이면 만석
    settled_at                 timestamptz,

    PRIMARY KEY (vehicle_observation_id, target_stop_order),

    -- 관측과 다른 판본의 순번이 끼어들지 못한다
    CONSTRAINT fk_vsp_obs FOREIGN KEY (vehicle_observation_id, route_reference_version_id)
        REFERENCES vehicle_observation (id, route_reference_version_id),
    CONSTRAINT fk_vsp_stop FOREIGN KEY (route_reference_version_id, target_stop_order)
        REFERENCES route_stop (route_reference_version_id, stop_order),
    CONSTRAINT fk_vsp_deployment FOREIGN KEY (model_deployment_id)
        REFERENCES model_deployment (id),
    CONSTRAINT fk_vsp_outcome FOREIGN KEY (outcome_observation_id)
        REFERENCES vehicle_observation (id),

    CONSTRAINT ck_vsp_target   CHECK (target_stop_order >= 1),
    -- 서빙 상한 12. 지평이 모델 키라 유도값인데도 열로 둔다
    CONSTRAINT ck_vsp_horizon  CHECK (horizon_stops >= 1 AND horizon_stops <= 12),
    CONSTRAINT ck_vsp_revision CHECK (cell_profile_revision >= 1),
    -- NaN · ±Infinity 까지 거절한다. 응답이 1 - NaN 이 되면 안 된다
    CONSTRAINT ck_vsp_praw     CHECK (p_full_raw >= 0 AND p_full_raw <= 1
                                      AND p_full_raw = p_full_raw),
    CONSTRAINT ck_vsp_pfull    CHECK (p_full >= 0 AND p_full <= 1 AND p_full = p_full),
    CONSTRAINT ck_vsp_expected CHECK (expected_seats IS NULL
                                      OR (expected_seats >= 0 AND expected_seats = expected_seats)),
    CONSTRAINT ck_vsp_arrived  CHECK (arrived_seats IS NULL OR arrived_seats >= 0),

    CONSTRAINT ck_vsp_settlement CHECK (settlement IN
        ('PENDING', 'SETTLED', 'SKIPPED', 'LOST', 'SEAT_MISSING')),
    -- SETTLED 일 때만 실제 잔여석이 있다
    CONSTRAINT ck_vsp_seats_state CHECK ((settlement = 'SETTLED') = (arrived_seats IS NOT NULL)),
    -- 도착 관측은 회수가 성공한 두 상태에만 있다
    CONSTRAINT ck_vsp_outcome_state CHECK (
        (settlement IN ('SETTLED', 'SEAT_MISSING')) = (outcome_observation_id IS NOT NULL)),
    -- PENDING 이면 회수 시각이 없고, 아니면 있다
    CONSTRAINT ck_vsp_settled_state CHECK ((settlement = 'PENDING') = (settled_at IS NULL)),
    CONSTRAINT ck_vsp_settled_order CHECK (settled_at IS NULL OR settled_at >= generated_at)
);

-- 라벨 회수 배치가 집는 경로 — 아직 안 닫힌 예보
CREATE INDEX ix_vsp_pending
    ON vehicle_stop_prediction (route_reference_version_id, generated_at)
    WHERE settlement = 'PENDING';

-- ----------------------------------------------------------------------------
-- 열 하나로는 못 거는 불변식 두 개. 쓰기 경로에서 강제하고 아래 질의로 감시한다.
--
--   horizon_stops = target_stop_order − (관측의 stop_order)
--   generated_at >= 관측의 observed_at
--
--   SELECT p.vehicle_observation_id, p.target_stop_order
--     FROM vehicle_stop_prediction p
--     JOIN vehicle_observation o ON o.id = p.vehicle_observation_id
--    WHERE p.horizon_stops <> p.target_stop_order - o.stop_order
--       OR p.generated_at < o.observed_at;
--
-- 다른 표의 값이라 CHECK 로 표현할 수 없다. 트리거로 걸 수도 있지만 쓰기 경로가
-- 한 곳(processor)뿐이라 그쪽 테스트로 막는 편을 택했다.
-- ----------------------------------------------------------------------------

끝의 주석 둘은 CHECK 로 못 거는 불변식이다 — 둘 다 다른 표(vehicle_observation)의 값을 봐야 한다. 트리거로 걸 수도 있지만 쓰기 경로가 processor 한 곳뿐이라 그쪽 테스트로 막는 편을 택했다. 주석 안의 감시 질의를 운영에서 주기적으로 돌려 두면 어긋남을 늦게라도 잡는다.

6. 발행 계층 — 이 마이그레이션은 건드리지 않는다

forecast_publicationstop_prediction 은 v4 계약에서 빠졌지만 이 파일은 두 표를 DROP 하지 않는다. 배포에서 하는 일은 코드를 걷어내는 데까지다. 이유와 정해지면 쓸 문장은 6절에 따로 적었다.

-- ============================================================
-- 6. 발행 계층 — 이 마이그레이션은 DROP 하지 않는다
--
--    forecast_publication 과 stop_prediction 은 v4 계약에서 빠졌다.
--    배포에서 하는 일은 발행 스케줄러를 멈추고, 봉인·승격 프로시저와
--    두 표를 읽는 코드를 걷어내는 데까지다.
--
--    **물리 DROP 을 언제 할지는 이 문서가 정하지 않는다**(미결 14).
--    두 표에는 발행본이 쌓인 적이 없어 옮길 자료도 백필도 없다.
--    정해지면 별도 마이그레이션으로 아래를 쓴다 —
--    stop_prediction 이 먼저다(forecast_publication 을 FK 로 가리킨다).
--
--      DROP TABLE IF EXISTS stop_prediction;
--      DROP TABLE IF EXISTS forecast_publication;
-- ============================================================

3. 신설 표 둘 열마다 무엇인가

근거는 파이프라인 계약 v4 의 열 설명이다. 여기 표는 그 요약이고 정본은 계약 쪽이다.

demand_profile — 셀 통계 · 열 11

(노선 판본, 정류장, 시간대) 셀 하나의 경험 통계다. A18 의 L_cell 이 읽고, L_segsum 이 통과 구간의 행들을 합쳐 읽는다. 한 노선·한 시간대의 행 전부를 한 번에 읽으면 z화 · 이웃 폴백(반경 4) · 구간합이 전부 그 안에서 끝난다 — 그래서 열이 아니라 행으로 펼쳐 둔다. 재계산은 같은 키를 덮어쓰므로 옛 세대의 값은 남지 않는다.

타입무엇인가
route_reference_version_id PKbigint노선 판본 FK. 노선은 판본이 결정하므로 노선 ID 열을 따로 두지 않는다. 순번의 뜻이 판본마다 달라서 판본에 매단다
stop_order PKint정류장 순번. 이웃 폴백과 구간합이 이 축 위에서 돈다. route_stop 과 복합 FK
time_cell_id PKvarchar(40)시간 셀 식별자. 지금 값은 morning(07~09) · evening(17~20) · other. 셀 정의는 이 표가 아니라 feature_contract_version 이 소유한다 — 문자열로 둔 이유는 셀을 더 잘게 나누는 실험이 DDL 변경을 부르지 않게 하려는 것이다
feature_contract_version PKvarchar(40)이 행을 만든 특징 계약 버전. 키에 들어가는 이유는 점유율·순수요가 정원으로 나뉜 값이라, 정원 상수가 바뀌면 값 자체가 달라지기 때문이다. 옛 정원 행과 새 정원 행이 섞이지 않는다
revisionint재계산 세대 번호. 배치가 한 판 돌 때마다 단조 증가한다. vehicle_stop_prediction.cell_profile_revision 이 이 값을 기록해 채점을 세대별로 가른다
occupancy_meandouble precision셀 평균 점유율. 그 정류장 도착 시 평균 몇 % 차 있었는가. 1 − 잔여석/정원 의 평균이고 날짜 균등가중이다
net_demand_meandouble precision셀 평균 순수요. (예측 시점 좌석 − 도착 시 좌석)의 평균을 정원 평균으로 나눈 값. 음수가 정상이다 — 하차 우세 정류장이 그렇다
sample_countint이 셀을 만든 유효 라벨 수. 이웃 폴백 판정의 재료다
day_countint이 셀에 기여한 날짜 수. 날짜 균등가중이라 표본 수만으로는 평균을 다시 합칠 수 없어서 함께 둔다
trained_throughtimestamptz이 시각까지의 도착 라벨만 들어갔다. 누출 차단 기준이자 평가 시 학습일 판정의 근거
computed_attimestamptz이 행을 마지막으로 계산한 시각. 재계산 주기와 주체는 미결이다

PK 는 넷을 묶은 것이다 — (route_reference_version_id, stop_order, time_cell_id, feature_contract_version). revision 은 키가 아니라 덮어쓰기의 결과다. 세대를 보관하려면 별도 설계가 필요하고 그건 미결이다.

vehicle_stop_prediction/board 의 본체 · 열 14

앞의 열 개가 예보고, 뒤의 넷이 나중에 회수하는 실제 결과다. 한 행이 응답의 한 항목이라, 확정된 StopArrivalseatAvailableProbability 를 필수로 두고 additionalProperties: false 인 것과 정확히 짝을 이룬다 — "예보 없는 항목"을 담을 자리가 양쪽 다 없다.

타입무엇인가
vehicle_observation_id PKbigint이 예보를 만든 관측 FK. 예측 시점의 차량 상태(잔여석 · 혼잡도 · 차량 유형 · 순번 · 시각)가 전부 그 행에 있다
route_reference_version_idbigint노선 판본. 관측의 판본과 복합 FK 로 일치가 강제된다
target_stop_order PKint예보 대상 정류장 순번. 응답에서 이 정류장의 arrivals 항목이 된다
horizon_stopsint몇 정류장 전에서 예측했는가. target_stop_order − 관측의 stop_order 와 같다. 유도값인데도 열로 두는 이유는 지평이 모델 키라서다 — 지평별로 따로 적합하고 채점도 지평별로 가른다. CHECK 로 1~12
model_deployment_idbigint이 예보를 낸 배포 FK. 읽기 필터가 아니라 출처 표시다 — api 는 스냅샷 poll 에 매달린 행을 배포로 거르지 않는다
cell_profile_revisionint읽은 demand_profile 세대. 없으면 서로 다른 통계로 낸 예보가 한 채점표에 섞인다. 채점을 가르기 위한 것이지 재현을 보장하지는 않는다
p_full_rawdouble precision사전확률 이동 적용 의 만석 확률. 채점용이 아니라 모델 입력이다 — 온라인 사전확률 이동이 (이 값, 실제 라벨) 쌍으로 다음 이동량을 만든다. 이동량은 logit(p_full) − logit(p_full_raw) 로 되찾으므로 따로 열을 두지 않는다
p_fulldouble precision응답에 쓰는 만석 확률. 좌석 분포의 0 지점이다. 응답의 seatAvailableProbability 는 api 서비스 계층이 1 − p_full 로 뒤집는다. 반올림하지 않는다. CHECK 가 NaN·±Infinity 까지 거절한다
expected_seatsdouble precision NULL도착 시 잔여좌석의 기댓값. 분포의 요약이라 NULL 이어도 p_full 은 유효하다. NULL 이면 api 가 그 필드를 응답에서 뺀다 — 확정 계약이 이 필드에 null 을 허용하지 않는다
generated_attimestamptz이 예보를 계산한 시각. 관측의 observed_at 과 같거나 그보다 늦다. 한 poll 의 행은 전부 같은 값을 갖는다
settlementvarchar(16)라벨 회수 상태. 다섯 값이고 아래 표에 따로 적었다
outcome_observation_idbigint NULL대상 정류장 도착 관측 FK. SETTLED·SEAT_MISSING 일 때만 NOT NULL. 여러 예보 행이 같은 도착 관측을 가리킬 수 있다 — 순번 40 에서 낸 지평 4 와 순번 42 에서 낸 지평 2 가 같은 도착을 본다
arrived_seatsint NULL도착 시 실제 잔여석. SETTLED 일 때만 NOT NULL. 0 이면 만석이고 그것이 채점의 이진 라벨이다
settled_attimestamptz NULL회수가 끝난 시각. PENDING 이면 NULL, 아니면 NOT NULL. generated_at 보다 뒤다

settlement 다섯 값

따라오는 열
PENDING아직 도착 전이거나 회수 배치가 안 돌았다셋 다 NULL
SETTLED같은 여정 안에서 대상 순번의 관측을 찾았고 잔여석이 유효하다도착 관측 · 실제 좌석 · 회수 시각 전부 있다
SEAT_MISSING도착 관측은 찾았지만 좌석이 결측이었다도착 관측과 회수 시각만 있다
SKIPPED여정이 대상 순번을 건너뛰었다회수 시각만 있다
LOST여정이 대상 순번에 닿기 전에 끊겼다. 여정 키가 없어 애초에 도착을 못 찾는 행도 여기로 닫는다회수 시각만 있다

채점과 demand_profile 과 사전확률 이동은 SETTLED 행만 쓴다. 나머지 넷은 왜 못 썼는지를 남기려고 구분한다 — 한 값으로 뭉개면 모델이 못 맞힌 것과 자료가 없는 것이 같아 보인다. 이 짝 규칙 셋(ck_vsp_seats_state · ck_vsp_outcome_state · ck_vsp_settled_state)은 등가식으로 걸어 두어서 한쪽만 채우면 INSERT 자체가 거절된다.

행을 만들지 않는 세 경우

4. 놓치기 쉬운 것 여기서 막힌다

SQL 을 절만 골라 옮기다가 ux_obs_context 를 빠뜨리면 마이그레이션이 중간에 죽는다.

ux_obs_context 를 함께 추가해야 한다

vehicle_stop_predictionfk_vsp_obs복합 FK 다 — (vehicle_observation_id, route_reference_version_id) 두 열로 vehicle_observation (id, route_reference_version_id) 를 가리킨다. PostgreSQL 은 복합 FK 의 참조 대상 열 짝에 unique 제약을 요구한다.

V1 의 vehicle_observation 에 있는 유니크는 (id) PK · ux_obs_row (poll_id, source_row_no) · 부분 유니크 인덱스 ux_obs_vehicle_per_poll (poll_id, vehicle_id) WHERE vehicle_id IS NOT NULL 셋인데 어느 것도 그 열 짝과 맞지 않는다(부분 인덱스는 애초에 FK 상대편이 될 수 없다). 그래서 ux_obs_context UNIQUE (id, route_reference_version_id) 를 먼저 만들지 않으면 fk_vsp_obs 를 만드는 자리에서 마이그레이션이 실패한다 — PostgreSQL 이 참조 대상에 맞는 unique 제약이 없다며 거절한다.

파일의 절 순서를 지키면 저절로 해결된다. SQL 3번 절(vehicle_observation)이 SQL 5번 절(vehicle_stop_prediction)보다 앞이다.

(id) 만으로 충분하지 않느냐는 물음이 자연스럽다. 충분하지 않다 — PostgreSQL 은 참조 열 목록 그대로에 unique 가 있어야 한다고 보고, (id) 가 유일하다는 사실에서 (id, route_reference_version_id) 가 유일하다는 것을 추론해 주지 않는다. 그리고 이 복합 FK 를 굳이 쓰는 이유가 있다 — 이렇게 걸어야 관측과 다른 판본의 순번이 예보 행에 끼어들지 못한다. (id) 하나로 걸면 판본 교차를 DB 가 못 막고, 그 검증이 코드로 내려간다.

같은 이유로 fk_vsp_stop 도 복합 FK 다 — (route_reference_version_id, target_stop_order)route_stop 을 가리키는데, 그쪽은 그 두 열이 이미 PK 라 새로 만들 유니크가 없다. 5절의 제약 시험 다섯째와 여섯째가 이 둘이 실제로 막는지를 확인한 것이다.

5. 검증 결과 PostgreSQL 16 실측

문법이 통과하는 것과 의도대로 막는 것은 다르다. 나쁜 행을 일부러 넣어 보고 거절당해야 통과로 셌다.

적용 자체는 통과했다

빈 PostgreSQL 16 에 V1 → V2 → V3 → V4 를 순서대로 적용해 전부 성공했다. 적용 뒤 8표 111열이 파이프라인 계약 v4완전히 일치했다 — 열 이름도, NULL 허용 여부도 어긋난 것이 없다. 인덱스 넷과 ux_obs_context 도 실제로 만들어졌다.

열 대조 — 8표 111열

call_ledger5v4 변경 없음
route_reference_version16이번에 4 늘었다
route_stop6v4 변경 없음
location_poll25이번에 1 늘었다
vehicle_observation17이번에 1 늘었다
model_deployment17v4 변경 없음
demand_profile11신설
vehicle_stop_prediction14신설
111계약과 불일치 0건

여기 여덟은 계약이 담은 표다. 적용 뒤의 DB 에는 forecast_publicationstop_prediction 도 그대로 남아 있는데, 그 둘은 v4 계약에서 빠져 대조 대상이 아니다 — 6절.

제약 시험 10건 — 전부 의도대로 거절됐다

#무엇을 넣어 봤나막은 제약결과
1지평 13 — 서빙 상한 12 초과ck_vsp_horizon거절
2p_full 이 NaNck_vsp_pfull거절
3PENDING 인데 실제 좌석이 있다ck_vsp_seats_state거절
4SETTLED 인데 도착 관측이 없다ck_vsp_outcome_state거절
5다른 판본의 순번에 예보를 붙인다fk_vsp_obs거절
6판본에 없는 순번을 대상으로 삼는다fk_vsp_stop거절
7점유율이 1.4ck_prof_occupancy거절
8날짜 수가 표본 수보다 많다ck_prof_counts거절
9첫차 시각이 25:00ck_ref_departure_format거절
10응답도 못 받았는데 예보를 끝냈다ck_poll_forecast거절

2번이 특히 중요하다. p_full 이 NaN 이면 응답의 빈자리 확률이 1 − NaN 이 되는데 그 값은 오류로 보이지 않고 조용히 화면까지 나간다. 그래서 범위 CHECK 에 p_full = p_full 을 함께 걸어 NaN·±Infinity 를 적재 자리에서 거절한다. 5번과 6번은 4절의 복합 FK 가 실제로 판본 교차를 막는지 확인한 것이다.

정상 행 3건 — 전부 통과했다

넣은 행왜 이 셋을 골랐나결과
SETTLED 정상 회수도착 관측 · 실제 좌석 · 회수 시각 셋이 다 찬 행. 짝 규칙 셋이 정상 행을 막지 않는지 본다통과
expected_seats 없이 유효기댓값이 NULL 이어도 p_full 만으로 응답 항목이 된다통과
순수요 음수하차 우세 정류장이 음수를 낸다. 범위를 0 이상으로 잡았으면 여기서 걸렸을 행이다통과

/board 조립 질의가 실제로 돌았다

씨앗 자료를 넣고 7절의 질의를 돌렸다. 한 행이 나왔다.

55|범계역|204000262|지평12|0.9100

순번 55 정류장 범계역 에, 차량 204000262지평 12(12 정류장 앞에서 낸 예보)로 빈자리 확률 0.9100 이라는 뜻이다. 저장값은 p_full = 0.09 이고 질의가 뒤집어 보여 준 값이 0.9100 이다. 스키마에서 /board 응답 한 항목까지가 실제로 이어진다는 것이 이 한 줄로 확인된다.

스냅샷 선택이 ix_poll_forecast_ready 를 실제로 타는 것도 EXPLAIN 으로 확인했다. 다만 계획은 자료량에 따라 바뀐다 — 행이 적으면 순차 스캔을 고르는 것이 정상이다.

6. 발행 계층 — 왜 DROP 하지 않나

forecast_publicationstop_prediction 은 v4 계약에서 빠졌는데도 표는 남는다.

이 마이그레이션이 하는 일과 하지 않는 일을 가른다. 하는 일은 배포에서 발행 스케줄러를 멈추고, 봉인·승격 프로시저와 두 표를 읽는 코드를 걷어내는 데까지다. 하지 않는 일은 물리 DROP 이다 — 언제 할지는 이 문서가 정하지 않는다(파이프라인 계약 v4 미결 14).

옮길 자료도 백필도 없다. 두 표에는 발행본이 쌓인 적이 없다 — 적재할 계수 번들이 없어 processor 가 뜬 적이 없기 때문이다. 그러니 DROP 을 미루는 것이 자료를 지키려는 것도 아니고, 되돌릴 자리를 남기는 것도 아니다. 한 번도 뜬 적 없는 경로를 안전장치라고 부를 수 없다(설계 개요 5절 ②). 스키마 변경과 코드 제거를 같은 배포에 묶지 않으려는 것뿐이다.

정해지면 별도 마이그레이션으로 아래를 쓴다. 순서가 있다 — stop_predictionforecast_publication 을 FK 로 가리키므로 stop_prediction 을 먼저 지운다.

-- 아직 실행하지 않는다. 시점이 정해지면 별도 마이그레이션(V5 이후)으로 옮긴다.
-- 순서 — stop_prediction 이 forecast_publication 을 FK 로 가리킨다.
--
--   DROP TABLE IF EXISTS stop_prediction;
--   DROP TABLE IF EXISTS forecast_publication;

V4 파일에도 같은 내용이 주석으로 들어 있다(2절 의 SQL 6번 절). 주석으로만 두는 이유는 파일을 그대로 실행했을 때 표가 사라지면 안 되기 때문이다.

7. /board 조립 질의

검증에 쓴 질의 그대로다. 크루가 이걸 시작점으로 쓴다.

이 한 질의에 세 가지가 들어 있다 — 스냅샷 선택(부분 인덱스와 같은 술어를 쓴다), 관측·예보·정류장 3중 조인, 승차 불가 정류장 제외다. 스냅샷을 부질의로 먼저 고르는 것이 핵심이다. 차량별 최신 관측을 긁어모으면 observedAt 하나가 여러 순간을 대표하게 되어 거짓말이 된다.

SELECT s.stop_order, s.name, o.vehicle_id, p.horizon_stops,
       round((1 - p.p_full)::numeric, 4) AS seat_available_probability
  FROM location_poll lp
  JOIN vehicle_stop_prediction p ON p.route_reference_version_id = lp.route_reference_version_id
  JOIN vehicle_observation o ON o.id = p.vehicle_observation_id AND o.poll_id = lp.id
  JOIN route_stop s ON s.route_reference_version_id = p.route_reference_version_id
                   AND s.stop_order = p.target_stop_order
 WHERE lp.id = (SELECT id FROM location_poll
                 WHERE route_reference_version_id = 1
                   AND outcome IN ('SUCCESS_ROWS','SUCCESS_EMPTY')
                   AND forecast_completed_at IS NOT NULL
                 ORDER BY response_received_at DESC LIMIT 1)
   AND s.boarding_allowed
 ORDER BY s.stop_order, p.horizon_stops;
그대로 제품에 넣지 마라 — 두 군데를 고쳐야 한다

① 뒤집기를 SQL 에서 하지 않는다. 위 질의의 1 - p.p_full 은 값을 눈으로 보려고 넣은 것이다. 제품 코드는 저장값 p_full 을 그대로 읽고 서비스 계층 메서드 한 곳에서 뒤집는다 — SQL 안에서 뒤집으면 그 규칙을 시험하는 데 DB 가 필요해진다. 반올림도 하지 않는다.

② 정류장당 2대 상한이 여기 없다. 확정 계약의 arrivalsmaxItems: 2 다. 지평 창(1~12)은 열 CHECK 가 이미 보장하지만 대수 상한은 조립하는 쪽이 잘라야 한다. 정렬은 horizonStops 오름차순이고 같은 지평이면 vehicleId 오름차순으로 서버가 고정한다.

질의에 route_reference_version_id = 1 이 박혀 있는 것은 검증 씨앗 자료의 값이다. 실제로는 활성 판본을 먼저 고른 뒤 그 id 를 넘긴다. model 필드는 이 질의에 없다 — 읽어 온 예보 행이 가리키는 배포에서 만들고, 차량 0대라 행이 없는 판에서만 ACTIVE 배포에서 읽는다.