기상청 단기예보 API 통합
August 2024 (2399 Words, 14 Minutes)
서론
이번 개발에서 특히 큰 도전 중 하나였던 것은 특히 공공데이터 API를 활용하여 실시간 정보를 제공하는 서비스를 구축하는 것이었다.
강원도 관광 액티비티 추천 플랫폼 개발 과정에서 기상청 단기예보 API를 통합하여 날씨 기반 추천 시스템을 구축한 경험을 공유하고자 한다.
1. 문제 정의와 해결 목표
우리 앱의 target 지역으로 삼았더 강원도는 산악 지역과 해안 지역이 공존하는 특별한 지리적 특성을 가지고 있다. 이 특성 때문에 다양한 activity 도 존재하는데, 설악산의 등산로, 강릉의 해수욕장, 평창의 스키장 등 다양한 액티비티가 존재하지만, 이들의 최적 이용 조건은 날씨에 크게 좌우된다.
예를 들어, 비가 오는 날에는 당연히 등산보다는 실내 체험 활동이 적합하고, 눈이 내리는 겨울에는 스키나 겨울 축제가 더욱 매력적이다. 기존의 관광 정보 서비스들은 정적인 관광지 정보만 제공할 뿐, 실시간 기상 조건을 고려한 맞춤형 추천을 제공하지 못했다. 이러한 한계를 극복하기 위해 기상청 API를 활용한 동적 추천 시스템이 필요했다.
2. 기상청 API 구조 이해와 설계
기상청 동네예보 API는 Lambert Conformal Conic 투영법을 사용하는 격자 좌표계를 사용한다. 이는 nx(격자 X), ny(격자 Y) 값으로 표현된다.
/**
* 강원도 18개 시군구의 기상청 격자 좌표 매핑 데이터
*
* 기상청 API는 Lambert Conformal Conic 투영법을 사용하는 격자 좌표계를 사용한다.
* 이는 한반도 지형의 왜곡을 최소화하기 위한 투영법으로,
* 일반적인 위경도 좌표와는 다른 nx, ny 격자값을 사용한다.
*
* 각 시군구의 대표 지점(주로 행정청사 위치)을 기준으로 격자 좌표를 설정하였다.
*/
const locationData = [
// 영서 지역 (태백산맥 서쪽)
{ locationTag: "춘천", nx: 73, ny: 134 }, // 춘천시청 기준
{ locationTag: "원주", nx: 76, ny: 122 }, // 원주시청 기준
{ locationTag: "강릉", nx: 92, ny: 131 }, // 강릉시청 기준
{ locationTag: "동해", nx: 97, ny: 127 }, // 동해시청 기준
{ locationTag: "태백", nx: 95, ny: 119 }, // 태백시청 기준
{ locationTag: "속초", nx: 87, ny: 141 }, // 속초시청 기준
{ locationTag: "삼척", nx: 98, ny: 125 }, // 삼척시청 기준
// 산간 지역
{ locationTag: "철원", nx: 65, ny: 139 }, // 철원군청 기준
{ locationTag: "화천", nx: 72, ny: 139 }, // 화천군청 기준
{ locationTag: "양구", nx: 77, ny: 139 }, // 양구군청 기준
{ locationTag: "인제", nx: 80, ny: 138 }, // 인제군청 기준
{ locationTag: "고성", nx: 85, ny: 145 }, // 고성군청 기준(강원)
{ locationTag: "양양", nx: 88, ny: 138 }, // 양양군청 기준
{ locationTag: "홍천", nx: 75, ny: 133 }, // 홍천군청 기준
{ locationTag: "횡성", nx: 77, ny: 125 }, // 횡성군청 기준
{ locationTag: "영월", nx: 86, ny: 119 }, // 영월군청 기준
{ locationTag: "평창", nx: 84, ny: 123 }, // 평창군청 기준
{ locationTag: "정선", nx: 89, ny: 123 }, // 정선군청 기준
];
2.1 API 호출 파라미터 시각화
기상청 API는 발표 시간이 정해져 있으며, 하루 8회(02, 05, 08, 11, 14, 17, 20, 23시) 업데이트된다. 가장 최신의 정확한 데이터를 얻기 위해서는 적절한 base_date와 base_time을 계산해야 한다.
function getOptimalForecastTime() {
const now = new Date();
const currentHour = now.getHours();
const currentMinute = now.getMinutes();
// 기상청 예보 발표 시간표 (실제 발표는 각 시간 + 10분)
const forecastTimes = ["02", "05", "08", "11", "14", "17", "20", "23"];
let targetTime = "02"; // 기본값: 새벽 2시
let targetDate = new Date();
// 현재 시각을 기준으로 가장 최근 발표된 예보 시간 찾기
for (let i = forecastTimes.length - 1; i >= 0; i--) {
const forecastHour = parseInt(forecastTimes[i]);
if (
currentHour > forecastHour ||
(currentHour === forecastHour && currentMinute >= 10)
) {
targetTime = forecastTimes[i] + "00";
break;
}
}
// 자정 이전에 호출하는 경우 전날 23시 예보 사용
if (currentHour < 2 || (currentHour === 2 && currentMinute < 10)) {
targetDate.setDate(targetDate.getDate() - 1);
targetTime = "2300";
}
const baseDate = targetDate.toISOString().split("T")[0].replace(/-/g, "");
return {
base_date: baseDate,
base_time: targetTime,
};
}
핵심구현: 3일간 날씨 데이터 수집 시스템
기상청 API에서 3일간의 날씨 데이터를 효율적으로 수집하고 구조화하는 핵심 함수를 구현하였다. 이 함수는 단일 API 호출로 72시간치 예보 데이터를 처리한다.
// services/weatherService.js
const axios = require("axios");
const moment = require("moment");
const dotenv = require("dotenv");
dotenv.config();
/**
* @param {string} locationTag - 지역명 (예: "춘천", "강릉")
* @returns {Object} nx, ny 격자 좌표 객체
* @throws {Error} 유효하지 않은 지역명인 경우 에러 발생
*/
function getCoordinates(locationTag) {
const location = locationData.find((loc) => loc.locationTag === locationTag);
if (!location) {
console.error(`Invalid location tag: ${locationTag}`);
throw new Error(`유효하지 않은 지역명입니다: ${locationTag}`);
}
console.log(
`좌표 변환 완료 - ${locationTag}: nx=${location.nx}, ny=${location.ny}`
);
return { nx: location.nx, ny: location.ny };
}
async function fetchThreeDaysWeatherData(locationTag, date) {
try {
// 1단계: 지역명을 기상청 격자 좌표로 변환
const { nx, ny } = getCoordinates(locationTag);
const serviceKey = process.env.SHORT_WEATHER; // 기상청 API 인증키
let weatherData = [];
// 2단계: API 호출을 위한 기준 시점 계산
const optimalTime = getOptimalForecastTime();
const baseDate = moment(date).format("YYYYMMDD");
const baseTime = optimalTime.base_time;
// 3단계: 기상청 API URL 구성
// numOfRows를 1000으로 설정하여 3일치 전체 데이터 확보
const apiUrl = `http://apis.data.go.kr/1360000/VilageFcstInfoService_2.0/getVilageFcst`;
const params = {
serviceKey: serviceKey,
numOfRows: 1000, // 3일 * 24시간 * 여러 기상요소 = 대용량 데이터
pageNo: 1,
dataType: "JSON",
base_date: baseDate,
base_time: baseTime,
nx: nx,
ny: ny,
};
console.log(
`날씨 데이터 요청 시작 - 지역: ${locationTag}, 좌표: (${nx}, ${ny}), ` +
`기준시각: ${baseDate} ${baseTime}`
);
// 4단계: 기상청 API 호출
const response = await axios.get(apiUrl, { params });
// 5단계: API 응답 유효성 검증
if (!response.data?.response?.body?.items?.item) {
console.error(`날씨 데이터 없음 - ${locationTag}, ${baseDate}`);
return [];
}
const items = response.data.response.body.items.item;
console.log(`수신된 날씨 데이터 항목 수: ${items.length}개`);
// 6단계: 대상 날짜 배열 생성 (현재일, 내일, 모레)
const targetDates = [
moment(date).format("YYYYMMDD"), // 오늘
moment(date).add(1, "days").format("YYYYMMDD"), // 내일
moment(date).add(2, "days").format("YYYYMMDD"), // 모레
];
console.log(`처리 대상 날짜: ${targetDates.join(", ")}`);
// 7단계: 날짜별 날씨 데이터 추출 및 구조화
targetDates.forEach((targetDate, dateIndex) => {
// 각 날짜별 기상 정보를 저장할 객체 초기화
let dailyWeatherData = {
date: targetDate,
temperature: null,
precipitationType: null,
skyStatus: null,
humidity: null,
windSpeed: null,
windDirection: null,
detailedForecast: [], // 시간별 상세 예보 (선택적)
};
// 해당 날짜의 모든 기상 데이터 항목을 순회하며 정보 추출
items.forEach((item) => {
if (item.fcstDate === targetDate) {
// 기상청 API의 카테고리 코드에 따른 데이터 분류
switch (item.category) {
case "TMP": // 1시간 기온 (°C)
if (dailyWeatherData.temperature === null) {
dailyWeatherData.temperature = parseFloat(item.fcstValue);
}
break;
case "PTY": // 강수형태 (0:없음, 1:비, 2:비/눈, 3:눈, 4:소나기)
if (dailyWeatherData.precipitationType === null) {
dailyWeatherData.precipitationType = getPrecipitationType(
item.fcstValue
);
}
break;
case "SKY": // 하늘상태 (1:맑음, 3:구름많음, 4:흐림)
if (dailyWeatherData.skyStatus === null) {
dailyWeatherData.skyStatus = getSkyStatus(item.fcstValue);
}
break;
case "REH": // 습도 (%)
if (dailyWeatherData.humidity === null) {
dailyWeatherData.humidity = parseInt(item.fcstValue);
}
break;
case "WSD": // 풍속 (m/s)
if (dailyWeatherData.windSpeed === null) {
dailyWeatherData.windSpeed = parseFloat(item.fcstValue);
}
break;
case "VEC": // 풍향 (deg)
if (dailyWeatherData.windDirection === null) {
dailyWeatherData.windDirection = getWindDirection(
parseFloat(item.fcstValue)
);
}
break;
// 추가적인 기상 요소들 (필요시 활용)
case "POP": // 강수확률 (%)
dailyWeatherData.precipitationProbability = parseInt(
item.fcstValue
);
break;
case "PCP": // 1시간 강수량 (mm)
if (item.fcstValue !== "강수없음") {
dailyWeatherData.precipitation = item.fcstValue;
}
break;
}
}
});
// 데이터 품질 검증 및 기본값 설정, 네트워크 오류와 API 오류를 구분하여 처리
//생략
3.1 데이터 변환 유틸리티 함수들
기상청 API의 숫자 코드를 사용자가 이해하기 쉬운 텍스트로 변환하는 함수들을 구현하였다.
기상청 API는 강수형태를 다음과 같은 숫자 코드로 제공한다:
-
- 0: 없음 (강수 없음)
-
- 1: 비 (액체 강수)
-
- 2: 비/눈 (진눈깨비, 슬릿)
-
- 3: 눈 (고체 강수)
-
- 4: 소나기 (대류성 강수)
실제 활용 -날씨 기반 액티비티 추천 로직
수집된 날씨 데이터를 실제 서비스 로직에서 활용하는 방법을 구현하였다. 이 과정에서 날씨 조건과 액티비티 특성을 매칭하는 알고리즘을 개발하였다.
결론- 활용 후기
기상청 API는 한 번 호출하면 엄청난 양의 데이터를 준다. 3일치 예보라고 해도 시간대별로 온도, 습도, 풍향, 풍속, 강수형태, 하늘상태 등이 모두 따로따로 들어온다.이걸 날짜별로 묶어서 의미있는 데이터로 만드는 로직을 짜는 게 생각보다 복잡했다. 특히 같은 날짜에도 시간대가 다른 데이터들이 섞여 있어서, 대표값을 어떻게 선택할지도 고민이었다.
또한!
기상청은 하루 8번 예보를 업데이트하는데(02, 05, 08, 11, 14, 17, 20, 23시), 각 시간마다 10분 정도의 처리 시간이 필요하다. 그래서 예를 들어 오전 8시 10분 이전에 API를 호출하면 아직 8시 예보가 준비되지 않아서 이전 시간 데이터를 받게 된다. 이 타이밍을 맞추는 로직을 짜는 게 은근히 까다로웠다. 특히 자정 넘어서 호출할 때 전날 23시 예보를 사용해야 하는 예외 처리도 필요했다.
schedule.scheduleJob("0 0 * * *", async () => {})- Cron 스케줄링을 써볼 수 있어서 신기했다. 매일 자정마다 자동으로 날씨 데이터를 수집하는 백그라운드 작업을 배포후 서버상에서도 잘 실행되는지 확인했는데, 잘 돌아가는 것을 보았을때 정말 환호했다