클라이언트 계약은 확정됐다
백엔드는 그것을 무엇으로 조립하나

v4-fe 2.0.0 이 /board 의 모양을 확정했다. 이 문서는 그 모양을 만드는 쪽을 적는다 — 어느 표에서 무엇을 읽어 어느 필드가 되는가, 그 질의가 어떻게 생겼는가, 확률을 어디서 뒤집는가, 무엇을 오류로 내고 무엇을 정상으로 내는가. 계약을 다시 쓰는 문서가 아니다. 계약과 어긋나는 줄이 있으면 이 문서가 틀린 것이다.

2026-08-19 작성 · 코로구 "0817 BE API 명세 v1"(v12)의 v4 판 · 표 계약은 파이프라인 계약 v4 · 설계 개요는 v4-be 도메인

한 장 요약 30초

엔드포인트는 셋 그대로다. 그중 하나만 바뀐다.

엔드포인트이 개정에서무엇이 달라지나
GET /api/v1/routes필드는 그대로응답 필드가 그대로다. 다만 status 한 값의 판정 근거가 봉인 발행본에서 활성 계수 묶음으로 옮기므로 그 값을 읽는 표가 바뀐다 — 확정본이 손댄 것도 RouteStatus 뜻풀이와 예시의 status 값이다
GET /api/v1/routes/{routeId}/board다시 만든다읽는 표가 바뀌고, 예보가 붙는 자리가 정류장에서 (차량, 정류장) 으로 옮긴다. 이 문서의 거의 전부가 여기다
GET /api/v1/routes/{routeId}/vehicles거의 그대로조회 경로도 응답 필드도 그대로다. 바뀌는 것은 Cache-Control 하나 — v3 은 고정 15초였고 v4 는 수집 주기를 따른다(6절)
/boardv3v4
읽는 표forecast_publication · stop_prediction · route_stoplocation_poll · vehicle_stop_prediction · vehicle_observation · route_stop
기준이 되는 것봉인된 발행본 하나예보까지 끝난 마지막 poll 하나
예보의 단위(노선, 정류장, 시간대)(차량, 정류장)
예보가 붙는 응답 자리stops[].seatForecaststops[].approachingVehicles[] (최대 3)
발행 메타forecast(ForecastMetaView)없다 — observedAt 하나로 대체
확률 뒤집기서비스 계층 메서드 한 곳그대로
캐시max-age=60 고정서버가 수집 단계에서 정한다 (15 · 20 · 60 · 240 · 600)
못 낼 때의 사유NO_CURRENT_PUBLICATIONNO_RECENT_OBSERVATION

표 넷에 route_reference_version(노선 골격·방향별 첫차·막차)과 model_deployment(model 표시·status 판정)가 덧붙는다. 두 표 자체는 v3 도 읽었지만 읽는 것이 달라진다 — 판본에서는 v4 에 새로 붙는 네 열을 더 읽고, 배포는 발행본을 거치지 않고 그 판의 예보 행에서 바로 찾는다.

1. 왜 둘로 갈라 두나 근거가 바뀐다

/board 와 /vehicles 를 가른 이유가 v4 에서 달라진다. 가른다는 결론은 그대로다.

v3 이 적은 근거는 갱신 주기가 24배 다르다였다. 예보는 시간대 경계에만 구워 하루 네 번 바뀌고 차량은 수집할 때마다 바뀌니, 한 문으로 내보내면 느린 쪽이 빠른 쪽의 캐시를 망치고 빠른 쪽이 느린 쪽을 매 요청 다시 읽게 만든다. 주기 차이가 곧 분리의 근거였다.

v4 에서는 그 근거가 사라진다. 둘 다 관측마다 바뀐다. 같은 poll 이 두 응답의 재료다. 그래도 합치지 않는다 — 가르는 이유가 주기에서 관점으로 바뀐다. /board 는 정류장에서 보고 /vehicles 는 차량에서 본다. 확정본이 같은 말을 적었다 — 예보 확률은 /board 에만 있고 차량의 실제 잔여석은 /vehicles 에만 있으며, 둘을 잇는 것은 vehicleId 다.

바뀌는 때담는 것읽는 표
예보 /board관측마다정류장 전부 + 정류장당 접근 중인 차량 최대 3vehicle_stop_prediction 중심
차량 /vehicles관측마다운행 차량만vehicle_observation 중심

읽기 경로를 공유하지 않는 것은 그대로다. 두 projection 은 서로의 질의를 부르지 않고 서로의 읽기 행을 쓰지 않는다. 한쪽이 죽어도 다른 쪽이 산다는 성질이 여기서 나온다 — /board 가 NO_RECENT_OBSERVATION 503 을 내는 동안에도 /vehicles 는 UNKNOWN 상태를 담아 200 을 낸다. 다만 "읽는 표가 겹치지 않는다"는 v3 의 성질은 v4 에서 깨진다 — location_poll 과 vehicle_observation 을 두 질의가 각각 읽는다. 남는 것은 질의와 읽기 행의 분리이고, 실패가 옮겨 붙지 않는 근거도 거기까지다.

합치면 무엇을 잃나

한 응답에 차량 목록과 정류장 목록을 같이 담으면 노선당 응답이 커지고, 화면이 둘 중 하나만 필요할 때도 전부를 받는다. 더 큰 값은 실패 격리다 — 예보를 못 내는 상황(활성 번들이 판본 미지원)과 차량을 못 보는 상황(수집 실패)은 원인이 다른데, 한 문이면 둘 중 하나만 깨져도 화면 전체가 빈다.

2. 무엇으로 조립하나 응답 필드 → 출처 열

확정본(v4-fe 2.0.0)의 Board 스키마가 기준이다. 필수 필드를 빠짐없이 적었다.

여섯 단계

단계무엇을 읽나무엇을 채우나
1route_reference_version 활성 판본route 의 이름들 · turnSequence · referenceVersionId · 방향별 첫차·막차
2route_stop 전부stops[] 골격과 directions[] 의 기·종점 이름. 승차 불가 정류장도 담고 그 approachingVehicles 만 빈 배열이다
3스냅샷 location_pollobservedAt(response_received_at) · vehiclesInService(stored_rows)
4그 poll 의 관측에 매달린 vehicle_stop_predictionapproachingVehicles 항목 전부. 항목은 여기서만 나온다
5model_deploymentmodel. 예보 행이 가리키는 배포에서 읽고, 차량 0대라 행이 없을 때만 ACTIVE 에서 읽는다
6ACTIVE 배포의 supported_scope_digestroute.status. 이 노선 판본을 담으면 FORECAST_READY 다. 담지 않으면 상태로 내리지 않고 MODEL_OUT_OF_SCOPE 503 이며(7절), PREPARING 은 확정본이 "아직 활성 계수 묶음이 없다"로 정의한 값이다

순서가 아니라 의존이다. 1·2·3 은 서로를 기다리지 않고, 4 는 3 이 고른 poll 을 받아야 돌며, 5 는 4 의 결과를 본다.

대조표

응답 필드출처 열따라붙는 말
route.idroute_reference_version.source_route_id공급자(GBIS) 원문이다. public_route_id 를 쓰지 않는 것은 제품 공개 ID 도입이 미결이라서다
route.displayNamedisplay_name표시명과 모델 노선 키는 문자열이 같아도 다른 개념이다
route.startStopName · endStopNamestart_stop_name · end_stop_name노선 전체의 기·종점이다. directions[] 의 기·종점은 이 열이 아니라 route_stop 에서 유도한다
route.statusmodel_deployment.state · supported_scope_digestv3 의 "봉인 발행본이 있다"에서 "활성 계수 묶음이 있다"로 판정 근거가 옮겼다
route.turnSequenceturn_sequence필수 필드이고 null 을 허용한다(단방향). 빠뜨리지 말고 null 로 내보낸다
route.referenceVersionIdreference_version_id/vehicles 의 같은 필드와 다르면 개편 중이라는 신호다
route.directions[].idroute_stop.direction회차 순번 이하가 UP, 초과가 DOWN
route.directions[].name유도 — 그 방향 종점 이름 + " 방면"열이 없다. 아래 terminalStopName 을 그대로 쓴다
route.directions[].originStopName · terminalStopName유도 — route_stop.nameUP 은 (stop_order=1 → turn_sequence), DOWN 은 (turn_sequence → 마지막 stop_order). DOWN 의 기점은 회차 정류장이다
route.directions[].firstDepartureTime · lastDepartureTimeup_first_departure_time 외 3열v4 에서 새로 붙는 네 열이다. 방향마다 다른 것이 존재 이유다
observedAtlocation_poll.response_received_at상류가 주는 queryTime 이 아니다 — 실측 1,294건 전부에서 우리 수신 시각보다 뒤에 있었다
model.releaseId · trainedThroughmodel_deployment.release_id · trained_through그 판의 예보 행이 가리키는 배포다. 행이 없으면 ACTIVE 에서 읽는다
vehiclesInServicelocation_poll.stored_rows새 열이 아니다. 같은 poll 안 차량 중복을 부분 unique index 가 막으므로 저장 행 수가 곧 distinct 차량 수다
stops[].sequenceroute_stop.stop_order순번은 정렬·위치 계산용이고 식별은 stopId 로 한다
stops[].stopId · name · direction · boardingAllowedroute_stop.station_id · name · direction · boarding_allowedstopId 만 이름이 바뀐다. DB 열은 station_id 그대로이고(상류 GBIS 와 맞춘다) 개명은 api 조립의 toStopView 한 줄에서만 일어난다. 나머지 셋은 카멜 변환뿐이고 값 변환은 없다
stops[].approachingVehicles[] 항목의 존재그 (관측, 대상 정류장)에 vehicle_stop_prediction 행이 있는가예보를 못 낸 자리는 항목째 빠진다. 확정본에 "예보 없는 항목"이라는 상태가 없다
approachingVehicles[].vehicleIdvehicle_observation.vehicle_id필수인데 null 을 허용한다. 공급자가 빠뜨린 드문 경우다
approachingVehicles[].horizonStopsvehicle_stop_prediction.horizon_stopsck_vsp_horizon 이 1~12 로 막고 계약도 같은 범위다
approachingVehicles[].seatAvailableProbability1 − p_fullSQL 이 아니라 서비스 계층에서 뒤집는다 — 4절
approachingVehicles[].expectedSeatsexpected_seats선택 필드다. NULL 이면 키째 뺀다. null 을 넣으면 additionalProperties: false 는 통과해도 타입이 어긋난다

vehicle_stop_prediction 열 14 중 응답에 닿는 것은 넷이다 — target_stop_order(정류장 짝짓기) · horizon_stops · p_full · expected_seats. p_full_raw · cell_profile_revision · model_deployment_id 와 회수 계열 넷은 채점과 계보를 위한 열이라 조회가 읽지 않는다. model_deployment_id 만 model 표시를 위해 한 번 더 읽힌다.

2026-08-19 개정 — 이름 셋과 상한 하나. DB 는 안 건드린다

확정본이 필드 이름 셋을 바꾸고 정류장당 대수 상한을 2 에서 3 으로 늘렸다. 마이그레이션은 없다. 표도 열도 그대로이고, 바뀌는 것은 api 계층의 조립뿐이다.

DB / 읽기 행응답 필드어디서 바뀌나
route_stop.station_id
StopRow.stationId
stops[].stopId BoardQueryService.toStopView 한 줄(5절). DB 열 이름은 station_id 를 유지한다 — 상류(GBIS)가 그렇게 부르고 수집기가 그 이름으로 적재하기 때문이다. 계약만 stop 으로 통일했다(stopName·startStopName·stops[] 와 짝을 맞춘다)
vehicle_observation.station_idvehicles[].stopId /vehicles 쪽 뷰의 같은 자리. 열 이름은 그대로다
vehicle_stop_prediction
PredictionRow
stops[].approachingVehicles[] 배열 이름과 항목 스키마 이름이 바뀐다(StopArrival → ApproachingVehicle). 읽기 행은 표 이름을 따라 PredictionRow 이고, 뷰만 ApproachingVehicleView 다
—배열 길이 상한 ② 질의의 rn <= 3 한 곳. ck_vsp_horizon 도 인덱스도 안 바뀐다 — 상한은 저장이 아니라 조회에 걸리는 규칙이다(스키마 참조)

DDL 변경 0건이다. V4__a18_seat_forecast.sql 을 다시 쓰지 않는다. 개명이 api 계층 한 곳에 갇혀 있다는 것이 이 개정의 값이고, 그래서 수집기·추론기·채점은 손대지 않는다.

필수 필드가 언제나 채워지는가

확정본의 Board 는 다섯을 필수로 둔다(route · observedAt · model · vehiclesInService · stops). 그런데 출처가 되는 두 열은 스키마에서 NULL 허용이다. 비지 않는다는 보장은 스냅샷을 고르는 술어에서 나온다.

3. 조립 SQL 실제로 도는 질의

아래 질의는 V1~V4 를 적용한 PostgreSQL 16 에서 실제로 돌려 확인했다(2026-08-19).

① 스냅샷 고르기 — ix_poll_forecast_ready 를 탄다

SELECT id, response_received_at, stored_rows
  FROM location_poll
 WHERE route_reference_version_id = :refVersionId
   AND outcome IN ('SUCCESS_ROWS', 'SUCCESS_EMPTY')
   AND forecast_completed_at IS NOT NULL
 ORDER BY response_received_at DESC
 LIMIT 1;

인덱스가 이 질의를 위해 만들어졌다 — 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 이다. 부분 인덱스의 술어와 질의의 WHERE 가 글자 그대로 같아야 계획기가 이 인덱스를 고른다. 두 술어가 인덱스 안에 있으므로 남는 것은 첫 열로 찾고 둘째 열 방향으로 한 칸 읽는 일뿐이고, 정렬이 따로 돌지 않는다. EXPLAIN 으로 실제 사용을 확인했다.

forecast_completed_at IS NOT NULL 을 빼면 안 된다

예보가 아직 안 붙은 판을 스냅샷으로 고르면 그 노선의 차량이 통째로 사라진다. 응답은 200 이고 approachingVehicles 만 전부 비어서, 화면에는 "곧 오는 버스 없음"으로 나간다. 쓰기 한 번 실패가 정상 응답의 모양으로 나가는 것이라 아무도 어긋남을 못 잡는다. 예보 대상이 0건이어도 이 시각을 찍는 이유가 그것이다.

② 도착 항목 — 정류장마다 앞의 둘만

WITH ranked AS (
    SELECT p.target_stop_order,
           o.vehicle_id,
           p.horizon_stops,
           p.p_full,
           p.expected_seats,
           row_number() OVER (
               PARTITION BY p.target_stop_order
               ORDER BY p.horizon_stops, o.vehicle_id, o.source_row_no) AS rn
      FROM vehicle_observation o
      JOIN vehicle_stop_prediction p
        ON  p.vehicle_observation_id     = o.id
        AND p.route_reference_version_id = o.route_reference_version_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 o.poll_id = :pollId
       AND s.boarding_allowed
)
SELECT target_stop_order, vehicle_id, horizon_stops, p_full, expected_seats
  FROM ranked
 WHERE rn <= 3
 ORDER BY target_stop_order, horizon_stops;
이 줄왜 그렇게 썼나
o.poll_id = :pollId스냅샷은 poll 하나를 통째로 쓴다. 차량별 최신 관측을 긁어모으면 observedAt 하나가 거짓말이 된다
관측 → 예보 조인의 두 열(vehicle_observation_id, route_reference_version_id) 는 fk_vsp_obs 가 ux_obs_context 를 상대로 잡은 복합 FK 그대로다. 판본이 섞이는 경로가 없다
JOIN route_stop대상 순번이 그 판본에 실재하는지 확인하고 boarding_allowed 를 읽으려고 건다. fk_vsp_stop 이 이미 실재를 보장하므로 이 조인은 행을 줄이지 않는다
s.boarding_allowed승차 불가 정류장은 approachingVehicles 가 항상 빈 배열이다. 여기서 걸러 두면 조립 쪽에 조건이 남지 않는다
row_number() … rn <= 3확정본의 maxItems: 3 다(2026-08-19 개정에서 2 → 3). 늘린 근거는 크기가 아니라 답이 바뀌는 자리다 — 앞의 두 대가 모두 빈자리 확률 70% 미만인 경우가 1.80% 이고 그중 42.3% 에서 세 번째가 답이다. 지평 12 안에 3대 이상이 오는 경우가 56.0%, 세 번째의 실제 대기가 중앙 22.7분이다. 4 로 늘리지 않는 것은 네 번째가 24.8분이라서다. 상한은 정류장마다 걸리므로 PARTITION BY target_stop_order 여야 한다. 노선 전체에 LIMIT 을 걸면 뜻이 달라진다
ORDER BY p.horizon_stops …확정본이 정한 정렬 축이다. 거리가 곧 도착 순서다 — 맨 앞 항목이 실제 첫 도착일 확률이 96.68% 이고 앞의 두 대끼리의 순서는 96.76% 맞는다. 세 번째 항목은 지평이 멀어 더 흔들린다
… , o.vehicle_id, o.source_row_no같은 지평이 겹칠 때의 결정성이다. 계약이 정한 축은 지평 하나이고 두 번째 축(vehicleId)까지는 설계 개요가 적었다. 세 번째 축은 이 문서의 제안이다 — vehicle_id 가 null 일 수 있어 둘만으로는 순서가 굳지 않는다
배포로 거르지 않는다ACTIVE 로 거르면 승격 직후 한 판의 차량이 전부 사라진다. 스냅샷 poll 에 매달린 행을 그대로 읽는다

두 조인 모두 인덱스 위에 있다 — 관측은 UNIQUE (poll_id, source_row_no) 의 앞자리로 찾고, 예보는 PRIMARY KEY (vehicle_observation_id, target_stop_order) 의 앞자리로 찾는다. 이 경로를 위해 새로 만든 인덱스는 없다.

③ 정류장 골격 — 승차 불가 정류장도 담는다

SELECT stop_order, station_id, name, direction, boarding_allowed
  FROM route_stop
 WHERE route_reference_version_id = :refVersionId
 ORDER BY stop_order;

② 와 갈라 두는 이유가 있다. stops[] 는 그 판본의 모든 정류장이고 ② 의 결과는 그중 일부에만 붙는다. 한 질의로 합치면 도착 항목이 없는 정류장을 살리려고 LEFT JOIN 이 되는데, 그러면 정류장 하나에 빈 행 하나가 섞여 조립 쪽에서 "항목이 없음"과 "항목이 하나"를 다시 갈라야 한다. 정류장 목록은 관측과 무관한 값이라 관측 쪽 질의에 얹지 않는다. 이 질의는 route_stop 의 PK (route_reference_version_id, stop_order) 를 그대로 훑는다.

검증 스크립트의 /board 질의는 ② 와 ③ 을 하나로 묶고 boarding_allowed 로 걸러 도착 항목만 냈다. 그 질의는 조립이 도는지를 본 것이지 응답을 그대로 만든 것이 아니다 — 승차 불가 정류장이 빠지므로 계약의 stops[] 가 되지 않는다. 실제 출력은 한 행이었고 차량 ID만 문서용 합성 가명으로 바꿔 적었다: 55 | 범계역 | SYNTH-VEH-001 | 12 | 0.9100. 씨앗 자료의 나머지 값이라 아래 응답 예시의 숫자와는 별개다.

④ 나머지 둘

-- 활성 노선 판본
SELECT id, reference_version_id, source_route_id, display_name,
       start_stop_name, end_stop_name, turn_sequence,
       up_first_departure_time,   up_last_departure_time,
       down_first_departure_time, down_last_departure_time
  FROM route_reference_version
 WHERE source_route_id = :sourceRouteId
   AND valid_to IS NULL;

-- 그 판의 예보를 낸 배포. 예보 행이 없으면 0행이 나오고, 그때만 ACTIVE 를 읽는다
SELECT d.release_id, d.trained_through
  FROM model_deployment d
 WHERE d.id = (SELECT min(p.model_deployment_id)
                 FROM vehicle_stop_prediction p
                 JOIN vehicle_observation o ON o.id = p.vehicle_observation_id
                WHERE o.poll_id = :pollId);

한 판의 예보 행은 같은 배포가 낸다 — 추론이 poll 하나에 한 번 돌기 때문이다. 그래도 min() 으로 하나를 고정하는 것은 결정성 때문이고, 값이 갈리는 판이 나오면 그것 자체가 사고 신호다.

응답 예시와 맞춰 보기

확정본의 morning 예시에서 정류장 하나만 떼어 왔다. vehicleId는 문자열 계약을 유지한 문서 전용 비숫자 합성 토큰이고, 나머지 값은 확정본 그대로다.

{
  "route": {
    "id": "204000057",              // route_reference_version.source_route_id
    "displayName": "3330",          // display_name
    "startStopName": "도촌동9단지앞",
    "endStopName": "안양역",
    "status": "FORECAST_READY",     // ACTIVE 배포가 이 판본을 지원한다
    "turnSequence": 43,             // turn_sequence
    "referenceVersionId": "3330-v5",
    "directions": [
      { "id": "UP",   "name": "안양역 방면",
        "originStopName": "도촌동9단지앞",   // route_stop, stop_order = 1
        "terminalStopName": "안양역",        // route_stop, stop_order = 43
        "firstDepartureTime": "04:50", "lastDepartureTime": "23:30" },
      { "id": "DOWN", "name": "도촌동9단지앞 방면",
        "originStopName": "안양역",          // 회차 정류장. 44 가 아니다
        "terminalStopName": "도촌동9단지앞", // route_stop, 마지막 stop_order = 85
        "firstDepartureTime": "05:00", "lastDepartureTime": "23:30" }
    ]
  },
  "observedAt": "2026-08-18T08:29:47+09:00",   // location_poll.response_received_at
  "model": {
    "releaseId": "seat-distribution-3330-20260819-001",
    "trainedThrough": "2026-08-18T23:59:59+09:00"
  },
  "vehiclesInService": 14,                     // location_poll.stored_rows
  "stops": [
    {
      "sequence": 55,                          // route_stop.stop_order
      "stopId": "209000135",
      "name": "롯데백화점.범계역",
      "direction": "DOWN",
      "boardingAllowed": true,
      "approachingVehicles": [
        { "vehicleId": "SYNTH-VEH-001", "horizonStops": 3,
          "seatAvailableProbability": 0.871,   // 1 - p_full
          "expectedSeats": 28.2 },
        { "vehicleId": "SYNTH-VEH-008", "horizonStops": 8,
          "seatAvailableProbability": 0.912,
          "expectedSeats": 30.7 },
        { "vehicleId": "SYNTH-VEH-009", "horizonStops": 12,
          "seatAvailableProbability": 0.946,
          "expectedSeats": 33.1 }
      ]
    },
    {
      "sequence": 62,                          // 학의JC(경유)
      "stopId": "277103156",                   // 접두 277 = 경유 지점 대역
      "name": "학의JC(경유)",
      "direction": "DOWN",
      "boardingAllowed": false,                // route_stop.boarding_allowed
      "approachingVehicles": []                // ② 가 s.boarding_allowed 로 걸러서 늘 빈 배열이다
    }
  ]
}

세 항목은 ② 가 낸 rn = 1 · 2 · 3 이다. 순번 55 에서 지평 3 인 차량은 지금 순번 52 에, 지평 8 인 차량은 47 에, 지평 12 인 차량은 순번 43(회차 정류장)에 있다. 회차를 지나야 오는 차량인지는 서버가 표시하지 않는다 — sequence − horizonStops 와 turnSequence 로 클라이언트가 유도하고, 두 재료가 모두 응답 안에 있다.

둘째 정류장이 boarding_allowed 경로다. ② 의 WHERE s.boarding_allowed 가 그 정류장의 예보 행을 아예 안 꺼내므로 ③ 이 낸 골격만 남고 배열이 빈다. 값 자체는 적재 때 정해진다 — boarding_allowed = NOT station_id LIKE '277%' 이고, 상류 getBusRouteStationListv2 가 승차 가능 여부를 주지 않아 ID 대역으로 판정한다 (파이프라인 계약의 RouteStop.boardingAllowed).

4. 뒤집기는 한 곳에서 v3 규약 그대로

DB 에는 만석 확률(p_full)을 저장하고 응답에는 빈자리 확률을 내보낸다. 그 사이의 뺄셈이 어디에 있느냐가 규약이다.

// BoardQueryService — 이 메서드가 유일한 뒤집기 지점이다
static double seatAvailableProbability(double pFull) {
    return 1.0d - pFull;
}
검증 질의에 있던 round() 는 운영 질의에 없다

DB 검증 스크립트의 /board 질의는 round((1 - p_full)::numeric, 4) 를 썼다. 사람이 터미널에서 눈으로 보려고 넣은 것이지 계약이 아니다. 운영 질의는 p_full 을 그대로 꺼내고, 뒤집기도 자릿수도 서비스 계층이 정한다. 질의를 그 스크립트에서 그대로 옮겨 오면 반올림 금지 규약이 조용히 깨진다.

5. api 객체의 v4 형태 예보 쪽만 열린다

/vehicles 쪽 여덟 객체(LiveVehicleController 외)는 안이 그대로다 — Cache-Control 을 내는 자리 하나만 6절대로 바뀐다. 아래는 예보 쪽이다.

객체v3 대비
BoardController그대로경로도 파라미터도 같다. 예외 → 상태 코드 매핑만 사유 하나가 바뀐다
BoardQueryService안이 바뀐다조회 조정과 뒤집기라는 역할은 그대로다. 조립할 것이 늘었다(정류장 피벗 · 방향 유도)
BoardQuery질의가 바뀐다발행본 읽기에서 스냅샷+예보 읽기로. 3절의 다섯 질의를 갖는다
BoardRow필드가 바뀐다발행 메타가 빠지고 스냅샷·방향별 시각·모델 표시가 붙는다
StopRow얇아진다예보 열이 빠져 골격만 남는다. route_stop 한 행이 그대로 담긴다
PredictionRow신설예보 한 건. v3 에는 이 단위가 없었다. 표 이름을 따른다 — 응답 이름(approachingVehicles)은 뷰 쪽에만 쓴다
BoardView필드가 바뀐다forecast(ForecastMetaView) 제거 · observedAt · vehiclesInService 신설
StopView필드가 바뀐다seatForecast 제거 · approachingVehicles 신설
ApproachingVehicleView신설접근 중인 차량 한 건 — 필드 넷. 확정본의 ApproachingVehicle 이 이 모양이다

ForecastMetaView 는 v8 장부의 api 14 객체에 세어져 있지 않다(중첩 뷰다). 그래도 코드에서는 사라진다. 같은 층위인 RouteView·DirectionView·ModelView 도 세지 않는다. 이 문서가 세면 예보 쪽이 7 에서 9 가 된다. 장부 표제 14 에 둘을 더하면 16 이고, 확정 문서가 나열한 클래스(예보 7 · 실시간 차량 8)로 세면 17 이다 — 표제와 목록이 하나 어긋나 있고 확정 도메인 설계가 그것을 미결로 적어 두었다. 객체 수의 정본은 객체 구조 쪽이다.

읽기 행

// 노선 골격 + 스냅샷 + 모델 표시. 한 요청에 한 개.
public record BoardRow(
        long           referenceVersionId,          // 아래 질의들의 키
        String         referenceVersionKey,         // reference_version_id — 응답에 나가는 값
        String         sourceRouteId,
        String         displayName,
        String         startStopName,
        String         endStopName,
        Integer        turnSequence,                // null = 단방향
        String         upFirstDepartureTime,
        String         upLastDepartureTime,
        String         downFirstDepartureTime,
        String         downLastDepartureTime,
        long           pollId,
        OffsetDateTime observedAt,                  // location_poll.response_received_at
        int            vehiclesInService) {         // location_poll.stored_rows
}

// route_stop 한 행. 예보가 붙지 않는다.
public record StopRow(
        int       stopOrder,
        String    stationId,                          // route_stop.station_id 그대로. 응답에서는 stopId 로 나간다
        String    name,
        Direction direction,
        boolean   boardingAllowed) {
}

// vehicle_stop_prediction 한 행 + 그 관측의 차량. p_full 은 아직 뒤집지 않았다.
public record PredictionRow(
        int    targetStopOrder,                     // StopRow.stopOrder 와 짝짓는 키
        String vehicleId,                           // 공급자가 빠뜨리면 null
        int    horizonStops,
        double pFull,
        Double expectedSeats) {                     // null 이면 응답에서 키째 뺀다
}

v3 의 StopRow 는 정류장 하나에 확률 하나를 들고 있었다. v4 에서 그 열이 빠지고 같은 정보가 PredictionRow 여럿으로 옮긴다. 읽기 행이 하나 늘어난 것이 이 변경의 모양이다.

질의

// SQL 만 안다. 계산하지 않고 뒤집지 않는다.
public interface BoardQuery {

    /** 활성 판본 + 예보까지 끝난 마지막 poll. 어느 하나가 없으면 비어 돌아온다. */
    Optional<BoardRow> findCurrentBoard(String sourceRouteId);

    /** 그 판본의 모든 정류장. 승차 불가 정류장도 담는다. */
    List<StopRow> findStops(long referenceVersionId);

    /** 그 poll 의 예보. 정류장마다 지평 오름차순 앞의 둘만. */
    List<PredictionRow> findPredictions(long pollId);

    /** 그 poll 의 예보를 낸 배포. 예보 행이 없으면 비어 돌아온다. */
    Optional<ModelView> findModel(long pollId);

    /** ACTIVE 배포. 위가 비었을 때만 부른다. */
    Optional<ModelView> findActiveModel();
}

조립

@Service
public class BoardQueryService {

    private final BoardQuery query;

    public BoardQueryService(BoardQuery query) {
        this.query = query;
    }

    @Transactional(readOnly = true)
    public BoardView findCurrentBoard(String sourceRouteId) {
        BoardRow board = query.findCurrentBoard(sourceRouteId)
                .orElseThrow(() -> new ForecastUnavailableException(NO_RECENT_OBSERVATION));

        ModelView model = query.findModel(board.pollId())
                .or(query::findActiveModel)
                .orElseThrow(() -> new ForecastUnavailableException(MODEL_OUT_OF_SCOPE));

        List<StopRow> stops = query.findStops(board.referenceVersionId());
        Map<Integer, List<PredictionRow>> byStop = query.findPredictions(board.pollId()).stream()
                .collect(groupingBy(PredictionRow::targetStopOrder, LinkedHashMap::new, toList()));

        return new BoardView(
                RouteView.of(board, stops),
                board.observedAt(),
                model,
                board.vehiclesInService(),
                stops.stream().map(s -> toStopView(s, byStop)).toList());
    }

    private static StopView toStopView(StopRow s, Map<Integer, List<PredictionRow>> byStop) {
        List<ApproachingVehicleView> approachingVehicles = byStop.getOrDefault(s.stopOrder(), List.of()).stream()
                .map(BoardQueryService::toApproachingVehicleView)
                .toList();
        return new StopView(s.stopOrder(), s.stationId(), s.name(),
                            s.direction(), s.boardingAllowed(), approachingVehicles);
    }

    private static ApproachingVehicleView toApproachingVehicleView(PredictionRow a) {
        return new ApproachingVehicleView(a.vehicleId(), a.horizonStops(),
                               seatAvailableProbability(a.pFull()),
                               a.expectedSeats());
    }

    /** 만석 확률을 빈자리 확률로. 뒤집기는 여기 한 곳뿐이고 반올림하지 않는다. */
    static double seatAvailableProbability(double pFull) {
        return 1.0d - pFull;
    }
}

groupingBy 가 ② 의 정렬을 그대로 물려받는다 — 질의가 ORDER BY target_stop_order, horizon_stops 로 내려주므로 묶음 안의 순서가 이미 지평 오름차순이다. 서비스 계층에서 다시 정렬하지 않는다. 정렬을 두 곳에 두면 어느 쪽이 정본인지 알 수 없게 된다.

응답

public record BoardView(
        RouteView      route,
        OffsetDateTime observedAt,
        ModelView      model,
        int            vehiclesInService,
        List<StopView> stops) {
}

public record RouteView(
        String              id,
        String              displayName,
        String              startStopName,
        String              endStopName,
        RouteStatus         status,
        Integer             turnSequence,       // null 이어도 키를 뺄 수 없다. 필수 필드다
        List<DirectionView> directions,
        String              referenceVersionId) {
}

public record StopView(
        int               sequence,
        String            stopId,       // ← 여기가 유일한 개명 지점: StopRow.stationId → StopView.stopId
        String            name,
        Direction         direction,
        boolean           boardingAllowed,
        List<ApproachingVehicleView> approachingVehicles) {           // 오는 차가 없으면 빈 배열. null 이 아니다
}

public record ApproachingVehicleView(
        String vehicleId,                       // 필수 · null 허용
        int    horizonStops,
        double seatAvailableProbability,
        @JsonInclude(JsonInclude.Include.NON_NULL)
        Double expectedSeats) {                 // 선택 · 없으면 키째 빠진다
}
직렬화 규칙을 클래스 전체에 걸면 계약을 어긴다

ApproachingVehicleView 에는 필수인데 null 을 허용하는 필드(vehicleId)와 없으면 키째 빠져야 하는 필드(expectedSeats)가 같이 있다. 클래스 수준에 NON_NULL 을 걸면 vehicleId 가 null 일 때 키가 사라지는데, 확정본은 그 필드를 required 로 두고 additionalProperties: false 로 잠갔다. RouteView.turnSequence 도 같은 자리다 — 단방향 노선에서 null 을 내보내야 하지 키를 빼면 안 된다. 필드 하나에만 거는 것이 규칙이다.

6. Cache-Control 을 서버가 정한다 v4-fe 확정 사항

프론트가 폴링 주기를 계산하지 않는다. 무엇이 급한지는 수집 쪽이 이미 안다.

v3 의 /board 는 max-age=60 고정이었다. 하루 네 번 바뀌는 값에 60초는 넉넉했다. v4 는 값이 관측마다 바뀌므로 고정값이 맞지 않는다 — 첨두에 60초를 주면 판을 세 번 놓치고, 심야에 60초를 주면 열 번 헛부른다. 확정본이 고른 답은 서버가 그때의 수집 단계를 헤더에 담는 것이다.

수집 단계시각대(KST)수집 간격응답 max-age
첨두07–09 · 17–2015초15
늦은 저녁20–2315초15
심야 꼬리23–0120초20
새벽04–0720초20
낮09–1720초20
심야01–04600초600

이 표는 v4-fe 2.0.0 과 파이프라인 계약 v4 에 같은 값으로 들어 있고 이 문서가 옮겨 적은 것이다. 시각대는 반열린 구간이다 — 07–09 는 07:00~08:59 를 뜻하고 경계 시각은 뒤 단계에 속한다. 간격은 세 값뿐이다(15 · 20 · 600초). 정본은 수집기 설정(quota-mode = 10000)이고 이 표는 그것을 옮겨 적은 것이다.

7. 오류 계약 사유 하나가 바뀐다

여섯 줄 중 넷이 그대로다. 하나가 빠지고 하나가 붙는데, 그 자리바꿈이 이 개정의 성격을 그대로 보여 준다.

상황HTTPv3 codev4
routeId 형식 오류400INVALID_ROUTE_ID그대로 아홉 자리 숫자다
노선 없음404ROUTE_NOT_FOUND그대로
활성 번들이 판본 미지원503MODEL_OUT_OF_SCOPE그대로 ACTIVE 배포의 supported_scope_digest 로 판정한다
유효한 완성 발행본 없음503NO_CURRENT_PUBLICATION제거
최근 관측 없음503—신설 NO_RECENT_OBSERVATION
DB·API 장애503SERVICE_UNAVAILABLE그대로

왜 하나가 빠지고 하나가 붙나

NO_CURRENT_PUBLICATION 은 가리킬 원천이 없어져서 뺀다. 그 사유는 "봉인된 발행본이 있어야 답할 수 있는데 그것이 없다"는 뜻이었다. v4 의 /board 는 발행본을 읽지 않고 발행 계층 자체가 계약에서 빠진다. 사유가 가리킬 표가 사라진 것이다.

NO_RECENT_OBSERVATION 은 새로 생긴 의존 때문에 붙는다. v3 은 "모델이 현재 차량을 입력으로 쓰지 않으므로 수집이 끊겨도 예보는 유효하다"고 적었다. A18 은 그 차량의 현재 잔여석을 조건으로 받는다. 그래서 /board 도 관측에 매인다 — 예보까지 끝난 마지막 poll 이 문턱보다 오래되면 어떤 답도 기준 시각을 가질 수 없다. 가용성의 성격이 바뀐 것이지 오류가 하나 늘어난 것이 아니다.

문턱값은 이 문서가 정하지 않는다

/vehicles 의 staleAt 과 같은 값이어야 두 화면이 두 말을 하지 않는다. 그 값과 "스냅샷의 최대 나이"를 같은 축으로 볼지는 파이프라인 계약 v4 의 미결 6·7 이다.

봉투는 exact 네 필드다

HTTP/1.1 503 Service Unavailable
Cache-Control: no-store
Retry-After: 300

{
  "code": "NO_RECENT_OBSERVATION",
  "message": "no vehicle observation recent enough to anchor a forecast",
  "requestId": "req-01J9X2ABCF",
  "retryable": true
}

503 으로 올리지 않는 것

v3 은 정류장 하나라도 점수화에 실패하면 발행 전체를 503 으로 되돌렸다. 발행이 한 판 단위였기 때문이다. v4 는 예보가 정류장이 아니라 차량에 붙으므로 그 규칙이 성립하지 않는다. 한 차량의 예보를 못 냈으면 그 항목만 빠지고 나머지는 정상으로 나간다. 503 은 노선 자체를 서빙할 수 없을 때만 낸다.

상황응답
오는 버스가 없다(첫차 전·막차 후)200 · approachingVehicles: []
버스는 오지만 지평 12 밖이다200 · approachingVehicles: []
운행이 끝났다200 · approachingVehicles: [] 와 vehiclesInService: 0
승차 불가 정류장이다200 · approachingVehicles: []
그 차량의 잔여석이 결측이라 예보를 못 냈다200 · 그 항목만 빠진다

마지막 줄은 값을 치른 선택이다. 잔여석이 빠진 차량은 approachingVehicles 에서 조용히 사라지고 클라이언트는 그것을 "그 자리에 버스가 없다"와 구별할 수 없다. 전수 314,688 관측행 중 237행(0.075%)이고 스냅샷의 1.07% 가 그런 차량을 하나 이상 담는다. /vehicles 는 같은 차량을 seat.kind = UNKNOWN 으로 계속 보여 주므로 두 화면이 어긋난다 — 파이프라인 계약의 미결 5 다.

8. 차량이 없을 때 가장 잘못 읽히는 자리

approachingVehicles 가 빈 것은 정상 응답이다. v3 에서는 거의 없던 상태라 서버 쪽도 시험을 새로 써야 한다.

v3 의 /board 는 발행본이 있으면 정류장마다 값이 늘 찼다. 시간대 평균이라 차량이 없어도 값이 있었다. v4 는 예보가 차량에 붙으므로 차가 없으면 낼 답이 없다. 승차 가능 정류장 기준 운행 시간대(06~23시) 실측 커버율이 90.4% 이니 나머지는 정상적으로 빌 수 있다. 빈 배열에 상태 코드도 사유 코드도 붙이지 않는다.

vehiclesInService: 0 을 "운행 종료"로 읽으면 안 된다

이 값은 관측이지 단정이 아니다. 서버가 판별자로 굽지 않고 정수 그대로 내보내는 이유가 셋이다.

조건뜻서버가 하는 일
vehiclesInService: 0지금 관측된 차가 없다정수와 방향별 막차를 준다. 문구를 고르지 않는다
0 초과인데 approachingVehicles 가 빔이 구간에 차가 없다같다. sequence 를 함께 주어 아래 함정을 가릴 재료를 준다
sequence ≤ 12 이고 빔뒤로 볼 칸이 짧다같다. "곧 오는 버스 없음"이 아니다

가운데가 아니라 마지막 줄이 함정이다. 순번 12 이하 정류장은 볼 수 있는 칸이 sequence − 1 개뿐이라 커버율이 24.47%(1650)·41.85%(3330) 로 떨어진다(그 밖 구간은 98.92%·99.05%). 실제 다음 차량이 차고지에서 대기 중이면 차량위치 API 에 아직 없다. 해당 정류장이 두 노선 합쳐 승차 가능 정류장의 14.0%(20/143)다. 서버가 할 수 있는 것은 두 경우를 가를 재료를 주는 데까지다 — 메우려면 차고지 발차 시각이나 도착정보 API 가 필요하고 둘 다 수집 대상이 아니다.

상태로 승격하지 않는다

approachingVehicles 가 비었다고 RouteStatus 를 PREPARING 으로 내리지 않는다. 내리면 매일 밤 노선이 PREPARING 으로 떨어졌다가 아침에 돌아온다. status 는 "답을 낼 수 있는가"이고 approachingVehicles 는 "지금 낼 답이 있는가"라 축이 다르다. 밤과 낮을 가르는 값은 status 가 아니라 vehiclesInService 와 방향별 막차 시각이다.

9. 대조하다 걸린 것 확정본은 고치지 않는다

확정본과 표 계약을 줄 단위로 맞춰 보다 나온 자리다. 계약을 어기는 것은 아니지만 구현 전에 답이 필요하다.

자리무엇이 걸리나지금 할 수 있는 것
방향별 첫차·막차가 비어 있는 판본DirectionInfo 의 여섯 필드는 전부 필수이고 null 을 허용하지 않는다. 그런데 그 네 열은 NULL 허용으로 붙는다(백필 0건이 그 대가다). 값이 들어오기 전에는 directions[] 를 만들 수 없고, BoardRoute 는 directions 를 필수 minItems: 1 로 둔다첫차·막차 적재가 끝나기 전 판본을 FORECAST_READY 로 올리지 않는 것. 정한 규칙이 없다
model.releaseId 가 노선마다 다른가확정본은 "노선별로 적합하므로 노선마다 다르다"고 적고 예시도 노선 번호를 담은 이름이다. 그런데 model_deployment 의 ACTIVE 는 부분 unique index 로 전역 1개다. 노선별 블록을 담은 번들 하나가 ACTIVE 이면 두 노선의 값이 같아진다계약을 어기지는 않는다(형식은 맞는다). 배포 단위를 노선별로 볼지 번들별로 볼지 정해야 한다
expectedSeats 를 어떻게 빼나선택 필드라 NULL 이면 키째 빼야 한다. null 을 넣으면 타입이 number 라 어긋난다필드 하나에만 NON_NULL 을 건다 — 5절
지평 상한이 두 곳에 있다계약이 maximum: 12, DB 가 ck_vsp_horizon 으로 12. 값이 같아 지금은 문제가 없다지평 상한을 옮기려면 두 곳을 같이 옮긴다. 한쪽만 올리면 응답이 계약을 벗어난다
정렬의 세 번째 축계약은 지평 하나, 설계 개요는 지평·vehicleId 둘을 적었다. vehicleId 가 null 일 수 있어 둘만으로는 순서가 굳지 않는다이 문서가 source_row_no 를 셋째 축으로 제안한다. 확정된 것이 아니다
이 문서가 정하지 않는 것

NO_RECENT_OBSERVATION 의 문턱값 · 스냅샷의 최대 나이 · vehicle_stop_prediction 의 보존과 파티션 축 · 좌석 결측 관측의 서빙 처리는 파이프라인 계약 v4 의 미결 5·6·7·8 이다. A18 이 무너졌을 때 /board 가 무엇을 내보낼지도 정해진 것이 없다(미결 15). 이 문서는 검토 요청안이고, 확정 계약은 여전히 v3 이다.