서울시청의 위도는 37.56, 경도는 126.98 이다. 기상청 단기예보 API 에 이 숫자를 넣을 칸은 없다.
요청에 들어가는 것은 nx=60&ny=127 이다. 기상청은 한반도를 가로세로 5km 칸으로 나눈 격자 위에 예보를 낸다. 동네예보 시절부터 이어진 방식이다. 예보는 지점이 아니라 칸마다 하나씩 있고, 서울시청은 60번째 열, 127번째 행 칸에 들어간다. 그래서 위치에서 날씨를 얻으려면 먼저 위경도를 칸 번호로 바꿔야 한다.
1편에서 서비스 키와 발표 시각을 정리했다. 이번 편은 그 다음 두 가지, 좌표를 격자로 바꾸는 일과 돌아온 응답을 쓸 수 있는 모양으로 펼치는 일이다.
평면 지도 위의 칸 번호
격자는 지구를 평면에 펼친 지도 위에 그려져 있다. 기상청이 쓰는 펼침 방식은 람베르트 정각원추도법이다. 지구에 원뿔을 씌워 북위 30도와 60도 두 선에서 닿게 하고, 그 원뿔을 펴서 평면으로 만든다. 한반도처럼 동서로 긴 중위도 지역을 덜 찌그러뜨리는 방식이라 기상 지도에 많이 쓴다. 기준점은 북위 38도·동경 126도이고, 그 점이 격자의 (43, 136) 칸이다.
변환식은 기상청 단기예보 활용가이드에 C 코드로 실려 있다. 이걸 C# 으로 옮겼다. 상수 여섯 개가 지도 하나를 정한다.
/// <summary>위경도 → 기상청 격자 (람베르트 정각원추도법, 활용가이드의 변환식)</summary>
public static class KmaGrid
{
private const double Re = 6371.00877; // 지구 반경 (km)
private const double GridKm = 5.0; // 격자 간격 (km)
private const double Slat1 = 30.0, Slat2 = 60.0; // 표준 위도
private const double Olon = 126.0, Olat = 38.0; // 기준점
private const double Xo = 43, Yo = 136; // 기준점 격자
public static (int Nx, int Ny) ToGrid(double lat, double lon)
{
const double degrad = Math.PI / 180.0;
var re = Re / GridKm;
var slat1 = Slat1 * degrad;
var slat2 = Slat2 * degrad;
var olon = Olon * degrad;
var olat = Olat * degrad;
var sn = Math.Tan(Math.PI * 0.25 + slat2 * 0.5) / Math.Tan(Math.PI * 0.25 + slat1 * 0.5);
sn = Math.Log(Math.Cos(slat1) / Math.Cos(slat2)) / Math.Log(sn);
var sf = Math.Pow(Math.Tan(Math.PI * 0.25 + slat1 * 0.5), sn) * Math.Cos(slat1) / sn;
var ro = re * sf / Math.Pow(Math.Tan(Math.PI * 0.25 + olat * 0.5), sn);
var ra = re * sf / Math.Pow(Math.Tan(Math.PI * 0.25 + lat * degrad * 0.5), sn);
var theta = lon * degrad - olon;
if (theta > Math.PI) theta -= 2.0 * Math.PI;
if (theta < -Math.PI) theta += 2.0 * Math.PI;
theta *= sn;
var x = Math.Floor(ra * Math.Sin(theta) + Xo + 0.5);
var y = Math.Floor(ro - ra * Math.Cos(theta) + Yo + 0.5);
return ((int)x, (int)y);
}
}
sn 은 원뿔이 얼마나 뾰족한지, ro 는 원뿔 꼭짓점에서 기준점까지의 거리, ra 는 꼭짓점에서 구하려는 점까지의 거리다. 평면에 펼친 원뿔은 부채꼴이 되니, 점의 위치는 꼭짓점을 중심으로 한 거리(ra)와 각도(theta)로 정해진다. 그걸 직교 좌표로 바꾸고 기준점 칸 번호를 더하면 끝이다. 마지막의 + 0.5 와 Floor 는 반올림이다.
옮긴 식이 맞는지는 활용가이드에 같이 실린 격자·위경도 표로 확인했다. 시·도 대표 지점 몇 곳을 넣어 보면 표의 값과 그대로 맞는다.
| 지점 | 위도, 경도 | 계산한 nx, ny | 활용가이드 표 |
|---|---|---|---|
| 서울 | 37.5635694, 126.9800083 | 60, 127 | 60, 127 |
| 부산 | 35.1770194, 129.0769528 | 98, 76 | 98, 76 |
| 강원 | 37.8826917, 127.7319750 | 73, 134 | 73, 134 |
| 제주 | 33.4856944, 126.5003333 | 52, 38 | 52, 38 |
한 가지 조심할 것은 격자 범위다. 시험 삼아 nx=999&ny=999 를 보내 봤는데 NORMAL_SERVICE 와 함께 예보가 돌아왔다. 범위를 벗어난 칸을 막아 주지 않는다는 뜻이다. 엉뚱한 좌표가 들어가도 그럴듯한 날씨가 나오니, 격자 변환은 호출하는 쪽에서 맞는지 확인해야 한다.
위치는 받지 않기로 했다
변환식까지 만들어 놓고, 정작 식단서랍은 사용자의 위치를 받지 않기로 했다.
이유는 두 가지다. 먼저 메뉴 추천에는 동네 단위 정밀도가 필요 없다. 「오늘 저녁이 쌀쌀한가」는 같은 시·도 안에서 거의 갈리지 않는다. 그런데 위치 권한을 묻는 순간, 식단 앱이 왜 내 위치를 원하는지 설명해야 하고 처리방침에 위치 정보 항목이 생긴다. 얻는 것에 비해 사용자에게 지우는 부담이 크다.
다음은 호출 수다. 사용자마다 다른 칸을 부르면 호출 수가 사용자 수를 따라 늘어난다. 시·도 대표 지점으로 고정하면 지역은 16개로 끝나고, 서버가 그 16칸만 주기적으로 받아 두면 된다. 요청이 들어올 때마다 기상청을 부를 필요도 없다.
그래서 사용자는 설정에서 시·도를 하나 고르고, 서버는 활용가이드 표에 있는 시·도 대표 지점의 격자를 그대로 쓴다.
public sealed record WeatherRegion(string Code, string Name, int Nx, int Ny, double Lat, double Lon);
public static readonly IReadOnlyList<WeatherRegion> All = new WeatherRegion[]
{
new("seoul", "서울", 60, 127, 37.5635694, 126.9800083),
new("busan", "부산", 98, 76, 35.1770194, 129.0769528),
new("jeju", "제주", 52, 38, 33.4856944, 126.5003333),
// … 시·도 16곳
};
KmaGrid.ToGrid 는 이 표를 검증하는 데 쓰고, 나중에 더 좁은 지역이 필요해지면 그때 쓴다. 위도·경도를 같이 들고 있는 것도 그래서다.
944줄을 시각별 날씨로 펼치기
이제 응답이다. 서울 칸의 02시 발표를 받으면 item 이 944개 온다. 한 줄이 「한 시각의 한 항목」이다.
{"baseDate":"20261027","baseTime":"0200","category":"TMP",
"fcstDate":"20261027","fcstTime":"0300","fcstValue":"13","nx":60,"ny":127}
category 가 항목이다. 단기예보에는 기온(TMP), 하늘상태(SKY), 강수형태(PTY), 강수확률(POP), 1시간 강수량(PCP), 1시간 적설(SNO), 습도(REH), 풍속(WSD), 풍향(VEC)과 바람 성분(UUU, VVV), 파고(WAV)까지 열두 항목이 시각마다 한 줄씩 있다. 여기에 최저(TMN)·최고(TMX)기온이 날마다 한 줄씩 더 붙는다. 이날 받은 944줄은 열두 항목 × 78개 시각에 TMN·TMX 가 네 줄씩이었다.
쓰기 좋은 모양은 「시각 하나에 기온·바람·하늘·강수가 같이 있는」 행이다. 세로 목록을 fcstDate + fcstTime 으로 묶어서 펼친다.
public sealed record WeatherHour(DateTime Kst, double? TempC, double? WindMs, int Pty, int Sky);
private static List<WeatherHour> Hours(IEnumerable<Row> rows, string tempCategory)
=> rows.Where(r => r.Category is not ("TMN" or "TMX"))
.GroupBy(r => r.Time)
.Select(g =>
{
string? V(string c) => g.FirstOrDefault(r => r.Category == c)?.Value;
return new WeatherHour(Kst(g.Key), Num(V(tempCategory)), Num(V("WSD")), Int(V("PTY")), Int(V("SKY")));
})
.OrderBy(h => h.Kst)
.ToList();
기온 항목 이름을 인자로 받는 이유는 다음 편에서 쓸 초단기예보 때문이다. 같은 기온이 단기예보에서는 TMP, 초단기예보에서는 T1H 다.
fcstValue 는 숫자처럼 보이지만 전부 문자열이고, 실제로 숫자가 아닌 값이 섞여 온다. 강수량 PCP 를 날짜별로 모아 보니 이랬다.
| 날짜 | PCP 로 온 값 |
|---|---|
| 오늘·내일 | 강수없음 |
| 모레 | 강수없음, 1mm 미만, 3.0mm |
| 글피 | 0, 1, 1mm 미만 |
| 그다음 날 | 0 |
가까운 날은 「강수없음」과 단위 붙은 범주 문자열이고, 먼 날로 가면 맨 숫자가 섞인다. 적설 SNO 도 적설없음 과 0 이 섞여 왔다. 시각 수도 날짜마다 다르다. 오늘과 내일, 모레는 1시간 간격이었지만 그 뒤로는 칸이 듬성듬성해져서 글피는 8개 시각, 마지막 날은 자정 하나뿐이었다. 먼 날의 예보는 덜 촘촘하다는 걸 데이터가 그대로 보여 준다.
그래서 숫자 변환은 실패를 정상으로 받아들이게 짰다.
// +900 이상·-900 이하는 결측 (활용가이드)
private static double? Num(string? v)
=> double.TryParse(v, NumberStyles.Float, CultureInfo.InvariantCulture, out var d) && d is > -900 and < 900 ? d : null;
TryParse 가 실패하면 null 이고, 활용가이드가 결측으로 정한 ±900 바깥 값도 null 이다. 식단서랍은 강수량 대신 강수형태 PTY 로 비·눈을 판단하기 때문에 PCP 문자열을 숫자로 해석할 일은 없었다. 강수량 그래프처럼 그 값이 필요하다면 강수없음 은 0, 1mm 미만 은 범주로 따로, 3.0mm 은 단위를 떼서 읽는 식으로 범주를 직접 다뤄야 한다. CultureInfo.InvariantCulture 도 빼면 안 된다. 서버 문화권에 따라 소수점이 쉼표로 읽힐 수 있다.
마치며
격자 변환은 식 하나를 옮기면 끝나는 일이었지만, 정말로 시간을 쓴 것은 「어느 칸을 부를 것인가」였다. 위치를 받지 않고 시·도 16칸으로 고정하자 개인정보 걱정과 호출 수 걱정이 같이 사라졌다. 응답은 숫자가 아닌 숫자를 조심하면 된다. 실패해도 null 로 떨어지게 만들어 두면, 기상청이 값을 빼거나 바꿔도 화면 한 칸이 비는 정도로 끝난다.
다음 편은 이렇게 받은 예보를 실제 기능으로 바꾸는 이야기다. 앞으로 6시간을 더 정확하게 주는 초단기예보를 단기예보 위에 겹치고, 기온과 풍속으로 체감온도를 계산해서, 식단서랍이 「오늘 저녁은 국물이 당기는 날」을 판단하는 방식을 다룬다.
자료 출처: 기상청, 공공데이터포털 「기상청_단기예보 조회서비스」. 글에 실은 예보 값은 2026년 10월 말 서울 격자(60, 127)로 받은 응답이다.