객체 54 개 중
무엇이 실제로 열리나

확정 v3 는 collector 20 · processor 20 · api 14 로 객체 54 를 셌다. A18(차량 상태 조건부 좌석 분포)을 채택하면 그 54 가 하나씩 어떻게 되는지를 적는다. 설계 개요는 v4-be 도메인 설계에 있고 이 문서는 그 상세편이다 — 객체 대조표와 Java 골격이 여기 있다.

2026-08-19 작성 · 객체 구조 v8(패키지 3 · 객체 54)과 파이프라인 계약 v4 기준 · 코드는 Spring Boot 4.1 · Java 21

한 장 요약 30초

패키지 셋은 그대로다. 열리는 곳은 processor 와 api 의 /board 쪽 둘뿐이다.

재는 것따라붙는 말
패키지셋 그대로collector · processor · api. 새 패키지를 만들지 않고 경계도 그대로다
collector 20필드 셋 · 신설 하나기존 세 객체에 필드가 붙고(VehicleObservation · LocationPoll · RouteReferenceVersion) 여정 키 값 객체 VehicleTripKey 하나가 선다
processor 20여기가 열린다발행 계층 셋과 판정 갈래가 빠지고, 모델 계약이 통째로 갈린다
api — /board 쪽 7안이 다시 쓰인다클래스와 경계는 남고 읽는 표와 담는 형이 바뀐다
api — /vehicles거의 그대로읽는 표도 응답 필드도 그대로다. 딱 하나Cache-Control 이 고정 15초에서 수집 주기에 맞춘 값으로 바뀌므로 그 값을 내는 곳 한 군데가 열린다
확정된 제거5ForecastPublication · StopPrediction · PublicationRepository · PredictionResultNotEstimated 갈래 · NotEstimatedReason.BOARDING_NOT_ALLOWED
객체 수 합계여기서 안 센다신설 객체는 아직 제안이다. 구현이 확정되면 v3 장부와 같은 방식으로 다시 센다
가장 큰 변화 한 줄

모델 계약의 반환형이 바뀐다 — 만석 확률 하나에서 좌석 분포로. p_full 은 그 분포의 0 지점이다. 입력도 (판본, 노선키, 순번, 시각) 에서 (관측, 대상 순번, 지평) 으로 바뀌어, 예측 시점의 그 차량 잔여석이 처음으로 입력에 들어온다. 나머지 변경은 대부분 이 둘에서 따라 나온다.

v8 장부의 수와 이름이 하나 어긋난다 — 표제는 api 14(예보 7 · 실시간 차량 7)인데 장부에 적힌 이름은 15개다. 확정 도메인 설계가 이미 적어 둔 어긋남이고 이 문서가 풀지 못한다. 다만 결론에는 영향이 없다 — 어느 쪽으로 세든 실시간 차량 쪽에서 v4 가 여는 것은 Cache-Control 을 내는 자리 하나뿐이다.

1. 패키지 경계 v4 에서도 안 바뀐다

셋으로 가른 근거가 A18 채택으로 흔들리지 않는다. 오히려 셋 다 쓰인다.

패키지프로세스왜 갈라 뒀나
collectorA · 배치 · 수집GBIS 응답을 받는 유일한 창구다. 라벨·예측·평가를 만들지 않는다
processorA · 배치 · 추론학습하지 않는다. 승인된 번들 하나를 검증하고 점수만 낸다. 학습·평가·선정은 외부 ML 환경의 일이다
apiB · 요청 · 조회읽어서 내보낸다. 예측을 계산하지 않고 수집도 하지 않는다

패키지끼리 직접 못 부른다. 두 프로세스는 PostgreSQL 을 사이에 두고 만나고, 코드 의존은 ArchUnit 이 CI 에서 막는다. v4 에서 processor 가 location_poll.forecastCompletedAt 을 찍는데, 그 열이 collector 소유의 표에 있어도 collector 의 코드를 부르지는 않는다 — processor 는 자기 포트로 그 표를 읽고 쓴다. 같은 이유로 관측 한 행을 읽는 형(VehicleObservationObservedVehicle)이 패키지마다 따로 있다. 형을 공유하면 그 순간 ArchUnit 규칙이 깨진다.

// 세 규칙이 CI 에서 돈다. 루트 패키지 이름은 구현 저장소가 정한다.
@AnalyzeClasses(packages = ROOT_PACKAGE, importOptions = ImportOption.DoNotIncludeTests.class)
class PackageBoundaryTest {

    @ArchTest
    static final ArchRule collector_는_processor_와_api_를_모른다 =
            noClasses().that().resideInAPackage("..collector..")
                    .should().dependOnClassesThat().resideInAnyPackage("..processor..", "..api..");

    @ArchTest
    static final ArchRule processor_는_collector_와_api_를_모른다 =
            noClasses().that().resideInAPackage("..processor..")
                    .should().dependOnClassesThat().resideInAnyPackage("..collector..", "..api..");

    @ArchTest
    static final ArchRule api_는_읽기만_한다 =
            noClasses().that().resideInAPackage("..api..")
                    .should().dependOnClassesThat().resideInAnyPackage("..collector..", "..processor..");
}

세 규칙 중 마지막이 v4 에서 가장 중요해진다. /board 가 관측에서 파생된 예보를 읽게 되면서 api 가 processor 의 형을 그대로 쓰고 싶은 자리가 늘기 때문이다. 그 유혹을 막는 것이 이 규칙 하나다.

경계에 걸린 규약v4 에서
api 의 두 projection 은 표를 공유하지 않는다그대로 /board/vehicles 는 여전히 다른 표를 읽는다. 다만 둘이 같은 vehicle_observation 을 원천으로 삼게 되어 어긋남이 보이는 자리가 생겼다 — 좌석이 결측인 차량을 /vehicleskind: UNKNOWN 으로 보여 주는데 /board 에서는 조용히 사라진다(미결)
뒤집기(1 − p_full)는 서비스 계층 메서드 한 곳그대로 SQL 안에서 하지 않는다 — 하면 테스트에 DB 가 필요해진다
확률은 반올림하지 않는다그대로 0.9961.00 이 되면 "반드시 자리가 있다"로 읽힌다. 표시 자릿수는 클라이언트가 정한다
서버는 학습하지 않는다그대로 다만 집계는 한다demand_profile 은 채점 대상이 아니라 모델이 읽는 입력이다. v8 의 "서버는 통계를 집계하지 않는다"는 문장은 이 지점에서 개정된다

2. 객체별 v3 → v4 대조 54 개를 하나씩

판정은 넷이다 — 그대로 손댈 것이 없다 · 필드 추가 형은 같고 필드가 는다 · 뜻이 바뀜 이름은 남고 안이 달라진다 · 제거 이 설계에 자리가 없다.

collector — 객체 20

v3 의 스무 개가 하나도 빠지지 않는다. A18 이 예측 시점에 읽는 값(remainingSeats · crowdedCode · lowPlateCode · stopOrder)은 이미 다 받고 있고, 새로 붙는 것은 여정 키 판정과 첫차·막차 적재뿐이다. 첫차·막차는 기존 객체 안에서 끝나고, 여정 키만 값 객체 하나를 새로 세운다.

객체판정한 줄 이유
CollectLocationJob그대로예보 쓰기는 관측 commit 뒤 별도 transaction 이라 수집 사다리에 섞이지 않는다
RealtimeObservationSource interface그대로받는 필드가 늘지 않는다. A18 은 이미 받는 값만 쓴다
GbisLocationSource그대로차량위치 호출이 그대로다
CallLedger그대로첫차·막차 호출도 같은 장부에서 예약한다. 노선당 하루 1회라 한도 10,000 옆에서 무시할 수 있다
CallLedgerRepository그대로예약 차감 규칙과 resultCode=4 처리가 그대로다
AttemptKey그대로호출 시도 식별이 바뀌지 않는다
LocationPoll필드 추가forecastCompletedAt 하나와 예보 완료를 찍는 메서드. 이 표시가 /board 한 판의 단위를 만든다
PollOutcome enum 9값그대로값이 늘지 않는다. /board 스냅샷은 SUCCESS_ROWS·SUCCESS_EMPTY 중에서만 고른다
FailureCode enum 4값그대로실패 분류가 바뀌지 않는다
VehicleObservation필드 추가vehicleTripKey 하나(NULL 허용). 상류 좌석 기울기는 같은 여정 안에서만 뜻이 있다
SeatReading그대로원문 −1 을 만석으로 세지 않는 해석이 그대로다. A18 의 라벨도 이 해석 위에 선다
SeatUnavailableReason enum 2값그대로MINUS_ONE·FIELD_ABSENT 둘. 회수 때 SEAT_MISSING 으로 이어진다
TrackPhase enum 3값그대로stopOrder 를 만드는 v3 규칙이 그대로다
LocationPollRepository그대로poll 예약과 확정이 그대로다. 예보 완료 표시는 processor 가 자기 포트로 쓴다 — 패키지 경계를 넘지 않기 위해서다
RefreshRouteReferenceJob뜻이 바뀜정류장 목록에 더해 방향별 첫차·막차를 갱신한다. 시간표만 바뀌면 판본을 새로 끊지 않고 같은 행을 UPDATE 한다
RouteReferenceSource interface뜻이 바뀜노선정보(getBusRouteInfoItemv2) 쪽 값이 계약에 들어온다
GbisRouteStopSource뜻이 바뀜같은 이유. 상류의 we*·sat*·sun* 세 벌을 다 받아 대조하고 다르면 적재를 실패시킨다(미결)
RouteReferenceVersion필드 추가방향별 첫차·막차 넷. contentDigest 대상이 아니다 — 시간표가 바뀌어도 판본이 새로 생기면 안 된다
RouteStop그대로신설 표 둘도 같은 (판본, 순번) 축에 붙는다
RouteReferenceRepository뜻이 바뀜판본 조회·대체에 더해 같은 판본 행의 시간표 UPDATE 가 생긴다

여정 키를 만드는 값 객체 VehicleTripKey 는 이 패키지에 새로 선다. 판정을 적재 시점에 하기 때문이다 — 조회 때 판정하면 분할 로직이 학습 쪽과 서빙 쪽에 두 벌 생기고 어긋나는 순간을 아무도 못 잡는다. 코드는 3절에 있다.

processor — 객체 20

여기가 가장 많이 열린다. 발행 계층 셋이 통째로 빠지고, 판정 결과의 갈래가 줄고, 모델 계약이 이름부터 바뀐다. 빠지는 자리보다 새로 서는 자리가 많다 — 셀 통계 읽기와 라벨 회수가 v3 에는 아예 없던 일이다.

객체판정한 줄 이유
ForecastJob뜻이 바뀜시간대 경계 발행에서 poll 마다 도는 배치로. buildCandidates(staged)·refreshBand(bandStart) 는 없다
RouteTargetQuery뜻이 바뀜발행 대상 조회에서 예보가 안 붙은 poll 과 그 관측 읽기로. 대상이 노선·순번이 아니라 차량이다
ForecastTarget제거좌표가 (판본, 노선키, 순번, targetAt) 이었다. VehicleStopTarget 이 대신한다
TimeBand enum 3값그대로MORNING 07~09 · EVENING 17~20 · OTHER. demand_profile.timeCellId 가 이 값이다
ApprovedModelRef뜻이 바뀜covers() 에 축이 하나 는다 — 지평 1~12 밖이면 범위 밖이다
FullnessProbabilityModel interface뜻이 바뀜반환형이 만석 확률 하나에서 좌석 분포로. 이름도 SeatForecastModel 로 간다
InteractionLogisticModel제거서빙 계약에서 빠진다. 기존 후보 일곱은 평가 대조군으로 남고 채점은 외부 ML 환경이 한다
CoefficientBundle뜻이 바뀜노선별 블록이 되고 지평 1~12 계수를 전부 담는다. golden vector 검증 규약은 그대로다
TermKey뜻이 바뀜계수 조회 키에 지평이 들어간다 — 지평별로 따로 적합하기 때문이다
CoefficientSource interface그대로번들 형식이 바뀌어도 적재 계약은 같다
ModelDeployment그대로표가 열 하나 안 바뀐다. modelKey·predictionTargetVersion·supportedScopeDigest이 A18 을 가리키게 될 뿐이다
ModelDeploymentRepository뜻이 바뀜CAS 승격은 그대로인데 candidate 발행본 봉인 검사가 갈 곳이 없다 — 발행본이 없다
PredictionResult sealed제거갈래가 하나만 남으면 sealed 가 할 일이 없다. 모델이 좌석 분포를 그대로 낸다
Estimated제거SeatDistribution 이 그 자리다
NotEstimated제거행을 못 만들면 그냥 안 만든다. 확정 계약에 "예보 없는 도착 항목"이라는 상태가 없다
FullnessProbability뜻이 바뀜독립 값이 아니라 분포의 0 지점이다 — SeatDistribution.pFull() 이 그 값이다
NotEstimatedReason enum 2값제거BOARDING_NOT_ALLOWED 는 확정 제거다(그 정류장에 행을 만들지 않는다). 남은 OUT_OF_SCOPE 는 담을 갈래가 없어지고, 그 뜻은 api 오류 MODEL_OUT_OF_SCOPE 하나로 남는다
ForecastPublication제거봉인·승격·시간대 경계 재발행이 함께 빠진다. 한 판의 단위는 location_poll.forecastCompletedAt 이 낸다
StopPrediction제거정류장당 값 하나였다. vehicle_stop_prediction 은 (차량, 정류장) 단위라 늘릴 대상이 아니라 신설이다
PublicationRepository제거봉인과 일괄 공개가 없다. 차량 예보에는 "판"이라는 단위가 없어 원자성을 봉인으로 만들지 않는다

확정된 제거는 다섯이다ForecastPublication · StopPrediction · PublicationRepository · NotEstimated 갈래 · NotEstimatedReason.BOARDING_NOT_ALLOWED. 위 표에서 그 밖의 제거 판정(ForecastTarget · InteractionLogisticModel · PredictionResult · Estimated · NotEstimatedReason)은 그 다섯과 계약에서 따라 나오는 정리이지 따로 확정된 항목이 아니다. 코드를 언제 지울지는 파이프라인 계약 v4 의 미결과 같은 자리에 둔다.

processor 에 새로 서는 것

객체무엇인가왜 필요한가
VehicleStopTarget예측 입력(관측, 대상 순번, 지평). 예측 시점의 그 차량 잔여석이 들어간다 — 3절
Horizon값 객체1~12 를 강제한다. 지평은 열이자 모델 키다 — 3절
SeatDistribution값 객체좌석 분포. pFull() 이 0 지점이고 expectedSeats() 가 기댓값이다 — 3절
SeatForecastModel interface모델 계약FullnessProbabilityModel 을 대신한다 — 3절
SeatDistributionModel구현A18 의 서빙 구현. v4 서빙 경로의 유일한 모델이다 — 3절
SeatForecastResult값 객체열이 둘이라 값도 둘이다 — pFullRaw 는 사전확률 이동 값이고 다음 예보의 이동량 재료다
ObservedVehicle읽기 행processor 가 읽는 관측 한 행. collector 의 VehicleObservation 과 형을 공유하지 않는다
VehicleStopPrediction예보 행vehicle_stop_prediction 한 행. 한 행이 곧 응답의 한 항목이다
VehicleStopPredictionRepository저장 경계한 poll 쓰기 · 완료 표시 · 회수를 같은 포트에 둔다 — 3절
DemandProfileCell · DemandProfile셀 통계한 노선 판본·한 시간대의 행 전부. z화·이웃 폴백·구간합이 그 안에서 닫힌다 — 3절
DemandProfileRepository저장 경계메서드가 하나뿐인 것이 요점이다. 셀 하나를 집어 오는 메서드를 두지 않는다
DemandProfileJob집계 배치셀 통계 재계산. 다른 시계에서 돈다 — 예보와 한 배치에 묶으면 통계 한 번 돌 때마다 예보가 멈춘다. 주기와 주체는 미결
Settlement enum 5값라벨 회수 상태PENDING · SETTLED · SKIPPED · LOST · SEAT_MISSING — 3절
SettleArrivalLabelsJob interface회수 배치v3 에서 연구 export 가 하던 라벨 만들기를 제품 DB 가 넘겨받는 자리다

api — 객체 14

/vehicles 쪽은 이 문서가 손대는 것이 하나도 없다. 읽는 표도, 좌석 값 해석도, 정렬도, 응답 필드도 그대로다. 열리는 것은 /board 쪽 일곱이고, 일곱 다 클래스는 남고 안이 다시 쓰인다 — 계산이 아니라 조립이라는 성격도 그대로다.

객체판정한 줄 이유
BoardController뜻이 바뀜경로와 파라미터는 같다. 바뀌는 것은 Cache-Control서버가 정한다는 것 하나뿐이다 — 프론트가 주기를 계산하지 않는다
BoardQueryService뜻이 바뀜뒤집기 한 곳 규약은 그대로. 조립이 정류장당 값 하나에서 arrivals 목록으로 바뀌고 정렬을 서버가 고정한다
BoardQuery뜻이 바뀜읽는 표가 통째로 바뀐다 — forecast_publication+stop_predictionlocation_poll+vehicle_stop_prediction+vehicle_observation
BoardRow뜻이 바뀜발행본 메타 대신 스냅샷 poll 을 담는다 — observedAt(responseReceivedAt) · vehiclesInService(storedRows) · 방향별 첫차·막차
StopRow뜻이 바뀜정류장 단위 한 행에서 (정류장, 차량) 쌍으로. 지평 창과 랩어라운드 제외와 상한 2 가 여기서 걸린다
BoardView뜻이 바뀜forecast(ForecastMetaView) 가 빠지고 observedAt 하나가 그 자리에 온다. vehiclesInService 신설, route.directions[] 확장
StopView뜻이 바뀜seatForecast 가 빠지고 arrivals(최대 2) 가 온다. 승차 불가 정류장도 담고 그 arrivals 만 빈 배열이다
LiveVehicleController그대로실시간 차량 projection 은 v4 가 손대지 않는다. 읽는 표(vehicle_observation·location_poll·route_stop)도, 좌석 값 → EXACT/UNKNOWN 해석도, directioncurrentStopSequence 정렬도 같다
LiveVehicleQueryService그대로
LiveVehicleQuery그대로
LiveSnapshotRow그대로
VehicleRow그대로
LiveVehicleView그대로
ObservationView그대로
VehicleView그대로

api 에 새로 서는 것은 응답 형 둘이다 — 도착 예정 차량 한 건 ArrivalView(vehicleId · horizonStops · seatAvailableProbability · expectedSeats 선택)와 방향 한 건 DirectionView(기·종점 이름과 첫차·막차). 조립 절차의 상세는 백엔드 API 에 있다. 기·종점 이름은 새 열 없이 route_stop 에서 유도한다 — 회차 지점이 구간을 가르고, DOWN 의 기점은 그 방향 첫 정류장이 아니라 회차 정류장이다. 빠지는 형은 ForecastMetaView 하나인데 v8 장부의 14 에는 이름이 없다.

"모델이 무엇으로 바뀌어도 이 패키지는 고칠 일이 없다"

v8 이 api 경계에 적은 이 문장이 v4 에서 시험대에 오른다. 결론은 문장이 살아남는 쪽이다 — 일곱이 열리는 이유는 모델이 아니라 응답 계약이고, api 는 여전히 모델 계약(SeatForecastModel·CoefficientBundle)에 의존하지 않는다. 예보 행을 배포로 거르지도 않는다. 같은 계약 안에서 모델만 갈아끼우면 이 일곱은 한 줄도 안 열린다 — 4절.

3. 코드 Spring Boot 4.1 · Java 21

이 절이 이 문서의 본체다. 아래는 골격이다 — 형과 계약을 보이려고 쓴 것이고 /* … */ 로 둔 자리는 본체가 빠진 곳이다. 모델 내부 수식은 여기 옮기지 않았다. 층 구조와 계수는 A18 모델 문서에 있다. 필드 이름은 파이프라인 계약 v4 의 camelCase 와 맞췄다.

VehicleStopTarget — 예측 입력 processor

v3 ForecastTarget 을 대신한다. 좌표가 (판본, 노선키, 순번, targetAt) 에서 (관측, 대상 순번, 지평) 으로 바뀌었다. 가장 큰 차이는 예측 시점의 그 차량 잔여석이 입력에 들어온다는 것이다 — v3 모델은 차량을 아예 보지 않아서 수집이 잠깐 끊겨도 값이 안 변했다. 그래서 생성자에서 두 가지를 막는다. 지평과 순번 차가 어긋난 대상, 그리고 잔여석이 결측인 관측이다.

/**
 * processor 가 읽는 관측 한 행. vehicle_observation 한 행이고 열 이름을 그대로 쓴다.
 * collector 의 VehicleObservation 과 형을 공유하지 않는다 — ArchUnit 이 그 의존을 막는다.
 */
public record ObservedVehicle(
        long id,                    // vehicle_stop_prediction.vehicleObservationId 가 이 값이다
        long routeReferenceVersionId,
        String vehicleId,           // 상류가 빠뜨리면 null. 응답의 StopArrival.vehicleId 가 이 값이다
        String vehicleTripKey,      // v4 신규 열. null 이어도 예보는 낸다 — 라벨 회수만 못 한다
        int stopOrder,              // 지금 통과·접근 중인 순번. 예보 대상은 +1 부터 +12 까지다
        Instant observedAt,
        Integer remainingSeats,     // null = 좌석 결측. 0 = 만석(입석이 없어 승차 불가와 같다)
        Integer crowdedCode,        // 1~4 만 유효. 원문 0 은 미산출이라 null
        Integer lowPlateCode        // 2 = 2층버스. 정원은 이 값에서 유도하지 않는다
) {}

/**
 * 예보 하나를 내는 데 필요한 입력 전부. v3 ForecastTarget 을 대신한다.
 *
 * 지평은 targetStopOrder 에서 유도되는 값인데도 따로 들고 다닌다 — 모델 키라서
 * 계수 조회와 채점이 이 축을 그대로 쓰기 때문이다.
 */
public record VehicleStopTarget(ObservedVehicle observation, int targetStopOrder, Horizon horizon) {

    public VehicleStopTarget {
        Objects.requireNonNull(observation, "observation");
        Objects.requireNonNull(horizon, "horizon");
        if (targetStopOrder != observation.stopOrder() + horizon.stops()) {
            throw new IllegalArgumentException("지평과 순번 차가 어긋난다: target=" + targetStopOrder
                    + " observed=" + observation.stopOrder() + " horizon=" + horizon.stops());
        }
        if (observation.remainingSeats() == null) {
            // 설계행렬이 잔여석을 그대로 쓰고 앵커도 거기서 출발하는데, 그 자리에는
            // 상류 기울기·직전 좌석과 달리 결측 지시자가 없다. 대상 자체를 만들지 않는다.
            throw new IllegalArgumentException("좌석 결측 관측으로는 대상을 만들 수 없다: obs=" + observation.id());
        }
    }

    /** 예측 시점의 그 차량 잔여석. v3 입력에 없던 값이고 A18 의 핵심 재료다. */
    public int remainingSeats() {
        return observation.remainingSeats();
    }
}

여정 키가 없다고 대상에서 거르지 않는다 — 설계행렬이 slopeprev_seat 에 각각 결측 지시자를 갖고 있어 여정의 첫 관측과 같은 방식으로 점수화된다. 못 하는 것은 라벨 회수뿐이다.

SeatDistribution — 좌석 분포 processor

도착 시 잔여좌석의 분포를 담는 값 객체다. mass[k] 가 "도착할 때 k 석 남아 있을 확률"이고, pFull() 은 그 분포의 0 지점, expectedSeats() 는 기댓값이다. 두 값이 각각 vehicle_stop_prediction.pFullexpectedSeats 열이 된다. 불변식은 생성자에서 검사한다 — 같은 조건을 DB CHECK 가 다시 막지만, 거기까지 가서 터지면 어느 층이 망가졌는지 모른다.

/**
 * 도착 시 잔여좌석의 분포. 좌석 수마다 확률을 하나씩 담는다.
 * 분포 전체를 DB 에 저장하지는 않는다 — 사이트 평가 규약의 지표가 전부 만석 여부 이진 사건에
 * 대한 것이라 pFull 한 열이면 사후 채점이 된다.
 */
public record SeatDistribution(double[] mass) {

    private static final double SUM_TOLERANCE = 1e-9;

    public SeatDistribution {
        Objects.requireNonNull(mass, "mass");
        if (mass.length == 0) {
            throw new IllegalArgumentException("분포가 비어 있다");
        }
        mass = mass.clone();   // record 라도 배열은 복사해야 불변이 된다
        double sum = 0;
        for (int k = 0; k < mass.length; k++) {
            double p = mass[k];
            if (!Double.isFinite(p)) {                     // NaN · ±Infinity 는 ck_vsp_pfull 도 거절한다
                throw new IllegalArgumentException("확률이 유한하지 않다: k=" + k);
            }
            if (p < 0 || p > 1) {
                throw new IllegalArgumentException("확률이 0~1 밖이다: k=" + k + " p=" + p);
            }
            sum += p;
        }
        if (Math.abs(sum - 1.0) > SUM_TOLERANCE) {
            throw new IllegalArgumentException("확률 합이 1 이 아니다: " + sum);
        }
    }

    /**
     * 정원. 그 차량이 보여 준 최대 잔여석이고 형식승인 좌석수와는 다른 양이다.
     * DB 열이 아니라 번들의 특징 계약이 소유한다 — 종전 47석 고정은 어느 근거에도 없던 수였다.
     */
    public int capacity() {
        return mass.length - 1;
    }

    /**
     * 만석 확률. 분포의 0 지점이다. vehicle_stop_prediction.pFull 이 이 값이다.
     * 반올림하지 않는다 — 0.996 이 1.00 이 되면 "반드시 자리가 있다"로 읽힌다.
     */
    public double pFull() {
        return mass[0];
    }

    /**
     * 잔여좌석 기댓값. vehicle_stop_prediction.expectedSeats 가 이 값이다.
     * 분포를 요약한 값이므로 이 값이 없어도 pFull 은 유효하다.
     */
    public double expectedSeats() {
        double e = 0;
        for (int k = 1; k < mass.length; k++) {
            e += k * mass[k];
        }
        return e;
    }

    // 빈자리 확률(1 − pFull)은 여기서 내지 않는다. 뒤집기는 api 서비스 계층 메서드 한 곳에서만 하고
    // SQL 안에서도 하지 않는다 — 두 곳에서 뒤집으면 어느 쪽이 뒤집은 값인지 모르게 된다.

    @Override
    public double[] mass() {
        return mass.clone();
    }

    @Override
    public boolean equals(Object o) {                       // 배열이라 record 기본 구현을 쓸 수 없다
        return o instanceof SeatDistribution other && Arrays.equals(mass, other.mass);
    }

    @Override
    public int hashCode() {
        return Arrays.hashCode(mass);
    }
}

Horizon — 지평 processor

"몇 정류장 전에서 예측했는가"를 담고 1~12 를 강제한다. 유도값인데도 값 객체와 열을 함께 두는 이유는 지평이 모델 키이기 때문이다 — A18 은 지평별로 따로 적합하고 채점도 지평별로 가른다. 계수 조회와 평가 질의가 이 축을 바로 써야 한다. 상한 12 는 확정값이다. 8 에서 12 로 넓히면 커버율이 79.2% 에서 86.3% 로 오르고 향상배수가 31 에서 24 로 줄지만, 16 까지 넓히면 커버율이 1.1%p 만 오르는데 향상배수가 17 로 떨어져 기각했다.

/**
 * 몇 정류장 전에서 예측했는가. 시간이 아니라 정류장 수다.
 *
 * 같은 값이 세 곳에 박힌다 — 이 상수 · vehicle_stop_prediction.ck_vsp_horizon ·
 * 두 계약의 horizonStops maximum. 상한을 바꾸려면 셋을 함께 연다(4절).
 */
public record Horizon(int stops) {

    public static final int MIN = 1;
    public static final int MAX = 12;

    public Horizon {
        if (stops < MIN || stops > MAX) {
            throw new IllegalArgumentException("지평은 " + MIN + "~" + MAX + " 다: " + stops);
        }
    }

    public static Horizon of(int stops) {
        return new Horizon(stops);
    }

    /** 관측 하나가 낼 수 있는 지평 전부. 대상이 여정 끝을 넘으면 호출 쪽에서 끊는다. */
    public static List<Horizon> all() {
        return IntStream.rangeClosed(MIN, MAX).mapToObj(Horizon::new).toList();
    }
}

SeatForecastModel — 모델 계약 processor

v3 FullnessProbabilityModel 을 대신한다. 바뀐 것은 이름이 아니라 반환형이다 — 만석 확률 하나가 아니라 좌석 분포를 낸다. p_full 은 그 분포의 0 지점이라 계약에서 따로 받지 않는다. 결과를 SeatForecastResult 로 감싼 이유는 열이 둘이기 때문이다. pFullRaw 는 인과적 사전확률 이동을 얹기 값이고, 이 값이 남아야 다음 예보의 이동량을 계산할 수 있다 — 채점용이 아니라 모델 입력이다. 이동량 자체는 logit(pFull) − logit(pFullRaw) 로 되찾을 수 있어 열을 두지 않는다.

/**
 * 좌석 예보 모델 계약. 서버는 이 계약을 실행만 한다 — 학습하지 않는다.
 * 지원 범위 밖이면 절편만으로 조용히 값을 내지 않는다. 그 판정은 승인 참조가 갖는다.
 */
public interface SeatForecastModel {

    /** 이 구현을 실은 승인 release 참조. covers() 가 노선 판본과 지평 1~12 를 함께 본다. */
    ApprovedModelRef approvedRef();

    /**
     * 대상 하나에 도착 시 좌석 분포를 낸다.
     * profile 은 그 노선 판본 · 그 시간대의 셀 전부다 — 셀 하나가 아니다.
     */
    SeatForecastResult predict(VehicleStopTarget target, DemandProfile profile);
}

/** 예보 하나의 결과. 열이 둘이라 값도 둘이다. */
public record SeatForecastResult(SeatDistribution distribution, double pFullRaw) {

    public SeatForecastResult {
        Objects.requireNonNull(distribution, "distribution");
        if (!Double.isFinite(pFullRaw) || pFullRaw < 0 || pFullRaw > 1) {
            throw new IllegalArgumentException("pFullRaw 가 0~1 밖이다: " + pFullRaw);
        }
    }

    /** 응답과 채점에 쓰는 만석 확률. 분포의 0 지점이다. */
    public double pFull() {
        return distribution.pFull();
    }
}

SeatDistributionModel — A18 구현 processor

A18 의 서빙 구현이고 v4 서빙 경로의 유일한 모델이다. 기존 후보 일곱은 평가 대조군으로 남아 현황판일일 평가에서 계속 채점되지만 이 경계에는 들어오지 않는다. 시그니처만 적는다 — 층 구조와 수식은 A18 모델 문서에 있고 여기 옮기면 두 벌이 된다.

/**
 * A18(차량 상태 조건부 좌석 분포)의 서빙 구현.
 * 계수는 외부 ML 환경이 승인한 번들에서 오고 이 클래스는 그것을 실행만 한다.
 */
public final class SeatDistributionModel implements SeatForecastModel {

    private final ApprovedModelRef approvedRef;
    private final CoefficientBundle bundle;   // 노선별 블록 · 지평 1~12 계수 전부 · golden vector 로 검증된 것

    public SeatDistributionModel(ApprovedModelRef approvedRef, CoefficientBundle bundle) {
        this.approvedRef = Objects.requireNonNull(approvedRef);
        this.bundle = Objects.requireNonNull(bundle);
    }

    @Override
    public ApprovedModelRef approvedRef() {
        return approvedRef;
    }

    /**
     * 층을 통과시켜 분포를 만들고 마지막에 사전확률 이동을 얹는다.
     * 이동 전 만석 확률이 pFullRaw 이고 얹은 뒤가 pFull 이다.
     * 층 정의와 수식은 A18 모델 문서에 있다 — 여기 옮기지 않는다.
     */
    @Override
    public SeatForecastResult predict(VehicleStopTarget target, DemandProfile profile) {
        /* 골격 — 본체는 A18 문서의 층 정의를 따른다 */
    }
}

VehicleTripKey — 여정 키 collector

같은 차량의 한 여정을 잇는 키다. 적재 시점에 한 번 정하고 이후 바뀌지 않는다. 여정은 회차가 아니다 — 회차를 이어서 지난 운행이 1650 94.0% · 3330 72.4% 이고 회차 전후 관측 간격이 중앙 0.3분이라, 회차에서 끊으면 재구성한 운행이 절반으로 잘려 상류 좌석 기울기의 재료가 사라진다. 끊기는 곳은 차량이 종점을 지나 순번 1 부터 다시 시작하는 지점이다. 값은 유도 가능해야 한다 — 열에 문자열로 남기지만 구성 요소가 전부 다른 열에 있다.

/**
 * 같은 차량의 한 여정을 잇는 키. vehicle_observation.vehicleTripKey 가 이 값의 텍스트다.
 * 여정 시작 시각은 같은 키를 가진 행들의 최소 observedAt 이므로 열을 따로 두지 않는다.
 */
public record VehicleTripKey(String sourceId, String vehicleId,
                             long routeReferenceVersionId, Instant startedAt) {

    private static final int COLUMN_MAX_LENGTH = 120;   // varchar(120)

    public VehicleTripKey {
        Objects.requireNonNull(sourceId, "sourceId");
        Objects.requireNonNull(startedAt, "startedAt");
        // vehicleId 가 없으면 여정도 특정할 수 없다. 그때는 이 객체를 만들지 않고 열에 null 을 넣는다 —
        // CHECK 가 "vehicleId 가 null 이면 vehicleTripKey 도 null" 을 강제한다.
        Objects.requireNonNull(vehicleId, "vehicleId");
    }

    /** 열에 저장하는 텍스트. 전역 ID 가 아니라 (sourceId, vehicleId) 가 키다. */
    public String asColumn() {
        String value = sourceId + '|' + vehicleId + '|' + routeReferenceVersionId + '|' + startedAt;
        if (value.length() > COLUMN_MAX_LENGTH) {
            throw new IllegalStateException("여정 키가 열 길이를 넘는다: " + value.length());
        }
        return value;
    }

    /**
     * 직전 관측과 견줘 새 여정이 시작됐는지 본다. 셋 중 하나면 새 여정이고 그 밖에는 직전 키를 잇는다.
     *
     *   1. stopOrder 가 감소한다            — 문턱 미결. 상류 지터로 잠깐 되돌아가는 경우와 구별해야 한다
     *   2. 직전 관측과의 간격이 문턱을 넘는다 — 문턱 미결. 회차 대기가 시간대에 따라 변한다
     *                                        (1650 기준 중앙이 07시 106.7분 → 21시 46.0분)
     *   3. routeReferenceVersionId 또는 poll 의 normalizationVersion 이 다르다
     *
     * 3330 은 회차 순번 언저리(42~43)에서 끝나고 다시 시작하는 여정이 있어 순번만으로는 갈리지 않는다.
     */
    public static boolean startsNewTrip(VehicleObservation previous, VehicleObservation next) {
        /* 골격 — 문턱 둘이 미결이라 값을 아직 못 박는다 */
    }
}

DemandProfile · DemandProfileRepository — 셀 통계 processor

이 포트의 메서드가 하나뿐인 것이 요점이다. 한 노선 판본 · 한 시간대의 행을 전부(정류장 수만큼) 한 번에 읽는다. 셀 하나씩 집어 오면 세 가지가 전부 깨진다 — z화는 표준화 상수를 저장하지 않고 같은 세대의 행들에서 그때 유도하고, 이웃 폴백은 반경 4 안의 다른 행이 손에 있어야 하며, 구간합은 통과 구간의 행들을 합쳐 읽는다. 셋이 한 객체 안에서 닫히도록 두면 그 위에서 모델은 순수 함수가 된다. 그래서 "셀 하나 조회" 메서드를 아예 두지 않는다.

/** KST 시간대 3구분. v3 그대로 쓴다. demand_profile.timeCellId 가 이 값이다. */
public enum TimeBand {
    MORNING,   // 07~09
    EVENING,   // 17~20
    OTHER;

    /** 열에 저장하는 문자열. 셀 정의의 소유자는 이 enum 이 아니라 featureContractVersion 이다. */
    public String timeCellId() {
        return name().toLowerCase(Locale.ROOT);
    }

    /**
     * 서빙은 예측 시각의 시간대를 쓴다 — 도착 시각을 모르기 때문이다.
     * 학습 라벨의 시간대는 도착 관측 시각에서 왔고, 지평 12 의 중앙 소요가 25.0분이라
     * 경계를 넘는 경우가 생긴다(미결 — 시간대 축).
     */
    public static TimeBand of(Instant at) {
        /* 골격 — KST 로 옮겨 07~09 · 17~20 을 가른다 */
    }
}

/** 셀 하나. demand_profile 한 행이고 열 이름을 그대로 쓴다. */
public record DemandProfileCell(
        int stopOrder,
        double occupancyMean,   // 그 정류장 도착 시 평균 점유율. 날짜 균등가중이다
        double netDemandMean,   // 평균 순수요 ÷ 정원. 음수가 정상이다 — 하차 우세 정류장이 그렇다
        int sampleCount,        // 이 셀을 만든 유효 라벨 수. 이웃 폴백 판정의 재료다
        int dayCount,           // 날짜 균등가중이라 sampleCount 만으로는 평균을 다시 합칠 수 없다
        Instant trainedThrough  // 이 시각까지의 도착 라벨만 들어갔다. 누출 차단 기준이다
) {}

/**
 * 한 노선 판본 · 한 시간대의 셀 전부. 정류장 수만큼 들어 있다.
 * z화 · 이웃 폴백 · 구간합이 전부 이 객체 안에서 닫힌다.
 */
public record DemandProfile(
        long routeReferenceVersionId,
        TimeBand timeBand,
        String featureContractVersion,
        int revision,                                     // 재계산 세대. cellProfileRevision 에 그대로 적는다
        NavigableMap<Integer, DemandProfileCell> cells    // key = stopOrder
) {
    /** 이웃 폴백 반경. 1/d² 가중이고 자기 자신은 뺀다. */
    public static final int FALLBACK_RADIUS = 4;

    public DemandProfile {
        Objects.requireNonNull(timeBand, "timeBand");
        Objects.requireNonNull(featureContractVersion, "featureContractVersion");
        if (revision < 1) {
            throw new IllegalArgumentException("세대 번호는 1 부터다: " + revision);
        }
        cells = Collections.unmodifiableNavigableMap(new TreeMap<>(cells));
    }

    /** 폴백 지시자를 열로 두지 않는 이유 — 그 셀의 행이 있는지가 곧 지시자다. */
    public boolean hasCell(int stopOrder) {
        return cells.containsKey(stopOrder);
    }

    /** 점유율의 z 값. 표준화 상수를 저장하지 않고 이 객체가 든 행들에서 그때 유도한다. */
    public double occupancyZ(int stopOrder) {
        /* 골격 — 규칙은 A18 모델 문서 */
    }

    /** 순수요의 z 값. 셀이 없으면 반경 4 안의 이웃이 받는다. */
    public double netDemandZ(int stopOrder) {
        /* 골격 — 규칙은 A18 모델 문서 */
    }

    /** 관측 순번 다음부터 대상 순번까지의 구간합. 지평이 길수록 이 값이 예보를 떠받친다. */
    public double netDemandSegmentSum(int fromExclusive, int toInclusive) {
        /* 골격 — 규칙은 A18 모델 문서 */
    }
}

/**
 * 셀 통계 읽기 경계. 메서드가 하나뿐인 것이 이 포트의 요점이다.
 *
 * 셀 하나를 집어 오는 메서드를 두면 z화가 깨진다 — 표준화 상수를 저장하지 않기 때문이다.
 * 이웃 폴백(반경 4)도 구간합도 이웃 행이 손에 있어야 닫힌다.
 * featureContractVersion 이 키에 들어가는 이유는 정원이 바뀌면 값 자체가 달라져서다 —
 * 옛 정원으로 계산한 행과 새 정원으로 계산한 행이 섞이지 않는다.
 */
public interface DemandProfileRepository {

    DemandProfile load(long routeReferenceVersionId, TimeBand timeBand, String featureContractVersion);
}

개편 직후 새 판본에는 셀 행이 없고 그 구간은 이웃 폴백이 받는다. 폴백만으로 며칠을 버틸 수 있는지는 미결이고, 설계 개요의 신규 노선 생애주기가 그 미결에 걸려 있다.

Settlement · 라벨 회수 배치 processor

예보를 낸 뒤 그 차량이 대상 정류장에 실제로 도착했는지, 도착했다면 좌석이 몇이었는지를 나중에 채운다. v3 에서 연구 export 가 하던 라벨 만들기를 제품 DB 가 넘겨받는 자리다. 다섯으로 가르는 이유는 왜 못 썼는지를 남기기 위해서다 — 한 값으로 뭉개면 모델이 못 맞힌 것과 자료가 아예 없는 것이 같아 보인다.

/** 라벨 회수 상태. vehicle_stop_prediction.settlement 가 이 값이다. */
public enum Settlement {

    PENDING,        // 아직 도착 전이거나 회수 배치가 안 돌았다
    SETTLED,        // 같은 여정 안에서 대상 순번의 관측을 찾았고 잔여석이 유효하다. arrivedSeats NOT NULL
    SEAT_MISSING,   // 도착 관측은 찾았는데 좌석이 결측이었다. 관측 FK 는 있고 arrivedSeats 는 null
    SKIPPED,        // 여정이 대상 순번을 건너뛰었다 — v3 라벨 조건이 학습에서 빼는 그 경우다
    LOST;           // 대상 순번에 닿기 전에 여정이 끊겼다. 여정 키가 없는 행도 여기로 닫는다

    /** 채점 · demand_profile · 사전확률 이동은 SETTLED 행만 쓴다. */
    public boolean scorable() {
        return this == SETTLED;
    }
}

/** 라벨 회수 배치. PENDING 행을 나중 관측과 맞춰 닫는다. */
public interface SettleArrivalLabelsJob {

    /** 한 번 돈다. 회수 주기와 창은 미결이다. */
    SettlementReport run(Instant now);
}

/** 한 번 돈 결과. 넷을 갈라 세는 이유는 왜 못 썼는지가 남아야 하기 때문이다. */
public record SettlementReport(int settled, int seatMissing, int skipped, int lost, int stillPending) {}

/**
 * 예보 쓰기와 라벨 회수의 저장 경계.
 * 한 poll 의 행 쓰기와 완료 표시를 같은 포트에 둔 것은 둘이 같은 transaction 안에서 끝나야 하기 때문이다.
 */
public interface VehicleStopPredictionRepository {

    /** 한 poll 의 행 전부. 한 transaction · 한 배포 · 같은 generatedAt 이다. */
    void saveAll(List<VehicleStopPrediction> rows);

    /** 다 쓰면 찍는다. 예보 대상이 0건이어도 찍는다 — 안 찍으면 그 판이 스냅샷 후보에서 빠진다. */
    void markForecastCompleted(long pollId, Instant completedAt);

    /** 회수 대상. settlement = PENDING 이고 대상 순번을 지났을 법한 행이다. */
    List<PendingLabel> findPending(long routeReferenceVersionId, Instant before, int limit);

    /** 회수 결과를 한 행에 적는다. 상태와 세 값의 조합은 DB CHECK 가 다시 막는다. */
    void settle(long vehicleObservationId, int targetStopOrder, Settlement settlement,
                Long outcomeObservationId, Integer arrivedSeats, Instant settledAt);
}

ForecastJob — v4 의 예보 배치 processor

시간대 경계 발행이 아니라 poll 마다 돈다. v3 의 buildCandidates(staged)refreshBand(bandStart) 는 없다 — 발행이라는 단위가 사라졌기 때문이다. 그 자리를 세 규칙이 대신한다. 한 poll 의 행은 한 transaction 에서, 한 배포 로, 같은 generatedAt 으로 쓴다. 다 쓰면 location_poll.forecastCompletedAt 을 찍는다. 이 넷이 "한 판은 같은 poll · 같은 배포에서 나온다"는 불변식을 만들고, api 는 그 표시가 찍힌 poll 만 스냅샷 후보로 본다. 쓰기 한 번이 실패했는데 표시가 찍히면 그 판은 노선 전체의 "곧 오는 버스 없음"으로 나가고 정상과 구별되지 않는다.

/**
 * v4 의 예보 배치. poll 하나가 곧 한 판이다.
 * 관측 INSERT 가 commit 된 뒤 별도 transaction 으로 돈다 —
 * 수집의 4단계 차감 사다리에 모델 추론 시간이 섞이지 않게 하기 위해서다.
 */
public class ForecastJob {

    private final RouteTargetQuery targets;                    // 예보가 안 붙은 poll 과 그 관측
    private final DemandProfileRepository profiles;
    private final ModelDeploymentRepository deployments;
    private final SeatForecastModel model;
    private final VehicleStopPredictionRepository predictions;
    private final Clock clock;

    // 생성자 생략

    @Transactional
    public void forecastPoll(long pollId) {

        // outcome 이 SUCCESS_ROWS·SUCCESS_EMPTY 인 판만 온다. SUCCESS_EMPTY 도 정상 스냅샷이다.
        PollSnapshot poll = targets.awaitingForecast(pollId);

        ModelDeployment deployment = deployments.active();   // 한 poll = 한 배포. transaction 내내 이 값을 쓴다
        Instant generatedAt = clock.instant();               // 한 poll = 같은 generatedAt

        // 셀 통계는 여기서 한 번만 읽는다. z화 · 이웃 폴백 · 구간합이 전부 이 객체 안에서 닫힌다.
        DemandProfile profile = profiles.load(
                poll.routeReferenceVersionId(),
                TimeBand.of(poll.responseReceivedAt()),
                deployment.featureContractVersion());

        List<VehicleStopPrediction> rows = new ArrayList<>();

        for (ObservedVehicle vehicle : poll.vehicles()) {
            if (vehicle.remainingSeats() == null) {
                continue;   // 좌석 결측 — 행을 만들 수 없다. 그 차량은 이 판의 arrivals 에서 사라진다(서빙 처리는 미결)
            }
            for (Horizon horizon : Horizon.all()) {
                int targetStopOrder = vehicle.stopOrder() + horizon.stops();
                if (targetStopOrder > poll.lastStopOrder()) {
                    break;      // 여정 끝을 넘어가는 대상은 만들지 않는다 — 종점 다음의 순번 1 은 다른 여정이다
                }
                if (!poll.boardingAllowed(targetStopOrder)) {
                    continue;   // 미정차 정류장은 행을 만들지 않는다. 사유는 route_stop 에 있으니 복제하지 않는다
                }
                SeatForecastResult result = model.predict(
                        new VehicleStopTarget(vehicle, targetStopOrder, horizon), profile);

                rows.add(VehicleStopPrediction.of(vehicle, targetStopOrder, horizon, result,
                        deployment.id(), profile.revision(), generatedAt));
            }
        }

        predictions.saveAll(rows);                                // 한 transaction
        predictions.markForecastCompleted(pollId, generatedAt);   // rows 가 비어도 찍는다
    }
}

상한과 정렬이 여기서 안 걸린다는 점을 봐 둘 만하다 — 정류장당 최대 2대와 정렬은 api 의 조립 쪽 일이다. processor 는 지평 창 안의 행을 전부 만들고, 자르는 것은 응답을 만드는 자리에서 한다. 규모로 보면 수집 한 번에 계산할 예보가 중앙 132~156개 · 최대 336~432개이고, 노선당 하루 89만~98만행이다.

4. 모델 교체 비용 요구가 바뀌면 몇 개가 열리나

v3 문서의 같은 표를 v4 기준으로 다시 낸다. 여기 적은 객체는 2절 대조표에 이름이 있는 것뿐이다.

요구가 바뀌면열리는 것안 열리는 것근거
같은 계약으로 모델 구현만 갈아끼운다 SeatForecastModel 구현 1 · CoefficientBundle 의 계수 블록 collector 전부 · api 전부 · 표 전부 api 는 예보 행을 읽기만 하고 모델 계약을 모른다. 배포로 거르지도 않는다
모델이 내는 것이 바뀐다 SeatForecastModel · SeatForecastResult · SeatDistribution · VehicleStopPrediction · vehicle_stop_prediction 열 · api /board 7 · 계약 둘 collector · api /vehicles v3 → v4 가 바로 이 경우다. 실제로 열린 목록이 2절 표다
지평 상한 12 를 바꾼다 Horizon.MAX · ApprovedModelRef.covers() · 번들의 지평 블록 · ck_vsp_horizon · 계약 둘의 horizonStops maximum api 조립 코드 · collector 지평이 열이자 모델 키라 같은 수가 코드·제약·계약 셋에 박힌다. 상수를 Horizon 한 곳에 두고 나머지는 시험으로 묶는다
시간 셀을 더 잘게 나눈다 TimeBand(또는 그것을 대신할 셀 유도) · 번들의 featureContractVersion DDL · api · collector timeCellId 를 문자열로 두고 셀 정의의 소유를 표가 아니라 특징 계약에 준 이유가 이것이다
정원 상수를 고친다 번들의 특징 계약 1 · featureContractVersion 이 바뀌면서 demand_profile 행이 새 키로 갈린다 DDL · 코드 전부 정원을 DB 열로 두지 않았다. 옛 정원으로 계산한 행과 새 정원으로 계산한 행이 섞이지 않는다
응답에 필드를 하나 더 낸다 api 쪽 3~4(BoardQuery · StopRow · 응답 형) · 계약 둘 그 값을 이미 저장하고 있으면 processor 는 안 열린다 expectedSeats 가 그런 경우다 — 열이 이미 있어 api 만 열린다
새 상류 소스를 붙인다 collector 의 RealtimeObservationSource 구현 1 processor · api · 표 v3 가 인터페이스를 둔 이유가 그것이고 v4 에서 안 바뀐다

v3 문서의 수치를 그대로 옮기지 않았다 — 대조 기준이 되는 v3 표의 값을 이 문서가 확인하지 않았기 때문이다. 위 표는 2절에서 센 것만 담았고, 신설 객체가 아직 제안이라 숫자는 구현이 확정되면 다시 센다.

가장 비싼 자리는 모델이 아니다

표의 두 번째 줄이 유일하게 세 패키지와 두 계약을 함께 여는 경우다. 나머지 여섯 줄은 한 패키지 안에서 끝나거나 계약 문서만 건드린다. 비용을 가르는 축은 "모델이 바뀌었나"가 아니라 "응답 계약이 바뀌었나"다. v3 의 경계 설계가 그 축을 이미 맞게 잡아 뒀고, v4 에서 그 설계를 고칠 이유가 나오지 않았다.