v3 → v4 백엔드 변경이력
백엔드 크루가 읽는 문서다. v3에서 v4로 무엇이 바뀌는지만 담았고, 갈래마다 v3(왼쪽) · v4(오른쪽)를 나란히 두고 상세 페이지로 넘긴다. 화면 쪽 변경은 FE 변경이력에 있다. 확정 계약은 여전히 v3이고 v4는 검토 요청이다.
요약
1예보의 단위가 (노선, 정류장, 시간대)에서 (차량, 정류장)으로 바뀐다
예보 단위 (노선, 정류장, 시간대)
발행 단위 시간대 경계마다 한 판
07 · 09 · 17 · 20 KST
노선당 하루 4~5판
저장 forecast_publication
stop_prediction
노선당 하루 340~445행
응답 기준 봉인된 발행본 하나
모델 입력 차량을 보지 않는다
// 수집이 끊겨도 예보는 유효했다예보 단위 (차량, 정류장) 발행 단위 없다 — poll 하나가 곧 한 판 원자성은 예보 완결 표시가 낸다 location_poll.forecast_completed_at 저장 vehicle_stop_prediction demand_profile 노선당 하루 89만~98만행 응답 기준 예보까지 끝난 마지막 poll 모델 입력 그 차량의 현재 잔여석 // 관측이 끊기면 예보도 못 낸다
v3의 모델은 노선·정류장 순번·시간대 셋만 받았다. 그 셋은 시간대 안에서 변하지 않으므로 경계마다 한 판을 구워 봉인하는 것이 맞는 설계였다. A18은 예측 시점 그 차량의 잔여석을 조건으로 받는다. 입력이 관측마다 바뀌니 굽는 판에 담기지 않는다. 그래서 발행 계층이 계약에서 빠지고, 예보는 관측 뒤에 계산해 행으로 저장한다.
요청 시점에 계산하지 않고 저장하는 이유는 모델 안에 있다.
A18의 L_p0shift 층이 그날 이미 도착이 관측된 사건의 (예보 원값, 실제 라벨) 쌍을 되읽어 절편을 옮긴다.
낸 예보를 저장하지 않으면 그 층이 성립하지 않는다 — 채점 편의가 아니라 모델 입력이다.
한 판의 계산량은 두 방식이 같다. 한 차량은 자기 앞 12개 정류장에만 나타나므로
예보 수가 정류장 수와 곱해지지 않고 차량 수에 선형이다.
이 변경은 되돌릴 수 없다(5절). 발행 계층은 한 번도 가동된 적이 없고,
다시 세워도 그것이 답하는 단위가 (노선, 정류장, 시간대)라 /board 응답 구조가 v3 형태로 함께 돌아간다.
세 갈래 — 스키마 · 도메인 · 백엔드 API
2스키마 — 표 8개는 그대로인데 안의 둘이 바뀐다
V1__collector.sql call_ledger route_reference_version route_stop location_poll vehicle_observation V2__model.sql model_deployment V3__forecast.sql forecast_publication 발행 메타 stop_prediction 정류장별 예보 // 표 8 — 그중 둘이 발행 계층이다
V4__a18_seat_forecast.sql route_reference_version +4 방향별 첫차·막차 location_poll +1 forecast_completed_at vehicle_observation +1 vehicle_trip_key demand_profile 신설 · 열 11 vehicle_stop_prediction 신설 · 열 14 forecast_publication 계약에서 빠진다 stop_prediction 계약에서 빠진다 // 표 8 — 신설 2 · 빠짐 2 // 기존 열의 타입 변경 0건 · 백필 0건
세는 값은 다섯이다 — 열 추가 6 · 신설 표 2 · 인덱스 3 · 제약 1 · 기존 열 타입 변경 0건. 남는 표에서 열 삭제도 NOT NULL 변경도 없고, 더하는 여섯 열이 전부 NULL 허용이라 기존 행을 다시 쓰는 UPDATE가 없다. 표를 잠그고 백필하는 마이그레이션이 아니다.
함정이 하나 있다. ux_obs_context(UNIQUE)를 함께 추가해야 한다 —
vehicle_stop_prediction의 fk_vsp_obs가 참조하는 unique인데
V1의 vehicle_observation에는 (id) PK만 있어 이것이 없으면 마이그레이션 자체가 실패한다.
요약의 제약 +1이 이 하나를 센 것이다.
실측으로 검증했다. PostgreSQL 16에 V1→V2→V3→V4를 순서대로 적용해 전부 성공했고,
8표 111열이 파이프라인 계약과 완전히 일치했으며,
제약 시험 10건이 전부 의도대로 거절됐다(지평 13 · NaN · PENDING인데 좌석 있음 ·
SETTLED인데 도착관측 없음 · 판본 교차 · 판본에 없는 순번 · 점유율 1.4 · 날짜수>표본수 ·
첫차 25:00 · 응답 없이 예보완료). 정상 행 3건은 전부 통과했고
/board 조립 질의도 성공했다. 스냅샷 선택이 ix_poll_forecast_ready를
실제로 타는 것까지 EXPLAIN으로 확인했다.
3도메인 — 발행 계층 셋이 빠지고 모델 계약이 갈린다
모델 계약 interface FullnessProbabilityModel predict(ForecastTarget) ForecastTarget = (판본, 노선키, 순번, targetAt) 반환 = 만석 확률 하나 sealed PredictionResult ├ Estimated └ NotEstimated NotEstimatedReason OUT_OF_SCOPE BOARDING_NOT_ALLOWED 발행 계층 ForecastPublication StopPrediction PublicationRepository ForecastJob.buildCandidates(staged) ForecastJob.refreshBand(bandStart)
모델 계약 interface SeatForecastModel predict(VehicleStopTarget, DemandProfile) VehicleStopTarget = (관측, 대상 순번, 지평) 반환 = SeatForecastResult └ SeatDistribution pFull() = 분포의 0 지점 확정된 제거 5 ForecastPublication StopPrediction PublicationRepository PredictionResult 의 NotEstimated 갈래 NotEstimatedReason.BOARDING_NOT_ALLOWED ForecastJob 시간대 경계 → poll 마다 TimeBand 그대로 · 3값
제거 다섯의 이유는 하나로 모인다 — v4에는 담을 자리가 없다.
발행본이 없으니 ForecastPublication·StopPrediction·PublicationRepository가
가리킬 대상이 사라진다. NotEstimated는 행을 못 만들면 그냥 안 만들기 때문에 빠지고
(확정본 v4-fe 2.0.0에 "예보 없는 도착 항목"이라는 상태가 없다),
BOARDING_NOT_ALLOWED는 그 정류장에 아예 행을 만들지 않으므로 사유로 남길 것이 없다.
ForecastTarget·InteractionLogisticModel·PredictionResult·Estimated·NotEstimatedReason은
이 다섯에서 따라 나오는 정리이지 따로 확정된 항목이 아니다.
가장 큰 변화는 predict()의 입력이다.
v3의 ForecastTarget은 (판본, 노선키, 순번, targetAt)이라 차량을 아예 보지 않았다.
v4의 VehicleStopTarget은 (관측, 대상 순번, 지평)이고
예측 시점의 그 차량 잔여석이 처음으로 입력에 들어온다.
좌석이 결측인 관측으로는 대상 자체를 만들지 않는다 — 설계행렬이 잔여석을 그대로 쓰고
앵커도 거기서 출발하는데 그 자리에는 결측 지시자가 없기 때문이다.
반환형도 만석 확률 하나에서 좌석 분포로 바뀌고 p_full은 그 분포의 0 지점이 된다.
이름은 남고 안이 달라지는 것들 — ApprovedModelRef.covers()에 축이 하나 늘어
지평 1~12 밖이면 범위 밖이 되고, TermKey의 계수 조회 키에 지평이 들어가며
(지평별로 따로 적합하기 때문이다), CoefficientBundle은 노선별 블록이 되어 지평 12개 계수를 전부 담는다.
ModelDeploymentRepository는 CAS 승격이 그대로인데 candidate 발행본 봉인 검사가 갈 곳이 없어진다.
collector 20은 거의 그대로다 — 세 객체에 필드가 붙고 여정 키 값 객체 하나가 선다.
패키지 경계 셋과 ArchUnit 규칙도 그대로다.
4백엔드 API — /board가 읽는 표가 옮겨간다
GET /api/v1/routes/{routeId}/board
읽는 표
forecast_publication
stop_prediction
route_stop
기준 봉인된 발행본 하나
예보 자리 stops[].seatForecast
발행 메타 forecast (ForecastMetaView)
캐시 max-age=60 고정
오류 코드 5종
INVALID_ROUTE_ID 400
ROUTE_NOT_FOUND 404
MODEL_OUT_OF_SCOPE 503
NO_CURRENT_PUBLICATION 503
SERVICE_UNAVAILABLE 503
// 정류장 하나가 실패하면 발행 전체 503GET /api/v1/routes/{routeId}/board
읽는 표
location_poll
vehicle_stop_prediction
vehicle_observation
route_stop
기준 예보까지 끝난 마지막 poll
예보 자리 stops[].arrivals[] 최대 2
발행 메타 없다 → observedAt 하나
캐시 서버가 수집 단계에서 정한다
15 · 20 · 60 · 240 · 600
오류 코드 5종 — 한 자리 교체
INVALID_ROUTE_ID 400
ROUTE_NOT_FOUND 404
MODEL_OUT_OF_SCOPE 503
NO_RECENT_OBSERVATION 503
SERVICE_UNAVAILABLE 503
// 그 항목만 빠진다 — 503으로 올리지 않는다오류는 늘지도 줄지도 않는다. 한 자리가 바뀔 뿐이다.
NO_CURRENT_PUBLICATION은 가리킬 표가 사라져서 뺀다 — 그 사유는 "봉인된 발행본이 없다"는 뜻이었다.
NO_RECENT_OBSERVATION은 새로 생긴 의존 때문에 붙는다.
v3은 모델이 차량을 입력으로 쓰지 않아 수집이 끊겨도 예보가 유효했지만,
A18은 그 차량의 현재 잔여석을 조건으로 받으므로 /board도 관측에 매인다.
가용성의 성격이 바뀐 것이지 오류가 하나 늘어난 것이 아니다.
봉투는 exact {code, message, requestId, retryable}이고
Cache-Control: no-store는 다섯 전부에 붙는다.
부분 실패 규칙이 바뀐다. v3은 정류장 하나가 점수화에 실패하면 발행 전체를 503으로 되돌렸다. 발행이 한 판 단위였기 때문이다. v4는 예보가 차량에 붙으므로 그 규칙이 성립하지 않는다 — 한 차량의 예보를 못 냈으면 그 항목만 빠지고 나머지는 정상 200으로 나간다. 503은 노선 자체를 서빙할 수 없을 때만 낸다. 그 대가로 봉인이 지키던 불변식 "승차 가능 정류장은 전부 답을 갖는다"가 사라진다 — 대체물은 없고 그것이 의도다.
다른 두 엔드포인트. /vehicles는 읽는 표도 응답 필드도 그대로인데
딱 하나 Cache-Control이 v3의 고정 15초에서 수집 주기를 따르는 값으로 바뀌므로
그 값을 내는 자리 한 군데가 열린다. "한 글자도 안 바뀐다"고 적으면 틀린다.
/routes는 응답 필드가 그대로이고 status 한 값의 판정 근거만
봉인 발행본에서 활성 계수 묶음으로 옮긴다.
확률 뒤집기(1 − p_full)가 서비스 계층 메서드 한 곳이라는 규약은 그대로다.
다만 두 projection이 읽는 표가 겹치지 않는다는 v3의 성질은 깨진다 —
location_poll과 vehicle_observation을 두 질의가 각각 읽는다.
남는 것은 질의와 읽기 행의 분리이고, 실패가 옮겨 붙지 않는 근거도 거기까지다.
배포
5되돌릴 수 없는 지점 — 무엇을 지나면 못 돌아오나
발행 계층이 코드에 살아 있다 forecast_publication stop_prediction 봉인 확인 5종 · 승격 CAS 되돌리기 = 발행 스케줄러를 다시 켠다 불변식 승차 가능 정류장은 전부 답을 갖는다 // 사실이 아니다 — 그 경로는 한 번도 // 가동된 적이 없다. 적재할 계수 번들이 // 없어 발행 processor 가 뜬 적이 없다
발행 스케줄러 정지 발행본 봉인·승격 프로시저 제거 두 표를 읽는 코드 제거 물리 DROP 은 이 계약이 정하지 않는다 forecast_publication 표는 남는다 stop_prediction 표는 남는다 언제 지울지는 미결 14 되살려도 답하는 단위가 (노선, 정류장, 시간대) 라 /board 응답 구조가 v3 로 함께 돌아간다 = 조용한 폴백이 아니라 계약 되돌림이다 새 불변식 한 판의 예보 행은 같은 poll · 같은 배포 STAGED → ACTIVE → RETIRED 그대로
쓰기만 멈추지 않고 지우는 이유는 그 경로가 한 번도 가동된 적이 없기 때문이다. 적재할 계수 번들이 없어 발행 processor가 뜬 적이 없다. 한 번도 뜬 적 없는 경로를 안전장치라고 부를 수는 없고, 남겨 두면 계약이 두 갈래로 읽힌다. 되살릴 계획이 없으므로 토글도 이중 경로도 남기지 않는다. 두 표에는 발행본이 쌓인 적이 없어 옮길 자료도 백필도 없다.
지우는 범위는 층마다 다르다. 계약에서는 두 표를 적지 않고,
코드에서는 봉인·승격 프로시저와 두 표를 읽는 자리를 걷어내며,
물리 표를 언제 DROP할지는 이 계약이 정하지 않는다(미결 14).
이번 배포에서 하는 일은 스케줄러 정지와 죽은 코드 삭제까지다.
V4__a18_seat_forecast.sql도 두 표를 건드리지 않는다.
지우는 것은 서빙 계약 쪽 발행 계층뿐이고 모델 평가 파이프라인은 그대로다.
빠지는 것은 발행본이지 배포가 아니다 — A18도 계수 묶음을 배포해야 하므로
model_deployment의 상태기계와 승격 CAS는 그대로 남는다.
그러므로 A18이 새 자료에서 무너졌을 때 무엇을 그 자리에 둘지는 정해진 것이 없다(미결 15).
지어내지 않고 미결로 남긴다.
6배포 순서가 강제된다 — ① 마이그레이션 ② 노선정보 수집 1회 ③ api 기동
route_reference_version up_first_departure_time NULL up_last_departure_time NULL down_first_departure_time NULL down_last_departure_time NULL 방향 둘 × 2 = 4열 location_poll forecast_completed_at NULL vehicle_observation vehicle_trip_key NULL demand_profile 0행 vehicle_stop_prediction 0행 // 여기서 바로 api 를 올리면 // /board 조립이 그 자리에서 막힌다 // DirectionInfo.firstDepartureTime 이 필수다
② 노선정보 수집 1회 노선당 한 번 방향별 첫차·막차 4열을 채운다 그 뒤 갱신은 노선당 하루 1회 (KST 03:30) 판본 신설 시점에도 돈다 call_ledger 에서 예약 · 합쳐 하루 2회라 한도 10,000 옆에서 무시할 수 있다 ③ api 기동 /board 조립이 성립한다 백필은 0건이다 더하는 여섯 열이 전부 NULL 허용 신설 표 둘은 빈 채로 선다 demand_profile 이 비는 동안은 이웃 폴백만 돈다
②를 건너뛰면 증상이 헷갈린다. 마이그레이션이 실패하는 것이 아니라
api가 정상 응답을 만들지 못한다 — 확정본 v4-fe 2.0.0의 DirectionInfo가
firstDepartureTime과 lastDepartureTime을 필수 필드로 두어서
빈 값을 담을 자리가 없고 응답 조립이 그 자리에서 막힌다.
마이그레이션 이전 자료는 백필하지 않는다.
vehicleTripKey가 NULL 허용이라 백필이 필요 없고, 다만 그 이전 관측은 여정이 비어
라벨 대상에서 빠진다(서빙은 결측 지시자로 받는다).
forecastCompletedAt도 NULL 허용이라 마이그레이션 이전 poll은 전부 스냅샷 후보에서 빠지는데,
어차피 그 poll들에는 예보 행이 없으므로 고르면 안 되는 것이 맞다.
model_deployment에는 새 열이 없어 백필이 없다 — 초안의 serving_role 백필은 철회됐다.
남은 것
7작업량 — 약 24~41 사람·일
| 세는 방식 | 값 | 어디에 있나 |
|---|---|---|
| 계획에 쓰는 값 | 약 24~41 사람·일 | 서버 한 사람 기준. 아래 둘을 감싸는 폭이다. 계획에는 넉넉한 쪽을 써라 |
| 구현 관점 | 23.5~36.5일 | 설계 개요 4절에 항목 12개로 갈라 있다 |
| 계약 관점 | 27~41일 | 파이프라인 계약이 따로 센다. 항목 묶음이 다를 뿐 겹치는 일을 두 번 세지 않았다 |
| 가장 불확실한 항목 | 라벨 회수 배치 | v3에서 연구 export가 하던 일을 제품이 넘겨받는다 — 계약 관점이 그렇게 적었다.
구현 관점에서 가장 큰 항목은 /board 재조립이고,
추론기 · 궤적 조회 · 재조립 셋이 합의 절반 가까이 된다 |
| 이 값에 안 들어간 것 | 넷 | 계수 번들 생산(외부 ML 환경 소유) · 프론트엔드 작업 · 일일 평가 편입 · 과거 관측의 여정 소급 계산 |
코드베이스를 보지 않고 계약 문서만 보고 낸 값이다.
실제 코드의 결합도에 따라 흔들린다. 시험 작성을 포함한 사람-일 단위다.
/board 응답 구조가 통째로 바뀌므로 기존 계약 시험은 대부분 다시 써야 한다 —
회귀가 아니라 교체다.
제품 밖 선결 조건이던 지평 5·7·9·10·11 부재는 해소됐고,
남은 것은 열두 지평 계수를 번들에 담아 배포하는 일이다.
8아직 안 정해진 것 — 미결 여섯
| # | 미결 | 무엇이 걸려 있나 |
|---|---|---|
| 5 | 좌석이 결측인 관측의 서빙 처리 | 직전 관측의 좌석으로 대신할지, crowdedCode로 거친 추정을 할지, 지금처럼 뺄지.
전수 314,688 관측행 중 237행(0.075%)이고, /vehicles는 같은 차량을
kind: UNKNOWN으로 계속 보여 주므로 두 응답이 어긋난다 |
| 6 | NO_RECENT_OBSERVATION 문턱값 |
/vehicles의 staleAt과 같은 값이어야 두 응답이 두 말을 하지 않는다.
같은 값으로 묶을지 따로 둘지 정한 것이 없다 |
| 7 | 스냅샷의 최대 나이 | 예보 쓰기가 계속 실패하면 스냅샷이 낡는다. 어느 나이부터 6번으로 넘길지, 6번과 같은 축으로 볼지 |
| 8 | vehicle_stop_prediction 보존과 파티션 축 |
채점 창과 저장 비용의 절충. 노선당 하루 89만~98만행이라
파이프라인에서 가장 빨리 자라는 표다. 회수가 끝난 행과 PENDING 행의 정리 규칙도 함께 |
| 14 | 발행 계층 두 표의 물리 정리 시점 | 계약에서는 빠졌다. 실제 DROP을 언제 할지, 그때까지 스케줄러 정지로 둘지 정한 것이 없다. 두 표에는 발행본이 쌓인 적이 없어 옮길 자료도 백필도 없다 |
| 15 | A18이 무너졌을 때의 안전장치 | 되돌릴 곳이 없다(5절). 무너졌다고 판정하는 기준도,
그때 /board가 내보낼 응답도 정해진 것이 없다. 코드보다 앞에 놓이는 결정이다 |
여섯은 배포를 막지 않지만 코드가 이 값을 요구하는 자리다. 미결 1~15 전량은 파이프라인 계약 v4 끝에 있고, v3의 미결 셋(계수 번들 부재 · 학습 사건의 노선 판본 부재 · 제품 공개 노선 ID)은 그대로 남아 있다.
파이프라인 계약 — 미결 전량 →이 문서는 검토 요청이다. 채택하지 않기로 하면 v3이 그대로 남는다. A18의 성적은 아직 오프라인 산출값이고 일일 평가 원장에는 들어 있지 않다 — 이 문서도 성능이 입증됐다고 적지 않는다. 화면 쪽 변경은 FE 변경이력, 모델 자체는 08번 모델 문서에 있다.