객체 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초에서 수집 주기에 맞춘 값으로 바뀌므로 그 값을 내는 곳 한 군데가 열린다 |
| 확정된 제거 | 5 | ForecastPublication · StopPrediction · PublicationRepository · PredictionResult 의 NotEstimated 갈래 · NotEstimatedReason.BOARDING_NOT_ALLOWED |
| 객체 수 합계 | 여기서 안 센다 | 신설 객체는 아직 제안이다. 구현이 확정되면 v3 장부와 같은 방식으로 다시 센다 |
모델 계약의 반환형이 바뀐다 — 만석 확률 하나에서 좌석 분포로. p_full 은 그 분포의 0 지점이다.
입력도 (판본, 노선키, 순번, 시각) 에서 (관측, 대상 순번, 지평) 으로 바뀌어, 예측 시점의 그 차량 잔여석이 처음으로 입력에 들어온다.
나머지 변경은 대부분 이 둘에서 따라 나온다.
v8 장부의 수와 이름이 하나 어긋난다 — 표제는 api 14(예보 7 · 실시간 차량 7)인데 장부에 적힌 이름은 15개다.
확정 도메인 설계가 이미 적어 둔 어긋남이고 이 문서가 풀지 못한다.
다만 결론에는 영향이 없다 — 어느 쪽으로 세든 실시간 차량 쪽에서 v4 가 여는 것은 Cache-Control 을 내는 자리 하나뿐이다.
1. 패키지 경계 v4 에서도 안 바뀐다
셋으로 가른 근거가 A18 채택으로 흔들리지 않는다. 오히려 셋 다 쓰인다.
| 패키지 | 프로세스 | 왜 갈라 뒀나 |
|---|---|---|
collector | A · 배치 · 수집 | GBIS 응답을 받는 유일한 창구다. 라벨·예측·평가를 만들지 않는다 |
processor | A · 배치 · 추론 | 학습하지 않는다. 승인된 번들 하나를 검증하고 점수만 낸다. 학습·평가·선정은 외부 ML 환경의 일이다 |
api | B · 요청 · 조회 | 읽어서 내보낸다. 예측을 계산하지 않고 수집도 하지 않는다 |
패키지끼리 직접 못 부른다. 두 프로세스는 PostgreSQL 을 사이에 두고 만나고, 코드 의존은 ArchUnit 이 CI 에서 막는다.
v4 에서 processor 가 location_poll.forecastCompletedAt 을 찍는데, 그 열이 collector 소유의 표에 있어도
collector 의 코드를 부르지는 않는다 — processor 는 자기 포트로 그 표를 읽고 쓴다.
같은 이유로 관측 한 행을 읽는 형(VehicleObservation 과 ObservedVehicle)이 패키지마다 따로 있다.
형을 공유하면 그 순간 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 을 원천으로 삼게 되어 어긋남이 보이는 자리가 생겼다 — 좌석이 결측인 차량을 /vehicles 는 kind: UNKNOWN 으로 보여 주는데 /board 에서는 조용히 사라진다(미결) |
뒤집기(1 − p_full)는 서비스 계층 메서드 한 곳 | 그대로 SQL 안에서 하지 않는다 — 하면 테스트에 DB 가 필요해진다 |
| 확률은 반올림하지 않는다 | 그대로 0.996 이 1.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_prediction → location_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 해석도, direction → currentStopSequence 정렬도 같다 |
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();
}
}여정 키가 없다고 대상에서 거르지 않는다 — 설계행렬이 slope 와 prev_seat 에
각각 결측 지시자를 갖고 있어 여정의 첫 관측과 같은 방식으로 점수화된다. 못 하는 것은 라벨 회수뿐이다.
SeatDistribution — 좌석 분포 processor
도착 시 잔여좌석의 분포를 담는 값 객체다. mass[k] 가 "도착할 때 k 석 남아 있을 확률"이고,
pFull() 은 그 분포의 0 지점, expectedSeats() 는 기댓값이다.
두 값이 각각 vehicle_stop_prediction.pFull 과 expectedSeats 열이 된다.
불변식은 생성자에서 검사한다 — 같은 조건을 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 에서 그 설계를 고칠 이유가 나오지 않았다.
관련 문서
확정 v3 객체 구조는 확정 도메인 설계에 있다. 이 문서는 검토 요청안이고 여기서 제안한 어느 항목도 팀 합의 전에는 적용되지 않는다.