날씨 정보를 내 앱이나 챗봇, 위젯에 넣고 싶을 때 가장 먼저 만나는 게 기상청 단기예보 API(getVilageFcst)입니다. 공공데이터라 무료고, 오늘부터 모레까지 시간대별 예보를 통째로 내려줍니다. 그런데 막상 붙여 보면 문서만 봐서는 모르는 함정이 줄줄이 나옵니다.

저는 가족용 아침 브리핑 텔레그램 봇에 이 API를 붙이면서 “최저기온이 새벽엔 안 나온다”, “위경도를 넣었더니 엉뚱한 동네가 나온다” 같은 삽질을 실제로 다 겪었습니다. 이 글은 그 과정에서 정리한, 문서엔 흐릿하게만 적힌 실전 포인트를 코드와 함께 풀어낸 기록입니다.

아래가 그 봇이 매일 아침 7시 30분에 보내주는 실제 결과물입니다. 이 글의 코드가 최종적으로 만들어내는 것이 바로 이 화면입니다.

실제 봇이 매일 아침 보내주는 날씨 브리핑

이 글은 작성 시점(2026년 6월) 기준이며, 공공데이터포털 정책·API 스펙은 바뀔 수 있습니다.

1. 왜 단기예보(getVilageFcst)인가 — 중기예보는 왜 안 쓰나

기상청 공공 API는 예보 기간에 따라 네 가지로 나뉩니다. “오늘 하루 흐름 + 비 오는 시간대 + 최저/최고기온”을 보여주려면 답은 단기예보 하나입니다.

종류예보 범위시간 해상도오늘 날씨에
초단기실황현재1시간(실황)지금 기온만, 하루 못 그림
초단기예보+6시간1시간오후까지뿐, 하루 전체 ✗
단기예보오늘~+3일1시간딱 맞음 ✓
중기예보+3일~+10일오전/오후오늘이 아예 없음 ✗

핵심은 중기예보에는 “오늘”이 없다는 점입니다. 중기예보는 모레까지를 단기예보가 이미 커버한다는 전제로, D+3(3일 뒤)부터 시작합니다. 게다가 시간대별이 아니라 ‘오전/오후’ 단위로 강수확률과 최저·최고기온만 줍니다. “오후 3~6시에 비”처럼 시간을 콕 집거나 “오전 맑음 → 밤 흐림” 같은 하루 흐름을 그리는 게 애초에 불가능한 구조입니다.

실제로 저도 처음엔 욕심내서 단기 + 중기를 같이 호출했습니다. 그런데 막상 화면에 쓰는 “오늘 흐름·비 시간대”는 전부 단기예보의 시간대별 데이터에서 나왔고, 중기예보 응답은 받아만 놓고 한 번도 안 썼습니다. 그래서 호출을 단기예보 하나로 줄였습니다. (모레 이후 주간 날씨를 보여줄 게 아니라면, 중기예보는 호출 수만 잡아먹습니다.)

2. 시작 전 반드시 아는 2가지 (여기서 다 막힌다)

① 위도·경도가 아니라 ‘격자 좌표(nx, ny)‘다

가장 흔한 첫 실수입니다. 이 API는 위경도(37.5, 126.9)를 안 받습니다. 기상청 고유의 격자 좌표 nx, ny를 받습니다. 예를 들어 서울 강서 일대는 nx=57, ny=127입니다.

위경도밖에 모른다면, 공공데이터포털의 단기예보 문서에 첨부된 **‘기상청 격자 좌표 엑셀’**에서 내 동네의 nx, ny를 찾아 넣으면 됩니다. (위경도→격자 변환 공식도 공개돼 있지만, 고정 위치라면 엑셀에서 값 하나 찾아 박는 게 제일 빠릅니다.)

위경도가 아니라 격자 좌표(nx, ny)를 넣는다

② 예보는 하루 8번만 발표된다 (+10분 유예)

단기예보는 아무 때나 최신값이 갱신되는 게 아닙니다. 하루 8번, 정해진 시각에만 발표됩니다.

발표 시각: 02, 05, 08, 11, 14, 17, 20, 23시

지금 시각에 따라 어느 발표본을 쓰는지

그래서 요청할 때 넣는 base_time은 “지금 시각”이 아니라 **“지금보다 앞선 가장 최근 발표 시각”**이어야 합니다. 게다가 발표 직후 약 10분간은 데이터가 안 올라옵니다. 안전하게 발표 시각 + 10분이 지난 시점을 기준으로 골라야 합니다.

from datetime import datetime, timedelta

def latest_base(now: datetime):
    valid = [2, 5, 8, 11, 14, 17, 20, 23]
    base_date = now.strftime("%Y%m%d")
    for h in sorted(valid, reverse=True):
        if now.hour > h or (now.hour == h and now.minute >= 10):
            return base_date, f"{h:02d}00"
    # 자정~02:10 사이엔 오늘 발표본이 없으니 '전날 23시' 발표를 쓴다
    yday = (now - timedelta(days=1)).strftime("%Y%m%d")
    return yday, "2300"

이 한 가지만 틀려도 “데이터가 비어 있다”는 응답을 받고 한참 헤매게 됩니다.

3. 실제 호출

준비물은 공공데이터포털에서 발급받은 서비스 키 하나입니다. 나머지는 위에서 정한 base_date/base_time과 격자 좌표를 끼우면 됩니다.

import requests

def fetch_forecast(service_key, nx, ny, base_date, base_time):
    url = "https://apis.data.go.kr/1360000/VilageFcstInfoService_2.0/getVilageFcst"
    params = {
        "serviceKey": service_key,
        "pageNo": 1,
        "numOfRows": 1000,   # 하루치 카테고리가 많아 넉넉히
        "dataType": "JSON",
        "base_date": base_date,
        "base_time": base_time,
        "nx": nx, "ny": ny,
    }
    r = requests.get(url, params=params, timeout=8)
    r.raise_for_status()
    return r.json()["response"]["body"]["items"]["item"]

numOfRows를 작게 잡으면 뒤쪽 시간대·카테고리가 잘려 나가니 넉넉히(1000) 두는 걸 권합니다.

4. 응답 읽는 법 — 카테고리 코드만 알면 된다

응답은 {category, fcstDate, fcstTime, fcstValue} 항목이 수백 개 쏟아지는 형태입니다. 핵심 카테고리는 여섯 개뿐입니다.

코드의미값 예시
TMP시간별 기온(℃)24
TMN그날 최저기온19
TMX그날 최고기온28
POP강수확률(%)60
PTY강수형태0없음·1비·2비/눈·3눈·4소나기
SKY하늘상태1맑음·3구름많음·4흐림

여기까지가 일반 튜토리얼이 다루는 범위입니다. 진짜 문제는 지금부터입니다.

5. ⭐ 진짜 함정: 최저기온(TMN)이 자꾸 비어 있다

아침 브리핑에 “최저 19℃ / 최고 28℃“를 넣고 싶었는데, 어느 날부터 최저기온이 통째로 안 나왔습니다. 코드는 그대로인데 값만 비는 전형적인 “조용한 실패”였습니다.

원인은 이렇습니다. 그날의 최저기온(TMN)은 새벽 02시 발표본에만 들어 있습니다. 최저기온은 보통 동트기 직전에 찍히니, 05시·08시처럼 이미 그 시각이 지난 발표본에는 “오늘의 최저”가 빠집니다. 즉 아침 7시에 최신 발표본(05시)을 받으면 TMX는 있어도 TMN이 없을 수 있습니다.

해결책은 02시 발표본을 따로 한 번 더 호출해 TMN만 보충하는 것입니다.

def pick_min_temp(items, today):
    for it in items:
        if it["category"] == "TMN" and it["fcstDate"] == today:
            return int(float(it["fcstValue"]))
    return None

# 최신 발표본에 오늘 TMN이 없으면, 02시 발표본에서만 보충
temp_min = pick_min_temp(items, today)
if temp_min is None:
    items_0200 = fetch_forecast(key, nx, ny, today, "0200")
    temp_min = pick_min_temp(items_0200, today)

# 그래도 없으면(자정 직후 등) 오늘 시간별 기온의 최솟값으로 대체
if temp_min is None:
    tmps = [int(float(it["fcstValue"])) for it in items
            if it["category"] == "TMP" and it["fcstDate"] == today]
    temp_min = min(tmps) if tmps else None

이 “02시 발표본 보완 + 시간별 기온 폴백” 2단 방어가 들어가야 비로소 어느 시간에 돌려도 최저기온이 안정적으로 나옵니다. 문서엔 한 줄로 흐릿하게 적혀 있어서, 직접 데이터가 빌 때까지 겪어봐야 알게 되는 부분입니다.

참고로 글 맨 위 스크린샷의 “최저 19℃“가 바로 이 보완 호출 덕분에, 이미 05시 발표본이 최신인 아침 7시 30분에도 빠지지 않고 찍힌 값입니다.

6. 한 걸음 더 — ‘비 오는 시간대’와 ‘하루 흐름’으로 가공하기

숫자만 던지면 안 읽힙니다. 시간대별 데이터를 사람이 읽는 문장으로 묶으면 가치가 확 올라갑니다.

  • 비 구간 묶기: PTY가 0이 아니거나 POP이 50% 이상인 연속된 시간을 하나로 합쳐 “오후 3~6시 비(최대 70%)“처럼 표현합니다.
  • 하루 하늘 흐름: 오전·낮·오후·밤 구간별로 가장 많이 등장한 SKY 값을 뽑아 “오전 맑음 → 오후 흐림 → 밤 비”처럼 한 줄로 잇습니다.
# 비 구간 묶기 (핵심 아이디어만)
windows, cur = [], None
for t in sorted(hourly):               # hourly: "HHMM" -> {PTY, POP, SKY, TMP}
    hh = int(t[:2]); d = hourly[t]
    pty = d.get("PTY", "0"); pop = int(d.get("POP", 0) or 0)
    if pty != "0" or pop >= 50:
        if cur and cur["end"] == hh - 1:      # 직전 시간과 연속이면 늘리고
            cur["end"] = hh; cur["maxpop"] = max(cur["maxpop"], pop)
        else:                                  # 끊겼으면 새 구간 시작
            if cur: windows.append(cur)
            cur = {"start": hh, "end": hh, "maxpop": pop}
if cur: windows.append(cur)
# → [{'start':15,'end':18,'maxpop':70}] → "15~18시 비 (최대 70%)"

이렇게 하면 “강수확률 70%” 같은 무미건조한 숫자가 “오후 3~6시에 비, 우산 챙기세요”로 바뀝니다. 봇이든 위젯이든 사람이 쓰는 건 결국 이 가공된 한 줄입니다. 글 맨 위 스크린샷의 “오늘 비 소식 없음”과 “오전 흐림 → 낮 맑음 → 오후 흐림”이 바로 이 가공 단계의 결과물입니다.

7. 해외 서버에서 돌린다면 — requests로는 자꾸 타임아웃이 났다

마지막은 코드가 아니라 인프라 함정입니다. 저는 이 봇을 오라클 클라우드 싱가포르 리전에서 돌렸는데, 어느 날부터 아침 브리핑에 날씨만 조용히 빠지기 시작했습니다. 로그를 보니 기상청 API 오류: timed out이 며칠째 반복되고 있었습니다.

실측해 보니 오라클 싱가포르 ↔ 기상청 서버(apis.data.go.kr) 구간에서 약 50% 확률로 간헐적 타임아웃이 났습니다. 성공할 땐 1~5초로 멀쩡한데, 절반은 응답이 안 왔습니다. 같은 서버에서 다른 공공 API(버스 도착정보 등)는 멀쩡했으니, 특정 해외 IP ↔ 기상청 구간의 네트워크 문제였습니다.

두 가지로 해결했습니다.

  1. requestshttpx로 교체. 같은 요청인데 requests는 자주 멈췄고 httpx는 확연히 안정적이었습니다. (해외 IP에서 더 그랬습니다.)
  2. 짧은 타임아웃 + 재시도. 무한정 기다리지 않고 8초로 끊고, 짧게 쉬며 여러 번 재시도해 “절반의 실패”를 흡수했습니다.
import httpx, time

def fetch_forecast(service_key, nx, ny, base_date, base_time, rows=1000):
    url = "https://apis.data.go.kr/1360000/VilageFcstInfoService_2.0/getVilageFcst"
    params = {
        "serviceKey": service_key, "pageNo": 1, "numOfRows": rows,
        "dataType": "JSON", "base_date": base_date, "base_time": base_time,
        "nx": nx, "ny": ny,
    }
    last_err = None
    for attempt in range(4):                 # 최대 4회 재시도
        try:
            r = httpx.get(url, params=params, timeout=8)   # 무한대기 대신 8초로 끊기
            r.raise_for_status()
            return r.json()["response"]["body"]["items"]["item"]
        except Exception as e:
            last_err = e
            if attempt < 3:
                time.sleep(0.8)              # 짧게 쉬고 다시
    raise last_err

국내 서버(또는 로컬)에서 돌린다면 이 문제는 거의 안 만납니다. 하지만 클라우드 무료 티어가 대개 해외 리전이라, 같은 증상을 겪는 분이 적지 않을 것입니다. “코드는 멀쩡한데 절반만 실패한다”면 의심해 볼 지점입니다.

마무리 — 문서가 아니라 데이터가 가르쳐준다

정리하면, 기상청 단기예보 API를 제대로 쓰려면 이것들을 기억하면 됩니다.

  1. 격자 좌표(nx, ny) — 위경도 아님
  2. 발표 시각 8번 + 10분 유예 — base_time은 “최근 발표본”
  3. TMN은 02시 발표본에만 — 새벽 보완 호출이 필요
  4. 해외 서버라면 httpx + 짧은 타임아웃 + 재시도 — 간헐 타임아웃 흡수

이것들은 전부 API 문서엔 흐릿하고, 실제로 값이 비는 걸 보고 나서야 손에 잡히는 것들입니다. 같은 데서 막혔던 분이라면 이 글이 몇 시간을 아껴주길 바랍니다.

관련 글: 집 시놀로지 나스로 개인 봇 돌리다 오라클 클라우드 무료 서버로 옮긴 이유 — 이 글의 봇을 24시간 어디서, 왜 그 서버에서 돌리는지에 대한 이야기.