서울시청에서 가장 가까운 대기오염 측정소는 400m 떨어진 「중구」 측정소였다. 두 번째와 세 번째는 「종로」와 「청계천로」였는데, 둘 다 1.7km 안쪽이었다.

거리만 보면 셋 다 그럴듯하다. 그런데 측정망 이름을 보면 이야기가 달라진다. 중구는 도시대기, 종로와 청계천로는 도로변대기 측정소다. 도로변대기 측정소는 이름 그대로 차가 많이 다니는 길가에 놓여 자동차 배출가스를 재는 곳이다. 「오늘 우리 동네 공기가 어떤가」를 물을 때 쓰는 측정소가 아니다.

3편까지 기상청 예보로 식단서랍의 날씨 판단을 만들었다. 이번에는 같은 공공데이터포털 키로 한국환경공단 에어코리아의 실시간 대기오염 정보를 붙여 본다. 날씨 칩 옆에 「미세먼지 보통」 한 줄을 얹기 위한 준비다. 실제 서비스에 넣기 전에 콘솔 프로그램으로 시·도 16곳을 돌려 보며 만들었고, 이 글의 값은 그때 받은 응답이다.

측정소 목록의 dmX 는 위도다

에어코리아의 실시간 측정값은 측정소 이름으로 묻는다. 그래서 먼저 「어느 측정소를 부를지」를 정해야 한다. 측정소정보 서비스의 getMsrstnList 를 조건 없이 부르면 전국 672곳이 온다.

{"stationName":"중구","mangName":"도시대기","addr":"서울 중구 덕수궁길 15 …",
 "dmX":"37.564639","dmY":"126.975961","item":"SO2, CO, O3, NO2, PM10, PM2.5","year":"1995"}

dmX 에 37, dmY 에 126 이 들어 있다. 지도에서 X 는 보통 가로(경도)인데, 여기서는 dmX 가 위도, dmY 가 경도다. 전국 672곳의 범위를 보면 dmX 는 33.238.6, dmY 는 124.6130.9 다. 한반도의 위도와 경도 범위 그대로다. 이름만 보고 X 를 경도로 넣으면 거리 계산이 통째로 엉뚱해진다. 오류는 나지 않는다.

측정소정보 서비스에는 가까운 측정소를 찾아 주는 getNearbyMsrstnList 도 있다. 그런데 이건 위경도가 아니라 TM 좌표를 받는다. 미터 단위의 평면 좌표라, 서울 혜화동이 tmX=200089, tmY=453946 처럼 나온다. 읍면동 이름을 TM 좌표로 바꿔 주는 getTMStdrCrdnt 와 이어서 쓰면 이렇게 된다.

getTMStdrCrdnt?umdName=혜화동          → tmX 200089.126044, tmY 453946.42329
getNearbyMsrstnList?tmX=…&tmY=…&ver=1.1 → 종로구(1.5), 종로(1.7), 청계천로(1.9)

2편에서 기상청은 격자, 이번에는 TM 이다. 같은 위치를 기관마다 다른 좌표로 묻는다. 이 방식을 쓰려면 위경도를 TM 으로 바꾸는 투영 변환이 하나 더 필요하다. 대신 목록에 위경도가 이미 들어 있으니, 목록을 한 번 받아 두고 거리를 직접 계산하기로 했다. 측정소는 하루 사이에 잘 바뀌지 않으니 목록은 하루 한 번이면 된다.

public sealed record Station(string Name, string Network, string Address, double Lat, double Lon);

.Select(i => new Station(S(i, "stationName")!, S(i, "mangName") ?? "", S(i, "addr") ?? "",
    Lat: double.Parse(S(i, "dmX")!, CultureInfo.InvariantCulture),   // dmX = 위도
    Lon: double.Parse(S(i, "dmY")!, CultureInfo.InvariantCulture)))  // dmY = 경도

가장 가까운 '도시대기' 측정소

거리는 하버사인 공식으로 구했다. 두 위경도 사이의 구면 거리를 km 로 주는 식이다. 수 km 안의 거리라 평면 근사로도 충분하지만, 식이 길지 않아서 그냥 정확한 쪽을 썼다.

/// <summary>가장 가까운 '도시대기' 측정소. 도로변·항만 측정소는 그 동네 공기를 대표하지 않는다.</summary>
public static (Station Station, double Km)? Nearest(IEnumerable<Station> stations, double lat, double lon)
    => stations.Where(s => s.Network == "도시대기")
        .Select(s => (Station: s, Km: Haversine(lat, lon, s.Lat, s.Lon)))
        .OrderBy(x => x.Km)
        .Cast<(Station, double)?>()
        .FirstOrDefault();

측정망 종류는 다섯 가지였다. 도시대기 532곳, 도로변대기 68곳, 항만 34곳, 교외대기 27곳, 섬 지역의 국가배경농도 11곳이다. 도시대기만 남기는 한 줄이 처음 장면의 문제를 해결한다. 이 필터를 빼면 서울은 지금처럼 중구가 나오지만, 대표 지점이 큰길가에 있는 도시라면 도로변 측정소가 뽑혀서 늘 실제보다 나쁜 공기를 보여 주게 된다.

식단서랍의 시·도 16곳 대표 지점으로 돌려 보니, 가장 가까운 도시대기 측정소까지의 거리는 제주 연동이 0.3km 로 가장 가까웠고 세종 한솔동이 3.3km 로 가장 멀었다. 기상청 격자가 5km 칸이니 측정소 거리도 그 안쪽이면 같은 「동네」로 봐도 된다고 판단했다.

실시간 측정값: ver 와 24:00

측정소를 정했으면 getMsrstnAcctoRltmMesureDnsty 로 그 측정소의 최근 측정값을 받는다. 여기서 첫 함정은 ver 파라미터다. 처음에 ver 없이 불렀더니 응답에 PM10 은 있는데 PM2.5 항목이 아예 없었다. 오류도 빈 값도 아니고 필드 자체가 빠진다. ver=1.3 을 붙여야 pm25Value, pm25Grade, 1시간 등급(pm10Grade1h, pm25Grade1h), 24시간 예측이동농도(pm10Value24, pm25Value24)가 같이 온다.

getMsrstnAcctoRltmMesureDnsty?returnType=json&stationName=종로구&dataTerm=DAILY&ver=1.3&numOfRows=24&pageNo=1

dataTerm=DAILY 는 최근 24시간을 뜻한다. 이걸 받아 시각별로 늘어놓다가 두 번째 함정을 만났다. 자정 무렵의 한 줄이 이랬다.

{"dataTime":"2026-10-26 24:00","pm10Value":"27","pm25Value":"13", …}

자정을 그날의 24:00 으로 적는다. DateTime.Parse("2026-10-26 24:00") 은 FormatException 을 던진다. 하루에 한 번, 자정이 지나고 한 시간 동안만 터지는 버그가 되기 딱 좋다. 다음 날 00:00 으로 바꿔 읽었다.

/// <summary>"2026-10-26 24:00" 처럼 자정을 24시로 적는다 — 다음 날 00:00 으로 읽는다.</summary>
public static DateTime ParseDataTime(string s)
{
    if (s.EndsWith(" 24:00", StringComparison.Ordinal))
        return DateTime.ParseExact(s[..10], "yyyy-MM-dd", CultureInfo.InvariantCulture).AddDays(1);
    return DateTime.ParseExact(s, "yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture);
}

'-' 와 통신장애, 그리고 조용한 빈 응답

측정값은 늘 있지 않다. 서울 「공항대로」 측정소의 최근 네 시간은 이랬다.

시각 pm10Value pm10Flag
18시 - 통신장애
17시 - 통신장애
16시 43 (없음)
15시 52 (없음)

값이 없으면 숫자 자리에 - 가 오고, 이유가 pm10Flag 같은 플래그 칸에 들어온다. 같은 시각에 전국 672곳을 한 번에 받아 보니 PM10 은 44곳, PM2.5 는 81곳이 - 였다. 대부분 통신장애였다. 등급도 같이 비어서, 통합대기환경지수 등급(khaiGrade)이 없는 측정소가 100곳이었다.

그래서 「최신 한 줄」을 그대로 쓰지 않고, 항목마다 값이 있는 가장 최근 시각을 골랐다. 너무 오래된 값은 지금 공기라고 하기 어려우니 세 시간까지만 인정했다.

// "-" 와 null 은 값 없음
private static int? I(JsonElement e, string name)
    => int.TryParse(S(e, name), NumberStyles.Integer, CultureInfo.InvariantCulture, out var n) ? n : null;

/// <summary>항목마다 가장 최근의 '값이 있는' 시각. 너무 오래된 값(maxAge 초과)은 쓰지 않는다.</summary>
public static (int Value, int? Grade, DateTime Kst)? Latest(IEnumerable<Measure> rows,
    Func<Measure, int?> value, Func<Measure, int?> grade, DateTime nowKst, TimeSpan maxAge)
    => rows.Where(r => value(r) != null && nowKst - r.Kst <= maxAge)
        .Select(r => ((int Value, int? Grade, DateTime Kst)?)(value(r)!.Value, grade(r), r.Kst))
        .FirstOrDefault();

PM10 과 PM2.5 를 따로 고르는 데도 이유가 있다. 전국 데이터에는 PM2.5 만 통신장애인 측정소가 45곳 있었다. 두 값을 한 줄로 묶어 고르면 멀쩡한 PM10 까지 한 시간 전 값으로 밀린다.

시각도 측정소마다 다르다. 같은 순간에 받은 전국 데이터에서 18시 값이 들어온 측정소가 457곳, 아직 17시가 최신인 측정소가 210곳이었다. 16곳을 돌린 결과에서도 세종 한솔동만 17시 값이 최신이었다. 그래서 화면에는 값 옆에 측정 시각을 같이 적었다.

마지막 함정은 측정소 이름이 틀렸을 때다. 없는 이름을 넣어도 HTTP 200 에 resultCode 00, totalCount 0 이 온다. 1편의 기상청처럼 오류 코드로 알려 주지 않는다. 측정소 이름은 목록에서 고른 값만 쓰고, 빈 목록은 「정보 없음」으로 처리한다.

열여섯 곳을 돌려 본 결과

이렇게 만든 코드로 시·도 16곳을 돌린 결과의 일부다.

서울     중구(0.4km)     PM10 33㎍/㎥ 보통 (18시) / PM2.5 8㎍/㎥ 좋음 (18시)
부산     연산동(0.9km)   PM10 21㎍/㎥ 좋음 (18시) / PM2.5 8㎍/㎥ 좋음 (18시)
세종     한솔동(3.3km)   PM10 25㎍/㎥ 좋음 (17시) / PM2.5 6㎍/㎥ 좋음 (17시)
제주     연동(0.3km)     PM10 29㎍/㎥ 좋음 (18시) / PM2.5 12㎍/㎥ 좋음 (18시)

API 를 붙이는 데 필요한 것은 많지 않았다. 측정소 목록 한 번, 측정소마다 측정값 한 번이다. 시간이 든 것은 응답이 「정상」이라고 말하면서 비어 있거나 모양이 다른 경우들이었다. 필드가 빠지고, 24시가 오고, 숫자 자리에 - 가 오고, 없는 측정소에도 정상 코드가 온다. 하나씩 null 로 떨어지게 만들어 두면, 측정소 하나가 통신장애여도 화면 한 칸이 「정보 없음」이 될 뿐이다.

남은 것은 이 숫자를 사람에게 어떻게 보여 줄지다. 응답에는 등급이 1시간 기준과 24시간 기준 두 가지로 오고, 에어코리아는 오늘·내일 대기질 예보도 따로 낸다. 다음 편에서는 등급을 고르는 기준, 「서울 : 보통,제주 : 좋음」으로 오는 예보 문자열을 시·도에 맞추는 일, 그리고 출처와 이용 조건을 지키며 화면에 붙이는 방법을 다룬다.


자료 출처: 한국환경공단 에어코리아, 공공데이터포털 「한국환경공단_에어코리아_대기오염정보」·「한국환경공단_에어코리아_측정소정보」. 글에 실은 값은 2026년 10월 말에 받은 실시간 자료로, 확정 전 자료라 측정소 사정에 따라 바뀌거나 빠질 수 있다.