356 lines
11 KiB
JavaScript
356 lines
11 KiB
JavaScript
/**
|
|
* Geocoding 서비스
|
|
* 카카오 Local API를 사용한 주소 ↔ 좌표 변환
|
|
*
|
|
* 문서: https://developers.kakao.com/docs/latest/ko/local/dev-guide
|
|
*/
|
|
|
|
const axios = require('axios');
|
|
|
|
class GeocodingService {
|
|
constructor() {
|
|
this.baseUrl = 'https://dapi.kakao.com/v2/local';
|
|
this.apiKey = process.env.KAKAO_REST_KEY;
|
|
|
|
this.client = axios.create({
|
|
baseURL: this.baseUrl,
|
|
timeout: 10000,
|
|
headers: {
|
|
Authorization: `KakaoAK ${this.apiKey}`,
|
|
},
|
|
});
|
|
}
|
|
|
|
// ============================================
|
|
// 주소 → 좌표 변환 (Geocoding)
|
|
// ============================================
|
|
|
|
/**
|
|
* 주소를 좌표로 변환
|
|
* @param {string} address - 주소 문자열
|
|
* @returns {Object|null} { mapx, mapy, address, sido, sigungu, ... }
|
|
*/
|
|
async geocode(address) {
|
|
try {
|
|
const response = await this.client.get('/search/address.json', {
|
|
params: { query: address },
|
|
});
|
|
|
|
const documents = response.data.documents;
|
|
if (!documents || documents.length === 0) {
|
|
console.log(`Geocoding: 결과 없음 - "${address}"`);
|
|
return null;
|
|
}
|
|
|
|
const result = documents[0];
|
|
const addr = result.address || {};
|
|
|
|
return {
|
|
mapx: parseFloat(result.x), // 경도 (longitude)
|
|
mapy: parseFloat(result.y), // 위도 (latitude)
|
|
address: result.address_name, // 전체 주소
|
|
addressType: result.address_type, // REGION, ROAD, ROAD_ADDR
|
|
sido: addr.region_1depth_name || null, // 시도
|
|
sigungu: addr.region_2depth_name || null, // 시군구
|
|
eupmyeondong: addr.region_3depth_name || null, // 읍면동
|
|
bCode: addr.b_code || null, // 법정동 코드
|
|
hCode: addr.h_code || null, // 행정동 코드
|
|
roadAddress: result.road_address?.address_name || null,
|
|
};
|
|
} catch (error) {
|
|
console.error('Geocoding 실패:', address, error.message);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 도로명 주소 검색
|
|
*/
|
|
async geocodeRoad(address) {
|
|
try {
|
|
const response = await this.client.get('/search/address.json', {
|
|
params: {
|
|
query: address,
|
|
analyze_type: 'exact', // 정확히 일치
|
|
},
|
|
});
|
|
|
|
const documents = response.data.documents;
|
|
if (!documents || documents.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
// 도로명 주소 결과 우선
|
|
const roadResult = documents.find(d => d.road_address);
|
|
if (roadResult) {
|
|
const road = roadResult.road_address;
|
|
return {
|
|
mapx: parseFloat(roadResult.x),
|
|
mapy: parseFloat(roadResult.y),
|
|
address: road.address_name,
|
|
roadAddress: road.address_name,
|
|
sido: road.region_1depth_name,
|
|
sigungu: road.region_2depth_name,
|
|
eupmyeondong: road.region_3depth_name,
|
|
roadName: road.road_name,
|
|
buildingName: road.building_name,
|
|
zoneNo: road.zone_no, // 우편번호
|
|
};
|
|
}
|
|
|
|
return this.geocode(address);
|
|
} catch (error) {
|
|
console.error('도로명 Geocoding 실패:', error.message);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// ============================================
|
|
// 좌표 → 주소 변환 (역지오코딩)
|
|
// ============================================
|
|
|
|
/**
|
|
* 좌표를 주소로 변환
|
|
* @param {number} mapx - 경도 (longitude)
|
|
* @param {number} mapy - 위도 (latitude)
|
|
*/
|
|
async reverseGeocode(mapx, mapy) {
|
|
try {
|
|
const response = await this.client.get('/geo/coord2address.json', {
|
|
params: {
|
|
x: mapx,
|
|
y: mapy,
|
|
},
|
|
});
|
|
|
|
const documents = response.data.documents;
|
|
if (!documents || documents.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
const result = documents[0];
|
|
const addr = result.address || {};
|
|
const road = result.road_address;
|
|
|
|
return {
|
|
address: addr.address_name,
|
|
roadAddress: road?.address_name || null,
|
|
sido: addr.region_1depth_name, // 시도
|
|
sigungu: addr.region_2depth_name, // 시군구
|
|
eupmyeondong: addr.region_3depth_name, // 읍면동
|
|
hCode: addr.h_code, // 행정동 코드
|
|
bCode: addr.b_code, // 법정동 코드
|
|
};
|
|
} catch (error) {
|
|
console.error('역지오코딩 실패:', mapx, mapy, error.message);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 좌표로 행정구역 정보만 조회
|
|
*/
|
|
async getRegionByCoord(mapx, mapy) {
|
|
try {
|
|
const response = await this.client.get('/geo/coord2regioncode.json', {
|
|
params: {
|
|
x: mapx,
|
|
y: mapy,
|
|
},
|
|
});
|
|
|
|
const documents = response.data.documents;
|
|
if (!documents || documents.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
// B: 법정동, H: 행정동
|
|
const bDong = documents.find(d => d.region_type === 'B');
|
|
const hDong = documents.find(d => d.region_type === 'H');
|
|
|
|
return {
|
|
sido: bDong?.region_1depth_name || hDong?.region_1depth_name,
|
|
sigungu: bDong?.region_2depth_name || hDong?.region_2depth_name,
|
|
eupmyeondong: bDong?.region_3depth_name || hDong?.region_3depth_name,
|
|
bCode: bDong?.code,
|
|
hCode: hDong?.code,
|
|
};
|
|
} catch (error) {
|
|
console.error('행정구역 조회 실패:', error.message);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// ============================================
|
|
// 키워드 검색
|
|
// ============================================
|
|
|
|
/**
|
|
* 키워드로 장소 검색
|
|
* @param {string} keyword - 검색어 (예: "가평 펜션")
|
|
* @param {Object} options - { x, y, radius, page, size }
|
|
*/
|
|
async searchKeyword(keyword, options = {}) {
|
|
try {
|
|
const params = {
|
|
query: keyword,
|
|
page: options.page || 1,
|
|
size: options.size || 15,
|
|
};
|
|
|
|
// 중심 좌표가 있으면 거리순 정렬
|
|
if (options.x && options.y) {
|
|
params.x = options.x;
|
|
params.y = options.y;
|
|
params.sort = 'distance';
|
|
if (options.radius) {
|
|
params.radius = Math.min(options.radius, 20000); // 최대 20km
|
|
}
|
|
}
|
|
|
|
const response = await this.client.get('/search/keyword.json', params);
|
|
|
|
return {
|
|
places: response.data.documents.map(place => ({
|
|
id: place.id,
|
|
name: place.place_name,
|
|
category: place.category_name,
|
|
phone: place.phone,
|
|
address: place.address_name,
|
|
roadAddress: place.road_address_name,
|
|
mapx: parseFloat(place.x),
|
|
mapy: parseFloat(place.y),
|
|
placeUrl: place.place_url,
|
|
distance: place.distance ? parseInt(place.distance) : null,
|
|
})),
|
|
meta: response.data.meta,
|
|
};
|
|
} catch (error) {
|
|
console.error('키워드 검색 실패:', error.message);
|
|
return { places: [], meta: {} };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 펜션 검색 (지역 + 펜션 키워드)
|
|
*/
|
|
async searchPensions(region, options = {}) {
|
|
return this.searchKeyword(`${region} 펜션`, options);
|
|
}
|
|
|
|
// ============================================
|
|
// 지역코드 매핑
|
|
// ============================================
|
|
|
|
/**
|
|
* 시도명 → TourAPI 지역코드 변환
|
|
*/
|
|
getAreaCode(sido) {
|
|
const areaCodeMap = {
|
|
'서울': '1', '서울특별시': '1',
|
|
'인천': '2', '인천광역시': '2',
|
|
'대전': '3', '대전광역시': '3',
|
|
'대구': '4', '대구광역시': '4',
|
|
'광주': '5', '광주광역시': '5',
|
|
'부산': '6', '부산광역시': '6',
|
|
'울산': '7', '울산광역시': '7',
|
|
'세종': '8', '세종특별자치시': '8',
|
|
'경기': '31', '경기도': '31',
|
|
'강원': '32', '강원도': '32', '강원특별자치도': '32',
|
|
'충북': '33', '충청북도': '33',
|
|
'충남': '34', '충청남도': '34',
|
|
'경북': '35', '경상북도': '35',
|
|
'경남': '36', '경상남도': '36',
|
|
'전북': '37', '전라북도': '37', '전북특별자치도': '37',
|
|
'전남': '38', '전라남도': '38',
|
|
'제주': '39', '제주특별자치도': '39',
|
|
};
|
|
|
|
return areaCodeMap[sido] || null;
|
|
}
|
|
|
|
/**
|
|
* 좌표에서 TourAPI 지역코드 조회
|
|
*/
|
|
async getAreaCodeFromCoords(mapx, mapy) {
|
|
const region = await this.getRegionByCoord(mapx, mapy);
|
|
if (!region) return null;
|
|
|
|
return {
|
|
areaCode: this.getAreaCode(region.sido),
|
|
sido: region.sido,
|
|
sigungu: region.sigungu,
|
|
};
|
|
}
|
|
|
|
// ============================================
|
|
// 주소 파싱 유틸리티
|
|
// ============================================
|
|
|
|
/**
|
|
* 주소 문자열에서 시도/시군구 추출
|
|
*/
|
|
parseAddress(address) {
|
|
if (!address) return { sido: null, sigungu: null };
|
|
|
|
const sidoPatterns = [
|
|
{ pattern: /^(서울특별시|서울)/, name: '서울' },
|
|
{ pattern: /^(부산광역시|부산)/, name: '부산' },
|
|
{ pattern: /^(대구광역시|대구)/, name: '대구' },
|
|
{ pattern: /^(인천광역시|인천)/, name: '인천' },
|
|
{ pattern: /^(광주광역시|광주)/, name: '광주' },
|
|
{ pattern: /^(대전광역시|대전)/, name: '대전' },
|
|
{ pattern: /^(울산광역시|울산)/, name: '울산' },
|
|
{ pattern: /^(세종특별자치시|세종)/, name: '세종' },
|
|
{ pattern: /^(경기도|경기)/, name: '경기' },
|
|
{ pattern: /^(강원특별자치도|강원도|강원)/, name: '강원' },
|
|
{ pattern: /^(충청북도|충북)/, name: '충북' },
|
|
{ pattern: /^(충청남도|충남)/, name: '충남' },
|
|
{ pattern: /^(전북특별자치도|전라북도|전북)/, name: '전북' },
|
|
{ pattern: /^(전라남도|전남)/, name: '전남' },
|
|
{ pattern: /^(경상북도|경북)/, name: '경북' },
|
|
{ pattern: /^(경상남도|경남)/, name: '경남' },
|
|
{ pattern: /^(제주특별자치도|제주)/, name: '제주' },
|
|
];
|
|
|
|
let sido = null;
|
|
let sigungu = null;
|
|
|
|
for (const { pattern, name } of sidoPatterns) {
|
|
const match = address.match(pattern);
|
|
if (match) {
|
|
sido = name;
|
|
|
|
// 시군구 추출
|
|
const remaining = address.slice(match[0].length).trim();
|
|
const sigunguMatch = remaining.match(/^([가-힣]+[시군구])/);
|
|
if (sigunguMatch) {
|
|
sigungu = sigunguMatch[1];
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
|
|
return { sido, sigungu, areaCode: this.getAreaCode(sido) };
|
|
}
|
|
|
|
/**
|
|
* 주소 정규화 (검색용)
|
|
*/
|
|
normalizeAddress(address) {
|
|
if (!address) return '';
|
|
return address
|
|
.replace(/특별시|광역시|특별자치시|특별자치도/g, '')
|
|
.replace(/\s+/g, ' ')
|
|
.trim();
|
|
}
|
|
}
|
|
|
|
// 싱글톤 인스턴스
|
|
const geocodingService = new GeocodingService();
|
|
|
|
module.exports = {
|
|
geocodingService,
|
|
GeocodingService,
|
|
};
|