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

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[].arrivals[] (최대 2)
발행 메타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관측마다정류장 전부 + 정류장당 도착 예정 차량 최대 2vehicle_stop_prediction 중심
차량 /vehicles관측마다운행 차량만vehicle_observation 중심

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

합치면 무엇을 잃나

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

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

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

여섯 단계

단계무엇을 읽나무엇을 채우나
1route_reference_version 활성 판본route 의 이름들 · turnSequence · referenceVersionId · 방향별 첫차·막차
2route_stop 전부stops[] 골격과 directions[] 의 기·종점 이름. 승차 불가 정류장도 담고 그 arrivals 만 빈 배열이다
3스냅샷 location_pollobservedAt(response_received_at) · vehiclesInService(stored_rows)
4그 poll 의 관측에 매달린 vehicle_stop_predictionarrivals 항목 전부. 항목은 여기서만 나온다
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순번은 정렬·위치 계산용이고 식별은 stationId 로 한다
stops[].stationId · name · direction · boardingAllowedroute_stop 의 같은 이름 열변환이 없다. 열 이름만 카멜로 바뀐다
stops[].arrivals[] 항목의 존재그 (관측, 대상 정류장)에 vehicle_stop_prediction 행이 있는가예보를 못 낸 자리는 항목째 빠진다. 확정본에 "예보 없는 항목"이라는 상태가 없다
arrivals[].vehicleIdvehicle_observation.vehicle_id필수인데 null 을 허용한다. 공급자가 빠뜨린 드문 경우다
arrivals[].horizonStopsvehicle_stop_prediction.horizon_stopsck_vsp_horizon 이 1~12 로 막고 계약도 같은 범위다
arrivals[].seatAvailableProbability1 − p_fullSQL 이 아니라 서비스 계층에서 뒤집는다4절
arrivals[].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_idmodel 표시를 위해 한 번 더 읽힌다.

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

확정본의 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 이고 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_obsux_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_idnull 일 수 있어 둘만으로는 순서가 굳지 않는다
배포로 거르지 않는다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 = 1rn = 2 다. 순번 55 에서 지평 3 인 차량은 지금 순번 52 에, 지평 12 인 차량은 순번 43(회차 정류장)에 있다. 회차를 지나야 오는 차량인지는 서버가 표시하지 않는다sequence − horizonStopsturnSequence 로 클라이언트가 유도하고, 두 재료가 모두 응답 안에 있다.

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 한 행이 그대로 담긴다
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 을 걸면 vehicleIdnull 일 때 키가 사라지는데, 확정본은 그 필드를 required 로 두고 additionalProperties: false 로 잠갔다. RouteView.turnSequence 도 같은 자리다 — 단방향 노선에서 null 을 내보내야 하지 키를 빼면 안 된다. 필드 하나에만 거는 것이 규칙이다.

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

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

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

수집 단계시각대(KST)수집 간격응답 max-age
첨두07–08 · 17–2015초15
늦은 저녁20–2315초15
새벽·심야 경계00 · 04–06 · 2320초20
09–1560초60
한산16240초240
심야01–03600초600

이 표는 v4-fe 2.0.0파이프라인 계약 v4 에 같은 값으로 들어 있고 이 문서가 옮겨 적은 것이다. 설계 개요가 수집 전략을 "15 / 20 / 60 / 600초" 넷으로 줄여 적은 곳이 있는데 한산 단계(240)가 빠진 서술이다. 정본은 위 표다.

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 이 문턱보다 오래되면 어떤 답도 기준 시각을 가질 수 없다. 가용성의 성격이 바뀐 것이지 오류가 하나 늘어난 것이 아니다.

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

/vehiclesstaleAt 과 같은 값이어야 두 화면이 두 말을 하지 않는다. 그 값과 "스냅샷의 최대 나이"를 같은 축으로 볼지는 파이프라인 계약 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 · 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 을 "운행 종료"로 읽으면 안 된다

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

조건서버가 하는 일
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 가 비었다고 RouteStatusPREPARING 으로 내리지 않는다. 내리면 매일 밤 노선이 PREPARING 으로 떨어졌다가 아침에 돌아온다. status 는 "답을 낼 수 있는가"이고 arrivals 는 "지금 낼 답이 있는가"라 축이 다르다. 밤과 낮을 가르는 값은 status 가 아니라 vehiclesInService 와 방향별 막차 시각이다.

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

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

자리무엇이 걸리나지금 할 수 있는 것
방향별 첫차·막차가 비어 있는 판본DirectionInfo 의 여섯 필드는 전부 필수이고 null 을 허용하지 않는다. 그런데 그 네 열은 NULL 허용으로 붙는다(백필 0건이 그 대가다). 값이 들어오기 전에는 directions[] 를 만들 수 없고, BoardRoutedirections 를 필수 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 둘을 적었다. vehicleIdnull 일 수 있어 둘만으로는 순서가 굳지 않는다이 문서가 source_row_no 를 셋째 축으로 제안한다. 확정된 것이 아니다
이 문서가 정하지 않는 것

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