포탈을 만드는 중 날씨 정보도 보여주면 좋을거 같은 생각이 들어 만들어 보게 되었다.
날씨정보를 가져오려면 api를 이용해야 하는데 날씨 정보를 가져올 수 있는 open api는 여러가지가 있다. 그 중 필자는 공공 데이터 포털(기상청 api)을 이용하였다. 해당 api를 이용하기 위해서는 api key를 발급받아야 한다. 발급받는 거는 그렇게 어렵지 않고 금방 발급해준다.
공공 데이터 포털 접속 후 회원가입 & 로그인
검색어에 기상청 단기예보 입력 후 활용신청

발급이 되면 다음과 같이 api key를 준다. Decoding 인증키를 사용하면 된다.

loadWeatherData는 localStorage에서 weatherData, 즉 저장된 기상청 데이터를 3시간 마다 불러오는 함수이다. 해당 api는 실시간으로 기상청 데이터를 가져오기 때문에 3시간이상이 넘어가면 오래된 데이터로 간주하고 데이터를 삭제하는 방식으로 했다.
❗ 처음에는 CACHE_DURATION 없이, 그리고 localStorage에 저장하는 코드 없이 바로 데이터를 불러오는 식으로 코드를 짰었다. 그런데 이러한 코드는 기상청 데이터를 실시간으로 불러오는데 api를 제대로 불러오지 못하는 경우가 잦아지면서 날씨 정보를 불러오는데 실패하는 현상이 자주 일어났다. 해당 문제를 해결하기 위해서 localStorage에 기상청 데이터를 저장해 놓고 api를 불러오지 못할 경우 localStorage에 저장된 데이터를 가져오도록 하였다.
const CACHE_DURATION = 3 * 60 * 60 * 1000; // 3시간(밀리초)
const weather = ref(null);
const forecastDays = ref([]);
const loadWeatherData = () => {
const weatherData = localStorage.getItem('weatherData');
if(!weatherData) return false;
const data = JSON.parse(weatherData);
const now = new Date().getTime();
// 캐시가 3시간 이상 지났는지 확인
if(now - data.timestamp > CACHE_DURATION) {
localStorage.removeItem('weatherData');
return false;
}
weather.value = data.weather;
forecastDays.value = data.forecastDays;
return true;
}
데이터를 localStorage에 저장하는 함수이다. weather는 오늘 날씨 정보를 forecateDays는 예보 날씨 정보를 timestamp는 localStorage에 저장된 시간때를 저장한다.
const saveToWeatherData = () => {
try {
const weatherData = {
weather: weather.value,
forecastDays: forecastDays.value,
timestamp: new Date().getTime()
}
localStorage.setItem('weatherData', JSON.stringify(weatherData));
} catch (e) {
console.error('캐시 저장 실패:', e);
}
}
사용자의 현재 위치, 즉 위도와 경도를 가져오는 함수이다. navigator.geolocation을 사용하여 브라우저의 위치 서비스에 접근한다. Promise를 반환하여 브라오저가 위치 서비스를 지원하지 않으면 에러를 반환(reject)하고 브라우저가 위치 서비스를 지원하면 getCurrentPosition()을 호출한다.
const getLocation = () => {
return new Promise((resolve, reject) => {
if (!navigator.geolocation) {
reject('Geolocation is not supported by your browser')
} else {
navigator.geolocation.getCurrentPosition(resolve, reject)
}
})
}
카카오맵 api를 사용하여 위도(lat)와 경도(lon) 좌표를 받아서 해당 위치의 주소를 가져오는 함수 이다.
const getAddressFromCoords = async (lat, lon) => {
try {
const response = await axios.get('https://dapi.kakao.com/v2/local/geo/coord2address.json', {
params: {
x: lon,
y: lat
},
headers: {
Authorization: `KakaoAK ${KAKAO_API_KEY}`
}
})
if (response.data.documents.length > 0) {
const addressInfo = response.data.documents[0].address
return `${addressInfo.region_1depth_name} ${addressInfo.region_2depth_name}`
}
return '알 수 없는 위치'
} catch (error) {
console.error('Error getting address:', error)
return '위치 확인 실패'
}
}
위도와 경도 좌표를 기상청에서 사용하는 격자 좌표로 변환하는 역할을 한다. 기상청 api는 일반 위경도 좌표가 아닌 격자 좌표 체계를 사용하므로 변환해야 한다.
const convertToGrid = (lat, lon) => {
const RE = 6371.00877; // 지구 반경(km)
const GRID = 5.0; // 격자 간격(km)
const SLAT1 = 30.0; // 투영 위도1(degree)
const SLAT2 = 60.0; // 투영 위도2(degree)
const OLON = 126.0; // 기준점 경도(degree)
const OLAT = 38.0; // 기준점 위도(degree)
const XO = 43; // 기준점 X좌표(GRID)
const YO = 136; // 기준점 Y좌표(GRID)
const DEGRAD = Math.PI / 180.0;
const re = RE / GRID;
const slat1 = SLAT1 * DEGRAD;
const slat2 = SLAT2 * DEGRAD;
const olon = OLON * DEGRAD;
const olat = OLAT * DEGRAD;
let 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);
let sf = Math.tan(Math.PI * 0.25 + slat1 * 0.5);
sf = Math.pow(sf, sn) * Math.cos(slat1) / sn;
let ro = Math.tan(Math.PI * 0.25 + olat * 0.5);
ro = re * sf / Math.pow(ro, sn);
let ra = Math.tan(Math.PI * 0.25 + (lat) * DEGRAD * 0.5);
ra = re * sf / Math.pow(ra, sn);
let theta = lon * DEGRAD - olon;
if (theta > Math.PI) theta -= 2.0 * Math.PI;
if (theta < -Math.PI) theta += 2.0 * Math.PI;
theta *= sn;
let x = Math.floor(ra * Math.sin(theta) + XO + 0.5);
let y = Math.floor(ro - ra * Math.cos(theta) + YO + 0.5);
return {x, y};
}
기상청 api에서 제공하는 두가지 날씨 코드를 받아서 사람이 이해하기 쉬운 날씨 설명으로 변환하는 함수이다.
pty는 강수상태 코드, sky는 하늘상태 코드이다. 먼저 강수 여부를 확인(pty값 체크)하고 강수가 없을때(pty === '0')만 하늘상태(sky)를 확인한다. 강수가 있으면 강수 종류를 반환한다.
const getWeatherDescription = (pty, sky) => {
if (pty === '0') {
if (sky === '1') return '맑음'
if (sky === '3') return '구름 많음'
if (sky === '4') return '흐림'
}
if (pty === '1') return '비'
if (pty === '2') return '비/눈'
if (pty === '3') return '눈'
if (pty === '4') return '소나기'
return '알 수 없음'
}
해당 함수는 날씨 정보를 가져오는 메인 함수이다.
const fetchWeather = async () => {
try {
loading.value = true
error.value = null
// 현재 위치 정보 가져오기
const position = await getLocation()
const {latitude, longitude} = position.coords
// 위도/경도로 주소 가져오기
const address = await getAddressFromCoords(latitude, longitude)
// 위도/경도를 기상청 격자 좌표로 반환
const {x, y} = convertToGrid(latitude, longitude)
const now = new Date()
// YYYYMMDD 형식으로 변환 (예: 20250101)
const formattedDate = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}`
// HH00 형식으로 변환 (예: 1400)
const formattedTime = `${String(now.getHours()).padStart(2, '0')}00`
// 3가지 api를 동시에 호출
const [ncstResponse, fcstResponse, villageFcstResponse] = await Promise.all([
// 1. 현재 날씨
axios.get(`https://apis.data.go.kr/1360000/VilageFcstInfoService_2.0/getUltraSrtNcst`, {
params: {
serviceKey: WEATHER_API_KEY,
numOfRows: '10',
pageNo: '1',
dataType: 'JSON',
base_date: formattedDate,
base_time: formattedTime,
nx: x,
ny: y
}
}),
// 2. 6시간 이내 예보
axios.get(`https://apis.data.go.kr/1360000/VilageFcstInfoService_2.0/getUltraSrtFcst`, {
params: {
serviceKey: WEATHER_API_KEY,
numOfRows: '60',
pageNo: '1',
dataType: 'JSON',
base_date: formattedDate,
base_time: formattedTime,
nx: x,
ny: y
}
}),
// 3. 3알 예보
axios.get(`https://apis.data.go.kr/1360000/VilageFcstInfoService_2.0/getVilageFcst`, {
params: {
serviceKey: WEATHER_API_KEY,
numOfRows: '1000',
pageNo: '1',
dataType: 'JSON',
base_date: formattedDate,
base_time: '0500',
nx: x,
ny: y
}
})
])
// api 응답에서 필요한 데이터 추출
const ncstItems = ncstResponse.data.response.body.items.item
const fcstItems = fcstResponse.data.response.body.items.item
const villageFcstItems = villageFcstResponse.data.response.body.items.item
// 현재 날씨 정보 찾기
const temp = ncstItems.find(item => item.category === 'T1H').obsrValue // 기온
const humidity = ncstItems.find(item => item.category === 'REH').obsrValue // 습도
const rainType = ncstItems.find(item => item.category === 'PTY').obsrValue // 강수형태
const sky = fcstItems.find(item => item.category === 'SKY').fcstValue // 하늘상태
// 날씨 정보 객체 생성
weather.value = {
location: address,
temp,
humidity,
pty: rainType,
sky,
description: getWeatherDescription(rainType, sky),
}
// 5일치 날씨 정보 처리
const processDateWeather = (date) => {
// 날짜 형식 변환
const targetDate = `${date.getFullYear()}${String(date.getMonth() + 1).padStart(2, '0')}${String(date.getDate()).padStart(2, '0')}`
// 해당 날짜의 예보 데이터만 필터링
const dateItems = villageFcstItems.filter(item => item.fcstDate === targetDate)
// 최고/최저 기온 계산
const maxTemp = Math.max(...dateItems.filter(item => item.category === 'TMX').map(item => Number(item.fcstValue) || -Infinity))
const minTemp = Math.min(...dateItems.filter(item => item.category === 'TMN').map(item => Number(item.fcstValue) || Infinity))
// 정오 시점의 강수상태와 하늘상태
const pty = dateItems.find(item => item.category === 'PTY' && item.fcstTime === '1200')?.fcstValue || '0'
const sky = dateItems.find(item => item.category === 'SKY' && item.fcstTime === '1200')?.fcstValue || '1'
return {
date: `${String(date.getMonth() + 1).padStart(2, '0')}/${String(date.getDate()).padStart(2, '0')}`,
maxTemp: maxTemp !== -Infinity ? maxTemp : '?',
minTemp: minTemp !== Infinity ? minTemp : '?',
pty,
sky,
}
}
// 앞으로 3일간의 날짜 생성
const forecastDates = Array.from({length: 3}, (_, i) => {
return new Date(now.getTime() + (i + 1) * 24 * 60 * 60 * 1000)
})
// 각 날짜별 날씨 정보 처리
forecastDays.value = forecastDates.map(date => processDateWeather(date))
// 날씨 데이터를 로컬 스토리지에 저장
saveToWeatherData();
} catch (err) {
error.value = '날씨 정보를 불러오는데 실패했습니다.';
loadWeatherData();
} finally {
loading.value = false
}
}
<template>
<div>
<div v-if="weatherStore.loading" class="text-center weahter-container">
<q-spinner color="primary" size="3em" />
</div>
<div v-else-if="weatherStore.weather" class="weahter-container">
<div class="text-subtitle1">{{ weatherStore.weather.location }}</div>
<q-card-section horizontal>
<q-card-section>
<img :src="weatherIcon" alt="Weather Icon" style="width: 5em; height: 4.5em; margin-left: 10px; margin-right: 30px;" />
</q-card-section>
<q-item-section>
<q-item-label class="text-h5">{{ weatherStore.weather.temp }}°C</q-item-label>
<q-item-label>{{ weatherStore.weather.description }}</q-item-label>
<q-item-label>습도: {{ weatherStore.weather.humidity }}%</q-item-label>
</q-item-section>
</q-card-section>
<div style="display: flex; justify-content: space-around; margin-top: 20px;">
<div v-for="day in weatherStore.forecastDays" :key="day.date" style="text-align: center;">
<div>{{ day.date }}</div>
<div>
<img :src="getWeatherIcon(day.pty, day.sky)" alt="Weather Icon" style="width: 2em; height: 2em; margin: 5px 0;" />
</div>
<div>{{ day.maxTemp }}° / {{ day.minTemp }}°</div>
</div>
</div>
</div>
<div v-else class="weahter-container" style="color: var(--q-negative)">
{{ weatherStore.error }}
</div>
</div>
</template>
<script setup>
import { computed, onMounted } from 'vue'
import { useWeatherStore } from '@/stores/openApi/weatherStore';
// 날씨 아이콘 이미지 import
import sunny from '@/assets/images/weathers/맑음.png';
import muchCloudy from '@/assets/images/weathers/구름많음.png';
import cloudy from '@/assets/images/weathers/흐림.png';
import littleRainy from '@/assets/images/weathers/소나기.png'
import rainy from '@/assets/images/weathers/비.png';
import snowDay from '@/assets/images/weathers/눈.png';
import sleet from '@/assets/images/weathers/진눈깨비.png';
const weatherStore = useWeatherStore();
// 날씨 아이콘 가져오는 함수
const getWeatherIcon = (pty, sky) => {
if (pty === '0') {
if (sky === '1') return sunny
if (sky === '3') return muchCloudy
if (sky === '4') return cloudy
}
if (pty === '1') return rainy
if (pty === '2') return sleet
if (pty === '3') return snowDay
if (pty === '4') return littleRainy
return 'help_outline'
}
// 현재 날씨 아이콘을 계산하는 computed 속성
const weatherIcon = computed(() => {
return getWeatherIcon(weatherStore.weather?.pty, weatherStore.weather?.sky)
})
onMounted(async() => {
await weatherStore.fetchWeather();
weatherStore.loadWeatherData();
})
</script>
<style scoped>
.weahter-container {
padding: 15px;
}
</style>
위 코드는 아래와 같은 형태로 보이게 된다.
