클라이언트 계약은 확정됐다
백엔드는 그것을 무엇으로 조립하나
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절) |
/board | v3 | v4 |
|---|---|---|
| 읽는 표 | forecast_publication · stop_prediction · route_stop | location_poll · vehicle_stop_prediction · vehicle_observation · route_stop |
| 기준이 되는 것 | 봉인된 발행본 하나 | 예보까지 끝난 마지막 poll 하나 |
| 예보의 단위 | (노선, 정류장, 시간대) | (차량, 정류장) |
| 예보가 붙는 응답 자리 | stops[].seatForecast | stops[].approachingVehicles[] (최대 3) |
| 발행 메타 | forecast(ForecastMetaView) | 없다 — observedAt 하나로 대체 |
| 확률 뒤집기 | 서비스 계층 메서드 한 곳 | 그대로 |
| 캐시 | max-age=60 고정 | 서버가 수집 단계에서 정한다 (15 · 20 · 60 · 240 · 600) |
| 못 낼 때의 사유 | NO_CURRENT_PUBLICATION | NO_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 | 관측마다 | 정류장 전부 + 정류장당 접근 중인 차량 최대 3 | vehicle_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 스키마가 기준이다. 필수 필드를 빠짐없이 적었다.
여섯 단계
| 단계 | 무엇을 읽나 | 무엇을 채우나 |
|---|---|---|
| 1 | route_reference_version 활성 판본 | route 의 이름들 · turnSequence · referenceVersionId · 방향별 첫차·막차 |
| 2 | route_stop 전부 | stops[] 골격과 directions[] 의 기·종점 이름. 승차 불가 정류장도 담고 그 approachingVehicles 만 빈 배열이다 |
| 3 | 스냅샷 location_poll | observedAt(response_received_at) · vehiclesInService(stored_rows) |
| 4 | 그 poll 의 관측에 매달린 vehicle_stop_prediction | approachingVehicles 항목 전부. 항목은 여기서만 나온다 |
| 5 | model_deployment | model. 예보 행이 가리키는 배포에서 읽고, 차량 0대라 행이 없을 때만 ACTIVE 에서 읽는다 |
| 6 | ACTIVE 배포의 supported_scope_digest | route.status. 이 노선 판본을 담으면 FORECAST_READY 다. 담지 않으면 상태로 내리지 않고 MODEL_OUT_OF_SCOPE 503 이며(7절), PREPARING 은 확정본이 "아직 활성 계수 묶음이 없다"로 정의한 값이다 |
순서가 아니라 의존이다. 1·2·3 은 서로를 기다리지 않고, 4 는 3 이 고른 poll 을 받아야 돌며, 5 는 4 의 결과를 본다.
대조표
| 응답 필드 | 출처 열 | 따라붙는 말 |
|---|---|---|
route.id | route_reference_version.source_route_id | 공급자(GBIS) 원문이다. public_route_id 를 쓰지 않는 것은 제품 공개 ID 도입이 미결이라서다 |
route.displayName | display_name | 표시명과 모델 노선 키는 문자열이 같아도 다른 개념이다 |
route.startStopName · endStopName | start_stop_name · end_stop_name | 노선 전체의 기·종점이다. directions[] 의 기·종점은 이 열이 아니라 route_stop 에서 유도한다 |
route.status | model_deployment.state · supported_scope_digest | v3 의 "봉인 발행본이 있다"에서 "활성 계수 묶음이 있다"로 판정 근거가 옮겼다 |
route.turnSequence | turn_sequence | 필수 필드이고 null 을 허용한다(단방향). 빠뜨리지 말고 null 로 내보낸다 |
route.referenceVersionId | reference_version_id | /vehicles 의 같은 필드와 다르면 개편 중이라는 신호다 |
route.directions[].id | route_stop.direction | 회차 순번 이하가 UP, 초과가 DOWN |
route.directions[].name | 유도 — 그 방향 종점 이름 + " 방면" | 열이 없다. 아래 terminalStopName 을 그대로 쓴다 |
route.directions[].originStopName · terminalStopName | 유도 — route_stop.name | UP 은 (stop_order=1 → turn_sequence), DOWN 은 (turn_sequence → 마지막 stop_order). DOWN 의 기점은 회차 정류장이다 |
route.directions[].firstDepartureTime · lastDepartureTime | up_first_departure_time 외 3열 | v4 에서 새로 붙는 네 열이다. 방향마다 다른 것이 존재 이유다 |
observedAt | location_poll.response_received_at | 상류가 주는 queryTime 이 아니다 — 실측 1,294건 전부에서 우리 수신 시각보다 뒤에 있었다 |
model.releaseId · trainedThrough | model_deployment.release_id · trained_through | 그 판의 예보 행이 가리키는 배포다. 행이 없으면 ACTIVE 에서 읽는다 |
vehiclesInService | location_poll.stored_rows | 새 열이 아니다. 같은 poll 안 차량 중복을 부분 unique index 가 막으므로 저장 행 수가 곧 distinct 차량 수다 |
stops[].sequence | route_stop.stop_order | 순번은 정렬·위치 계산용이고 식별은 stopId 로 한다 |
stops[].stopId · name · direction · boardingAllowed | route_stop.station_id · name · direction · boarding_allowed | stopId 만 이름이 바뀐다. DB 열은 station_id 그대로이고(상류 GBIS 와 맞춘다) 개명은 api 조립의 toStopView 한 줄에서만 일어난다. 나머지 셋은 카멜 변환뿐이고 값 변환은 없다 |
stops[].approachingVehicles[] 항목의 존재 | 그 (관측, 대상 정류장)에 vehicle_stop_prediction 행이 있는가 | 예보를 못 낸 자리는 항목째 빠진다. 확정본에 "예보 없는 항목"이라는 상태가 없다 |
approachingVehicles[].vehicleId | vehicle_observation.vehicle_id | 필수인데 null 을 허용한다. 공급자가 빠뜨린 드문 경우다 |
approachingVehicles[].horizonStops | vehicle_stop_prediction.horizon_stops | ck_vsp_horizon 이 1~12 로 막고 계약도 같은 범위다 |
approachingVehicles[].seatAvailableProbability | 1 − p_full | SQL 이 아니라 서비스 계층에서 뒤집는다 — 4절 |
approachingVehicles[].expectedSeats | expected_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 표시를 위해 한 번 더 읽힌다.
확정본이 필드 이름 셋을 바꾸고 정류장당 대수 상한을 2 에서 3 으로 늘렸다. 마이그레이션은 없다. 표도 열도 그대로이고, 바뀌는 것은 api 계층의 조립뿐이다.
| DB / 읽기 행 | 응답 필드 | 어디서 바뀌나 |
|---|---|---|
route_stop.station_idStopRow.stationId | stops[].stopId |
BoardQueryService.toStopView 한 줄(5절). DB 열 이름은 station_id 를 유지한다 — 상류(GBIS)가 그렇게 부르고 수집기가 그 이름으로 적재하기 때문이다. 계약만 stop 으로 통일했다(stopName·startStopName·stops[] 와 짝을 맞춘다) |
vehicle_observation.station_id | vehicles[].stopId |
/vehicles 쪽 뷰의 같은 자리. 열 이름은 그대로다 |
vehicle_stop_predictionPredictionRow | 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 허용이다. 비지 않는다는 보장은 스냅샷을 고르는 술어에서 나온다.
observedAt—ck_poll_forecast가forecast_completed_at IS NOT NULL인 행에response_received_at IS NOT NULL을 강제한다. 스냅샷 후보가 그 조건을 달고 있으므로 비는 경우가 없다.vehiclesInService— 후보 outcome 이SUCCESS_ROWS·SUCCESS_EMPTY둘뿐이고 그 둘에서는stored_rows가 언제나 있다(SUCCESS_EMPTY면 0).stops—minItems: 1이다. 판본이 있으면route_stop행이 있고, 없는 판본은 애초에 스냅샷을 갖지 못한다.
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;
}- SQL 안에서 하지 않는다. 질의가
1 - p_full을 내면 그 규칙을 시험하는 데 DB 가 필요해진다. 한 줄짜리 순수 함수로 두면 시험이 DB 없이 돈다. 이것이 v3 이 이 자리를 고른 이유이고 v4 에서 바뀌지 않는다. - 반올림하지 않는다. 0.996 을 두 자리로 자르면 1.00 이 되고, 화면에서 "반드시 자리가 있다"로 읽힌다. 확정본도 같은 문장을 계약에 박았다 — 표시 자릿수는 클라이언트가 정한다.
- 범위는 저절로 지켜진다.
ck_vsp_pfull이p_full을 [0,1] 로 묶고 NaN·±Infinity 까지 거절하므로1 − p_full은 언제나 계약의minimum: 0 · maximum: 1안에 든다. api 에서 다시 자르는 코드를 두지 않는다.
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–20 | 15초 | 15 |
| 늦은 저녁 | 20–23 | 15초 | 15 |
| 심야 꼬리 | 23–01 | 20초 | 20 |
| 새벽 | 04–07 | 20초 | 20 |
| 낮 | 09–17 | 20초 | 20 |
| 심야 | 01–04 | 600초 | 600 |
이 표는 v4-fe 2.0.0 과 파이프라인 계약 v4 에 같은 값으로 들어 있고 이 문서가 옮겨 적은 것이다.
시각대는 반열린 구간이다 — 07–09 는 07:00~08:59 를 뜻하고 경계 시각은 뒤 단계에 속한다. 간격은 세 값뿐이다(15 · 20 · 600초). 정본은 수집기 설정(quota-mode = 10000)이고 이 표는 그것을 옮겨 적은 것이다.
- 어디서 오나. 표를 소유하는 것은 수집 전략 판(
collection_strategy_version=adaptive-kst-v1.0.1)이다. api 는 스냅샷 poll 의 전략 판과response_received_at의 KST 시각으로 단계를 정해max-age를 낸다. 전략 판이 바뀌면 이 표도 함께 바뀐다 — api 가 수집 쪽 결정에 붙는 자리가 하나 생긴다는 뜻이라, 표를 코드에 흩지 말고 한 곳에 둔다. /vehicles도 같은 값이다. 두 응답이 같은 poll 을 보므로 다음 판이 나오는 시각이 같다. 두 엔드포인트가 서로 다른 주기를 말하면 화면이 어긋난다./routes는public, max-age=300그대로다. 노선 개편 때만 바뀐다.- 오류 응답은
no-store다. 4xx·5xx 를 캐시하면 회복된 뒤에도 오류가 남는다.
7. 오류 계약 사유 하나가 바뀐다
여섯 줄 중 넷이 그대로다. 하나가 빠지고 하나가 붙는데, 그 자리바꿈이 이 개정의 성격을 그대로 보여 준다.
| 상황 | HTTP | v3 code | v4 |
|---|---|---|---|
routeId 형식 오류 | 400 | INVALID_ROUTE_ID | 그대로 아홉 자리 숫자다 |
| 노선 없음 | 404 | ROUTE_NOT_FOUND | 그대로 |
| 활성 번들이 판본 미지원 | 503 | MODEL_OUT_OF_SCOPE | 그대로 ACTIVE 배포의 supported_scope_digest 로 판정한다 |
| 유효한 완성 발행본 없음 | 503 | NO_CURRENT_PUBLICATION | 제거 |
| 최근 관측 없음 | 503 | — | 신설 NO_RECENT_OBSERVATION |
| DB·API 장애 | 503 | SERVICE_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
}additionalProperties: false다. 디버깅용 필드를 하나 얹으면 계약 위반이다.Cache-Control: no-store는 v4 의 오류 코드 다섯 전부에 붙는다.Retry-After는 실제 재시도 시각을 아는 retryable 503 에만 붙인다. 모르면 붙이지 않는다 — 숫자를 지어내면 클라이언트가 그 시각에 몰려 온다.message는 사람이 읽는 설명이고 화면에 그대로 띄우는 값이 아니다.
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 을 "운행 종료"로 읽으면 안 된다
이 값은 관측이지 단정이 아니다. 서버가 판별자로 굽지 않고 정수 그대로 내보내는 이유가 셋이다.
- 방향이 둘인데 값은 하나다. 막차는 방향마다 다르다 — 1650 은 상행 22:35 · 하행 23:55 로 80분 어긋난다.
노선 하나를 통째로 "운행 종료"라고 말하면 그 80분 동안 틀린다.
그래서 계약이
directions[].lastDepartureTime을 필수로 올렸고, 단정은 그 값과 함께 읽어야 나온다. - 상류가 빈 응답을 줄 때가 있다. 운행 중에도 0 이 되며 실측 22,010 스냅샷 중 1건(0.005%)이다.
GBIS
resultCode=4는 "호출은 정상인데 그 순간 차량이 없다"일 뿐이고, 심야에 반복된 뒤 다음 수집에서 다시 나타난 기록이 있다. - 미매핑 관측이 빠진다.
stored_rows는excluded_rows를 세지 않으므로 실제 운행 대수보다 작아질 수 있다.
| 조건 | 뜻 | 서버가 하는 일 |
|---|---|---|
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 이다.
관련 문서
스키마 DDL 은 스키마, 객체 장부는 객체 에 있다. 확정 도메인 설계는 여기, A18 모델은 모델 문서 다.