”공공데이터를 활용해 날씨 정보를 제공하는 방법을 소개하려고 합니다. (Posted on May 21, 2020)
이병준제품길드 H파티 프론트엔드 엔지니어
가장 먼저 공공데이터 포털 홈페이지에서 필요한 API에 활용 승인 신청을 하고, 요청할 때 쓰이는 키를 발급 받습니다.
서버 구현 필요 기능
✅ XML 파서 : 미세먼지는 응답이 XML 뿐이기 때문에 파서가 필요합니다.
✅ k-d 트리 : 측정소 목록으로 미리 트리를 만들어 두고, 필요 시점에 인접한 측정소를 빠르게 찾기 위해 필요합니다.
✅ 캐시 라이브러리 : API 요청을 매번 하게 될 경우, 응답이 느리고 서버 부하가 크며 API 콜 수 제한이 있기 때문에 캐싱은 필수입니다.
k-d 트리 캐싱 외엔 직렬화가 가능한 데이터이기에 필요시 Redis를 적용할 수도 있습니다.
플로우

클라이언트
// Geolocation.jsexport default { data () { return { lon: undefined, lat: undefined } }, computed: { latlon () { return { lat: +this.lat, lon: +this.lon } } }, // watch: { latlon: 'fetchWeather' }, // mounted () { return this.geolocation() }, methods: { // fetchWeather ({ lat, lon }) { /* 서버로 API 요청 */ }, watchPositionOnce () { /* ...생략... */ }, toast () { /* ...토스트 알림... */ }, getCurrentPosition (options) { return new Promise((resolve, reject) => navigator.geolocation .getCurrentPosition(resolve, reject, options)) }, async updateLatlon (options) { const { coords } = await this.getCurrentPosition(options) const { latitude: lat, longitude: lon } = coords return Object.assign(this, { lat, lon }) }, geolocation (watch = true) { const msgs = [ '위치 정보 권한이 거부 되었습니다', '위치 정보를 확인 할 수 없습니다' ] return this.updateLatlon({ timeout: 3000 }) .catch(e => { const msg = msgs[e.code - 1] if (msg) this.toast(msg) console.error(e) }) .finally(() => { if (watch) this.watchPositionOnce() }) } } }
위 Vue.js 컴포넌트 옵션은 날씨 정보가 필요한 컴포넌트에서 mixin 받아 서버로 요청하는 fetchWeather 함수를 구현한 뒤, watch: { latlon: ‘fetchWeather’ } 하여 lat, lon이 바뀌는 시점에 날씨를 요청하도록 준비합니다. GPS에 접근이 필요한 시점에 geolocation 함수를 실행하면 작동하도록 되어있는 예시 코드입니다.
서버
배포・스테이지・개발 각각 다른 환경마다 다른 설정 파일들이 나뉘어 있고, 환경에 따라 다른 값이 서비스 키에 들어가도록 구성했습니다.
동네예보 조회서비스 API (공통)
기상청 API는 위경도가 아닌 전국을 격자로 나눠 x,y 좌표로 조회하기 때문에 클라이언트로부터 전달 받은 위경도 값을 변환하는 로직이 필요합니다. 기상청 홈페이지 소스를 참고했습니다.
const serviceKey = process.env.publicApiKey const Axios = require('axios') const axios = Axios.create({ baseURL: 'http://apis.data.go.kr/1360000/VilageFcstInfoService', params: { serviceKey, pageNo: 1, numOfRows: 99, dataType: 'JSON' } })
axios 인스턴스를 생성할 때 기본 옵션을 넘겨주어 매번 넣어줄 필요가 없도록 했습니다. (axio 0.19 버전에서 params 기본값이 작동하지 않아 0.18 버전을 사용 중입니다.)
그 외 요청 변수
- base_date : 조회 날짜 YYYYMMDD
- base_time : 조회 시각 HHmm
- nx : 기상청 격자 X 좌표
- ny : 기상청 격자 Y 좌표
동네예보 조회서비스 API (초단기실황조회)
base_data와 base_time은 초단기실황이고, 현재 시간만 의미있기 때문에 왜 필요한지는 이해가지 않지만 😅 문서를 보면

base_time은 매시 정각이고 API 제공 시간은 40분이니, 대충 40분 전 시간의 정각을 리턴하는 함수를 작성했습니다.
const getBaseDateTime = ({ minutes = 0, provide = 40 } = {}, dt = Date.now()) => { const pad = (n, pad = 2) => ('0'.repeat(pad) + n).slice(-pad) const date = new Date(dt - (provide * 60 * 1000)) // provide분 전 return { base_date: date.getFullYear() + pad(date.getMonth() + 1) + pad(date.getDate()), base_time: pad(date.getHours()) + pad(minutes) } }
그럼 날씨를 받아오는 함수는 아래와 같은 모습이 됩니다.
const getWeahterNow = async (nx, ny) => { const params = { ...getBaseDateTime(), nx, ny } const { data } = await axios.get('/getUltraSrtNcst', { params }) if (!data.response) throw Error('getUltraSrtNcst 응답값 없음') return mergeValues(data.response.body.items.item) }
nx, ny를 인자로 받아 base_data, base_time을 자동 생성하여 요청하는 코드입니다. 응답 값을 원하는 형식으로 가공하는 mergeValues 함수를 작성했고, 조회 실패 시 값들이 모두 -998.9로 들어오는 문제를 겪어 예외 처리가 필요했습니다.
동네예보 조회서비스 API (초단기예보조회)
const getForecast = async (nx, ny) => { const params = { ...getBaseDateTime({ minutes: 30, provide: 45 }), nx, ny } const { data } = await axios.get('/getUltraSrtFcst', { params }) if (!data.response) throw Error('getUltraSrtFcst 응답값 없음') return _(data.response.body.items.item) .groupBy(x => x.fcstDate + x.fcstTime) .map(mergeValues).value() }
위에서 만들었던 코드와 거의 동일합니다. 다만, 아래 API 제공 시간이 45분이고 base_time이 매시 30분으로 조금 다릅니다.

또한, 날짜별로 분리가 필요하기 때문에 lodash를 사용하여 예보 날짜를 키로 모아준 후 각자 mergeValues 했습니다.
이제 초단기 예보에서 첫번째 값 SKY 코드로 하늘 상태를 예측할 수 있으니 아이콘을 그릴 수 있게 되었습니다. (낙뢰 코드를 참고하여 아이콘에 반영해도 좋을 것 같았습니다.) PTY 등의 실측정 정보를 우선 보고, 예측 값인 SKY 코드를 가장 나중에 봐야 한다는 점만 주의하면 될 것 같습니다.
const weatherState = (ptyCode, skyCode) => { switch (ptyCode) { case 1: case 4: return 'rainy' case 2: return 'snowAndRainy' case 3: return 'snow' } switch (skyCode) { case 1: return 'clear' case 3: return 'partlyClear' case 4: return 'cloudy' } }
한국환경공단 API (공통)
응답값으로 JSON 형식을 지원하지 않기 때문에 XML을 JSON으로 변환하는 작업이 필요합니다. htmlparser2 모듈로 빠르게 구현했습니다.
const serviceKey = process.env.publicApiKey const Axios = require('axios') const axios = Axios.create({ baseURL: 'http://openapi.airkorea.or.kr/openapi/services/rest', params: { serviceKey, pageNo: 1, numOfRows: 1000 } }) const xmlParser = require('./xmlParser')
한국환경공단 측정소정보 (측정소 목록 조회 API)
미세먼지 측정값을 조회하기 위해 문서를 살펴보니 측정소 이름을 전달해야 했습니다. 측정소 목록을 조회하여 가까운 측정소를 찾았습니다.

각 측정소마다 dmX와 dmY로 위경도를 알 수 있어 클라이언트로부터 받은 위경도 값으로 가장 가까운 측정소를 찾을 수 있겠다 생각했습니다. 모든 측정소 정보를 조건 없이 가져오기 때문에 추가 파라미터가 필요하지 않았습니다.
const getStationList = async () => { const { data } = await axios.get('/MsrstnInfoInqireSvc/getMsrstnList') return xmlParser(data).response.body.items.$children }
가장 가까운 측정소 찾기
클라이언트의 위경도 값에서 가장 근접한 측정소를 찾아야 하니, ‘k-최근접 이웃’ 알고리즘을 쓰는 게 낫겠다 생각했고 ‘k-d 트리’를 쓰기로 했습니다. k-d 트리는 kd-tree-javascript 모듈이 원하는 형태로 구현되어 있어 사용을 결정했습니다. 추가로 k-d 트리 생성ㅇ에 사용할 평가 함수가 필요했습니다. 평가 함수의 결과는 거리가 나와야 했고, 지구는 구체이니 구에서 두 점 사이의 최단거리를 구하는 하버 사인 공식을 쓰기로 했습니다.
const distance = (pos1, pos2) => { const { lat: lat1, lon: lon1 } = pos1 const { lat: lat2, lon: lon2 } = pos2 const { PI, sin, cos, atan2, sqrt } = Math const R = 6371e3 // metres const φ1 = lat1 * PI / 180 // φ, λ in radians const φ2 = lat2 * PI / 180 const Δφ = (lat2 - lat1) * PI / 180 const Δλ = (lon2 - lon1) * PI / 180 const a = sin(Δφ / 2) * sin(Δφ / 2) + cos(φ1) * cos(φ2) * sin(Δλ / 2) * sin(Δλ / 2) const c = 2 * atan2(sqrt(a), sqrt(1 - a)) const d = R * c // in metres return d }
하버 사인 공식을 지구의 반경으로 구하는 식을 구현해 놓은 코드를 발견해 그대로 사용했습니다.
const { kdTree: KdTree } = require('kd-tree-javascript/kdTree') const getNearestStationTree = async () => { const stations = await getStationList() .filter(x => !(isNaN(x.dmX) || isNaN(x.dmY))) .map(x => { const { dmX: lat, dmY: lon, ...rest } = x return Object.assign(rest, { lat, lon }) }) return new KdTree(stations, distance, ['lat', 'lon']) }
이렇게 필요한 준비물은 다 챙겼고, 트리를 생성해 주었습니다. 간혹 dmX나 dmY가 없는 측정소가 있어 주의가 필요했습니다.
const getNearestStations = async (lat, lon, max = 3) => { const tree = await getNearestStationTree() return tree.nearest({ lat, lon }, max) .sort((a, b) => a[1] - b[1]) .map(([x, distance]) => ({ ...x, distance })) }
마지막으로 위경도로 가까운 측정소를 찾는 함수를 작성해 주면 측정소도 준비 완료입니다. 혹시 모르니 기본값으로 3개까지 찾도록 했습니다.
한국환경공단 대기오염정보 (측정소별 실시간 측정정보 조회 API)

const getStationMeasured = async stationName => { const params = { stationName, dataTerm: 'DAILY', ver: 1.3 } const { data } = await axios.get('/ArpltnInforInqireSvc/getMsrstnAcctoRltmMesureDnsty', { params }) const json = xmlParser(data) if (!json.response) throw Error('getMsrstnAcctoRltmMesureDnsty 응답값 없음') return json.response.body.items.$children }
위와 동일한 방식으로 문서에 나온 파라미터들을 채워 API 응답을 받는 코드를 작성했습니다. 리턴 배열의 첫 번째 값이 가장 최신 측정치였습니다. 이렇게 날씨 정보에 이어 미세먼지 정보에 관한 데이터도 받았습니다. 이제 필요한 정보는 모두 모였습니다.
서버 캐싱
공공데이터도 API 요청 한도가 있습니다. (개발 계정에서 운영 계정으로 전환하면 늘려줍니다. 😅) 요청이 시간당 몇 만 건이 들어간다면 한 시간도 되지 않아 제한에 걸려 그날은 중지가 될 것이 뻔했죠. 하지만 캐싱을 쓴다면 충분한 한도로 보여 캐싱을 적용하기로 했습니다. 또한 데이터의 업데이트 주기가 있기 때문에 똑같은 응답을 받는 횟수를 줄이고 만료 전까지 네트워크 통신을 하지 않으니 빠르게 응답이 가능했습니다.
캐싱을 위해 구구절절 코드를 쓰지 않고 어떻게 간결하게 표현할지 고민하다가 cached-call 이란 모듈을 만들었습니다. 사용법은 단순히 함수를 캐싱 옵션과 함께 래핑해 주면 끝! 위에서 구현한 함수들에 적용했습니다.
날씨
const CachedCall = require('cached-call') const cache = new CachedCall() const cacheError = 10000 const dayjs = require('dayjs') const everyHour = min => () => dayjs().add(60 - min, 'm').startOf('m').minute(min) - Date.now()const cachedFn = { getWeahterNow: cache({ getWeahterNow, cacheError, maxAge: everyHour(40) }), getForecast: cache({ getForecast, cacheError, maxAge: everyHour(45) }) }
day.js와 cached-call 모듈을 사용해 ‘초단기실황조회’ getWeahterNow는 매시 40분에, ‘초단기예보조회’ getForecast는 매시 45분에 캐시가 만료되도록 함수를 래핑 한 모습입니다. maxAge 초단기실황의 경우, 10분마다 업데이트된다고하니 운영을 하다가 요청 한도가 충분하다면 maxAge를 10분으로 잡는 것도 좋겠습니다. cacheError 에러가 나서 캐싱이 실패한다면 요청이 계속 들어가므로 요청 한도에 걸릴 수 있습니다. 에러도 10초간 캐싱 하여 최소 10초마다 요청을 하도록 했습니다.
이렇게 캐싱을 적용해 아래와 같이 날씨 함수 getWeather가 구현되었습니다.
const getWeather = async (lat, lon) => { const { x, y } = convertXY.toXY(lat, lon) const { getWeahterNow, getForecast } = cachedFn const [weather, forecast] = await Promise.all([ getWeahterNow(x, y).catch(x => ({})), getForecast(x, y).catch(x => {}) ]) const [{ SKY }] = forecast || [{}] return Object.assign(weather, { SKY, forecast }) }
미세먼지
const CachedCall = require('cached-call') const cache = new CachedCall() const cacheError = 10000 const cachedFn = { getNearestStationTree: cache({ getNearestStationTree, cacheError, maxAge: 24 * 60 * 60 * 1000 }), getStationMeasured: cache({ getStationMeasured, cacheError, maxAge: 10 * 60 * 1000 }) }
가까운 측정소를 찾는 getNearestStations 함수 내부에 한 줄을 추가하여 캐싱을 적용했습니다.
const getNearestStations = async (lat, lon, max = 3) => { const { getNearestStationTree } = cachedFn const tree = await getNearestStationTree() return tree.nearest({ lat, lon }, max) .sort((a, b) => a[1] - b[1]) .map(([x, distance]) => ({ ...x, distance })) }
마지막으로 측정정보조회까지 캐싱 하여 미세먼지 함수 getDust도 구현되었습니다.
const getDust = async (lat, lon) => { const { getStationMeasured } = cachedFn const [station] = await getNearestStations(lat, lon) const { stationName } = station const [state, ...histories] = await getStationMeasured(stationName) return Object.assign({}, station, state, { histories }) }
이렇게 캐싱까지 완료하여 얻은 값을 적절히 컨버팅해 아이콘과 문구・상태 등을 표현해 주었습니다. 캐싱이 되기 전까지 동시에 요청이 여러개 들어오면 API 요청을 여러 번 하게 됩니다. 이는 axios 인스턴스를 생성하는 부분에서 요청 프로미스를 캐싱 하는 어댑터를 추가하면 해결할 수 있습니다.
// 예시) const Axios = require('axios') const setAdapter = require('./axiosThrottleAdapter') const axios = Axios.create({ baseURL: 'http://openapi.airkorea.or.kr/openapi/services/rest', params: { serviceKey, pageNo: 1, numOfRows: 1000 } }) setAdapter(axios, { maxAage: 1000 })
예시 코드에는 지웠지만 catch 전에 로깅함수를 추가하여 로깅을 하는 코드도 들어갔습니다. k-d 트리 생성 함수 외에는 리턴 값이 직렬화 가능한 함수들이기 때문에 추후 Redis로 인스턴스 간의 캐싱도 적용하면 좋겠습니다.
이상으로 공공데이터를 활용해 날씨 정보를 제공하는 방법이었습니다!