클라이언트 계약은 확정됐다
백엔드는 그것을 무엇으로 조립하나
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[].arrivals[] (최대 2) |
| 발행 메타 | 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 | 관측마다 | 정류장 전부 + 정류장당 도착 예정 차량 최대 2 | 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[] 의 기·종점 이름. 승차 불가 정류장도 담고 그 arrivals 만 빈 배열이다 |
| 3 | 스냅샷 location_poll | observedAt(response_received_at) · vehiclesInService(stored_rows) |
| 4 | 그 poll 의 관측에 매달린 vehicle_stop_prediction | arrivals 항목 전부. 항목은 여기서만 나온다 |
| 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 | 순번은 정렬·위치 계산용이고 식별은 stationId 로 한다 |
stops[].stationId · name · direction · boardingAllowed | route_stop 의 같은 이름 열 | 변환이 없다. 열 이름만 카멜로 바뀐다 |
stops[].arrivals[] 항목의 존재 | 그 (관측, 대상 정류장)에 vehicle_stop_prediction 행이 있는가 | 예보를 못 낸 자리는 항목째 빠진다. 확정본에 "예보 없는 항목"이라는 상태가 없다 |
arrivals[].vehicleId | vehicle_observation.vehicle_id | 필수인데 null 을 허용한다. 공급자가 빠뜨린 드문 경우다 |
arrivals[].horizonStops | vehicle_stop_prediction.horizon_stops | ck_vsp_horizon 이 1~12 로 막고 계약도 같은 범위다 |
arrivals[].seatAvailableProbability | 1 − p_full | SQL 이 아니라 서비스 계층에서 뒤집는다 — 4절 |
arrivals[].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 표시를 위해 한 번 더 읽힌다.
필수 필드가 언제나 채워지는가
확정본의 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 이고 arrivals 만 전부 비어서, 화면에는 "곧 오는 버스 없음"으로 나간다.
쓰기 한 번 실패가 정상 응답의 모양으로 나가는 것이라 아무도 어긋남을 못 잡는다.
예보 대상이 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 <= 2
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 | 승차 불가 정류장은 arrivals 가 항상 빈 배열이다. 여기서 걸러 두면 조립 쪽에 조건이 남지 않는다 |
row_number() … rn <= 2 | 확정본의 maxItems: 2 다. 상한은 정류장마다 걸리므로 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[] 가 되지 않는다.
실제 출력은 한 행이었다: 55 | 범계역 | 204000262 | 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 예시에서 정류장 하나만 떼어 왔다. 값은 확정본 그대로다.
{
"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
"stationId": "209000135",
"name": "롯데백화점.범계역",
"direction": "DOWN",
"boardingAllowed": true,
"arrivals": [
{ "vehicleId": "204000262", "horizonStops": 3,
"seatAvailableProbability": 0.871, // 1 - p_full
"expectedSeats": 28.2 },
{ "vehicleId": "204000271", "horizonStops": 12,
"seatAvailableProbability": 0.946,
"expectedSeats": 33.1 }
]
}
]
}두 항목은 ② 가 낸 rn = 1 과 rn = 2 다. 순번 55 에서 지평 3 인 차량은 지금 순번 52 에,
지평 12 인 차량은 순번 43(회차 정류장)에 있다. 회차를 지나야 오는 차량인지는 서버가 표시하지 않는다 —
sequence − horizonStops 와 turnSequence 로 클라이언트가 유도하고, 두 재료가 모두 응답 안에 있다.
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 한 행이 그대로 담긴다 |
ArrivalRow | 신설 | 예보 한 건. v3 에는 이 단위가 없었다 |
BoardView | 필드가 바뀐다 | forecast(ForecastMetaView) 제거 · observedAt · vehiclesInService 신설 |
StopView | 필드가 바뀐다 | seatForecast 제거 · arrivals 신설 |
ArrivalView | 신설 | 도착 예정 차량 한 건 — 필드 넷 |
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,
String name,
Direction direction,
boolean boardingAllowed) {
}
// vehicle_stop_prediction 한 행 + 그 관측의 차량. p_full 은 아직 뒤집지 않았다.
public record ArrivalRow(
int targetStopOrder, // StopRow.stopOrder 와 짝짓는 키
String vehicleId, // 공급자가 빠뜨리면 null
int horizonStops,
double pFull,
Double expectedSeats) { // null 이면 응답에서 키째 뺀다
}v3 의 StopRow 는 정류장 하나에 확률 하나를 들고 있었다. v4 에서 그 열이 빠지고
같은 정보가 ArrivalRow 여럿으로 옮긴다. 읽기 행이 하나 늘어난 것이 이 변경의 모양이다.
질의
// SQL 만 안다. 계산하지 않고 뒤집지 않는다.
public interface BoardQuery {
/** 활성 판본 + 예보까지 끝난 마지막 poll. 어느 하나가 없으면 비어 돌아온다. */
Optional<BoardRow> findCurrentBoard(String sourceRouteId);
/** 그 판본의 모든 정류장. 승차 불가 정류장도 담는다. */
List<StopRow> findStops(long referenceVersionId);
/** 그 poll 의 예보. 정류장마다 지평 오름차순 앞의 둘만. */
List<ArrivalRow> findArrivals(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<ArrivalRow>> byStop = query.findArrivals(board.pollId()).stream()
.collect(groupingBy(ArrivalRow::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<ArrivalRow>> byStop) {
List<ArrivalView> arrivals = byStop.getOrDefault(s.stopOrder(), List.of()).stream()
.map(BoardQueryService::toArrivalView)
.toList();
return new StopView(s.stopOrder(), s.stationId(), s.name(),
s.direction(), s.boardingAllowed(), arrivals);
}
private static ArrivalView toArrivalView(ArrivalRow a) {
return new ArrivalView(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 stationId,
String name,
Direction direction,
boolean boardingAllowed,
List<ArrivalView> arrivals) { // 오는 차가 없으면 빈 배열. null 이 아니다
}
public record ArrivalView(
String vehicleId, // 필수 · null 허용
int horizonStops,
double seatAvailableProbability,
@JsonInclude(JsonInclude.Include.NON_NULL)
Double expectedSeats) { // 선택 · 없으면 키째 빠진다
}ArrivalView 에는 필수인데 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–08 · 17–20 | 15초 | 15 |
| 늦은 저녁 | 20–23 | 15초 | 15 |
| 새벽·심야 경계 | 00 · 04–06 · 23 | 20초 | 20 |
| 낮 | 09–15 | 60초 | 60 |
| 한산 | 16 | 240초 | 240 |
| 심야 | 01–03 | 600초 | 600 |
이 표는 v4-fe 2.0.0 과 파이프라인 계약 v4 에 같은 값으로 들어 있고 이 문서가 옮겨 적은 것이다. 설계 개요가 수집 전략을 "15 / 20 / 60 / 600초" 넷으로 줄여 적은 곳이 있는데 한산 단계(240)가 빠진 서술이다. 정본은 위 표다.
- 어디서 오나. 표를 소유하는 것은 수집 전략 판(
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 · arrivals: [] |
| 버스는 오지만 지평 12 밖이다 | 200 · arrivals: [] |
| 운행이 끝났다 | 200 · arrivals: [] 와 vehiclesInService: 0 |
| 승차 불가 정류장이다 | 200 · arrivals: [] |
| 그 차량의 잔여석이 결측이라 예보를 못 냈다 | 200 · 그 항목만 빠진다 |
마지막 줄은 값을 치른 선택이다. 잔여석이 빠진 차량은 arrivals 에서 조용히 사라지고
클라이언트는 그것을 "그 자리에 버스가 없다"와 구별할 수 없다.
전수 314,688 관측행 중 237행(0.075%)이고 스냅샷의 1.07% 가 그런 차량을 하나 이상 담는다.
/vehicles 는 같은 차량을 seat.kind = UNKNOWN 으로 계속 보여 주므로 두 화면이 어긋난다 — 파이프라인 계약의 미결 5 다.
8. 차량이 없을 때 가장 잘못 읽히는 자리
arrivals 가 빈 것은 정상 응답이다. v3 에서는 거의 없던 상태라 서버 쪽도 시험을 새로 써야 한다.
v3 의 /board 는 발행본이 있으면 정류장마다 값이 늘 찼다. 시간대 평균이라 차량이 없어도 값이 있었다.
v4 는 예보가 차량에 붙으므로 차가 없으면 낼 답이 없다.
운행 시간대(06~23시) 실측 커버율이 90.4%(1650)·91.1%(3330) 이니 나머지 9% 쯤은 정상적으로 빈다.
빈 배열에 상태 코드도 사유 코드도 붙이지 않는다.
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 초과인데 arrivals 가 빔 | 이 구간에 차가 없다 | 같다. sequence 를 함께 주어 아래 함정을 가릴 재료를 준다 |
sequence ≤ 12 이고 빔 | 뒤로 볼 칸이 짧다 | 같다. "곧 오는 버스 없음"이 아니다 |
가운데가 아니라 마지막 줄이 함정이다. 순번 12 이하 정류장은 볼 수 있는 칸이 sequence − 1 개뿐이라
커버율이 37~44% 로 떨어진다(그 밖 구간은 98.7~98.9%). 그런데 실제 다음 버스는 중앙 8.3~12.0분 뒤에 오고,
그 차의 96.2~96.8% 가 차고지에서 대기 중이라 차량위치 API 에 아직 없다.
해당 정류장이 두 노선 합쳐 전체의 13.8%(24/174)다.
서버가 할 수 있는 것은 두 경우를 가를 재료를 주는 데까지다 — 메우려면 차고지 발차 시각이나 도착정보 API 가 필요하고 둘 다 수집 대상이 아니다.
상태로 승격하지 않는다
arrivals 가 비었다고 RouteStatus 를 PREPARING 으로 내리지 않는다.
내리면 매일 밤 노선이 PREPARING 으로 떨어졌다가 아침에 돌아온다.
status 는 "답을 낼 수 있는가"이고 arrivals 는 "지금 낼 답이 있는가"라 축이 다르다.
밤과 낮을 가르는 값은 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 모델은 모델 문서 다.