활용 신청을 마친 키로 첫 요청을 보냈는데 403 이 돌아왔다.

{
  "OpenAPI_ServiceResponse": {
    "cmmMsgHeader": {
      "errMsg": "SERVICE_KEY_IS_NOT_REGISTERED_ERROR",
      "returnAuthMsg": "등록되지 않은 서비스키",
      "returnReasonCode": "30"
    }
  }
}

등록되지 않은 서비스 키라고 한다. 그런데 공공데이터포털 마이페이지에는 「기상청_단기예보 조회서비스」가 승인으로 떠 있었고, 키는 그 화면에서 그대로 복사해 온 것이었다. 키를 다시 복사해 붙여도 결과는 같았다.

식단서랍의 메뉴 추천에 날씨를 붙이려던 참이었다. 「비 오는 저녁엔 전」, 「쌀쌀한 날엔 국물」처럼 추천 문장 앞에 날씨 한 줄을 얹는 기능이다. 그러려면 서버가 기상청 예보를 받아야 했고, 그 첫 호출에서 막혔다. 이 글은 그 403 부터 시작해서 기상청 단기예보 API 를 처음 붙일 때 걸리는 세 가지, 서비스 키와 발표 시각과 응답 코드를 정리한 것이다. 예제는 C#(.NET) 이지만 요청 주소와 응답 모양은 언어와 상관없이 같다.

키는 맞았다, 두 번 인코딩했을 뿐

공공데이터포털은 일반 인증키를 두 가지 모양으로 보여 준다. Encoding 키와 Decoding 키다. 둘은 같은 키다. Decoding 키에 들어 있는 +, /, = 같은 문자를 URL 에 넣을 수 있게 %2B, %2F, %3D 로 바꿔 놓은 것이 Encoding 키다.

문제는 내 코드가 키를 받자마자 Uri.EscapeDataString 으로 한 번 더 인코딩하고 있었다는 것이다. 환경 변수에 넣어 둔 키는 Encoding 키였다. 이미 %2B 로 바뀐 키를 한 번 더 인코딩하면 % 가 %25 가 되어 %252B 가 된다. 서버는 이걸 한 번만 풀어서 %2B 가 그대로 남은 문자열을 키로 받는다. 그런 키는 등록돼 있을 리가 없다.

인코딩을 빼고 보내자 바로 NORMAL_SERVICE 가 돌아왔다. 다만 키를 넣는 사람이 매번 어느 쪽 키를 넣었는지 기억해 주길 바랄 수는 없다. 운영 서버에는 언젠가 다른 쪽 키가 들어갈 것이다. 그래서 키 모양을 보고 알아서 판단하게 했다.

var key = options.KmaServiceKey!.Trim();
// Decoding 키는 인코딩해서, 이미 인코딩된 키(%xx)는 그대로
var encodedKey = key.Contains('%') ? key : Uri.EscapeDataString(key);

Decoding 키에는 % 가 나올 일이 없고, Encoding 키에는 거의 반드시 있다. 둘 중 어느 것을 넣어도 서버에는 한 번만 인코딩된 키가 간다.

하나 더 알아 둘 것이 있다. 같은 SERVICE_KEY_IS_NOT_REGISTERED_ERROR 는 키가 그 서비스에 활용 신청되지 않았을 때도 나온다. 공공데이터포털의 키는 계정마다 하나지만, 쓸 수 있는 서비스는 신청한 것뿐이다. 같은 키로 기상청은 잘 되는데 에어코리아 대기오염 API 를 부르니 똑같은 403 이 났다. 이 오류가 나면 인코딩을 먼저 보고, 그다음 그 서비스를 신청했는지, 승인 직후라 아직 반영 전인지를 본다.

base_time 은 아무 시각이나 받지 않는다

키가 통하자 다음 벽은 base_time 이었다. 단기예보 조회(getVilageFcst)의 요청은 이렇게 생겼다.

https://apis.data.go.kr/1360000/VilageFcstInfoService_2.0/getVilageFcst
  ?serviceKey={키}&pageNo=1&numOfRows=500&dataType=JSON
  &base_date=20261027&base_time=0200&nx=60&ny=127

base_date 와 base_time 은 「언제 나온 예보를 달라」는 뜻이다. 「몇 시의 날씨를 달라」가 아니다. 단기예보는 하루 여덟 번, 02·05·08·11·14·17·20·23시에 발표되고, 발표 시각에서 10분쯤 지나야 API 로 받을 수 있다. 그 사이의 시각을 넣으면 받아 주지 않는다.

여기서 걸리는 지점이 응답 모양이다. 키가 틀리면 HTTP 403 이 왔지만, base_time 이 틀리면 HTTP 200 이 온다. 성공 응답과 같은 상태 코드에, 본문의 resultCode 만 다르다. 실제로 받아 본 것들이다.

요청 HTTP resultCode resultMsg
서비스 키 이중 인코딩·미신청 403 (형식이 다름) SERVICE_KEY_IS_NOT_REGISTERED_ERROR
아직 오지 않은 발표 시각 200 10 현재 이후의 base_time은 호출할 수 없습니다.
사흘(72시간)보다 오래된 발표 200 10 최근 3일 간의 자료만 제공합니다.
발표 시각이 아닌 0600 200 03 NO_DATA
정상 200 00 NORMAL_SERVICE

res.IsSuccessStatusCode 만 보고 본문을 파싱하면, resultCode 10 이 온 날 body 가 없어서 예외가 난다. dataType=JSON 을 빼먹으면 XML 이 오는 것도 같은 종류의 함정이다. 기본값이 XML 이다. 그래서 응답을 읽는 쪽은 JSON 인지, resultCode 가 00 인지를 먼저 확인하고, 아니면 「날씨 없음」으로 처리한다.

// 응답 JSON 의 item 목록. 키 오류 등은 모양이 달라서 JSON 이 아니거나 resultCode 가 00 이 아니면 null
private static List<Row>? ReadRows(string body)
{
    if (string.IsNullOrWhiteSpace(body) || body.TrimStart()[0] != '{') return null;
    try
    {
        using var doc = JsonDocument.Parse(body);
        var response = doc.RootElement.GetProperty("response");
        if (response.GetProperty("header").GetProperty("resultCode").GetString() != "00") return null;
        return response.GetProperty("body").GetProperty("items").GetProperty("item").EnumerateArray()
            .Select(i => new Row(i.GetProperty("category").GetString() ?? "",
                                 i.GetProperty("fcstDate").GetString() + i.GetProperty("fcstTime").GetString(),
                                 i.GetProperty("fcstValue").GetString()))
            .ToList();
    }
    catch (Exception ex) when (ex is JsonException or KeyNotFoundException or InvalidOperationException)
    {
        return null;
    }
}

키 오류의 403 본문은 response 가 아니라 OpenAPI_ServiceResponse 로 시작한다. GetProperty("response") 에서 KeyNotFoundException 이 나고, 그것도 null 로 떨어진다. 어떤 실패든 호출한 쪽은 「이번엔 날씨가 없다」 하나만 알면 된다. 메뉴 추천은 날씨 없이도 돌아가야 하니까.

어느 발표를 고를 것인가

남은 문제는 지금 시각에 맞는 base_time 을 계산하는 일이다. 가장 흔한 방법은 「지금보다 앞선 가장 최근 발표」를 고르는 것이다. 그런데 식단서랍에 필요한 것은 최신 예보가 아니라 오늘 하루 전체였다. 아침 최저기온과 낮 최고기온이 있어야 「일교차가 큰 날」이나 「어제보다 부쩍 추워진 날」을 알 수 있다.

단기예보에서 최저기온(TMN)은 06시 칸에, 최고기온(TMX)은 15시 칸에 한 줄씩 들어 있다. 그런데 오후 발표에는 오늘의 06시가 이미 지나서 오늘 TMN 이 없다. 오늘의 최저와 최고를 둘 다 담은 발표는 새벽 02시 발표다. 02시 발표의 첫 예보 시각은 03시라서, 오늘 03시부터 앞으로 나흘 정도가 다 들어 있다.

/// <summary>단기예보 기준 — 그날 최저·최고가 모두 들어 있는 02시 발표분.
/// 02:10 전이면 전날 23시 발표분(오늘이 +1일).</summary>
public static (string BaseDate, string BaseTime, DateTime TargetDate) VillageBaseFor(DateTime utcNow)
{
    var kst = utcNow.AddHours(9);
    if (kst.TimeOfDay >= new TimeSpan(2, 10, 0))
        return (kst.ToString("yyyyMMdd", CultureInfo.InvariantCulture), "0200", kst.Date);
    return (kst.AddDays(-1).ToString("yyyyMMdd", CultureInfo.InvariantCulture), "2300", kst.Date);
}

두 가지를 일부러 이렇게 했다. 하나는 한국 시각을 DateTime.Now 가 아니라 UtcNow.AddHours(9) 로 만든 것이다. 서버의 시간대 설정이 어떻게 되어 있든 기상청 기준인 한국 시각이 나온다. 또 하나는 자정부터 02:10 사이다. 이 시간에는 오늘 02시 발표가 아직 없다. 그래서 어제 23시 발표를 받고, 그 안에서 날짜가 「오늘」인 줄만 골라 쓴다. 이렇게 하면 이 시간대에는 오늘 최저기온이 없을 수 있는데, 그건 「모름」으로 두고 비교를 건너뛴다.

numOfRows 도 그냥 크게 잡으면 안 된다. 이날 서울 격자로 받아 본 전체 건수는 02시 발표가 944줄, 05시 발표가 907줄, 17시 발표가 1,052줄이었다. 넉넉하게 numOfRows=1000 을 넣었다면 17시 발표는 마지막 52줄이 잘려 온다. 오류는 나지 않는다. totalCount 를 보지 않으면 모르고 지나간다. 식단서랍은 오늘 하루치만 쓰고 응답이 날짜와 시각 순서로 오기 때문에 500줄이면 충분했다. 나흘치를 다 써야 한다면 totalCount 를 보고 다음 페이지를 이어 받아야 한다.

처음 붙일 때 다시 보게 될 것들

돌아보면 처음 붙일 때 막힌 것은 모두 「실패가 실패처럼 보이지 않는」 곳이었다. 키 문제는 403 이었지만 이유가 키 자체가 아니라 인코딩이었고, 발표 시각 문제는 200 안에 숨어 있었고, 줄 수 문제는 아무 표시도 없이 잘려 왔다. 그래서 기상청 응답을 다루는 코드는 HTTP 상태 코드보다 resultCode 를 먼저 믿고, 잘못된 응답은 모두 「날씨 없음」 하나로 모아서 기능 전체가 흔들리지 않게 만들었다.

여기까지 하면 요청은 통한다. 하지만 요청 주소의 nx=60&ny=127 은 아직 설명하지 않았다. 위도와 경도가 아니라 기상청이 쓰는 5km 격자 번호다. 다음 편에서는 위경도를 이 격자로 바꾸는 변환식과, 1,000줄 가까운 세로 목록을 「시각별 날씨」로 펼치는 파싱을 다룬다.


자료 출처: 기상청, 공공데이터포털 「기상청_단기예보 조회서비스」. 글에 실은 예보 값은 2026년 10월 말 서울 격자(60, 127)로 받은 응답이다.