변경 이력

명세와 사이트의 변경 이력. 최신이 위다.

2026-08-31Client API v4-1을 확정했다

실코드 2822dcf와 게시 명세를 전 항목 대조했다. 오류 응답은 retryable을 뺀 세 필드가 됐고 오류 코드는 5종에서 9종으로 늘었다. 보드 응답에는 required staleAt(observedAt + 5분)이 추가됐다. SUCCESS_ROWS인데 읽어낸 차량이 0행인 경우는 UNKNOWN으로 명시했다. 공개 경로는 팀 레포 계약대로 routes·board·vehicles 세 개로 고정했다.

horizonStops 스키마는 1~12로 유지한다. 바뀐 것은 산문이다 — sequence - horizonStops는 현재 순번이 아니라 지나온 정류장 수이며, 현재 위치와 회차 전후는 /vehicles.currentStopSequence로 판단한다. 저장 계층은 V4~V9가 들어왔지만 그 변화는 Client 응답 모양에 새 필드를 더하지 않는다.

변경 10건·유지 13건·보류 3건 → v4-1 Client API →

2026-08-23실제 LIVE 차량번호와 8/22 완료일 성적표를 반영했다

Watch의 차량번호 계약을 vehicle-plate-live-v1로 확정했다. GBIS 번호는 durable commit 뒤 process memory에서만 응답 시점에 결합하며, SQLite·serving S3·모델·평가 artifact·로그에는 저장하지 않는다. 공개 상태이면 화면에는 마지막 네 자리만 보이고, 결손·충돌은 추측값 없이 fail-closed한다. API 문서는 과거 재생 데모를 제거하고 실제 FastAPI 실행 계약에서 다시 생성했다. 이 항목은 당시 별도 runtime의 이력이며 현재 팀 레포 Client 계약에는 포함되지 않는다.

8/21 원천 이상 레코드 4건을 전체 레코드 단위로 격리한 뒤 8/22까지 다시 채점했다. 말뭉치는 통과 사건 104,447건·만석 1,387건이고, 종합 점수는 지평 1 92.0 · 지평 8 75.6 · 지평 12 69.9다. 평가일은 8일이라 여전히 잠정이며 지평마다 표본이 달라 점수 차이를 순위로 읽지 않는다.

최신 LIVE 지표 → 현재 LIVE API →

2026-08-19v4 개정안에 팀 검토 넷을 반영했다 — 이름 셋 · 상한 하나

팀 검토에서 넷이 확정됐고 전부 반영했다. 아래 v3 → v4 절은 이 개정 이전에 쓴 판이라 옛 이름과 옛 상한이 남아 있던 곳을 이 개정에 맞춰 고쳤다. 확정 계약은 여전히 v3다 — 바뀐 것은 검토 요청 중인 개정안이다.

바뀐 것개정 전개정 후근거
접근 중인 버스 상한maxItems: 2maxItems: 3 지평 12 안에 3대 이상이 56.0%이고 3번째는 중앙 지평 9 = 22.7분 뒤다. 앞 두 대가 모두 빈자리 확률 70% 미만인 경우가 1.80%인데 그중 42.3%에서 3번째가 답이다. 4대로는 늘리지 않는다 — 4번째는 24.8분이라 한계 효용이 떨어진다
배열 이름arrivalsapproachingVehicles 계약이 차량을 부르는 말이 일관되게 vehicle 인데 이 둘만 벗어나 있었다
항목 스키마 이름StopArrivalApproachingVehicle
정류장 ID 필드stationIdstopId
StopState · Vehicle 둘 다
계약에 "stop" 이 8개 필드명에 쓰이는데 ID 만 "station" 이었다. Vehicle 은 같은 장소를 stationId 와 stopName 두 이름으로 불렀다. DB 는 station_id 를 유지한다(상류와 맞춘다) — 개명은 api 계층 한 곳이고 DDL 변경은 0건이다
boardingAllowed 판정규칙 없음 NOT stopId.startsWith("277") 상류 getBusRouteStationListv2 는 승차 가능 필드를 주지 않는다. 접두 277 이 경유 지점 전용 대역이다 — 3330 은 7곳 · 1650 은 24곳이고 전부 이름에 「(경유)」 이며 다른 접두에 경유는 0곳이다. 277 은 구리·서울·성남·의왕·안양 다섯 시에 걸쳐 있어 지역 코드가 아니다

mobileNo 없음으로 판정하면 틀린다. 경유 지점은 mobileNo 가 없지만, 1650 의 서울 중앙차로 정류장 19곳(석촌역(중) 등)도 mobileNo 가 없으면서 진짜 승차 정류장이다 — 그쪽은 centerYn: Y 로 구분된다. 실측 뒷받침으로 경유 지점의 좌석변화율 중앙은 0.000(31곳), 일반 정류장은 0.053(138곳)이다. 미정차 정류장에는 예보를 만들지 않는다 — 계약이 이미 그렇게 규정한다.

v3 → v4 전체 이력 → v4-fe 계약에서 보기 →

2026-08-19경유 지점을 08번의 예측 대상에서 뺐다

08번(A18)의 학습·평가 말뭉치가 경유(미정차) 지점을 예측 대상으로 쓰고 있었다. boardingAllowed = !stationId.startsWith("277") 로 걸러 105,058행(18.4%)을 빼고 지평 1~12를 전부 재적합·재채점했다. 뺀 것은 예측 대상이고, 차량이 경유를 지나며 남긴 관측은 특징으로 남는다. 계약 문구가 바뀐 곳은 없다.

숫자. 만석 라벨 9,777 → 4,851, 기저율 1.7168% → 1.0445%, 대상 정류장 174곳 → 143곳. 지평 12에서 만석 구분 0.4194 → 0.3729, 채점 만석 표본 686 → 355, 종합 71.6 → 70.3, 운행시간대 커버율 90.8% → 89.9%. 지평 상한은 그대로 12다. 사이트의 08번 성적·코호트·커버율·기저율은 전부 새 값이다.

08번 모델 설명 →

2026-08-191차 운영 모델을 08번으로 교체했다

팀 검토로 08번 「오고 있는 버스 보기」(차량 상태 조건부 좌석 분포)를 1차 운영 모델로 정했다. 2026-08-17 부터 02번 「재료 다 넣기」가 갖고 있던 자리다. 1차 운영 선택은 운영 적용을 위한 팀 선택이고 성능 입증이 아니다 — 평가는 여전히 잠정이고 일일 채점도 계속된다.

02번은 폐기가 아니다. 평가 파이프라인에 대조군으로 남고 서빙 계약에서는 빠진다. 일일 채점은 계속 받지만 /board가 읽는 자리에는 들어가지 않는다. 두 모델은 재료가 달라 채점 표본도 다르므로 같은 표에서 순위를 매기지 않는다.

이 선택이 계약을 움직인다. 08번은 예측 시점 그 차량의 잔여석을 쓰므로 /board가 시간대 평균 하나를 주던 구조로는 담을 수 없다. 아래 v3 → v4 개정안이 그래서 나왔다. 그 계약은 아직 검토 요청 상태다 — 모델은 골랐고 계약은 팀 검토를 기다린다.

08번 모델 설명 → 모델 현황판 →

2026-08-19v4 개정안을 뒤집었다 — /board 재구성

처음 낸 v4 안은 v3 위에 선택 필드만 더하는 개정이었다. 08번 모델을 /vehicles에 붙이고 /board는 손대지 않는 것이 그 안의 안전장치였다. 그 전제를 뒤집었다 — 지금 /board가 주는 시간대 평균은 새 모델이 이미 재료로 쓰는 값이라, 따로 내보내면 재료를 덜 가공해서 내보내는 셈이 된다. 그래서 /board는 정류장마다 도착 예정인 버스별 탑승 확률을 주는 모양으로 바뀌고, 개정안은 파괴적 변경(v4-fe 2.0.0 · v4-be 4.0.0)이 된다. 프론트엔드 수정이 함께 필요하다. 무엇이 그대로이고 무엇이 왜 바뀌는지는 아래 명세 v3 → v4 절에 다시 썼다 — 안 바뀌는 것(0절)부터 시작해 FE는 1~5절, BE는 6절, 미결은 7절이다. 확정 계약은 여전히 v3다.

2026-08-19지평 12 확정 — 평가기 등록과 LIVE 별도 구획

08번의 서빙 지평을 12정류장으로 확정하고 평가 규약의 지평 모델 목록에 seat_distribution_j12를 등록했다. 고른 기준은 점수가 아니다 — 지평마다 채점 표본이 달라 점수를 서로 뺄 수 없기 때문이다. 기준은 예보를 낼 수 있는 도착 비율과 만석 구분의 맞바꿈이고, 8→12는 79.2%를 86.3%로 늘리며 기저 대비 향상을 31배에서 24배로 내준다. 12→16은 87.4%로 1.1%p를 더 얻자고 17배까지 내주므로 기각했다.

고친 곳무엇을왜
평가 규약 horizon_models에 seat_distribution_j12를 더하고, 상류 좌석을 붙이는 지평 목록에 12를 더했다. 지평 12를 운영 평가기가 실제로 채점하려면 그 지평의 상류 관측이 평가 행에 붙어 있어야 한다. 공개 7모델의 지표·점수는 한 비트도 바뀌지 않았다.
LIVE 일일 평가 표 아래에 차량 조건부 예보 구획을 따로 뒀다. 같은 표에 섞지 않았다. 08번은 레인이 다르다 — 예측 시점 그 차량의 잔여석을 조건으로 받고, 상류 관측이 붙은 도착만 채점한다. 같은 표에 놓으면 "같은 표본 위의 비교"라는 전제가 깨진다. 지평 모델은 아직 내부 산출이라 일일 평가 API도 이 값을 주지 않는다.
모델 현황판 08번 카드에 12정류장 줄을 얹고 지평을 고른 근거를 적었다. 12의 71.6이 8의 69.3보다 높은 것은 확률 맞춤이 60.6에서 89.1로 뛴 탓이고, 만석 구분은 0.536에서 0.419로 계속 떨어진다. 점수만 보면 거꾸로 읽힌다.
v3·v3 파이프라인·도메인 3330의 routeId를 204000057로, turnSequence를 43으로, 기점 이름을 도촌동9단지앞으로 고쳤다. 초판 예시에 1650의 값이 3330 자리에 들어가 있었다. 상류 getBusRouteStationListv2·getBusRouteInfoItemv2를 직접 조회해 확정한 값으로 바꿨다. 계약의 모양은 그대로이고 예시 값만 고쳤다.

지평은 시간이 아니라 정류장 수다. 12정류장의 중앙 소요는 25.0분이지만 같은 8정류장이 10%분위 9.1분에서 90%분위 23.0분까지 2.5배로 흩어진다. 걸린 시간은 도착한 뒤에야 알 수 있어서 예측에 쓰면 누출이다.

2026-08-19새 후보 모델과 v4 검토 요청안

후보에 08번 오고 있는 버스 보기를 더했다 — 지금 오고 있는 그 버스가 목표 정류장에 도착할 때 좌석이 몇 석 남을지를 분포로 낸다. 이 모델을 담으려면 /board 응답이 바뀌어야 해서 개정안을 v4-fe·v4-be 두 묶음으로 올렸다. 확정 계약은 여전히 v3다. 무엇이 그대로이고 무엇이 왜 바뀌는지는 아래 명세 v3 → v4 절에 있다.

2026-08-19사이트 전반 서술 갱신 — 08번이 바꾼 개념 다섯

08번 후보가 들어오면서 사이트 여러 곳의 전제가 바뀌었다. 설계는 그대로 두고 내용만 고쳤다. 지운 것은 없고 전부 덧댄 것이며, 정정은 정원 하나다. 어디가 왜 바뀌었는지는 아래 표에 있다.

바뀐 것어떻게 바뀌었나고친 곳
예측 대상 만석 이진에서 좌석 분포로 넓혔다. 만석은 그 분포의 0 지점이다. 이진 라벨 정의는 그대로 두고 분포 관점을 덧댔다. 분포로 배우는 이유는 표본에 있다 — 만석은 전체의 1.7%뿐이지만, 분포로 배우면 나머지 98.3%도 학습에 들어온다. 평가 규약 1절 · 재료 · 데이터 스택
지표 체계 분포 정확도(CRPS)와 보정비를 설계 검증 축으로 더하고, 한 지표 단독 판정을 금지로 명시했다. 원칙이었던 것에 실측 근거가 생겼다 — 이번 검증에서 분포 지표와 만석 지표가 정반대를 가리킨 층이 세 번 나왔다. 일일 평가의 지표 10개·가중치·관문은 바뀌지 않았다. 평가 규약 4-1절
학습 단위 노선별로 나눠 배우고 계수를 공유하지 않는 후보가 생겼다. 규약이 어느 쪽도 강제하지 않되 선언을 요구하도록 했다. 채택되면 계수 번들이 노선마다 하나씩 필요하다. 평가 규약 2절 · 도메인 설계
관측 한계 세 가지를 명문화했다. 입석이 없다는 것은 확정이고(형식승인 입석 정원 49/49가 0), 그래서 잔여석 0 = 승차 불가는 근사가 아니라 정확하다. 대신 승하차를 나눠 볼 수 없고(한 홉 변화의 57.1%가 0), 만석 이후 수요는 완전히 절단된다(연속 만석 중앙 6·최대 12 정류장). 평가 규약 7절 · 데이터 스택
정원 교정
유일한 사실 정정
08번 학습 데이터셋의 단층 정원이 47로 박혀 있었다. 두 노선 배속 103대 어디에도 없는 값이다 (차량이력 실측은 45석 61대·46석 35대·42석 1대·40석 1대·2층 69석 3대·71석 2대). 258,482행을 45로 고쳤다. 모델이 쓰는 정원은 일반 단층 45 · 저상 계열 40 · 2층 68 세 갈래다 — 전수 22,010 스냅샷에서 lowPlate=1 두 대가 정확히 40에서 멈추고(675회·1,151회) 2층 7대가 68에서 멈춘다. 차량이력 정원이 아니라 API가 파는 좌석의 상한에서 유도한 값이라야 잔여석과 단위가 맞는다. 차량의 형식승인 좌석수는 이것과 다른 양이다 — 저상 신차는 41이 유력하고 잔여석이 형식승인 좌석수를 넘는 차량도 49대 중 6대라, 정원은 단일 진실값으로 합치지 않고 출처별로 남긴다. 데이터 스택 · 재료

함께 고친 것 — 「후보 모델 7개」를 8개로(포털·도메인·데이터 스택), 그리고 재료 문서의 오해 소지 하나. 「로지스틱+상호작용」의 서빙 재료 표는 연구 변형 기준인데 확정된 운영 v1(입력 세 개, 실시간 좌석 안 봄)과 헷갈릴 수 있어 주석을 달았다.

2026-08-17평가 규약 cohort-v2 — 공휴일 달력 도입

공휴일·대체공휴일·선거일을 주말 국면으로 분류한다(2026년 22일). 오늘(광복절 대체공휴일)이 평일 코호트를 오염시키는 문제의 교정이며, 내일 일일 평가부터 적용된다. 구간 정의는 평가 규약에 있다.

2026-08-17확정 명세 2판 — 실시간 차량 엔드포인트 추가

GET /api/v1/routes/{routeId}/vehicles를 신설했다 — 차량 위치·잔여석·vehicleId 원문, 60초 폴링. 갱신 주기가 예보와 24배 달라 문을 나눴다: 보드는 순수 예보가 되고(관측 블록은 /vehicles로 이동), 한쪽이 503이어도 다른 쪽은 산다. 응답의 schemaVersion 필드는 제거됐다. 명세에서 보기 · 세부 대비는 아래 표.

2026-08-17확정 API 계약 공개

확정 계약을 v3 문서 묶음으로 공개했다 — Client(노선 예보 조회, 명세 1.0.0)·Data Pipeline(DB 표 8개)·GBIS Upstream·Operations 네 문서. 옛 문서 주소 /api/v1은 /v3/client로 넘어간다(서버 경로 /api/v1은 그대로다). 1차 운영 모델 선택(상호작용 로지스틱 회귀)을 모델 현황판에 반영했다. 확정 전 명세 초안 v2·v1은 그대로 보존한다. 무엇이 왜 바뀌었는지는 아래 명세 v2 → v3 절에 있다.

2026-08-17상황판 개편

홈에 수집·평가 상태 스트립을 달았다. 모델 현황판은 일일 평가와 연동돼 매일 갱신된다. 관문·코호트·지표·분할 정의는 평가 규약 한 곳으로 모았다. 확정 도메인 설계를 /domain에 공개했고, 보드 가이드는 API 초안 v2 어휘로 갱신했다.

2026-08-16일일 모델 평가 시작

매일 00:15 KST에 전 후보를 자동 평가하고 결과를 LIVE에 공개한다. 구형 도메인 문서는 로컬에 보존하고 공개 목록에서 내렸다.

2026-08-15모델 문서 공개

후보 7개의 상세 문서와 재료 문서를 열었다. 현재 평가와 M3 연구 이력은 분리해 실었다.

2026-08-19 — 명세 v3 → v4 (검토 요청)

분야별로 나눠 본 것은 여기다. 이 절은 FE와 BE가 한자리에 섞여 있다. 화면 쪽만 볼 사람과 서버 쪽만 볼 사람을 위해 같은 개정을 분야별로 갈라 놓은 문서를 따로 뒀다. 이 절은 둘을 함께 놓고 보는 판이다.

FE 변경 이력 (v4-fe) → BE 변경 이력 (v4-be) →

확정 계약은 여전히 v3다. 이 절은 새 예보 모델 차량 상태 조건부 좌석 분포(A18)를 채택할 경우의 개정안이고, 팀에 검토를 요청하는 제안이다. 이 절은 2026-08-19 팀 검토 반영을 거친 판이다 — 같은 날 이름 셋(arrivals → approachingVehicles · StopArrival → ApproachingVehicle · stationId → stopId)과 상한 하나(2 → 3)가 바뀌었고 아래 서술은 그 뒤의 이름·상한으로 적혀 있다. V3 쪽 창과 「검토 도중에 있었던 모양」 창은 그 시점 사실이라 손대지 않았다. 처음 낸 안(v3 위에 선택 필드만 더하는 1.1.0)은 폐기됐다. A18을 /vehicles에 붙이고 /board는 손대지 않는 안이었는데, 전제가 뒤집혔다 — 지금 /board가 주는 시간대 평균은 새 모델이 이미 재료로 쓰는 값이라, 따로 내보내면 재료를 덜 가공해서 내보내는 셈이 된다. 그래서 이 개정은 /board의 재구성이고 파괴적 변경(2.0.0)이다. 프론트엔드 수정이 필요하며 그 사실을 감추지 않는다. 큰 변경 8개(0~7), 나머지는 표. FE는 0~5절, BE는 6절, 미결은 7절을 읽으면 된다.

식별자 예시 고지. 이 절의 vehicleId 문자열은 계약 타입을 유지한 문서 전용 비숫자 합성 토큰이며 실제 차량을 가리키지 않는다.

0안 바뀌는 것부터 — 골격은 그대로다

FE — v3 그대로
GET /api/v1/routes           그대로
GET …/routes/{id}/vehicles   그대로
GET …/routes/{id}/board      경로만 그대로

route 8필드                  그대로
  id · displayName
  startStopName · endStopName
  status · turnSequence
  referenceVersionId · directions

stops[] 5필드                자리 그대로
  sequence · stopId · name
  direction · boardingAllowed
// 다섯 중 ID 이름 하나만 바뀐다
//   stationId → stopId (2026-08-19 개정)

// directions 항목에만 필드 둘이 는다
//   originStopName · terminalStopName
//   firstDepartureTime · lastDepartureTime
// 오류 envelope · 코드 5종 그대로
//   (사유 이름 하나만 바뀐다 — 2절)
BE — v3 그대로
call_ledger                  그대로
route_reference_version      그대로
route_stop                   그대로
location_poll                그대로
model_deployment             그대로

STAGED → ACTIVE 원자 승격    그대로
노선 판본 유효기간 관리      그대로
상류 API 1종 · 수집 필드     그대로

// 기존 열의 삭제·타입 변경 0건
// 학습·평가·선정은 여전히 외부 ML 환경이
//   소유한다 — v3의 경계 그대로

파괴적 변경이라는 말에 화면을 통째로 다시 그릴 필요는 없다. 노선 화면의 골격 — 정류장 목록·이름·방면, 회차 판정, 개편 감지(referenceVersionId) — 은 v3 그대로다. /routes와 /vehicles는 응답 스키마가 한 필드도 바뀌지 않는다 (설명만 두 곳 고쳤다 — /vehicles의 폴링 권고와 Observation의 주석). /board의 route도 여덟 필드 그대로인데 directions 항목 안에만 originStopName·terminalStopName·firstDepartureTime·lastDepartureTime 넷이 필수로 는다 — 운행 종료를 vehiclesInService 하나로 단정하지 말라는 3절과 짝이다. 상류 API도 늘지 않는다 — A18이 쓰는 재료는 이미 받고 있는 차량 위치 하나뿐이고 새 필드를 요구하지 않는다. 바뀌는 것은 /board 응답 안에서 예보를 담는 부분 하나다.

할 일 — FE가 고칠 곳은 정류장 하나를 그리는 컴포넌트와 그 위의 응답 타입이다. BE가 기존 표에서 만질 것은 vehicle_observation에 열 하나를 더하는 것뿐이고 나머지는 신설이다(6절).

1예보의 단위가 정류장에서 차량으로 옮겨간다

V3 — 주어는 정류장
"stops": [ {
  "sequence": 9, "name": "범계역",
  "seatForecast": {
    "kind": "ESTIMATED",
    "seatAvailableProbability": 0.832 } } ]

// 정류장당 값 1개
// 입력 셋 — 노선 · 정류장 순번 · 시간대
// 답: 08~09시에 여기 지나는 버스라면
V4 — 주어는 "이 버스"
"stops": [ {
  "sequence": 55, "name": "롯데백화점.범계역",
  "approachingVehicles": [
    { "vehicleId": "SYNTH-VEH-001",
      "horizonStops": 3,
      "seatAvailableProbability": 0.871,
      "expectedSeats": 28.2 },
    { "vehicleId": "SYNTH-VEH-008",
      "horizonStops": 12,
      "seatAvailableProbability": 0.946, … } ] } ]

// 정류장당 값 = 도착 예정 차량 수
// 입력에 그 차량의 현재 잔여석이 더해진다
// 답: 지금 오는 저 버스가 도착할 때

v3이 답하던 질문은 "이 시간대에 이 정류장을 지나는 버스라면"이었다. 사용자가 실제로 묻는 것은 "지금 오는 저 버스에 앉을 수 있나"다. 새 모델은 그 차량의 현재 잔여석을 조건으로 받으므로 버스를 특정해야 답이 나온다.

두 낱말을 먼저 정한다. 지평은 시간이 아니라 노선 순번 차이다 — 대상 정류장까지 남은 칸 수(horizonStops)이고, 경유 지점도 한 칸으로 센다. 모델은 지평마다 따로 적합되며 상한은 12다. seatAvailableProbability는 이 버스가 이 정류장에 도착할 때 빈자리가 한 자리 이상 남을 모델 확률이다 — 0~1 실수를 그대로 주고 서버는 반올림하지 않는다. 뜻은 v3과 같고 차량이 특정된다는 점만 다르다. 정류장에 몇 명이 서 있는지는 모르므로 개인 한 명의 승차 성공 확률은 아니다.

시간대 평균을 폴백으로도 남기지 않은 이유는 둘이다. 새 모델이 그 값을 이미 재료로 쓴다 — (노선, 정류장, 시간대)별 평균 점유율과 순수요를 기억하고 거기에 현재 잔여석을 더해 답을 낸다. 따로 내보내면 재료를 덜 가공해서 내보내는 셈이다. 그리고 차량이 없는 경우는 셋뿐인데(운행 자체가 없다 · 다음 버스가 지평 밖이다 · 추적 이력이 모자라다) 셋 다 시간대 평균이 답이 아니다.

FE 할 일 — stop.seatForecast 하나를 읽던 자리를 stop.approachingVehicles[] 반복으로 바꾼다. 정렬은 서버가 horizonStops 오름차순으로 고정한다. sequence − horizonStops가 그 차량의 현재 순번이라 노선 위에 버스를 찍을 수 있고, route.turnSequence와 견주면 회차를 지나야 오는 차량인지도 알 수 있다 (실측 approachingVehicles 항목의 10.0%(1650)·12.8%(3330)가 여기 해당한다). vehicleId는 필수 필드지만 공급자가 값을 빠뜨리는 드문 경우가 있어 null이 올 수 있다 — 타입은 string | null로 잡는다. 확률만 띄우고 horizonStops를 감추지 마라 — 만석 버스를 골라내는 능력(PR-AUC)이 1~4정류장 0.73~0.84에서 9~12정류장 0.42~0.54로 떨어진다. 무작위 대비로는 43~50배에서 24~31배이니 먼 쪽도 쓸모는 있지만, 같은 확률이라도 난이도가 다르다.

v4-fe 명세에서 보기 →

2발행본 개념이 사라진다 — 시간대 경계 굽기에서 관측마다로

V3 — 굽고 봉인한 한 판
"forecast": {
  "publicationId": "pub-01J9X2",
  "targetAt":     "…08:00:00+09:00",
  "publishedAt":  "…08:00:02+09:00",
  "validUntil":   "…09:00:00+09:00",
  "model": { "releaseId": …, … } }

// 07 · 09 · 17 · 20 KST 경계마다 한 판
// 수집이 끊겨도 예보는 유효했다
V4 — 관측 하나가 기준
"observedAt": "2026-08-18T08:29:47+09:00",
"model": {
  "releaseId": "seat-distribution-3330-…",
  "trainedThrough": "…" },
"vehiclesInService": 14

// 값이 바뀌는 횟수 하루 4번 → 관측마다
//   08-18 실측 노선당 4,097회
// 503 사유 이름이 바뀐다
//   NO_CURRENT_PUBLICATION
//     → NO_RECENT_OBSERVATION

v3의 발행은 시간대 경계마다 한 판을 구워 봉인하는 구조였고, 모델 입력이 그 시간대 안에서 변하지 않으니 맞는 설계였다. 새 예보는 관측마다 바뀌므로 그 틀에 담기지 않는다. ForecastMeta 넷을 observedAt 하나로 대체한다. 그 값은 우리 수집기가 상류 응답을 받은 시각이다 — 상류가 주는 queryTime은 실측 1,294건 전부에서 우리 수신 시각보다 2.2~20.5초 뒤라 시계가 어긋나 있어 쓸 수 없다.

대신 v3에 없던 결합이 하나 생긴다. v3은 수집이 끊겨도 예보가 유효했지만 v4의 /board는 관측을 읽으므로 최근 관측이 없으면 NO_RECENT_OBSERVATION 503이다. 같은 이유로 RouteStatus.FORECAST_READY의 뜻도 옮겨간다 — "지금 시각에 유효한 봉인 발행본이 있다"에서 "활성 계수 묶음이 있고 관측이 들어오면 예보를 낸다"로, 즉 "답을 갖고 있다"에서 "답을 낼 수 있다"로. 값은 그대로 2종이다. /vehicles와 서로 죽지 않는 성질은 유지된다.

FE 할 일 — validUntil로 다음 요청 시각을 잡던 코드를 지운다. /board의 Cache-Control이 60초에서 15초로 짧아지고, 자료의 나이는 지금 − observedAt으로 잰다(수집은 적응형이라 15 / 20 / 60 / 600초 간격이다). 503 사유 코드 문자열도 교체한다. BE 할 일 — 발행 계층을 제거한다(6절).

v4-fe 명세에서 보기 →

3빈 approachingVehicles의 뜻 — 갈래가 넷이다

V3 — 빈 자리가 없었다
"stops": [ {
  "boardingAllowed": true,
  "seatForecast": {
    "kind": "ESTIMATED",
    "seatAvailableProbability": 0.977 } } ]

// 승차 가능 정류장은 늘 값을 가졌다
// 하나라도 점수화에 실패하면
//   발행 전체를 503으로 되돌렸다
V4 — 빈 배열이 흔하고 정상이다
"vehiclesInService": 0,
"stops": [ { …, "approachingVehicles": [] } ]
// → "운행이 끝났습니다" (기다려도 안 온다)

"vehiclesInService": 11,
"stops": [ { …, "approachingVehicles": [] } ]
// → "곧 오는 버스 없음" (기다리면 온다)

// 응답 모양은 같고 뜻이 다르다
// 둘 다 정상 200이다 — 503이 아니다
// 이 둘로 끝이 아니다 — 아래 표의 넷

빈 배열은 드문 예외가 아니라 자주 나오는 상태다. 셀 191만 개(스냅샷 11,005개 × 정류장 174개) 기준으로 커버율 86.3%이고, 비는 나머지는 노선 앞머리라 뒤에 볼 구간이 짧은 경우 7.9% · 지평(12정류장) 밖 3.4% · 운행 0대 2.2%다. 운행 시간대(06~23시)만 보면 커버율이 90.4%(1650)·91.1%(3330)이고 심야는 30%로 완전히 다르다. 이만큼 흔한 상태를 오류로 다루면 화면이 자주 깨진다.

빈 approachingVehicles의 조건뜻화면
vehiclesInService: 0 노선에 도는 차가 없다 lastDepartureTime과 견줘 "운행이 끝났습니다" — 기다려도 안 온다
boardingAllowed: false 여기서는 못 탄다(경유·미정차) 승차 불가 표시. 예보 자리를 비운다
sequence ≤ 12 노선 앞머리라 뒤로 볼 칸이 모자란다 "곧 오는 버스 없음"이라고 쓰면 거짓이다
그 밖 이 구간에 차가 없다 "곧 오는 버스 없음" — 기다리면 온다

셋째 줄이 함정이다. 순번 12 이하 정류장은 뒤로 볼 수 있는 칸이 sequence − 1개뿐이라 커버율이 37~44%로 떨어진다(그 밖의 정류장은 98.7~98.9%다). 그런데 실측하면 실제 다음 버스는 중앙 8.3~12.0분 뒤에 온다 — 그 차가 차고지에서 대기 중이라 차량위치 API에 아직 안 나타날 뿐이다(96.2~96.8%가 그렇다). 해당 정류장은 노선의 13.8%다. 배차 간격(첨두 5~6분·비첨두 15분)을 함께 보여주는 편이 정직하다.

정류장마다 사유를 붙이지 않고 최상위에 값 하나만 둔 이유는 운행 여부가 노선 전체의 사실이기 때문이다. 항목마다 상태 열거형을 두려던 안은 뺐다 — 이유는 4절에 있다. 드물게 상류가 빈 응답을 주면 운행 중에도 0이 된다(실측 22,010 스냅샷 중 1건, 0.005%). observedAt의 나이로 거른다.

FE 할 일 — approachingVehicles: []에 스피너나 오류를 띄우지 마라. 문구는 위 표의 네 갈래로 고른다. vehiclesInService 하나로는 갈라지지 않으므로 sequence와 boardingAllowed를 함께 읽는다. vehiclesInService: 0도 관측일 뿐 단정이 아니다 — "운행이 끝났습니다"는 route.directions[].lastDepartureTime과 견주고 나서 쓴다. 방향마다 다르다: 1650은 상행 막차가 22:35인데 하행은 23:55로 80분 차이가 나서, 노선 하나를 통째로 종료라고 하면 그 사이 시간에 틀린다.

v4-fe 명세에서 보기 →

4넣었다가 뺀 것들 — "왜 이건 없나"의 답

검토 도중에 있었던 모양
"arrivals": [ {
  "vehicleId": "SYNTH-VEH-001",
  "horizonStops": 3,
  "confidence": "HIGH",
  "viaTurnaround": false,
  "seatForecast": {
    "state": "READY",
    "seatAvailableProbability": 0.871,
    "expectedSeats": 28.2,
    "seatQuantiles": {
      "p10": 3, "p50": 12, "p90": 24 } } } ]
확정본 — 필드 넷
"approachingVehicles": [ {
  "vehicleId": "SYNTH-VEH-001",           필수
  "horizonStops": 3,                  필수
  "seatAvailableProbability": 0.871,  필수
  "expectedSeats": 28.2 } ]           선택

// 뺀 자리에 최상위 값 하나만 뒀다
//   — vehiclesInService (3절)
뺀 것왜 뺐나대신 무엇으로 읽나
confidence 등급 horizonStops가 이미 난이도를 말한다. 등급 경계를 그을 근거가 없고, 같은 것을 두 번 말하면 화면이 둘 중 하나만 읽는다. horizonStops
seatQuantiles
(p10 · p50 · p90)
화면이 쓸 수 있는 범위는 확률과 기대 좌석 수까지다. 세 값을 더 내보내려면 BE가 열 셋을 더 저장해야 한다 — 초안의 seatP10·seatP50·seatP90은 철회됐다. expectedSeats
viaTurnaround 관측 순번 ≤ turnSequence < 대상 순번의 순수한 유도값이다. 서버가 따로 주면 turnSequence와 어긋날 수 있는 값이 하나 는다. confidence를 뺀 것과 같은 논리다. sequence − horizonStops와 route.turnSequence로 클라이언트가 계산
seatForecast 껍데기 두 필드를 감싸기만 해서 층이 하나 늘었다. v3에는 kind 판별자가 있어 껍데기가 필요했지만 v4에는 판별자가 없다. 두 필드를 approachingVehicles 항목에 바로 둔다
항목 상태 열거형
"이력 부족" 등
그 상태가 드물다. 모델의 핵심 재료는 그 차량의 현재 잔여석인데, 상류가 그 값을 빠뜨리는 경우가 전수 314,688 관측행 중 237행(0.075%)이다. 상류 이력이 짧아 기울기를 못 구하는 경우(1.4%)는 다른 층이 채워 예보가 나온다.

다만 0 은 아니다. 잔여석이 빠진 차량은 approachingVehicles에서 조용히 사라져 "그 자리에 버스가 없다"와 구별되지 않는다 — 스냅샷의 1.07%가 그런 차량을 담고, 한 차량이 연속 18회까지 이어진 적이 있다(9대). /vehicles는 같은 상황을 seat.kind = UNKNOWN으로 드러내므로 두 엔드포인트가 다르게 다룬다.
예보를 못 내는 항목은 아예 담지 않는다
NOT_APPLICABLE 판별자
v3에 있던 것
승차 불가 정류장은 빈 배열이면 뜻이 전달된다. 판별자를 유지하려고 껍데기를 남길 이유가 없다. approachingVehicles: []

뺀 기준은 하나다 — 다른 필드에서 유도되거나, 자료에 없는 상태이거나, 층만 늘리는 것은 계약에 넣지 않는다. 계약에 들어간 값은 FE가 화면을 만들고 BE가 열을 파야 하는 약속이라, 늘리는 쪽이 아니라 줄이는 쪽에 근거를 뒀다.

할 일 — 위 이름들은 검토 과정에만 있었고 확정본에는 없다. 중간 초안을 보고 작업을 시작했다면 필드 넷으로 맞춘다.

5v3 예시의 오류 넷 — 계약 변경이 아니라 교정이다

v3 초판 예시
"routes": [ {
  "id": "234000016",
  "displayName": "3330",
  "startStopName": "구리수택차고지", … } ]

"route": { …
  "turnSequence": 31 }

1650 status: "PREPARING"
상류 API로 직접 확인한 값
"routes": [ {
  "id": "204000057",
  "displayName": "3330",
  "startStopName": "도촌동9단지앞", … } ]

"route": { …
  "turnSequence": 43 }   1650은 44

1650 status: "FORECAST_READY"

// 회차 정류장은 둘 다 안양역(208000069)
// 정류장 수 1650=89 · 3330=85

셋은 3330 자리에 다른 값이 들어가 있던 것이다 — 구리수택차고지는 1650의 기점이고, 234000016과 31은 확인된 두 노선 어느 쪽의 값도 아니다. 상류 getBusRouteStationListv2·getBusRouteInfoItemv2를 직접 조회해 확정했다. 계약의 모양은 그대로이고 예시 값만 고쳤다. 앞의 셋은 같은 날 v3 문서에도 반영했다(위 「지평 12 확정」 항목의 표).

넷째는 성격이 조금 다르다. v3 예시는 1650을 PREPARING으로 뒀는데, A18은 노선별로 적합하고 계수 묶음이 노선마다 나오므로(블록 2노선 × 지평 12 = 24) 한 노선만 준비되는 그림이 아니다. v4 예시는 둘 다 FORECAST_READY로 뒀다.

할 일 — v3 예시를 그대로 베낀 목업·픽스처·테스트 상수가 있으면 고친다. 204000057이 3330, 234000050이 1650이다.

6백엔드 — 발행본을 굽는 대신, 관측 시점에 계산해 저장한다

V3 — 발행본을 굽고 봉인한다
forecast_publication  발행 메타
stop_prediction       정류장별 예보
  단위: (발행본, 정류장)
  봉인 확인 5종 · CAS 승격
  노선당 하루 4~5판 · 340~445행

// 라벨 만들기는 연구 export의 일이었다
V4 — 표 신설 2 · 제거 2 · 열 추가 1
vehicle_stop_prediction   신설 · 열 14
  단위: (관측, 대상 정류장)
demand_profile            신설 · 열 11
  단위: (판본, 정류장, 시간대)
vehicle_observation
  + vehicleTripKey          열 1
인덱스 3건

forecast_publication  제거
stop_prediction       제거

// 남는 표에서 열 삭제·타입 변경 0건
// 기존 8개 중 둘을 지우고,
//   남는 여섯에서 열이 바뀌는 표는 하나뿐

왜 요청 시점에 계산하지 않고 저장하나. 결정적 근거는 모델 안에 있다 — A18의 L_p0shift 층은 그날 이미 도착이 관측된 사건의 (예보 원값, 실제 라벨) 쌍을 되읽어 절편을 옮긴다. 낸 예보를 저장하지 않으면 그 층이 성립하지 않는다. 채점 편의가 아니라 모델 입력이다. 덧붙는 근거 둘 — /board 캐시가 15초이고 수집이 15~60초라 화면 하나만 열려 있어도 요청 수가 poll 수를 넘고, 요청 시점 계산은 채점 표본이 사람이 본 시간대에만 남아 심야가 0이 된다. 한 판의 계산량은 두 방식이 같다 — 한 차량은 자기 앞 12개 정류장에만 나타나므로 예보 수는 정류장 수와 곱해지지 않고 차량 수에 선형이다.

무엇을 잃는지도 적는다. 발행 계층을 제거하면 봉인 확인이 지키던 불변식 "승차 가능 정류장은 전부 답을 갖는다"가 사라진다. 대체물은 없고 그것이 의도다 — 예보가 정류장이 아니라 차량에 붙으므로 정류장 단위 전수 보장이 성립하지 않는다.

쓰기만 멈추는 것이 아니라 지우는 이유는 그 경로가 한 번도 가동된 적이 없기 때문이다 — 적재할 계수 번들이 없어 발행 processor 가 뜬 적이 없다. 한 번도 뜬 적 없는 경로를 안전장치라고 부를 수는 없고, 남겨 두면 계약이 두 갈래로 읽힌다. 되돌리기도 이 경로로는 안 된다 — 발행 계층을 되살리면 /board 응답 구조가 v3 형태로 함께 돌아간다. 그건 조용한 폴백이 아니라 계약 되돌림이다.

지우는 것은 서빙 계약 쪽 발행 계층뿐이고 모델 평가 파이프라인은 그대로다. 후보 7종은 후보 모델 현황판과 일일 평가에 남아 같은 규약으로 계속 채점된다. 지우는 범위도 층마다 다르다 — 계약에서는 두 표를 적지 않고, 코드에서는 봉인·승격 프로시저와 두 표를 읽는 자리를 걷어내며, 물리 표를 언제 DROP 할지는 아직 정하지 않았다(7절).

더하는 열 하나가 vehicle_observation.vehicleTripKey다. 순번 되돌림으로 나눈 한 번의 운행을 가리키는 키다 — A18이 쓰는 slope(상류 좌석 기울기)가 같은 여정 안에서만 뜻이 있어서, 나누지 않으면 이번 여정의 40번 정류장이 지난 여정의 40번과 짝지어진다. v3에서도 같은 판정을 했지만 그건 연구 export가 하고 저장하지 않았다. 적재 때 한 번 정해 저장한다. 나누는 문턱은 아직 미정이다(7절).

BE 할 일 — 표 둘을 만들고 열 하나·인덱스 셋을 더하며, 발행 표 둘과 그 경로를 지운다. 작업량 추정과 항목별 내역은 짝 문서인 v4-be 파이프라인 계약에 있다. 가장 불확실한 항목은 라벨 회수 배치다 — v3에서 연구 export가 하던 일을 제품이 넘겨받는다. 그 다음이 A18 추론기, /board 재조립 순이다. 응답 구조가 통째로 바뀌므로 기존 /board 계약 시험은 대부분 다시 써야 한다. FE와 맞물리는 곳은 2절(observedAt·503 사유)과 3절(vehiclesInService)이다.

v4-be 파이프라인 계약 → v4-be 도메인 설계 →

7아직 정해지지 않은 것 — 배포를 막는 것은 없다

미결무엇이 비어 있나배포를 막는가
지평 5·7·9·10·11 모델 부재 해소 A18은 지평별로 따로 적합하는데 /board는 1부터 12까지 열둘을 전부 요구한다. 처음 적합된 것은 1·2·3·4·6·8·12·16뿐이라 다섯 자리가 비어 있었다. 자료를 다시 만들어(569,260행) 열두 지평을 전부 적합·채점했다. 채점 표본은 지평 1의 47,384건에서 지평 12의 39,840건까지다. 막지 않는다. 브라이어와 만석 구분이 열두 지평 모두 단조로 나와 빈 지평을 끼워 넣은 자리가 없다는 것도 확인했다. 남은 실무는 열두 지평 계수를 번들에 담는 일이다
v4-be 문서가 옛 응답 모양을 전제한다 해소 v4-fe는 예보를 못 내는 항목을 아예 담지 않는다 (seatAvailableProbability가 필수이고 additionalProperties: false라 확률 없는 항목을 담을 자리가 없다). 그런데 v4-be 파이프라인 계약의 「Client 계약과의 대응」 표가 viaTurnaround와 선택 객체 seatForecast를 전제하고 있었다. v4-be 두 문서를 확정본 쪽으로 고쳤다 — 중첩 객체와 viaTurnaround·confidence·seatQuantiles·kind 판별자를 담지 않는다고 명문화하고, "예보 행이 없으면 항목이 seatForecast 없이 나간다"던 탈출구를 "그 항목이 응답에서 통째로 빠진다"로 다시 썼다. 막지 않는다. 확정된 쪽은 v4-fe이고 v4-be가 따라간 것이다
연구와 평가기가 보는 사건 수가 다르다 정원은 맞췄다 정원 규칙은 2026-08-19 양쪽을 맞췄다 — 45/68 구간화를 없애고 그 차량이 보여 준 최대 잔여석 그대로를 쓴다. 계열 판정이 사라져 저상이 45로 잡히던 것과 49석 한 대가 2층으로 잡히던 것이 함께 풀렸다. 재채점해도 열두 지평 모두 최대 0.1점만 움직였다 — 정원은 점유율의 분모일 뿐 라벨과 무관하기 때문이다.

남은 차이는 말뭉치 구성이다. 사이트 숫자는 계약 라벨을 결합한 연구 자료에서 나오고 평가기는 레코드에서 통과 사건을 직접 만든다. 평가기가 더 적게 본다 — 8정류장 −409행 · 12정류장 −630행(만석 −11). 그래서 8정류장 69.3 대 69.7, 12정류장 71.6 대 71.9로 갈린다.
막지 않는다. 다만 매일 게시될 값은 평가기 쪽이므로 두 경로가 통과 사건을 어디서 다르게 세는지 찾아 맞춰야 한다
시간대 축이 학습과 서빙에서 다르다 쟀다 학습 라벨의 시간대는 도착 관측 시각에서 왔는데 서빙은 도착 시각을 모른다 (도착정보 API를 수집하지 않는다). 어긋나는 비율은 지평 2에서 1.00%, 4에서 2.72%, 8에서 6.35%, 12에서 10.11%다. 예측 시각 기준으로 처음부터 다시 적합하고 다시 채점했다 — 종합 점수가 83.2→83.1 · 77.5→77.3 · 69.3→69.0 · 71.6→71.7로, 가장 큰 손실이 0.3점이다. 막지 않는다. 보고하는 점수는 기존 7모델과 같은 라벨 정의를 써야 비교가 되므로 도착 시각 기준을 유지하고, 서빙 실측이 그보다 최대 0.3점 낮다는 것을 함께 적는다
A18이 무너졌을 때의 안전장치 되돌릴 곳이 없다 — 발행 계층은 한 번도 가동된 적이 없고, 되살려도 /board 응답 구조가 v3 형태로 함께 돌아간다. 무너졌다고 판정하는 기준도, 그때 /board가 내보낼 응답도 정해진 것이 없다. 코드보다 앞에 놓이는 결정이다(도메인 설계 5절 ②)
발행 표 둘의 물리 정리 시점 계약에서는 빠졌다. 실제 DROP을 언제 할지, 그때까지 스케줄러 정지로 둘지 정한 것이 없다. 두 표에는 발행본이 쌓인 적이 없어 옮길 자료도 백필도 없다. 막지 않는다. 배포에서 하는 일은 스케줄러 정지와 죽은 코드 삭제까지다
온라인 사전확률 이동의 집단 정의 이동량을 만드는 "그날 이미 도착이 관측된 사건"을 노선별로 세는지 지평별로 세는지 통으로 세는지. min_n = 50이라 하루의 첫 50건 전에는 이동이 없다. 화면에 어떻게 보이는지 확인이 필요하다
여정 분할 문턱 순번 되돌림을 새 여정의 신호로 쓰는데, 상류 지터로 잠깐 되돌아가는 경우와 구별할 문턱이 없다. 관측 공백으로 끊는 문턱도 같다. 적재 코드가 이 값을 요구한다
유도 정원을 넘는 잔여석 학습은 그런 행을 뺐지만(교정 전 294,637행 중 96행) 서빙에는 온다. 자르는지, 정원 등급을 올리는지, 예보를 내지 않는지. 특징 계약이 정한다
NO_RECENT_OBSERVATION 문턱 /vehicles의 staleAt과 같은 값이어야 화면이 두 말을 하지 않는다. 같은 값으로 묶을지 따로 둘지. FE·BE가 함께 정한다
저장 정책 vehicle_stop_prediction의 보존 기간과 파티션 축. 노선당 하루 89만~98만행(08-18 실측 관측 × 지평 12)이라 파이프라인에서 가장 빨리 자라는 표다. 배포 전 결정을 권한다
demand_profile의 세대·주기·콜드스타트 지금 설계는 최신 한 세대만 두고 revision만 기록한다. 재계산 주기와 주체도 미정이고, 노선 개편 직후 새 판본에는 행이 없어 이웃 폴백만 돈다. 개편 때 드러난다
수집 밀도 확정 A18은 적응형 수집(15 / 20 / 60 / 600초, adaptive-kst-v1.0.1) 위에서 학습했다. v3 문서가 전제한 60초 균일로 되돌리면 slope·prev_seat·streak_full의 성적이 달라진다. 적응형으로 확정해야 한다
v3 → v4 전환 절차 경로는 /api/v1 그대로인데 응답 모양이 바뀐다. 두 판을 한동안 함께 서빙할지, FE·BE를 같은 날 함께 올릴지 정한 것이 없다. FE·BE가 함께 정한다

v3의 미결 셋(계수 번들 부재 · 학습 사건의 노선 판본 부재 · 제품 공개 노선 ID)은 그대로 남아 있다. A18의 성적은 여전히 오프라인 산출값이다 — 사이트 평가 규약·같은 지표로 지평 12에서 종합 71.6 · 브라이어 0.01242 · PR-AUC 0.419 · 커버율 86.3%(운행 시간대 06~23시 90.4~91.1%)가 나왔고, 일일 평가 원장에는 아직 들어 있지 않다. 이 절도 성능이 입증됐다고 적지 않는다.

그 외 — v3 → v4

항목v3 (확정)v4 (제안)이유
경로/routes·/board·/vehicles변경 없음새 엔드포인트를 만들지 않는다. /board만 응답이 바뀐다
/routes·/vehicles 응답—스키마는 한 필드도 안 바뀐다. 설명만 두 곳 고쳤다차량 관점 뷰는 이미 맞는 모양이었다. 고친 둘은 /vehicles 폴링 권고와 Observation 주석(수집이 끊겨도 예보는 유효하다는 문장)이다
Board 필수 필드route·forecast·stopsroute·observedAt·model·vehiclesInService·stops발행본 메타가 사라지고 관측 시각과 운행 대수가 들어온다
StopState 필수 필드골격 5 + seatForecast골격 5 + approachingVehicles골격 다섯은 그대로다. 마지막 하나만 바뀐다
stops[].approachingVehicles 대수 상한—(v3에는 배열이 없다) maxItems: 3 "먼저 도착할 버스"라는 뜻을 지키되, 앞 두 대가 다 만석일 때 답을 남기기 위해서다. 상한이 없으면 지평 12 안에 최대 12대까지 담긴다. 지평 12 안에 3대 이상이 56.0%이고 3번째는 중앙 지평 9 = 22.7분 뒤다. 앞 두 대가 모두 빈자리 확률 70% 미만인 경우가 1.80%인데 그중 42.3%에서 3번째가 답이다. 4대로는 늘리지 않는다 — 4번째는 24.8분이라 한계 효용이 떨어진다.

정렬은 horizonStops 오름차순인데 도착 순서와 같다고 보장하지 않는다 — 버스 뭉침으로 2대 이상일 때 7.87%가 어긋난다. 다만 맨 앞 항목이 실제 첫 도착인 비율이 96.68%이고, 앞의 두 대끼리의 순서는 96.76% 맞는다. 세 번째는 더 흔들린다. 가까울수록 정확해져서 1정류장 앞이면 96.6%, 10정류장 앞이면 88.5%다 — 중요한 순간에 가장 정확하다

상한 2 를 고르던 때의 근거(정류장의 11.2%가 네 대 이상 · 두 대로 자르면 73.7%가 원래대로 · 세 대째는 대기 중앙 27분)는 2026-08-19 개정으로 기준이 달라져 뺐다. 그 수치들은 상한 2 기준으로 잰 값이다.
DirectionInfo 필수 필드id·name originStopName·terminalStopName·firstDepartureTime·lastDepartureTime 넷 신설 route의 여덟 필드 중 모양이 바뀌는 유일한 곳이다(status는 값 2종 그대로이고 뜻만 옮겨간다).

기·종점 이름은 프론트엔드 요청으로 넣었다. 구간을 회차 지점이 가른다 — UP은 (노선 기점 → 회차 정류장), DOWN은 (회차 정류장 → 노선 종점)이다. DOWN의 originStopName이 그 방향의 첫 정류장(turnSequence+1)이 아니라 회차 정류장인 이유는, 타는 사람에게 "안양역에서 출발해 도촌동9단지앞으로 간다"가 맞는 말이기 때문이다.

운행 시각 둘은 "운행이 끝났습니다"를 vehiclesInService만으로 단정하지 않기 위한 권위 근거이고, 방향마다 달라서 한 노선을 통째로 종료라고 하면 틀린다(1650은 상행 22:35·하행 23:55로 80분 차이)
approachingVehicles에 담기는 범위—max(1, sequence − 12) ≤ 차량 순번 ≤ sequence − 1. 한 바퀴 돌아 오는 차량은 담지 않는다랩어라운드 차량이 실제 다음 도착 차량인 경우가 5.1%(1650)·15.6%(3330)뿐이고 그 앞에 중앙 4~7대가 먼저 지나간다
SeatForecast unionESTIMATED·NOT_APPLICABLE 판별자제거승차 불가는 빈 배열로 표현된다. 판별자가 할 일이 없다
ForecastMetapublicationId·targetAt·publishedAt·validUntil제거 → observedAt굽는 발행본이 없어졌다. 기준은 관측 하나다
ModelInfoforecast.model 안Board 최상위로 이동필드는 그대로다. 감싸던 forecast가 사라져 자리만 올라왔다
503 사유NO_CURRENT_PUBLICATIONNO_RECENT_OBSERVATION없는 것이 "발행본"에서 "최근 관측"으로 바뀐다. 코드 5종이라는 수는 그대로
부분 실패정류장 하나가 실패하면 발행 전체 503그 항목만 빠진다예보가 차량에 붙으므로 전수 보장이 성립하지 않는다. 503은 노선 자체를 서빙할 수 없을 때만
/board 캐시max-age=60max-age=15값이 관측마다 바뀐다. 하루 4번 캐시에서 15초 캐시로 간다
/vehicles 폴링 권고60초 고정수집 주기가 상한. 곧 도착할 버스(horizonStops ≤ 2)를 보는 화면만 그 주기로, 그 밖의 노선 화면은 60초2정류장 앞은 중앙 2.3분 뒤 도착이라 한 번의 갱신 안에 상황이 끝난다
수집 주기60초 균일 · 3노선 하루 4,320회적응형 15 / 20 / 600초 · 하루 예상 8,556회(한도 10,000)A18이 이 밀도 위에서 학습했다. 균일 60초로 되돌리면 재료 성적이 달라진다(7절). 설정값 8,556 은 08-18 원자료 8,556 판과 맞는다 — 한도 10,000 안이다
horizonStops—신설(필수) · 1~1213정류장 앞 버스의 실제 대기가 중앙 20.5분, 26정류장은 137분이라 그 너머는 "안 온다"에 가깝다
지평 상한을 12로 고른 근거—8 → 12에서 커버율 79.2% → 86.3%12 → 16은 87.4%로 1.1%p뿐인데 만석 구분(PR-AUC)이 0.419에서 0.325로 더 내려간다
expectedSeats—선택 필드로 신설분포 모델이라 "몇 석 남아서 올지"까지 말할 수 있다. 확률의 뜻은 v3과 같다
"몇 분 후 도착"주지 않음여전히 주지 않음도착정보 API를 수집하지 않는다. 같은 8정류장이 9.1분~23.0분으로 흩어져 환산도 안 된다
회차 표시turnSequence로 방향을 가름그대로. 회차 너머 차량도 approachingVehicles에 담는다운행의 94.0%(1650)·72.4%(3330)가 회차를 이어서 지나고 앞뒤 관측 간격이 중앙 0.3분이다 — 회차는 운행의 끝이 아니다
회차 너머 예보의 근거—차량 상태가 아니라 정류장 통계회차를 넘으면 현재 잔여석의 설명력이 무너지지만(R² 0.645 → 0.034) 그 구간은 만석이 관측된 적이 없어 답은 맞는다
모델 학습 단위노선 범주 계수를 한 모델이 공유노선별 · 계수 공유 없음 · 지평별로도 따로계수 묶음이 노선마다 필요하다. 블록 2 × 12 = 24
모델이 쓰는 정원 상수명세에 없음(구현이 47 고정)2층 68 · 저상 40 · 그 밖 45, lowPlateCode로 유도47은 관측 최대 잔여석에도 형식승인 좌석수에도 없던 값이었다. 점유율과 순수요가 정원으로 나뉘므로 상한이 틀리면 두 값이 함께 어긋난다
DB 표8개8개(신설 2 · 제거 2)지우는 둘은 발행 계층이다. 남는 표에서 열의 삭제·타입 변경은 0건이고, 열이 바뀌는 표는 vehicle_observation 하나다
예보 저장량노선당 하루 340~445행노선당 하루 89만~98만행08-18 실측 관측 수 × 지평 12. 보존 기간과 파티션 축은 미결(7절)
발행 계층굽고 봉인 · CAS 승격표와 경로를 제거한다적재할 계수 번들이 없어 이 경로는 한 번도 가동된 적이 없다. 뜬 적 없는 경로를 안전장치로 남겨 두면 계약이 두 갈래로 읽힌다
학습 파생물의 소유학습·평가·선정은 외부 ML 환경유지. 다만 라벨 회수와 셀 통계 집계가 제품 쪽으로 넘어온다모델이 자기 예보와 라벨을 되읽으므로 그 고리가 제품 DB 안에 있어야 한다. DB 스키마 판 v8(v3의 그것)의 "서버는 통계를 집계하지 않는다" 문장은 개정된다
되돌리기—발행 경로로는 되돌릴 수 없다이전 안은 /board를 안 건드리는 것이 안전장치였다. 지금 안은 그 값을 치르고 화면을 바꾼다. 발행 계층을 되살려도 응답 구조가 v3 형태로 함께 돌아가므로 되돌림은 계약 되돌림이다

이 절은 확정이 아니라 검토 요청이다. 채택하지 않기로 하면 v3이 그대로 남는다 — 다만 앞선 개정안과 달리 이번에는 프론트엔드 수정이 함께 필요하고, 되돌림도 계약 되돌림이 된다. FE 쪽 제안은 v4-fe Client API에, BE 쪽은 v4-be 파이프라인 계약과 도메인 설계에 있다. 모델 자체는 08번 모델 문서에 있다.

2026-08-17 — 명세 v2 → v3 (확정)

2026-08-17 팀 최종 검토를 반영한 확정 계약이다. 원칙 하나가 전체를 관통한다 — 1차 운영 모델(재료 다 넣기 · 상호작용 로지스틱 회귀)이 실제로 만들지 않는 값은 응답에 넣지 않는다. 큰 변경 7개, 나머지는 표.

1차량 참조 제거 — 예보는 차량을 고르지 않는다

V2 — 접근 차량 기준
"activeVehicles": [
  { "ref": "vehicle-3", … } ],
"stops": [ {
  "nextArrival": {
    "kind": "FORECAST",
    "vehicleRef": "vehicle-3", … } } ]
V3 — 정류장 기준, 차량 없음
"stops": [ {
  "sequence": 9, "name": "범계역",
  "seatForecast": {
    "kind": "ESTIMATED",
    "seatAvailableProbability": 0.832 } } ]

모델 입력은 노선·정류장 순번·KST 시간대 셋뿐이다. vehicleRef를 붙이면 "이 차 기준"이라는 거짓 의미가 되고, activeVehicles도 같은 이유로 예보 응답에서 뺐다 — 실시간 차량은 별도 /vehicles 엔드포인트가 맡는다(2판).

명세에서 보기 →

2응답 값 축소 — 확률 하나만

V2 — 값 다섯 + 판정
"nextArrival": {
  "expectedSeats": 7,
  "seatAvailableProbability": 0.83,
  "verdict": "UNLIKELY",
  "lowConfidence": false,
  "fullStreak": { "kind": "KNOWN", "count": 0 } }
V3 — 확률 하나
"seatForecast": {
  "seatAvailableProbability": 0.832 }

// 0~1 실수 그대로. 서버는 반올림하지 않는다
// 표시 자릿수는 화면이 정한다

판정·예상 좌석·연속 만석은 이 모델의 출력이 아니다. 0.996을 두 자리로 자르면 1.00이 되어 "반드시 자리가 있다"로 읽힌다.

명세에서 보기 →

3수집 신선도와 모델 신선도를 분리

V2 — freshness 하나가 보드의 신선도
"freshness": {
  "observedAt": "…22:30:12Z",
  "staleAt":    "…22:33:12Z" }
// 수집 신선도가 보드 신선도를 겸함
V3 — 두 신선도를 따로, 문도 따로
/board → "forecast": {
  "publishedAt": …, "validUntil": … }
// 모델 신선도
/vehicles → "observation": {
  "state": "VEHICLES_PRESENT",
  "observedAt": …, "staleAt": … }
// 수집 신선도 — 별도 엔드포인트(2판)

수집이 잠깐 끊겨도 예보는 유효하다 — 수집 신선도를 예보 신선도로 표시하면 오해가 생긴다.

명세에서 보기 →

4운행 종료 단정 제거

V2 — 빈 응답이면 보드 종류가 갈림
{ "kind": "OUT_OF_SERVICE",
  "route": …,
  "lastObservedAt": "…13:01:44Z" }
// stops 없음 — 예보도 없음
V3 — 관측 상태일 뿐, 예보는 계속
/vehicles → "observation": {
  "state": "NO_VEHICLES_OBSERVED" },
"vehicles": []
/board → "stops": [ { "seatForecast": {
    "seatAvailableProbability": 0.977 } } ]
// 예보는 그대로 나간다

GBIS resultCode=4는 "호출은 정상인데 그 순간 차량이 없다"일 뿐 운행 종료의 증거가 아니다. 실측에서는 심야에 이 응답이 반복된 뒤 다음 수집에서 차량이 다시 나타났다. 운행 종료 표시는 운행 시간표 같은 권위 근거가 생긴 뒤에만 쓴다.

명세에서 보기 →

5발행 주기 — 매 수집 직후에서 시간대 경계로

V2 — 수집 성공 직후마다 발행
발행 = 수집 성공 직후, 한 트랜잭션
예보 행(prediction_log)이 쌓임
— 하루 52,000행(v8 산정)
V3 — 새 발행은 셋뿐
시간대 경계 07:00 · 09:00 · 17:00 · 20:00 KST
모델 승격 · 노선 판본 변경

// validUntil이 지나면 옛 값을 200으로
// 주지 않는다 → 503 + Retry-After

모델 입력이 같은 시간대 안에서 변하지 않으므로 값도 변하지 않는다. targetAt은 도착 예정 시각이 아니라 "그 시간대에 출발한다고 가정했을 때"의 기준 시각이다. 노선당 하루 4~5판이면 충분하다.

pipeline 명세 보기 →

6정류장 사유를 좁히고, 실패는 발행 전체 실패로

V2 — 정류장마다 사유가 섞임
"nextArrival": { "kind": "FORECAST |
  NO_APPROACHING_VEHICLE |
  SEAT_DATA_UNAVAILABLE |
  INSUFFICIENT_PROFILE" }
V3 — 닫힌 union 둘, 나머지는 503
"seatForecast": {
  "kind": "ESTIMATED | NOT_APPLICABLE" }
// NOT_APPLICABLE 사유는
//   BOARDING_NOT_ALLOWED 하나
// 승차 가능 정류장의 모델 실패
//   = 발행 전체 실패 → 503

절반만 맞는 보드를 200으로 주는 것보다 503이 낫다. 503 코드는 MODEL_OUT_OF_SCOPE·NO_CURRENT_PUBLICATION·SERVICE_UNAVAILABLE 셋으로 확정했다.

명세에서 보기 →

7내부 계약 재편 — 테이블 12개에서 8개로

V2 — 4계층 12테이블
route · route_feature · route_stop
snapshot · vehicle_observation
demand_profile · stop_arrival_rate
board_publication · board_vehicle
prediction_log · board_queue · board_gap
V3 — 테이블 8개
call_ledger
route_reference_version · route_stop
location_poll · vehicle_observation
model_deployment  배포 영수증
forecast_publication · stop_prediction

학습·평가·선정은 외부 ML 환경이 소유한다 — 학습 파생 테이블을 제품 DB에서 뺐다. 발행은 STAGED → ACTIVE 원자 승격(전용 프로시저만), 노선 판본은 유효기간으로 관리한다(겹침 금지).

pipeline 명세 보기 →

그 외 변경 — v2 → v3

항목v2v3이유
RouteStatusACTIVE·LEARNING·PAUSEDFORECAST_READY·PREPARINGLEARNING·PAUSED는 외부 평가 상태 — 제품은 예보 준비 상태만 남긴다
featuresSEAT_FORECAST·QUEUE_ESTIMATE제거보드가 한 종류라 켜고 끌 기능 목록이 없다
대기열 추정실험적 휴리스틱(queueEstimate)범위 밖모델이 만들지 않는 값
실시간 차량보드 안 activeVehicles별도 GET /routes/{id}/vehicles(2판·60초 폴링)갱신 주기가 24배 달라 분리 — 예보 캐시 보존, 한쪽 503이어도 다른 쪽 생존
schemaVersion응답마다 '1.0'제거(2판)—
노선 IDgg-3330 합성 IDGBIS 원문 9자리(^[0-9]{9}$) + ID 4종 구분공개·공급자·표시명·모델 키를 가른다. 원문 결합은 의도된 것 — 공급자 변경은 파괴적 변경
정류장 식별{노선}-{순번} 합성 id·좌표stationId 원문 + sequence순번은 정렬용이고 개편되면 밀린다. 안 바뀌는 것은 stationId
referenceVersionId—신설정적 정보의 주인은 노선 판본. 값이 바뀌면 개편 — 화면을 다시 그린다
forecast.model—releaseId·trainedThrough 신설어느 배포가 만든 값인지 응답에 박는다. 바뀌면 전후를 갈라 채점
오류 envelope형식 느슨exact {code, message, requestId, retryable}·코드 5종Retry-After는 재시도 시각을 아는 503에만
수집 주기시간대별 잡(피크 1분·낮 20분 등)60초 고정라벨 조건이 "간격 90초 이하" — 5분 주기 산정이면 점 라벨 0건(v8). 3노선 하루 4,320회 = 한도의 43%
호출 상태4가지9가지(location_poll.outcome)"예약했는데 안 보냄"과 "보냈는데 결과 모름"이 실제로 생긴다
vehId 처리HMAC 뒤 원문 폐기10진 canonical text 원문 저장(opaque 내부 키)연속 관측을 이어야 라벨이 생긴다. 번호판은 구조 제거 뒤 해시(evidence_payload_sha256)
혼잡도1~3 유효1~4 유효, 0은 미산출 NULL0을 "가장 안 붐빔"으로 학습하면 안 된다
operations 명세metrics 초안확정 범위 = /actuator/health(lastPollAt·lastPollOutcome·callBudgetUsedToday)한도 소진이 밖에서 보이게

검토 결론은 조건부 승인이다. 평가가 잠정이라 성능 입증은 아니며, 계수 번들을 적재해야 서빙이 시작된다. 세부는 Client · Pipeline · Upstream · Operations 네 명세에 있다.

2026-08-12 — 명세 v1 → v2

명세 검토와 FE 피드백 반영. 큰 변경 6개, 나머지는 표.

1보드를 운행 중 / 운행 종료로 나눔

V1 — 보드는 한 모양
{
  "route": { … },
  "freshness": { "state": "OUT_OF_SERVICE", … },
  "vehicles": [],
  "stops": [ /* 전부 '차량 없음'으로 채움 */ ]
}
V2 — kind로 갈림
{ "kind": "OPERATING",
  "route": …, "freshness": …,
  "activeVehicles": […], "stops": […] }

{ "kind": "OUT_OF_SERVICE",
  "route": …,
  "lastObservedAt": "2026-08-10T13:01:44Z" }

운행 종료 보드의 정류장 상태는 전부 같은 채움값 — 정보가 없음. 상태가 다른 게 아니라 응답의 종류가 다른 것이라 타입으로 분리.

명세에서 보기 →

2freshness 상태 enum 제거

V1 — 서버가 상태를 적음
"freshness": {
  "state": "FRESH | STALE | OUT_OF_SERVICE | UNKNOWN",
  "observedAt": "… | null",
  "staleAt":    "… | null"
}
V2 — 시각만 주고 판정은 클라이언트
"freshness": {
  "observedAt": "2026-08-10T22:30:12Z",
  "staleAt":    "2026-08-10T22:33:12Z"
}
// 낡음 = 지금 > staleAt
// 지금 = Date 헤더 + 경과 (+ Age 보정)

신선도와 운행 여부는 독립 정보인데 한 enum에 묶여 있었음. 본문의 STALE은 ETag가 같으면 304만 오가서 본문이 갱신되지 않는, 지켜질 수 없는 값. UNKNOWN은 생산 조건 없음 — 보드가 없으면 404.

명세에서 보기 →

3features 2개로 축소

V1 — 4종, 조합이 응답과 불일치
"features": [
  "SEAT_FORECAST",
  "BOARDING_VERDICT",
  "QUEUE_ESTIMATE",
  "UPSTREAM_RECOMMENDATION"
]
V2 — 2종
"features": [
  "SEAT_FORECAST",   // 판정 포함
  "QUEUE_ESTIMATE"
]

판정은 예보와 한 계산 — 따로 켜고 끌 수 없음. v1 예시(1650, 예보만 켜짐)는 스키마로 표현 불가한 조합. UPSTREAM_RECOMMENDATION은 v1 범위 밖.

명세에서 보기 →

4방면 이름 교정

V1 — 정의와 반대
{ "id": "UP",   "name": "도촌동 방면" }
{ "id": "DOWN", "name": "안양역 방면" }
V2 — UP = 기점→회차
{ "id": "UP",   "name": "안양역 방면" }
{ "id": "DOWN", "name": "도촌동 방면" }

그대로 구현하면 사용자에게 반대 방면 표시. Direction enum에 의미 서술 추가.

명세에서 보기 →

5필드명 변경

V1
"vehicles": [ … ]

"queueEstimate": {
  "estimatedPeople": 13.7, … }
V2 — 이름이 내용을 말함
"activeVehicles": [ … ]

"queueEstimate": {
  "pointEstimate": 13.7, … }

vehicles는 '운행 중인 차량만 담긴다'는 범위가 이름에 없음. estimatedPeople은 점추정(보조값)임이 드러나지 않아 주 표시로 오용될 여지 — 주 표시는 display.

명세에서 보기 →

6발행을 행 저장으로 전환 (내부 계약)

V1 — 보드 JSON 통짜
published_boards
  └ payload  "{ …보드 전체… }"
V2 — 행으로 발행, api는 조립만
board_publication   발행 메타
board_vehicle       차량
prediction_log      예보·판정 = 채점 로그
board_queue         대기열
board_gap           없는 자리의 사유

화면에 나간 값 = 채점 기록(같은 행). 확정된 DB 설계(v3) 반영.

pipeline 명세 보기 →

그 외 변경 — v1 → v2

항목v1v2이유
BoardRoute—status·turnSequence 추가보드만으로 학습 중·중단 구분, 회차 지점 표시
InsufficientProfilesamples(integer)만vehicleRef 추가, samples는 가중 표본량(number)접근 차량 선택을 FE가 다시 하지 않게
SeatDataUnavailable.vehicleRef연결 설명 없음activeVehicles 연결·존재 보장 명시참조 규칙 명문화
vehicleRef 형식미정의pattern vehicle-[0-9]+, 위치 순 부여, 응답 간 비교 금지"보드 안에서만 유효"를 지키는 규칙
elapsedMinutesnumberinteger발행 시 반올림. 실제 출력과 일치
freshness.observedAt·staleAtnullablenon-nullnull은 운행 종료뿐 → 보드 분리로 소멸. 생성 타입 변경(파괴적)
대기열 UNAVAILABLE 사유6종5종OUT_OF_SERVICE는 보드 종류가 표현
Verdict·lowConfidence설명 부족값별 정의 보강TIGHT = 줄 위치에 따라 갈림
/routes ETag규칙 없음본문 콘텐츠 해시재검증 구현을 하나로
board 429미선언선언목록과 같은 제한 정책
routeId pattern최대 51자40자 이내저장 한도와 일치
예시계산상 불가능한 조합 3건재작성 + LEARNING 예시 추가판정 규칙·최근접 차량 규칙에 맞게
upstream 명세—실측 의미론 추가좌석값은 도착(승차 전) 상태, crowded 0은 센서 없음
operations 명세변경 없음—

계약 반영 완료. DB 실물(v3 다이어그램·Flyway DDL) 후속 4건: route_feature enum 축소, gap_reason에서 OUT_OF_SERVICE 제거, gap vehicle_ref의 INSUFFICIENT_PROFILE 허용, gap samples를 number로.

기준일 2026-08-19