다음 토이프로젝트의 개발 기록이다.
Kakao 지도 API, react-kakao-maps-sdk, Kakao Local API, 공공 data 포털을 활용했다.
카카오 지도도 불러왔겠다, 지도로 검색한 장소를 기상청 API에 보내면 해당 지역의 날씨가 날라올 것이라 기대했었다. 그러나 상상치도 못한 난관에 부딪혔는데, 기상청 API는 심상찮은 파라미터를 받고 있던 것 아닌가.
Kakao Map, Kakao Local API에 대한 자세한 내용은 다음 링크로...!
Kakao Map SDK로 지도를 띄웠으니, 이제 검색을 할 차례이다. Map SDK로 지도를 조작하고, 마커를 띄우고, 키워드로 검색을 해서 좌표까지도 받아올 수 있지만, 그 좌표를 가지고 행정동, 법정동을 알아낼 수는 없다.
이 문제를 해결해 줄 API가 바로 Kakao Local API. 카카오 지도 SDK로 검색할 결과를 다음 API의 인자에 넣어 호출해 좌표를 받아올 수 있다.
메서드 | URL | 인증 방식 |
---|---|---|
GET | https://dapi.kakao.com/v2/local/geo/coord2regioncode.${FORMAT} | REST API 키 |
이름 | 설명 | 필수 |
---|---|---|
Authorization | Authorization: KakaoAK ${REST_API_KEY} 인증 방식, REST API 키로 인증 요청 | O |
이름 | 타입 | 설명 | 필수 |
---|---|---|---|
x | String | X 좌표값, 경위도인 경우 경도(longitude) | O |
y | String | Y 좌표값, 경위도인 경우 위도(latitude) | O |
input_coord | String | x, y 로 입력되는 값에 대한 좌표계지원 좌표계: WGS84 , WCONGNAMUL , CONGNAMUL , WTM , TM (기본값: WGS84 ) | X |
output_coord | String | 결과에 출력될 좌표계지원 좌표계: WGS84 , WCONGNAMUL , CONGNAMUL , WTM , TM (기본값: WGS84 ) | X |
이름 | 타입 | 설명 |
---|---|---|
region_type | String | H (행정동) 또는 B (법정동) |
address_name | String | 전체 지역 명칭 |
region_1depth_name | String | 지역 1Depth, 시도 단위바다 영역은 존재하지 않음 |
region_2depth_name | String | 지역 2Depth, 구 단위바다 영역은 존재하지 않음 |
region_3depth_name | String | 지역 3Depth, 동 단위바다 영역은 존재하지 않음 |
region_4depth_name | String | 지역 4Depthregion_type 이 법정동이며, 리 영역인 경우만 존재 |
code | String | region 코드 |
x | Double | X 좌표값, 경위도인 경우 경도(longitude) |
y | Double | Y 좌표값, 경위도인 경우 위도(latitude) |
curl -v -G GET "https://dapi.kakao.com/v2/local/geo/coord2regioncode.json?x=127.1086228&y=37.4012191" \
-H "Authorization: KakaoAK ${REST_API_KEY}"
HTTP/1.1 200 OK
Content-Type: application/json;charset=UTF-8
{
"meta": {
"total_count": 2
},
"documents": [
{
"region_type": "B",
"address_name": "경기도 성남시 분당구 삼평동",
"region_1depth_name": "경기도",
"region_2depth_name": "성남시 분당구",
"region_3depth_name": "삼평동",
"region_4depth_name": "",
"code": "4113510900",
"x": 127.10459896729914,
"y": 37.40269721785548
},
{
"region_type": "H",
"address_name": "경기도 성남시 분당구 삼평동",
"region_1depth_name": "경기도",
"region_2depth_name": "성남시 분당구",
"region_3depth_name": "삼평동",
"region_4depth_name": "",
"code": "4113565500",
"x": 127.1163593869371,
"y": 37.40612091848614
}
]
}
기상청단기예보 ((구)동네예보) 조회서비스 API를 활용하였다.
이렇게 구한 x, y, 혹은 법정동을 기상청의 API의 파라미터에 넣으면 될 것이라고 막연하게 생각했지만, 그렇지가 않았다.
내가 받으려는 초단기실황조회 API의 명세는 다음과 같았다.
http://apis.data.go.kr/1360000/VilageFcstInfoService_2.0/getUltraSrtNcst
항목명(영문) | 항목명(국문) | 항목크기 | 항목구분 | 샘플데이터 | 항목설명 |
---|---|---|---|---|---|
serviceKey | 인증키 | 100 | 1 | 인증키 | |
(URL Encode) | 공공데이터포털에서 발급받은 인증키 | ||||
numOfRows | 한 페이지 결과 수 | 4 | 1 | 10 | 한 페이지 결과 수 |
pageNo | 페이지 번호 | 4 | 1 | 1 | 페이지 번호 |
dataType | 응답자료형식 | 4 | 0 | XML | 요청자료형식(XML/JSON) |
Default: XML | |||||
base_date | 발표일자 | 8 | 1 | 20210628 | ‘21년 6월 28일 발표 |
base_time | 발표시각 | 4 | 1 | 0600 | 06시 발표(정시단위), 매시각 40분 이후 호출 |
nx | 예보지점 X 좌표 | 2 | 1 | 55 | 예보지점의 X 좌표값 |
*별첨 엑셀 자료 참조 | |||||
ny | 예보지점 Y 좌표 | 2 | 1 | 127 | 예보지점의 Y 좌표값 |
*별첨 엑셀 자료 참조 |
x, y가 당연히 실좌표값일 줄 알았건만, 이게 왠걸. 실제 좌표와는 전혀 상관없는 임의의 값이었던 것이다. 당연히 Kakao Local로 검색한 실제 좌표는 사용할 수가 없게 되었다.
어떻게 해야 하나 고민하던 중, Kakao Local geo/coord2regioncode이 요청의 응답으로 제공하는 값 중 code 값이 눈에 띄였다. 리턴값인 Document는 두 개의 요소를 가진 배열인데, 각각 요청 좌표의 행정동, 법정동에 해당되는 정보를 제공하고 있었다.
혹시나 법정동의 code가 행정구역과 연관이 있지 않을까 테스트 해보았는데, 놀랍게도 동일한 값을 가리키고 있었다. 즉, 위의 엑셀 데이터에서 행정구역으로 검색해서 기상청 API가 요구하는 x, y 값을 구할 수 있게 된 것이다.
그러나.
기상청 API가 최종적으로 받는 것은 x, y이지 행정구역 코드가 아니기에, 필연적으로 해당 엑셀 데이터에서 행정구역 코드를 찾아야만 했다.
해당 엑셀 파일을 레포에 넣어서 접근하는 것도 방법이겠지만, 나는 해당 파일을 JSON으로 변환시키고 싶었다.
다행히도 어렵지 않게 엑셀 데이터를 json으로 convert해주는 사이트를 찾았고, 해당 엑셀 파일을 JSON으로 바꿀 수 있었다.
import axios, { AxiosInstance } from 'axios';
const url = import.meta.env.VITE_KAKAO_REST_URL;
const serviceKey = import.meta.env.VITE_KAKAO_REST_KEY;
export interface IRegion {
address_name: string;
code: string;
region_1depth_name: string;
region_2depth_name: string;
region_3depth_name: string;
region_4depth_name: string;
region_type: string;
x: number;
y: number;
}
const instance: AxiosInstance = axios.create({
baseURL: url,
headers: {
'Content-Type': 'application/json',
Authorization: `KakaoAK ${serviceKey}`,
},
timeout: 3000,
});
const getKakaoLocal = {
// 좌표
getKakaoSearchCoord: async (x: number, y: number): Promise<IRegion | undefined> => {
const params = {
x: x,
y: y,
};
const url = '/geo/coord2regioncode';
try {
const result = await instance.get(url, { params });
console.log('result', result);
// 행정동 법정동 2개의 데이터 존재
const documents = result.data.documents as IRegion[];
// 법정동의 code가 기상청 사용 API와 정확히 일치
return documents[1];
} catch (e) {
let message;
if (e instanceof Error) message = e.message;
else message = String(e);
console.error(message);
return undefined;
}
},
};
export default getKakaoLocal;
https://dapi.kakao.com/v2/local/geo/coord2regioncode?x=127.104268750245&y=37.2194198658269
{
"meta": {
"total_count": 2
},
"documents": [
{
"region_type": "B",
"code": "4159013100",
"address_name": "경기도 화성시 영천동",
"region_1depth_name": "경기도",
"region_2depth_name": "화성시",
"region_3depth_name": "영천동",
"region_4depth_name": "",
"x": 127.10621687504614,
"y": 37.21063110283336
},
{
"region_type": "H",
"code": "4159059000",
"address_name": "경기도 화성시 동탄5동",
"region_1depth_name": "경기도",
"region_2depth_name": "화성시",
"region_3depth_name": "동탄5동",
"region_4depth_name": "",
"x": 127.12355021369194,
"y": 37.209277134186244
}
]
}
code_local은 엑셀 데이터를 json으로 convert한 결과물이다.
import { getKakaoLocal } from '@src/API';
import setLocalCoordInfo from './setLocalCoordInfo';
import _short_local from '@src/Assets/short_api_locals.json';
import _code_local from '@src/Assets/short_api_code.json';
// 기상청 API에서 제공하는 지역 좌표 정보
// 기상청 API는 nx, ny를 인자로 받아 사용하지만 실제 지도와는 다른 임의의 값 사용
// kakao API로 얻은 지역 정보(도, 시)를 JSON 객체에서 찾아 nx, ny 값을 얻음
const short_local = _short_local as IPlaceCoordJson;
const code_local = _code_local as ICodeCoordJson[];
interface ICodeCoordJson {
code: number;
depth1: string;
depth2: string;
depth3: string;
x: number;
y: number;
}
interface IPlaceCoordJson {
[province: string]: {
[city: string]: {
x: number;
y: number;
};
};
}
interface ICoord {
lat: number;
lng: number;
}
const transName = (name: string) => {
if (name === '강원특별자치도') return '강원도';
if (name === '전북특별자치도') return '전라북도';
return name;
};
const transLocaleToCoord = async (position: ICoord) => {
// 카카오 API를 통해 좌표를 카카오 지도의 주소로 변환
// x : 경도(lng), y : 위도(lat)
const result = await getKakaoLocal.getKakaoSearchCoord(position.lng, position.lat);
if (!result) return null;
const province = transName(result.region_1depth_name);
const city = result.region_2depth_name.replace(' ', '') || province;
const address = result.address_name;
const localeCode = result.code;
let nx, ny: number;
const correctItem = code_local.find(item => item.code === Number(localeCode));
nx = correctItem.x;
ny = correctItem.y;
// 로컬 스토러지에 좌표 정보 저장
// 만약 이미 로컬에 저장한 좌표, 지역이면 null 반환
if (setLocalCoordInfo({ nx, ny, province, city })) {
return { nx, ny, province, city, address, localeCode };
} else return null;
};
export default transLocaleToCoord;
기상청 API를 요청하는 함수이다.
import axios, { AxiosInstance } from 'axios';
import { format, getMinutes, subHours } from 'date-fns';
const url: string = import.meta.env.VITE_API_SHORT_URL;
const serviceKey: string = import.meta.env.VITE_API_SERVICE_KEY;
const instance: AxiosInstance = axios.create({
baseURL: url,
timeout: 3000,
headers: {
'Content-Type': 'application/json',
},
});
export interface IParseObj {
[category: string]: string;
}
export interface ICoord {
nx: number;
ny: number;
}
interface IItem {
baseDate: string;
baseTime: string;
category: string;
obsrValue: string;
nx: number;
ny: number;
}
const params = {
serviceKey: serviceKey,
dataType: 'JSON',
base_date: '',
base_time: '',
numOfRows: '1000',
nx: 0,
ny: 0,
};
// nx와 ny를 조절해서 지역을 변경할 수 있어야 함
const isVaildCategory = (category: string) => {
const vaildCategory = ['T1H', 'REH', 'PTY', 'RN1'];
// T1H : 기온, REH : 습도, PTY : 강수형태, RN1 : 1시간 강수량
// PTY 종류 : 없음(0), 비(1), 비/눈(2), 눈(3), 빗방울(5), 빗방울눈날림(6), 눈날림(7)
return vaildCategory.includes(category);
};
const getWeatherLive = async (base_date: Date, location: ICoord): Promise<IParseObj | undefined> => {
const url = '/getUltraSrtNcst';
const date = format(base_date, 'yyyyMMdd');
// 10분 이전이면 1시간 전 데이터를 가져옴
if (getMinutes(base_date) <= 10) base_date = subHours(base_date, 1);
const hour = format(base_date, 'HH');
params.base_date = date;
params.base_time = hour + '00';
params.nx = location.nx;
params.ny = location.ny;
const items: IParseObj = {};
try {
const response = await instance.get(url, { params });
const dataArr = response.data.response.body.items.item;
dataArr.forEach((item: IItem) => {
const { category, obsrValue } = item;
if (isVaildCategory(category)) items[category] = obsrValue;
});
return items;
} catch (e) {
let message;
if (e instanceof Error) message = e.message;
else message = '/getUltraSrtNcst error';
console.error(message);
}
};
export default getWeatherLive;
위에서 작성한 API 요청 함수 getWeatherLive를 query 함수로 사용한다.
import { useQuery } from '@tanstack/react-query';
import { getWeatherLive } from '@src/API';
import { IParseObj, ICoord } from '@src/API/getWeatherLive';
// FooterPlaces에서 사용하는 타입
export interface LocateDataType {
position: {
lat: number;
lng: number;
};
placeName: string;
placeId: string; // kakao map placeId, 유일한 값
}
export type markerStatus = 'bookmark' | 'search' | 'pin';
// Marker에서 사용하는 타입
export interface KakaoMapMarkerType extends LocateDataType {
image: {
src: string;
size: {
width: number;
height: number;
};
};
status: markerStatus;
}
// Place에서 사용하는 타입
export interface KakaoSearchType extends LocateDataType {
apiLocalPosition: {
lat: number;
lng: number;
};
province: string;
city: string;
isBookmarked: boolean;
localeCode: string; // 카카오 지도의 지역 코드, 같은 지역일 경우 중복될 수 있음
// 쿼리 키로 사용해서 같은 지역일 경우 중복 요청을 막고 캐시를 사용
}
const useLiveDataQuery = (today: Date, marker: KakaoSearchType) => {
const { data, isLoading, error, status } = useQuery<IParseObj | undefined>({
queryKey: ['live', marker.localeCode, marker.placeName],
queryFn: async () => {
// 퀴리키가 없을 떄, 새로 요청할 때 호출
const location: ICoord = {
nx: marker.apiLocalPosition ? marker.apiLocalPosition.lng : 0,
ny: marker.apiLocalPosition ? marker.apiLocalPosition.lat : 0,
};
// endpoint : getUltraSrtNcst
const result = await getWeatherLive(today, location);
if (!result) {
throw new Error('실시간 날씨 정보를 가져오지 못했습니다.');
}
return result;
},
select: data => {
// 캐시된 데이터를 사용할 때 호출
if (data && marker) {
data.province = marker.province;
data.city = marker.city;
data.content = marker.placeName;
}
return data;
},
retry: 3,
retryDelay: 1000,
enabled: marker !== null,
staleTime: 1000 * 60, // 1분
});
return { data, isLoading, status, error };
};
export default useLiveDataQuery;
nx=62&ny=119, 실제 좌표가 아닌 기상청이 요구한 x, y 파라미터로 바꾼 값이다!
https://apis.data.go.kr/1360000/VilageFcstInfoService_2.0/getUltraSrtNcst?serviceKey=serviceKey&dataType=JSON&base_date=20241017&base_time=1600&numOfRows=1000&nx=62&ny=119
{
"response": {
"header": {
"resultCode": "00",
"resultMsg": "NORMAL_SERVICE"
},
"body": {
"dataType": "JSON",
"items": {
"item": [
{
"baseDate": "20241017",
"baseTime": "1600",
"category": "PTY",
"nx": 62,
"ny": 119,
"obsrValue": "0"
},
{
"baseDate": "20241017",
"baseTime": "1600",
"category": "REH",
"nx": 62,
"ny": 119,
"obsrValue": "68"
},
{
"baseDate": "20241017",
"baseTime": "1600",
"category": "RN1",
"nx": 62,
"ny": 119,
"obsrValue": "0"
},
{
"baseDate": "20241017",
"baseTime": "1600",
"category": "T1H",
"nx": 62,
"ny": 119,
"obsrValue": "21.3"
},
{
"baseDate": "20241017",
"baseTime": "1600",
"category": "UUU",
"nx": 62,
"ny": 119,
"obsrValue": "-0.1"
},
{
"baseDate": "20241017",
"baseTime": "1600",
"category": "VEC",
"nx": 62,
"ny": 119,
"obsrValue": "167"
},
{
"baseDate": "20241017",
"baseTime": "1600",
"category": "VVV",
"nx": 62,
"ny": 119,
"obsrValue": "0.9"
},
{
"baseDate": "20241017",
"baseTime": "1600",
"category": "WSD",
"nx": 62,
"ny": 119,
"obsrValue": "0.9"
}
]
},
"pageNo": 1,
"numOfRows": 1000,
"totalCount": 8
}
}
}