예측값을 현재값처럼 말하지 않는 법
forecast worker, confidence, backtest로 도착 전 판단값의 불확실성을 표시했다
주차 예측을 처음 넣으려 했을 때 가장 조심스러웠던 것은 숫자의 힘이었다. “30분 뒤 12대 남음”이라는 표현은 편리하지만, 사용자는 그 숫자를 사실처럼 읽기 쉽다.
실제로는 그렇지 않다. 원천 데이터가 늦게 갱신될 수 있고, 주차장 회전율은 시간대마다 다르고, 행사와 날씨와 통제 같은 외부 요인이 갑자기 끼어든다. 그래서 한강자리의 예측은 정답을 약속하는 기능이 아니라, 도착 전 판단에 참고할 수 있는 조심스러운 숫자로 두었다.
처음부터 큰 ML 플랫폼을 만들 생각은 없었다. 필요한 것은 모바일 앱이 빠르게 읽고, 나중에 맞았는지 확인할 수 있으며, 화면에서 과하게 단정하지 않는 예측이었다. 그래서 baseline, confidence, reason code, backtest를 한 묶음으로 봤다.
예측은 요청 전에 만들어 둔다
한강자리에는 두 종류의 예측이 있다.
| 예측 | 단위 | 목적 |
|---|---|---|
| 주차 예측 | 주차장 단위 | 도착 시간대의 주차 실패 위험 비교 |
| 공원 혼잡 예측 | 공원 단위 | 나들이 때 참고할 혼잡도와 믿을 만한 정도 |
공통으로 지킨 것은 같다. 요청 시점에 무거운 계산을 하지 않는다. worker가 미리 계산하고, API는 미리 만든 최신 값을 빠르게 읽어 돌려준다.
이 방식은 조금 돌아가는 길처럼 보일 수 있다. 하지만 모바일 앱에서는 요청 순간에 계산을 몰아넣는 비용이 사용자 대기 시간으로 드러난다. 예측은 “필요할 때 즉석에서 계산”하기보다, worker가 계속 만들어두고 API가 확인된 최신 결과를 읽는 쪽이 장애를 보기도 쉬웠다.
주차 예측: 현재값을 미래처럼 말하지 않기
주차 예측은 현재 잔여 대수를 복사하는 일이 아니다. 현재값, 최근 변화, 과거 같은 시간대의 변화를 함께 보고, 몇 분 뒤에 위험이 커질 수 있는지 알려주는 일이다.
주차 예측의 핵심은 가중치보다 변화의 방향이다. 현재값과 과거 변화를 어떤 순서로 보고, 근거가 약할 때 confidence와 상태를 어떻게 낮추는지가 숫자를 믿고 읽을 수 있는지를 더 크게 좌우한다.
flowchart TB Snapshots["주차 상태 스냅샷"] --> Buckets["5분 단위 변화"] Buckets --> Features["예측 특성<br/>최근 변화 · 시간대 기준선"] Features --> Worker["예측 워커"] Worker --> Run["예측 실행<br/>버전 · 만든 시각"] Worker --> Result["예측 결과<br/>기간 · 범위 · 위험도 · 신뢰도"] Result --> Cache["Redis 예측 캐시"] Result --> API["예측 API"] Cache --> API API --> Client["iOS 예측 화면"]
응답에는 값만 넣지 않는다. 모바일 UI가 예측을 어떻게 보여줄지 고르려면 메타데이터가 필요하다.
generated_at: 언제 계산했는지.model_version: 어떤 로직으로 만들었는지.horizon_minutes: 몇 분 뒤를 보는지.risk_level: 사용자가 이해할 위험 수준.failure_probability: 내부 계산을 앱에서 다룰 수준으로 정리한 확률 값.confidence: 근거가 충분한지.reason_codes: 왜 위험하다고 보는지 설명하는 단서.
이런 값이 있어야 앱이 “위험”, “근거 부족”, “정보 없음”을 나눠 말한다. 숫자를 하나 더 보여주는 것보다, 그 숫자를 얼마나 조심해서 읽어야 하는지 함께 내려주는 쪽이 제품에 더 가까웠다.
공원 혼잡 예측: row 없음은 한가함이 아니다
공원 혼잡은 주차장보다 더 애매하다. 사용자는 “사람이 많나?”를 알고 싶지만, 출처마다 현재값과 예측값, 갱신 시각이 다르다.
한강자리는 공원 실시간 상황과 공식 예측값을 함께 사용한다. 가까운 시간대의 예측 row가 있으면 그 값을 우선하고, 없으면 현재 상황을 fallback으로 쓴다. 이때 confidence는 낮춰야 한다.
flowchart LR
Context["현재 공원 상황"] --> Resolver["예측 리졸버"]
Official["공식 혼잡 예측"] --> Resolver
Resolver --> Fresh{"목표 시간대 예측 있음?"}
Fresh -->|예| Forecast["예측값 사용<br/>일반 신뢰도"]
Fresh -->|아니오| Fallback["현재값 대체<br/>낮은 신뢰도"]
Forecast --> Response["공원 예측 응답"]
Fallback --> Response
row가 없다는 것은 한가하다는 뜻이 아니다. 정보가 없는 상태를 성공처럼 보이면 안 된다.
이 선택은 예측 전체에 반복된다. 예측이 없을 때 화면을 비워둘지, 현재값으로 대체할지, 낮은 confidence로 보여줄지는 화면에서 정할 일이다. 다만 어떤 선택을 하든 “예측 없음”을 “문제 없음”처럼 보이게 하면 안 된다.
첫 화면도 예측이 바뀌면 같이 바꾼다
예측은 별도 화면에서만 쓰이지 않는다. 한강자리의 홈 화면도 공원별 요약을 보여준다. 그래서 forecast worker는 예측을 만든 뒤 관련 cache를 무효화하고, 자주 읽히는 home summary를 다시 데운다.
sequenceDiagram autonumber participant Worker as 예측 워커 participant PG as Postgres participant Redis as Redis participant API as 첫 화면 API participant App as iOS 앱 Worker->>PG: 예측 실행과 결과 저장 Worker->>Redis: 예측 캐시 비우기 Worker->>Redis: 첫 화면 캐시 미리 갱신 App->>API: 첫 화면 요약 요청 API->>Redis: 준비된 첫 화면 값 읽기 Redis-->>API: 캐시된 응답 반환 API-->>App: 판단에 쓸 첫 화면 요약 반환
이 방식은 모바일 성능과 직접 연결된다. 사용자가 앱을 열 때마다 DB aggregation을 반복하지 않는다. worker가 비싼 계산을 미리 끝내고, API는 미리 만든 값을 읽는다.
장애를 볼 때도 이 방식이 편했다. 예측 run이 바뀌면 어떤 cache가 영향을 받는지 말할 수 있고, home summary가 오래됐을 때 forecast worker와 cache warmup 중 어디를 봐야 하는지도 나눠 볼 수 있다.
출시 후에는 예측을 계속 채점한다
예측은 출시했다고 끝나지 않는다. 데이터가 쌓이면 계속 채점해야 한다.
한강자리에는 forecast backtest를 위한 job과 label/metric 저장 방식이 있다. 초기에는 복잡한 계산식보다 검증 가능한 baseline이 먼저였다. 계산 방식을 바꾸려면 “더 좋아 보인다”가 아니라, 이전 버전과 비교한 metric이 있어야 한다.
내부 수식보다 나중에 다시 확인할 수 있는지가 더 오래 남는다. 어떤 계산 방식을 쓰든 run, model version, label, metric이 남지 않으면 개선 여부를 설명할 수 없다. 예측은 출시보다 출시 후에 지켜보는 시간이 더 길다.
채점할 때는 다음을 봤다.
- 시간대별 오차가 어디서 커지는가.
- 특정 공원이나 주차장에서 과소 예측이 반복되는가.
- 행사·날씨·통제 같은 외부 요인이 confidence를 낮춰야 하는가.
- 원천 데이터 장애가 예측에 어떤 영향을 주는가.
- p10/p50/p90 범위가 실제 변동성을 설명하는가.
예측을 다르게 보게 된 점
처음에는 예측값을 만드는 일이 핵심처럼 보였다. 실제로는 입력값을 다시 만들 수 있는지, 계산한 시각과 근거가 남는지, row 없음과 낮은 혼잡도를 화면에서 다르게 말할 수 있는지가 더 오래 영향을 줬다.
모바일 앱에서는 요청 때 계산하는 똑똑함보다 미리 만든 값을 안정적으로 읽는 편이 나았다. model version과 backtest metric도 연구용 기록이 아니라, 나중에 숫자를 낮춰 말할 수 있게 해주는 근거였다.
한강자리에서 예측은 “미래를 맞히는 도구”가 아니라 “불확실성을 화면에 정직하게 옮기는 일”에 가까웠다. 그래서 숫자 하나보다 confidence와 최신성 정보가 더 오래 남았다.