TL;DR
- 우리 가격 예측에는 "폭염 신호"가 있었습니다. 최근 14일 중 가장 더운 날의 최고기온이 31℃를 넘으면 넘은 만큼 값이 생기는 변수입니다. 채소류에만, 6~9월에만 씁니다.
- 그런데 매일 새벽 기상 관측을 받아 오는 작업이 "어제" 하루치를 요청하면서, 그 시각 기상청 API가 아직 내주지 않은 오전 10시 이후를 빠뜨리고 있었습니다. 저장된 것은 하루 중 자정부터 오전 9시까지, 10시간이었습니다. 최고기온은 대개 오후에 나오므로 저장된 "최고기온"은 사실상 새벽·오전 기온이었습니다.
- 실행 기록을 보면 2026-06-04부터 2026-09-23까지 성공으로 남은 실행 중 101번이 11개 지점 × 10시간 = 110건만 받아 왔습니다. 정상이라면 11개 지점 × 24시간 = 264건입니다.
- 2026-09-25에 고쳤습니다. 최근 사흘을 매일 다시 받고, 24시간이 다 차지 않은 날은 따로 기록하며, 감시가 날짜뿐 아니라 시간 수까지 봅니다. 여름 전체를 다시 받았습니다.
- 다시 받은 관측으로 재면 2026-06-01~08-10의 71일 중 35일에 폭염 신호가 켜집니다. 신호는 살아났습니다.
- 그런데 신호가 살아난 상태로 예측을 다시 돌려 보니, 결과를 보기 전에 정해 둔 기준만큼 예측을 낫게 만들지 못했습니다. 그래서 폭염 신호와 폭우 신호를 껐습니다. 이 노트는 "고쳤더니 좋아졌다"가 아니라 "고치고 나서야 기여가 없다는 것을 알게 됐다"는 기록입니다.
무엇을 믿고 있었나
폭염 신호는 2026년 6월에 예측에 붙였습니다. 당시 백테스트에서 채소류 여름 예측을 개선한다는 결과가 나왔고, 내부 문서에는 "켜져 있다"고 적혀 있었습니다. 실제로 스위치는 켜져 있었습니다.
하지만 켜져 있는 것과 작동하는 것은 다릅니다. 이 변수는 기준 온도(31℃)를 넘을 때만 값이 생기는 구조라서, 입력 기온이 기준에 닿지 못하면 매일 0이 됩니다. 값이 0이어도 예측 모델은 오류 없이 돌고, 배치는 성공으로 기록되고, 테스트도 통과합니다. 겉으로는 아무 일도 없습니다.
그 당시 근거로 삼았던 개선 수치는 이 노트에 옮기지 않습니다. 그 측정은 실제 예측을 만드는 코드가 아니라 그것을 따로 흉내 낸 도구로 이뤄졌고, 지금 같은 방식으로 다시 확인할 수 없습니다.
무엇이 틀려 있었나 — 하루의 앞 10시간
기상 관측을 받아 저장하는 작업(적재)은 매일 한국 시각 새벽 3시 10분에 돌면서 "어제" 하루의 시간별 관측을 요청합니다. 그 시각 기상청 지상관측(ASOS) 시간자료 API는 어제 자료를 오전 9시 관측분까지만 돌려줬습니다. 적재 작업은 받은 것을 그대로 하루치로 집계해 저장했습니다.
- "최고기온"이 오전까지의 최고기온이었습니다. 하루의 최고기온은 보통 오후에 나옵니다. 오전 9시까지의 최댓값은 그보다 낮습니다. 그 값으로 31℃ 임계를 판정하니 여름 내내 신호가 거의 켜지지 않았습니다.
- 감시는 이것을 볼 수 없었습니다. 우리 적재 감시는 "가장 최근 관측 날짜가 며칠 전인가"를 봅니다. 날짜 행은 매일 생겼으므로 감시는 매일 통과했습니다. 데이터가 없어진 것이 아니라 하루가 잘려 있었기 때문입니다.
- 배치는 매번 성공으로 남았습니다. 실행 기록에는 받은 건수가 함께 남는데, 그 숫자가 110이었습니다. 정상 건수(264)와 나란히 놓고 보면 바로 보이는 차이였지만, 그 둘을 비교하는 사람도 장치도 없었습니다.
2024·2025년 여름 관측은 나중에 과거분으로 한꺼번에 받아서 24시간이 다 들어 있었습니다. 잘린 것은 2026년 여름 매일 적재분이었습니다. 즉 모델은 "폭염 신호가 자주 켜지는 여름"으로 학습하고, 올여름에는 "거의 안 켜지는 여름"을 입력받고 있었습니다.
처음에는 원인을 잘못 짚었습니다
8월에 이미 "폭염 신호가 거의 0"이라는 것은 알아챘습니다. 그때 우리는 원인을 이렇게 적었습니다 — 임계 31℃는 한 지점 관측 기준인데, 입력이 11개 도시 평균이라 평균이 임계를 못 넘는다.
그럴듯한 설명이었고, 그래서 입력이 완전한지를 확인하지 않았습니다. 2026-09-25 다른 작업(기상 변수 재설계 측정) 중에 하루당 저장된 시간 수를 세어 보고서야, 진짜 원인이 관측이 잘려 저장된 것이라는 것을 알았습니다. 다시 받은 관측으로 같은 11개 도시 평균을 쓰면 신호는 아래 표처럼 켜집니다. 평균이 문제가 아니었습니다.
같은 결함 때문에 내부적으로 믿고 있던 다른 두 가지도 틀렸습니다. "2026년 여름은 서늘했다"와 "예보가 관측보다 덥게 나온다"는 둘 다 잘린 관측을 근거로 한 판단이었습니다. 다시 받은 관측에서도 2026년 여름은 앞선 두 해보다 신호가 켜진 날이 적지만(아래 표), 우리가 믿었던 만큼 서늘하지는 않았습니다. 숫자가 이상할 때 그럴듯한 설명을 먼저 세우면 입력이 온전한지를 묻지 않게 됩니다. 이번에 배운 것은 그 순서입니다.
무엇을 고쳤나
- 매일 최근 사흘을 다시 받습니다. "어제"만 받지 않고 그제·사흘 전까지 다시 요청합니다. 새벽에 덜 들어온 날은 다음 날 채워집니다.
- 덜 찬 날을 따로 기록합니다. 24시간이 다 차지 않은 지점·날짜를 실행 기록에 남깁니다. "받았다"와 "다 받았다"를 다른 사실로 적습니다.
- 감시가 시간 수를 봅니다. 날짜가 있는지만이 아니라, 이틀 전 날짜에 24시간이 다 들어왔는지를 확인합니다.
- 여름 전체를 다시 받았습니다. 2026-06-01~09-30의 11개 지점 × 122일 = 1,342개 지점·일 가운데 24시간이 다 차지 않은 날은 현재 0개입니다.
같은 날 일기예보 쪽 결함 두 가지도 함께 고쳤습니다. 하루에도 여러 번 새로 발표되는 예보를 같은 날짜끼리 전부 평균하던 것을 가장 최근에 발표된 예보 하나만 쓰게 했고, 한 시간치만 들어 있는 날의 단기예보가 그날의 중기예보를 덮어쓰던 것을 막았습니다.
다시 쟀습니다 — 기준은 결과를 보기 전에 적었습니다
고친 뒤 신호가 살아났는지부터 확인했습니다. 다시 받은 관측으로 같은 정의를 적용하면 아래와 같습니다. 정의는 운영 예측 코드와 같습니다 — 11개 지점 일 최고기온의 평균을 날짜별로 내고, 직전 14일(당일 포함)의 최댓값이 31℃를 넘는 날을 셉니다. 마지막 줄은 8월에 "폭염 신호가 거의 0"이라고 알아챘을 때 들여다봤던 바로 그 구간입니다.
| 구간 | 날짜 수 | 폭염 신호가 켜진 날 |
|---|---|---|
| 2024년 6~8월 | 92일 | 78일 |
| 2025년 6~8월 | 92일 | 74일 |
| 2026년 6~8월 | 92일 | 56일 |
| 2026-06-01~08-10 | 71일 | 35일 |
그다음 예측에 기여하는지를 쟀습니다. 운영 예측 코드를 그대로 호출해 과거 여러 시점에서 예측을 다시 만들고, 폭염 신호를 켠 것과 끈 것을 같은 실측과 비교했습니다. 판정 기준은 결과를 보기 전에 적어 두었고, 결과를 본 뒤 바꾸지 않았습니다.
- "기여한다"의 조건(셋 다 참이어야 함): 채소류 7일 뒤 예측과 30일 뒤 예측에서 각각 평균 오차가 0.30%p 이상 줄고, 같은 조건끼리 짝지어 비교했을 때 신호를 켠 쪽이 더 잘 맞은 비율이 0.55 이상일 것. 90일 뒤 예측은 0.10%p보다 더 나빠지지 않을 것.
- 폭염 신호: 7일 뒤 예측에서는 개선이 없었습니다. 30일 뒤 예측에서는 개선하는 방향이었지만 오차 폭과 승률 두 문턱에 모두 조금씩 못 미쳤습니다. 기준 미달입니다.
- 폭우 신호(큰비가 기준을 넘었는지를 같은 구조로 나타내는 변수 — 함께 쟀습니다): 어느 예측 기간에서도 개선이 없었고, 30일 뒤 예측은 오히려 나빠지는 쪽이었습니다. 기준 미달입니다.
측정에서 나온 오차 차이 수치는 이 노트에 싣지 않습니다. 이번 측정은 운영 예측 코드를 직접 호출했지만, 결과는 내부 실험 기록에만 남아 있어 아래 "재현법"의 쿼리로 다시 셀 수 없기 때문입니다. 이 시리즈는 무엇을 어떻게 셌는지 쿼리로 남길 수 없는 숫자를 공개 수치로 내놓지 않습니다.
그래서 껐습니다 — 음성 결과
2026-09-26 새벽 예측 실행부터 폭염 신호와 폭우 신호는 꺼져 있습니다. 매일의 예측 실행 기록에는 이 스위치 상태가 함께 남고, 아래 쿼리로 날짜별로 확인할 수 있습니다.
이것은 "폭염이 채소 가격과 무관하다"는 주장이 아닙니다. 우리가 주장하는 것은 "고친 데이터로 다시 재도, 이 형태의 신호가 지금 우리 예측을 기준만큼 낫게 만든다는 것을 확인하지 못했다"까지입니다. 계절 패턴과 최근 가격 수준을 반영하는 다른 장치들이 같은 정보를 이미 담고 있을 가능성도 있지만, 그것은 확인한 사실이 아닙니다.
다시 켤지 판단하는 것은 2027년 여름 자료가 쌓인 뒤입니다. 그때도 같은 정의, 같은 기준으로 잽니다. 기준을 낮춰서 통과시키는 일은 하지 않습니다.
아직 안 고친 것
- 관측 지점이 전부 도시입니다. 우리가 쓰는 기상청 지점은 11곳(지점 번호 105·108·112·119·131·133·143·146·156·159·184 — 강릉·서울·인천·수원·청주·대전·대구·전주·광주·부산·제주)이고, 대관령 같은 고랭지 산지 관측소는 없습니다. 고랭지 배추 같은 품목의 산지 날씨를 도시 기온으로 대신하고 있다는 뜻입니다.
- 산지 지점을 단순히 더하는 것은 답이 아닙니다. 산지는 도시보다 서늘해서, 평균에 더하면 31℃ 임계를 오히려 덜 넘게 됩니다. 품목과 산지를 짝지어 따로 보는 방식이 필요하고, 아직 하지 않았습니다.
- 여름은 한 번뿐입니다. 고친 매일 적재로 잴 수 있는 여름은 2026년 하나입니다. 이번 판정도 한 계절의 결과입니다.
- 한파 신호는 재지 못했습니다. 같은 구조의 겨울 신호는 원래 꺼져 있었고, 고친 관측으로 잴 수 있는 겨울이 아직 없습니다.
Caveats — 이 노트를 어디까지 믿어야 하나
- "수리 전에는 신호가 며칠 켜졌나"는 이제 확인할 수 없습니다. 여름 관측을 다시 받으면서 잘린 값이 덮어써졌습니다. 우리가 당시 본 것은 "대부분의 날 0"이었지만, 지금 데이터베이스에서 그 숫자를 다시 셀 방법이 없어 숫자로 적지 않습니다. 남아 있는 증거는 실행 기록의 받은 건수(110)입니다.
- 모든 실행이 잘린 것은 아닙니다. 같은 기간에 정상 건수(264)를 받은 실행이 4번 있었습니다. 그래서 "매일"이 아니라 "성공한 실행 중 101번"이라고 적습니다.
- 날짜 기준이 둘입니다. 실행 날짜는 한국 시각으로 바꿔 적었습니다. 관측 날짜는 관측 당일입니다. 새벽 3시 10분 실행은 그 전날 관측을 받으므로 두 날짜는 하루 어긋납니다.
- 수치는 2026-10-05 기준입니다. 관측은 매일 늘어나고 실행 기록도 쌓입니다. 인용하실 때는 기준일을 함께 적어 주십시오.
재현법 — 직접 확인
아래 쿼리는 우리 운영 데이터베이스에서 2026-10-05에 그대로 돌려 위 숫자를 얻은 것입니다. 데이터베이스는 공개돼 있지 않으므로 외부에서 직접 실행할 수는 없습니다. 무엇을 어떻게 셌는지 정확히 남기기 위해 싣습니다.
- ① 잘린 적재 실행: SELECT metadata_json::jsonb ->> 'fetched' AS fetched, count(*) AS runs, min((started_at AT TIME ZONE 'Asia/Seoul')::date), max((started_at AT TIME ZONE 'Asia/Seoul')::date) FROM batch_logs WHERE job_name = 'cron:ingest_weather' AND status = 'success' AND metadata_json::jsonb ->> 'fetched' IN ('110','264') GROUP BY 1; → 110건: 101회(2026-06-04~09-23), 264건: 4회
- ② 다시 받은 뒤 덜 찬 날: SELECT count(*), count(*) FILTER (WHERE hour_count < 24) FROM weather_observations WHERE obs_date BETWEEN '2026-06-01' AND '2026-09-30'; → 1,342 / 0
- ③ 폭염 신호가 켜진 날: WITH daily AS (SELECT obs_date, avg(ta_max) AS tmax FROM weather_observations WHERE ta_max IS NOT NULL GROUP BY obs_date), rolled AS (SELECT obs_date, max(tmax) OVER (ORDER BY obs_date ROWS BETWEEN 13 PRECEDING AND CURRENT ROW) AS rmax FROM daily) SELECT count(*), count(*) FILTER (WHERE rmax > 31) FROM rolled WHERE obs_date BETWEEN '2026-06-01' AND '2026-08-10'; → 71 / 35 (표의 다른 구간은 날짜만 바꿈)
- ④ 관측 지점: SELECT string_agg(DISTINCT stn_id, ',' ORDER BY stn_id), count(DISTINCT stn_id) FROM weather_observations; → 105,108,112,119,131,133,143,146,156,159,184 / 11
- ⑤ 스위치 상태: SELECT (started_at AT TIME ZONE 'Asia/Seoul')::date, metadata_json::jsonb #>> '{run_card,config,regressor_heat_enabled}', metadata_json::jsonb #>> '{run_card,config,regressor_heavy_rain_enabled}' FROM batch_logs WHERE job_name = 'cron:generate_forecasts' ORDER BY started_at; → 2026-09-25 실행까지 true, 2026-09-26 실행부터 false
- 예측 실적: https://ecochn.org/track-record — 기상 신호를 끈 뒤의 예측도 같은 정의로 매일 채점되어 공개됩니다.
- 이전 노트: 노트 #04 — 공개 API의 백테스트 정의가 어긋나 있던 사례. 이번 노트는 정의가 아니라 입력 데이터가 어긋나 있던 사례입니다.
맺음 — 끈 것도 기록합니다
"기상 변수를 반영한 예측"은 듣기 좋은 문장입니다. 이 신호를 계속 켜 두고 그렇게 말할 수도 있었습니다. 실제로 몇 달 동안은 켜 둔 채 거의 작동하지 않고 있었습니다.
고쳐서 제대로 작동하게 만든 뒤에야 이 신호가 지금 우리 예측에 보태는 것이 기준에 못 미친다는 것을 알 수 있었습니다. 작동하지 않는 것을 고치는 일과, 고친 것이 쓸모 있는지 재는 일은 다른 일이었습니다. 둘 다 했고, 결과는 끄는 쪽이었습니다. 끈 것은 폭염·폭우 신호 두 개이고, 하루 평균기온과 강수량 같은 기본 기상 변수는 지금도 예측에 들어갑니다.
다음 여름에 같은 기준으로 다시 재겠습니다. 그때 결과가 달라지면 그것도 여기에 적겠습니다.
고지
이 노트는 투자·매매 조언이 아닙니다. EcoChain은 공개 데이터 기반의 가격 참고 정보를 제공하며, 구매·계약 의사결정의 책임은 이용자에게 있습니다.