/** * 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, };