같은 날 서울의 미세먼지 예보가 세 번 나왔다. 새벽 5시 발표는 「좋음」, 오전 11시 발표도 「좋음」, 오후 5시 발표는 「보통」이었다.
셋 다 에어코리아 대기질 예보 API 의 한 번 조회에 같이 들어 있었다. 응답 목록의 순서에 기대서 맨 위의 「오늘」 예보를 집으면, 그날은 마침 17시 발표가 맨 위라 맞는 답이 나온다. 하지만 그건 순서가 우연히 맞았을 뿐이다. 코드는 어느 발표를 골랐는지 모른다.
4편에서 가까운 도시대기 측정소를 찾고 실시간 PM10·PM2.5 를 읽었다. 숫자는 손에 들어왔다. 이번 편은 그 숫자를 「좋음」, 「보통」이라는 말로 바꿔 화면에 올리는 이야기다. 등급을 어떤 기준으로 고를지, 예보를 어떻게 읽을지, 그리고 에어코리아의 이용 조건을 어떻게 지킬지를 다룬다.
등급이 두 가지로 온다
ver=1.3 으로 받은 실시간 측정값에는 PM10 등급이 두 개 있다. pm10Grade 와 pm10Grade1h 다. 대부분 같은 값이지만, 같은 순간 전국 측정소를 받아 보니 두 등급이 다른 곳이 꽤 있었다. 전북 영파동 측정소가 그랬다.
| 항목 | 값 |
|---|---|
pm10Value (1시간 농도) |
31 |
pm10Value24 (24시간 예측이동농도) |
28 |
pm10Grade1h |
2 (보통) |
pm10Grade |
1 (좋음) |
환경부 미세먼지 예보 등급 기준은 PM10 이 30 이하 좋음, 80 이하 보통, 150 이하 나쁨, 그 위가 매우나쁨이고, PM2.5 는 15, 35, 75 가 경계다. 1시간 농도 31 은 「보통」의 문턱을 막 넘었고, 24시간 기준 농도 28 은 아직 「좋음」 안쪽이다. 두 등급은 서로 다른 숫자로 매긴 것이고, 둘 다 맞다.
어느 쪽을 보여 줄지는 무엇을 묻느냐에 달려 있다. pm10Grade 는 24시간 기준이라 하루 동안 이 공기를 마셨을 때를 말한다. pm10Grade1h 는 지금 한 시간의 공기를 말한다. 식단서랍의 날씨 칩에 붙일 값은 「지금 이 끼니」를 위한 것이라 1시간 등급을 골랐다. 하루 건강 영향을 안내하는 서비스라면 반대로 24시간 등급이 맞다.
하나 더 정한 것은 등급을 직접 계산하지 않는다는 것이다. 위 기준표가 있으니 농도로 등급을 다시 매기는 코드는 금방 짤 수 있다. 하지만 에어코리아 데이터의 이용허락범위는 「저작자표시-변경금지」다. 응답에 들어 있는 등급을 그대로 쓰고, 14 를 「좋음」「매우나쁨」이라는 말로 바꾸는 것까지만 했다. 값이 없으면 지어내지 않고 「정보 없음」이다.
public static string GradeText(int? grade) => grade switch
{
1 => "좋음", 2 => "보통", 3 => "나쁨", 4 => "매우나쁨", _ => "정보 없음",
};
예보는 발표 시각부터 골라야 한다
실시간 값은 「지금」이고, 예보는 「오늘 남은 시간과 내일」이다. 저녁 메뉴를 물을 때는 지금 공기도 좋지만 「오늘 저녁부터 나빠진다」가 더 쓸모 있을 수 있다. 그래서 getMinuDustFrcstDspth 도 붙였다.
getMinuDustFrcstDspth?returnType=json&searchDate=2026-10-27&numOfRows=100&pageNo=1
searchDate 는 「이 날짜에 발표된 예보」라는 뜻이다. 대기질 예보는 05·11·17·23시에 발표되고, 한 번 조회하면 그날 나온 발표가 전부 섞여 온다. 위 요청의 오후 6시 응답은 20줄이었다. 발표 세 번에, 발표마다 미세먼지(PM10)·초미세먼지(PM25)·오존(O3)이 오늘·내일(17시 발표는 모레까지) 한 줄씩이다. 오존은 17시 발표에 「오늘」 줄이 없었다. 하루가 다 끝나 가는 시각이니 오늘 예보를 다시 내지 않는다.
발표 시각은 dataTime 에 문자열로 들어 있다.
{"informCode":"PM10","informData":"2026-10-27","dataTime":"2026-10-27 17시 발표",
"informGrade":"서울 : 보통,제주 : 좋음,전남 : 좋음, … ,경기북부 : 보통,인천 : 보통", …}
informData 가 「예보 대상 날짜」, dataTime 이 「발표 시각」이다. 이름만 봐서는 바꿔 읽기 쉽다. 앞 13글자(2026-10-27 17)를 시각으로 읽어, 같은 대상 날짜의 예보 중 가장 늦게 발표된 것을 골랐다. 새벽 5시 전에는 오늘 날짜로 발표된 것이 아직 없으니 전날 발표분에서 찾는다.
public sealed record Forecast(string Code, DateTime Date, DateTime IssuedAt, Dictionary<string, string> Grades);
/// <summary>그날(target) 예보 중 가장 늦게 발표된 것. 05시 발표 전이면 전날 발표분에서 찾는다.</summary>
public async Task<Forecast?> LatestForecastAsync(string code, DateTime target, DateTime nowKst, CancellationToken ct)
{
var all = await GetForecastsAsync(nowKst.Date, ct);
if (!all.Any(f => f.Code == code && f.Date == target))
all.AddRange(await GetForecastsAsync(nowKst.Date.AddDays(-1), ct));
return all.Where(f => f.Code == code && f.Date == target).MaxBy(f => f.IssuedAt);
}
이렇게 고치고 나서 처음 장면의 세 발표를 늘어놓아 보니, 서울은 17시 발표의 「보통」이 골라졌다. 응답 순서가 바뀌어도 답은 같다.
「서울 : 보통」 문자열과 둘로 나뉜 권역
등급은 JSON 배열이 아니라 한 줄짜리 문자열로 온다. 쉼표로 권역을 나누고, 콜론으로 이름과 등급을 나눈다. 콜론 앞뒤에 공백이 있으니 잘라 낸다.
public static Dictionary<string, string> ParseInformGrade(string s)
=> s.Split(',', StringSplitOptions.RemoveEmptyEntries)
.Select(p => p.Split(':', 2))
.Where(p => p.Length == 2)
.GroupBy(p => p[0].Trim())
.ToDictionary(g => g.Key, g => g.First()[1].Trim());
문제는 이름이다. 예보 권역은 19곳인데 시·도와 1:1 이 아니다. 경기는 경기남부와 경기북부, 강원은 영서와 영동으로 나뉜다. 반대로 식단서랍은 전남과 광주를 「전남·광주」 한 지역으로 다루는데, 예보에서는 전남과 광주가 따로다. 시·도 대표 지점 하나에 권역 여럿이 걸리는 셈이다.
이럴 때는 더 나쁜 쪽을 보여 주기로 했다. 경기남부가 좋음, 경기북부가 보통이면 경기는 보통이다. 공기 정보에서 낙관적인 쪽으로 틀리는 것보다 조심스러운 쪽으로 틀리는 편이 낫다.
// 예보 권역 — 시·도와 1:1 이 아니다 (경기는 남부·북부, 강원은 영서·영동)
["경기"] = new[] { "경기남부", "경기북부" },
["강원"] = new[] { "영서", "영동" },
["전남·광주"] = new[] { "전남", "광주" },
/// <summary>여러 권역에 걸친 시·도는 더 나쁜 쪽을 보여 준다.</summary>
public static string? WorstOf(Dictionary<string, string> grades, string region)
=> ForecastAreas.TryGetValue(region, out var areas)
? areas.Select(a => grades.GetValueOrDefault(a)).Where(g => g != null)
.OrderByDescending(g => Array.IndexOf(Order, g)).FirstOrDefault()
: null;
이 표 없이 시·도 이름을 그대로 예보에서 찾으면 경기, 강원, 전남·광주는 늘 「정보 없음」이 된다. 예보 문자열에 「경기」나 「강원」이라는 이름이 아예 없기 때문이다. 이것도 오류 없이 빈칸으로만 드러나는 종류의 문제다.
같은 시각에 실측과 예보를 나란히 놓아 보면, 둘이 다른 날이 흔하다. 인천은 지금 PM10 이 24㎍/㎥ 로 「좋음」인데 오늘 예보는 「보통」이었고, 강원은 반대로 실측이 「보통」인데 예보는 「좋음」이었다. 하나는 측정소 한 곳의 한 시간, 하나는 권역 전체의 하루 평균에 대한 전망이니 다를 수 있다. 그래서 둘을 섞어 하나의 등급으로 만들지 않고, 「지금」과 「오늘 예보」로 따로 적었다.
화면에 붙일 때 지킬 것
마지막은 이용 조건이다. 에어코리아 데이터는 출처 표기가 필수이고 변경금지 조건이 붙어 있다. 그래서 화면에 올릴 모양을 이렇게 정했다.
서울 미세먼지 보통 · 초미세먼지 좋음 (18시 · 중구 측정소)
오늘 예보: 미세먼지 보통 · 초미세먼지 좋음 (17시 발표)
자료: 한국환경공단 에어코리아 · 실시간 자료는 확정 전 값입니다
측정 시각과 측정소 이름을 같이 적은 것은, 4편에서 본 것처럼 측정소마다 최신 시각이 다르고 통신장애로 한두 시간 전 값을 쓸 때가 있어서다. 「확정 전 값」 안내는 에어코리아 실시간 자료가 나중에 검증을 거쳐 바뀔 수 있기 때문이다. 값을 고치지 않는 대신, 값이 어떤 성격인지를 같이 보여 주는 쪽을 택했다.
호출 수도 정리해 두었다. 측정소 목록은 하루 한 번, 시·도 16곳의 측정값은 한 시간에 한 번씩이라 하루 384번, 예보는 발표 시각에 맞춰 하루 네 번 남짓이다. 기상청 때와 마찬가지로 백그라운드에서 받아 두고, 사용자 요청 때는 받아 둔 값만 읽는다.
마치며
기상청과 에어코리아를 차례로 붙이고 나니 공공데이터 API 에서 시간이 드는 곳이 어디인지 보였다. 호출 자체는 주소 하나와 파라미터 몇 개로 끝난다. 시간이 드는 것은 응답이 「정상」이라고 말할 때 정말 정상인지를 확인하는 일, 그리고 기관마다 다른 좌표와 지역 구분을 내 서비스의 지역 하나에 맞추는 일이었다. 격자, TM, 측정소, 예보 권역까지, 같은 「서울」이 API 마다 다른 이름과 다른 경계로 나온다.
이 연재의 예제는 모두 C# 이지만, 함정은 언어를 가리지 않는다. 키를 한 번만 인코딩하고, resultCode 와 totalCount 를 확인하고, 숫자 자리의 문자열과 24시를 조심하고, 출처를 적으면 대부분의 공공데이터 API 를 덜 헤매고 붙일 수 있다.
자료 출처: 한국환경공단 에어코리아, 공공데이터포털 「한국환경공단_에어코리아_대기오염정보」·「한국환경공단_에어코리아_측정소정보」. 글에 실은 값은 2026년 10월 말에 받은 실시간 자료와 예보로, 실시간 자료는 확정 전 값이라 바뀌거나 빠질 수 있다.