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건이다 |
| 기존 표에 붙는 열 | +6 | route_reference_version +4(방향별 첫차·막차) · location_poll +1(forecast_completed_at) · vehicle_observation +1(vehicle_trip_key) |
| 신설 표 | 2개 | demand_profile 열 11 · vehicle_stop_prediction 열 14 — 3절 |
| 인덱스 | +3 | ix_poll_forecast_ready · ix_obs_trip · ix_obs_stop_recent. 신설 표에 딸린 ix_vsp_pending 은 그 표와 함께 생긴다 |
| 제약 | +1 | ux_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 는 셋까지 와 있다.
| 판 | 파일 | 담긴 표 | 이 페이지에서 |
|---|---|---|---|
| V1 | V1__collector.sql | call_ledger · route_reference_version · route_stop · location_poll · vehicle_observation | 다시 쓰지 않는다 |
| V2 | V2__model.sql | model_deployment | 다시 쓰지 않는다 |
| V3 | V3__forecast.sql | forecast_publication · stop_prediction | 다시 쓰지 않고 DROP 도 하지 않는다 — 6절 |
| V4 | V4__a18_seat_forecast.sql | 아래 2절 전문 | 파일 내려받기 |
V1~V3 의 DDL 은 이 페이지에 옮겨 적지 않는다. 원본은 컨플루언스
「0817 BE DB 스키마」(pageId 9994292, space HEM52VQZUW) 다.
V4 이후의 열 정의·제약의 정본은 파이프라인 계약 v4 이고,
아래 SQL 은 그 계약을 PostgreSQL 문법으로 옮긴 것이다.
적용 순서가 강제된다
① 마이그레이션 → ② 노선정보 수집 1회 → ③ api 기동 순서다. ②가 방향별 첫차·막차 4열을 채운다. 그 열은 NULL 허용으로 생기므로 마이그레이션 직후에는 비어 있다.
②를 건너뛰면 /board 를 낼 수 없다 — 확정 계약의 DirectionInfo 가
firstDepartureTime 과 lastDepartureTime 을 필수 필드로 두었다.
빈 값을 담을 자리가 없어서 응답 조립이 그 자리에서 막힌다.
마이그레이션이 실패하는 것이 아니라 api 가 정상 응답을 만들지 못한다 — 증상이 다르니 헷갈리지 마라.
②는 노선당 한 번이면 된다. 갱신은 그 뒤로 노선당 하루 1회(KST 03:30)와 판본 신설 시점에 돈다.
호출은 call_ledger 에서 예약하는데 합쳐 하루 2회라 한도 10,000 회 옆에서 무시할 수 있다.
2. SQL 전문 복사해서 그대로 쓴다
파일을 절 단위로 나눠 싣고 각 절 앞에 왜 필요한지 적었다. 순서를 바꾸지 마라 —
아래 SQL 3번 절의 ux_obs_context 가 SQL 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[].firstDepartureTime 과 lastDepartureTime 을 필수로 올렸다.
값은 상류 노선정보에 이미 있고 방향마다 다르다 — 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_publication 과 stop_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 PK | bigint | 노선 판본 FK. 노선은 판본이 결정하므로 노선 ID 열을 따로 두지 않는다. 순번의 뜻이 판본마다 달라서 판본에 매단다 |
stop_order PK | int | 정류장 순번. 이웃 폴백과 구간합이 이 축 위에서 돈다. route_stop 과 복합 FK |
time_cell_id PK | varchar(40) | 시간 셀 식별자. 지금 값은 morning(07~09) · evening(17~20) · other. 셀 정의는 이 표가 아니라 feature_contract_version 이 소유한다 — 문자열로 둔 이유는 셀을 더 잘게 나누는 실험이 DDL 변경을 부르지 않게 하려는 것이다 |
feature_contract_version PK | varchar(40) | 이 행을 만든 특징 계약 버전. 키에 들어가는 이유는 점유율·순수요가 정원으로 나뉜 값이라, 정원 상수가 바뀌면 값 자체가 달라지기 때문이다. 옛 정원 행과 새 정원 행이 섞이지 않는다 |
revision | int | 재계산 세대 번호. 배치가 한 판 돌 때마다 단조 증가한다. vehicle_stop_prediction.cell_profile_revision 이 이 값을 기록해 채점을 세대별로 가른다 |
occupancy_mean | double precision | 셀 평균 점유율. 그 정류장 도착 시 평균 몇 % 차 있었는가. 1 − 잔여석/정원 의 평균이고 날짜 균등가중이다 |
net_demand_mean | double precision | 셀 평균 순수요. (예측 시점 좌석 − 도착 시 좌석)의 평균을 정원 평균으로 나눈 값. 음수가 정상이다 — 하차 우세 정류장이 그렇다 |
sample_count | int | 이 셀을 만든 유효 라벨 수. 이웃 폴백 판정의 재료다 |
day_count | int | 이 셀에 기여한 날짜 수. 날짜 균등가중이라 표본 수만으로는 평균을 다시 합칠 수 없어서 함께 둔다 |
trained_through | timestamptz | 이 시각까지의 도착 라벨만 들어갔다. 누출 차단 기준이자 평가 시 학습일 판정의 근거 |
computed_at | timestamptz | 이 행을 마지막으로 계산한 시각. 재계산 주기와 주체는 미결이다 |
PK 는 넷을 묶은 것이다 — (route_reference_version_id, stop_order, time_cell_id, feature_contract_version).
revision 은 키가 아니라 덮어쓰기의 결과다. 세대를 보관하려면 별도 설계가 필요하고 그건 미결이다.
vehicle_stop_prediction — /board 의 본체 · 열 14
앞의 열 개가 예보고, 뒤의 넷이 나중에 회수하는 실제 결과다.
한 행이 응답의 한 항목이라, 확정된 StopArrival 이 seatAvailableProbability 를 필수로 두고
additionalProperties: false 인 것과 정확히 짝을 이룬다 — "예보 없는 항목"을 담을 자리가 양쪽 다 없다.
| 열 | 타입 | 무엇인가 |
|---|---|---|
vehicle_observation_id PK | bigint | 이 예보를 만든 관측 FK. 예측 시점의 차량 상태(잔여석 · 혼잡도 · 차량 유형 · 순번 · 시각)가 전부 그 행에 있다 |
route_reference_version_id | bigint | 노선 판본. 관측의 판본과 복합 FK 로 일치가 강제된다 |
target_stop_order PK | int | 예보 대상 정류장 순번. 응답에서 이 정류장의 arrivals 항목이 된다 |
horizon_stops | int | 몇 정류장 전에서 예측했는가. target_stop_order − 관측의 stop_order 와 같다. 유도값인데도 열로 두는 이유는 지평이 모델 키라서다 — 지평별로 따로 적합하고 채점도 지평별로 가른다. CHECK 로 1~12 |
model_deployment_id | bigint | 이 예보를 낸 배포 FK. 읽기 필터가 아니라 출처 표시다 — api 는 스냅샷 poll 에 매달린 행을 배포로 거르지 않는다 |
cell_profile_revision | int | 읽은 demand_profile 세대. 없으면 서로 다른 통계로 낸 예보가 한 채점표에 섞인다. 채점을 가르기 위한 것이지 재현을 보장하지는 않는다 |
p_full_raw | double precision | 사전확률 이동 적용 전의 만석 확률. 채점용이 아니라 모델 입력이다 — 온라인 사전확률 이동이 (이 값, 실제 라벨) 쌍으로 다음 이동량을 만든다. 이동량은 logit(p_full) − logit(p_full_raw) 로 되찾으므로 따로 열을 두지 않는다 |
p_full | double precision | 응답에 쓰는 만석 확률. 좌석 분포의 0 지점이다. 응답의 seatAvailableProbability 는 api 서비스 계층이 1 − p_full 로 뒤집는다. 반올림하지 않는다. CHECK 가 NaN·±Infinity 까지 거절한다 |
expected_seats | double precision NULL | 도착 시 잔여좌석의 기댓값. 분포의 요약이라 NULL 이어도 p_full 은 유효하다. NULL 이면 api 가 그 필드를 응답에서 뺀다 — 확정 계약이 이 필드에 null 을 허용하지 않는다 |
generated_at | timestamptz | 이 예보를 계산한 시각. 관측의 observed_at 과 같거나 그보다 늦다. 한 poll 의 행은 전부 같은 값을 갖는다 |
settlement | varchar(16) | 라벨 회수 상태. 다섯 값이고 아래 표에 따로 적었다 |
outcome_observation_id | bigint NULL | 대상 정류장 도착 관측 FK. SETTLED·SEAT_MISSING 일 때만 NOT NULL. 여러 예보 행이 같은 도착 관측을 가리킬 수 있다 — 순번 40 에서 낸 지평 4 와 순번 42 에서 낸 지평 2 가 같은 도착을 본다 |
arrived_seats | int NULL | 도착 시 실제 잔여석. SETTLED 일 때만 NOT NULL. 0 이면 만석이고 그것이 채점의 이진 라벨이다 |
settled_at | timestamptz 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 자체가 거절된다.
행을 만들지 않는 세 경우
- 미정차 정류장(
route_stop.boarding_allowed = false) — 사유를 이 표에 복제하지 않는다. api 가route_stop에서 그대로 읽어arrivals를 빈 배열로 낸다. - 여정 끝을 넘어가는 대상 — 종점 다음의 순번 1 은 다른 여정이고 그 차량이 그 여정을 돌지 우리는 예측하지 않는다.
- 좌석이 결측인 관측 — 설계행렬을 채울 수 없다. 서빙 처리는 미결이다.
4. 놓치기 쉬운 것 여기서 막힌다
SQL 을 절만 골라 옮기다가 ux_obs_context 를 빠뜨리면 마이그레이션이 중간에 죽는다.
ux_obs_context 를 함께 추가해야 한다
vehicle_stop_prediction 의 fk_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_ledger | 5 | v4 변경 없음 |
route_reference_version | 16 | 이번에 4 늘었다 |
route_stop | 6 | v4 변경 없음 |
location_poll | 25 | 이번에 1 늘었다 |
vehicle_observation | 17 | 이번에 1 늘었다 |
model_deployment | 17 | v4 변경 없음 |
demand_profile | 11 | 신설 |
vehicle_stop_prediction | 14 | 신설 |
| 합 | 111 | 계약과 불일치 0건 |
여기 여덟은 계약이 담은 표다. 적용 뒤의 DB 에는 forecast_publication 과
stop_prediction 도 그대로 남아 있는데, 그 둘은 v4 계약에서 빠져 대조 대상이 아니다 — 6절.
제약 시험 10건 — 전부 의도대로 거절됐다
| # | 무엇을 넣어 봤나 | 막은 제약 | 결과 |
|---|---|---|---|
| 1 | 지평 13 — 서빙 상한 12 초과 | ck_vsp_horizon | 거절 |
| 2 | p_full 이 NaN | ck_vsp_pfull | 거절 |
| 3 | PENDING 인데 실제 좌석이 있다 | ck_vsp_seats_state | 거절 |
| 4 | SETTLED 인데 도착 관측이 없다 | ck_vsp_outcome_state | 거절 |
| 5 | 다른 판본의 순번에 예보를 붙인다 | fk_vsp_obs | 거절 |
| 6 | 판본에 없는 순번을 대상으로 삼는다 | fk_vsp_stop | 거절 |
| 7 | 점유율이 1.4 | ck_prof_occupancy | 거절 |
| 8 | 날짜 수가 표본 수보다 많다 | ck_prof_counts | 거절 |
| 9 | 첫차 시각이 25:00 | ck_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_publication 과 stop_prediction 은 v4 계약에서 빠졌는데도 표는 남는다.
이 마이그레이션이 하는 일과 하지 않는 일을 가른다. 하는 일은 배포에서 발행 스케줄러를 멈추고, 봉인·승격 프로시저와 두 표를 읽는 코드를 걷어내는 데까지다. 하지 않는 일은 물리 DROP 이다 — 언제 할지는 이 문서가 정하지 않는다(파이프라인 계약 v4 미결 14).
옮길 자료도 백필도 없다. 두 표에는 발행본이 쌓인 적이 없다 — 적재할 계수 번들이 없어 processor 가 뜬 적이 없기 때문이다. 그러니 DROP 을 미루는 것이 자료를 지키려는 것도 아니고, 되돌릴 자리를 남기는 것도 아니다. 한 번도 뜬 적 없는 경로를 안전장치라고 부를 수 없다(설계 개요 5절 ②). 스키마 변경과 코드 제거를 같은 배포에 묶지 않으려는 것뿐이다.
정해지면 별도 마이그레이션으로 아래를 쓴다. 순서가 있다 —
stop_prediction 이 forecast_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대 상한이 여기 없다. 확정 계약의 arrivals 는 maxItems: 2 다.
지평 창(1~12)은 열 CHECK 가 이미 보장하지만 대수 상한은 조립하는 쪽이 잘라야 한다.
정렬은 horizonStops 오름차순이고 같은 지평이면 vehicleId 오름차순으로 서버가 고정한다.
질의에 route_reference_version_id = 1 이 박혀 있는 것은 검증 씨앗 자료의 값이다.
실제로는 활성 판본을 먼저 고른 뒤 그 id 를 넘긴다.
model 필드는 이 질의에 없다 — 읽어 온 예보 행이 가리키는 배포에서 만들고,
차량 0대라 행이 없는 판에서만 ACTIVE 배포에서 읽는다.
관련 문서
이 문서는 검토 요청안이다. 제품의 확정 계약은 여전히 v3 이고, 여기 적은 어느 항목도 팀 합의 전에는 적용되지 않는다. 다만 SQL 자체는 이미 실측 검증을 마쳤다 — 합의만 되면 그대로 돈다.