본 게시물에서는 네이버 클라우드 플랫폼 Naver Map's Enterprise API를 사용하여 지도에서 주소를 가져올 수 있는 Reverse Geocoding 구현 방법을 정리하였습니다. 참고문서
사용 스택: Next.js, TypeScript, tailwindCSS
❗️2023년 1월부터 네이버 클라우드 플랫폼 Maps 서비스 중 Reverse Geocoding을 포함하여 일부 서비스에 가격변동있음 공지링크
지도 API를 사용하기로 마음먹은 후부터 API를 이해하고 적용하기까지 시간이 꽤 걸렸다. 네이버 지도 문서를 옛날 것으로 보고 있다거나, 코드를 그냥 가져다 붙혀넣는 둥... 그리고 네이버 맵의 Reverse Geocoding에 대한 API 사용법과 예제가 네이버 클라우드 플랫폼 기술문서에 나와 있긴 하지만 코드에 대한 설명이 한 번에 알아보기 쉽지 않아 글로 정리해보려 한다.
"API를 이해하고 적용하기까지 순서가 있다면 더 접하기 쉬웠을 텐데..." 라고 생각하며 정리해보았다.
지도를 사용하기 위한 API는 NAVER Developers(네이버 로그인, 검색 등등)가 아니라 NAVER Cloud Platform에서 등록해야 한다.
등록한 후 받은 Client Id를 가져와 스크립트를 호출해야 한다. 이 Client Id는 .env파일에 숨겨서 환경변수로 만들어준다.
// .env
CLIENT_ID=myclientid
(등록하는 방법은 메인이 아니기 때문에 따로 정리해두었다. → API 등록하기 정리글)
등록하고 Client Id까지 가져왔다면 API에 대해서 조금 알아가야 할 차례이다. 내가 구현할 코드를 다른 사람이 이미 구현했다고 해서 그 코드를 무턱대고 복사 붙여 넣기를 했다간 더 많은 시간을 낭비할 수 있다.
지도 API는 생성하고 - 설정(커스텀)해주고 - 뿌려주기의 단계를 거친다. 지도를 클릭해서 어떠한 이벤트를 발생시키려면 자바스크립트와 똑같이 Event Listener를 걸어서 그 안에 함수를 넣어주면 된다. 생김새만 다르고 움직이는 건 우리가 해왔던 것과 비슷하다.
이제부터 Reverse Geocoding을 적용하면서 사용한 API의 메서드, 이벤트 그리고 클래스 등을 소개해보겠다.
Map 클래스는 애플리케이션에서 지도 인스턴스를 정의합니다. 이 객체를 생성함으로써 개발자는 지정한 DOM 요소에 지도를 삽입할 수 있습니다.
지도 생성 및 기본 동작new naver.maps.Map(mapDiv, mapOptions)
설명
new 키워드를 사용하여 Map을 생성한다. 매개변수는 두 가지를 받는데 첫 번째에는 지도를 삽입할 HTML 요소의 id를 주고, 두 번째에는 지도를 어떻게 만들지 옵션을 주어야 한다.
// MapSearch.tsx
let nmap = new naver.maps.Map('map', {
center: new naver.maps.LatLng(myLocation.latitude, myLocation.longitude),
zoom: 9,
zoomControl: true,
size: { width: 500, height: 500 },
});
return (
<div id='map'></div>
);
더 알아보기 - naver.maps.Map - MapOptions
LatLng 클래스는 위/경도 좌표를 정의합니다.
Class: naver.maps.LatLngnew naver.maps.LatLng(lat: number, lng: number)
숫자로 된 위도와 경도를 전달해주면 좌표를 객체로 반환한다.
위에서 지도를 생성할 때, 지도의 중심을 정하는 center 옵션에 이 클래스로 좌표 객체를 만들어 전달해주는 것이다.
new naver.maps.LatLng(37.5666103, 126.9783882);
NAVER 지도 API의 이벤트 시스템을 구현합니다. 대상 객체에서 이벤트 알림을 받아 핸들러를 호출하는 리스너를 등록합니다.
StaticObjects: Eventnaver.maps.Event.addListener(target, eventName, listener);
자바스크립트의 addEventListener와 같이 지도에서 일어나는 이벤트를 등록하여 줄 때 사용한다. Event에는 addListener 메서드를 제외하고도 clearListener, hasListner 등 이벤트에 관한 메서드들이 많다.
그중에서도 addListener 메서드는 지도 위에서 일어나는 이벤트를 감지하여 함수를 실행한다.
naver.maps.Event.addListener(nmap, 'click', function (e) {
alert(e.coord); // 클릭한 지점 좌표
});
더 알아보기 -UI 이벤트
InfoWindow 클래스는 지도 위에 올리는 정보 창을 정의합니다.
Class: naver.maps.InfoWindownew naver.maps.InfoWindow(options);
설명
new 키워드를 사용하여 정보 창을 생성한다.
아래 사진처럼 주소 말고도 우리가 입력하고 싶은 텍스트를 넣어서 정보창을 띄울 수 있다.
new naver.maps.InfoWindow({ content: '', borderWidth: 0 });
infoWindow.setContent(
['<div style="padding:10px;min-width:200px;line-height:150%;>',
'서울특별시 송파구 잠실동',
'</div>'
].join(''),
);
infoWindow.open(nmap, latlng);
특정 주소의 좌표를 반환하는 geocode API를 호출합니다.
geocodenaver.maps.Service.geocode(options, callback)
먼저, naver.maps.Service는 객체는 NAVER 지도 API v3을 이용해 호출할 수 있는 서버 API들을 메서드로 제공한다. 그 메서드 중 하나인 geocode 메서드는 주소를 전달해주면 좌표를 반환해주는 메서드이다.
첫 번째 매개변수 options 중 하나인 query에 주소를 전달해주면 callback 함수의 매개변수로 status와 response를 받는데, response 객체 안에서 좌표와 내가 검색한 주소의 전체 주소를 받아 볼 수 있다.
naver.maps.Service.geocode(
{
query: address, // 주소 전달
},
function (status, response) {
console.log(response); // 응답 객체
}
);
response 응답객체↓
특정 좌표에 해당하는 주소를 반환하는 reversegeocode API를 호출합니다.
naver.maps.Service.reverseGeocode(options, callback)
geocode와 동작하는 방식은 같지만 reverse 단어 뜻 그대로 geocode의 반대로, 좌표를 받아 주소를 받아오는 방식이다. options 중 coords에 좌표를 전달해주면 response 객체에 주소가 결과로 들어온다. 우리는 지도 클릭 이벤트 안에 이 함수를 넣어서 주소를 가져오고 infoWindow로 띄워줄 예정이다.
naver.maps.Service.reverseGeocode(
{
coords: latlng,
orders: [naver.maps.Service.OrderType.ADDR, naver.maps.Service.OrderType.ROAD_ADDR].join(','),
},
function (status, response) {
console.log(response);
}
)
본 게시물에서는 지도에서 클릭한 지점의 주소를 정보 창으로 띄우는 것과 , 검색창에 주소를 검색하여 그 주소로 이동하여 정보 창을 띄우는 것을 정리하였기 때문에 API를 전부 자세하게 기재하진 않았다. 다른 정보는 네이버 기술문서에서 참고하면 좋다.
지금부터 API를 적용해 보겠다. 구현할 내용을 다시 한번 정리해보자면,
클릭해서 주소 가져오기
검색해서 주소 가져오기
총 두 가지를 차례대로 구현할 것이다.
API를 사용하여 구현하기 전에 세팅부터 시작해보겠다.
참고문서 - TypeScript 사용
npm i -D @types/navermaps
Naver Map API를 타입스크립트와 같이 쓰고 싶다면 NPM 패키지를 설치하여 NAVER 지도 API 타입 정의 파일을 가져올 수 있다.
const latlng: naver.maps.LatLng = new naver.maps.LatLng(latitude, longitude);
이렇게 패키지가 제공하는 타입을 지정할 수 있다. (any 범벅을 피할 수 있다.)
참고문서 - 시작하기
<script type="text/javascript" src="https://openapi.map.naver.com/openapi/v3/maps.js?ncpClientId=YOUR_CLIENT_ID"></script>
해당 URL을 script태그에 넣어서 불러와 준다.
YOUR_CLIENT_ID
부분에 Application 등록 후 받은 Client Id를 넣어준다.
여기서 주의할 점은, 네이버 map의 옛날 버전이 아직 존재하여 헷갈릴 수 있다. ncpClientId=~로 넣어줘야 할 부분을 cliendId=~로 넣으면 안 되기 때문에 에러가 뜬다면 한 번쯤 확인해야 할 부분이다.
Next.js로 구현하고 있다면 _document.tsx 파일에서 스크립트를 불러와 준다.
// pages/_document.tsx
mport { Html, Head, Main, NextScript } from 'next/document';
import Script from 'next/script';
export default function Document() {
return (
<Html lang="ko">
<Head>
<Script
strategy="beforeInteractive"
type="text/javascript"
src={`https://openapi.map.naver.com/openapi/v3/maps.js?ncpClientId=${process.env.NEXT_PUBLIC_MAP_KEY}&submodules=geocoder&callback=initMap`}
></Script>
</Head>
<body>
<Main />
<NextScript />
</body>
</Html>
);
}
지도를 가져올 준비를 마쳤다면 이제부터 지도를 만들어 보자!
참고자료 - Naver Map 자유롭게 활용하기 by silverbeen.log
지도를 띄우고 초기 좌표를 사용자의 현재위치로 설정하려면 현재위치를 받아와야 한다. 여기서는 사용자의 현재위치를 여러 곳에서 불러와야 해서 Custom Hook으로 만들어 주었다.
// src/lib/types.ts
type Coord = { latitude: number; longitude: number } | '';
// src/components/useGetCurrentLocation.ts
import { Coord } from '../lib/types';
const useGetCurrentLocation = () => {
const [myLocation, setMyLocation] = useState<Coord>('');
}
...
const useGetCurrentLocation = () => {
const [myLocation, setMyLocation] = useState<Coord>('');
useEffect(() => {
if (navigator.geolocation) {
// 위치 가져오기
navigator.geolocation.getCurrentPosition(success, error);
}
// 성공했다면 상태 값에 사용자 현재 위치 좌표 저장
// position은 nested Object이기 때문에 [key: string]: any 타입으로 지정해 줌.
const success = (position: { [key: string]: any }) => {
setMyLocation({
latitude: position.coords.latitude,
longitude: position.coords.longitude,
});
}
// 실패했다면 상태 값에 Default 좌표 저장
const error = () => {
setMyLocation({ latitude: 37.3595316, longitude: 127.1052133 });
}
}, []);
// 다른 곳에서 사용할 수 있도록 myLocation을 반환해준다.
return myLocation;
};
우리는 initMap이라는 함수 안에서 지도를 생성하고 clickEvent를 걸어줄 것이다. clickEvent를 초기에 걸어주지 않으면 이후의 clickEvent도 작동 하지 않는다. 지도는 initMap 안에서 생성하면 다른 함수에서 인자를 전달할 때 사용하지 못하기 때문에 최상위에 먼저 선언을 해준다.
지도를 생성하면 useRef를 사용하여 지도를 참조하는 mapRef의 current에 넣어주고, 이 mapRef와 사용자의 현재 위치인 myLocation을 useEffect 의존배열 안에 넣어준다. 지도가 생성되면
// mapSearch.tsx
const mapRef = useRef<HTMLElement | null>(null);
const myLocation = useGetCurrentLocation();
let nmap: naver.maps.Map;
const initMap = () => {
if (typeof myLocation !== 'string') {
nmap = new naver.maps.Map('map', {
center: new naver.maps.LatLng(myLocation.latitude, myLocation.longitude),
zoomControl: true,
size: { width: 500, height: 500 },
});
naver.maps.Event.addListener(nmap, 'click', function (e) {
// 좌표에서 주소로 변환하는 함수
searchCoordinateToAddress(e.coord, nmap as naver.maps.Map);
});
}
};
useEffect(() => {
initMap();
}, [mapRef, myLocation])
지도 클릭 시 정보 창안에 주소가 뜨도록, clickEvent와 infoWindow API를 사용하는 searchCoordinateToAddress 함수를 만든다.
coords
에 넣어준다.(여기서 도로명 주소와 지번 주소 둘 다 넣고 싶다면 반복문으로 address 안에 넣으면 됨.)
// src/lib/searchCoordinateToAddress.ts
export const searchCoordinateToAddress = (latlng: naver.maps.LatLng, nmap: naver.maps.Map) => {
naver.maps.Service.reverseGeocode(
// options
{
coords: latlng,
orders: [naver.maps.Service.OrderType.ADDR, naver.maps.Service.OrderType.ROAD_ADDR].join(','),
},
// callback
function (
status: naver.maps.Service.Status,
response: naver.maps.Service.ReverseGeocodeResponse,
) {
// 응답을 못 받으면 'Something went wrong' alert 띄우기
if (status !== naver.maps.Service.Status.OK) {
return alert('Something went wrong!');
}
// 도로명 주소가 있다면 도로명 주소를, 없다면 지번 주소를 address 변수에 담는다.
const address = response.v2.address.roadAddress
? response.v2.address.roadAddress
: response.v2.address.jibunAddress;
},
);
};
export const searchCoordinateToAddress = (latlng: naver.maps.LatLng, nmap: naver.maps.Map) => {
// infoWindow 생성
const infoWindow = new naver.maps.InfoWindow({ content: '', borderWidth: 0 });
naver.maps.Service.reverseGeocode(
{
coords: latlng,
orders: [naver.maps.Service.OrderType.ADDR, naver.maps.Service.OrderType.ROAD_ADDR].join(','),
},
function (
status: naver.maps.Service.Status,
response: naver.maps.Service.ReverseGeocodeResponse,
) {
if (status !== naver.maps.Service.Status.OK) {
return alert('Something went wrong!');
}
const address = response.v2.address.roadAddress
? response.v2.address.roadAddress
: response.v2.address.jibunAddress;
// infoWindow 안에 넣어줄 html을 setContent 메서드에 넣어준다.
infoWindow.setContent(
['<div style="padding:10px;min-width:200px;line-height:150%;">', address, '</div>'].join('')
);
// open 메서드에 지도와 좌표를 전달하여 정보 창을 열어준다.
infoWindow.open(nmap, latlng);
},
);
};
검색 시 주소로 좌표를 가져오는 함수를 만들기 전에 input과 이벤트를 걸어준다.
<form onSubmit={handleSubmit}>
<input type="text" placeholder="(예:잠실)" />
<button onClick={handleClick} type="button">
주소 검색
</button>
</form>
input에 입력값을 넣고 엔터를 누르면 form이 submit 되어 onSubmit 함수가 실행되니 onSubmit 실행 함수부터 만들어보자.
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => { e.preventDefault(); const inputValue = (e.target as HTMLElement).querySelector('input')?.value as string; // 주소에서 좌표로 바꿔주는 함수 ↓ searchAddressToCoordinate(inputValue, nmap); };
- form이 submit 되면 페이지가 자동으로 새로고침 되는 기본값이 있어서, 마음에 들지 않는다면 e.preventDefault로 새로고침을 막아준다.
- 사용자의 입력값을 event 객체에서 가져온 다음 주소에서 좌표로 바꿔주는 함수 searchAddressToCoordinate의 첫 번째 인자로, 지도는 두 번째 인자로 전달해준다.
const handleClick = (e: React.MouseEvent<HTMLElement>) => { const inputValue = ((e.target as HTMLElement).parentNode as HTMLElement).querySelector('input') ?.value as string; searchAddressToCoordinate(inputValue, nmap); };
이렇게 간단하면 좋겠지만 input에는 함정이 있다. 바로 빈값에서 엔터를 치면 submit이 된다는 것. 참고 ↓
When there is only one single-line text input field in a form, the user agent should accept Enter in that field as a request to submit the form.
-HTML 2.0 specification (Section 8.2) - Form Submission
그래서 빈 값일 때도 submit 돼서 새로고침 되는 게 마음에 안 든다면 input을 form 안에 두 개 만들어서 하나는 숨기거나, input의 event.code가 'Enter'일 때 submit을 막아야 한다.
const handleKeydown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.code === 'Enter' && (e.target as HTMLInputElement).value === '') {
e.preventDefault();
}
}
<form onSubmit={handleSubmit}>
<input onKeyDown={handleKeydown} type="text" placeholder="(예:잠실)" />
<button onClick={handleClick} type="button">
주소 검색
</button>
</form>
주소로 장소를 검색하는 방법은 간단하다. Reverse Geocoding과 비슷하다. 사용자가 입력한 입력값을 넘겨주고, 지도 이동해주는 점만 다르다.
export function searchAddressToCoordinate(
address: string,
nmap: naver.maps.Map,
) {
naver.maps.Service.geocode(
{
query: address,
},
function (status: naver.maps.Service.Status, response: naver.maps.Service.GeocodeResponse) {
// 응답을 못 받으면 'Something went wrong' alert 띄우기
if (status === naver.maps.Service.Status.ERROR) {
return alert('Something went Wrong!');
}
}
);
}
naver.maps.Service.geocode(
{
query: address,
},
function (status: naver.maps.Service.Status, response: naver.maps.Service.GeocodeResponse) {
if (status === naver.maps.Service.Status.ERROR) {
return alert('Something went Wrong!');
}
// 주소를 도로명으로 찾을 때, 건물명까지 입력하지 않으면 응답받지 못한다.
if (response.v2.meta.totalCount === 0) {
return alert('no results');
}
const item = response.v2.addresses[0]; // 찾은 주소 정보
const point = new naver.maps.Point(Number(item.x), Number(item.y)); // 지도에서 이동할 좌표
const address = item.roadAddress ? item.roadAddress : item.jibunAddress;
const infoWindow = new naver.maps.InfoWindow({
content: ['<div style="padding:10px;"><h4>' + address + '</h4></div>'].join(''),
borderWidth: 0,
});
// 검색한 주소를 중심으로 지도 움직이기
nmap.setCenter(point);
infoWindow.open(nmap, point);
},
);
infoWindow.open(map, anchor)
메서드의 두 번째 인자 anchor
자리에 LatLng 또는 Point 객체가 올 수 있다.anchor에 LatLng 또는 Point 객체로 지도 좌표를 설정하면 해당 좌표를 가리키도록 정보 창을 위치시킵니다. 정보 창 Tutorials
도로명 + 건물명 형식으로 입력하지 않았을 때 화면
알맞은 형식으로 검색했을 시 화면
지도를 클릭했을 시 화면
이슈: naver, window, Map을 못 찾는 경우
해결: 네이버 지도 스크립트를 로드 하기 전에 API를 가져와서 발생하는 경우가 많다.
만약 지도를 비동기 방식으로 로드했다면 스크립트 뒤에 &callback=초기함수이름
을 넣어 지도를 생성할 수 있다.
src=`https://openapi.map.naver.com/openapi/v3/maps.js?ncpClientId=${process.env.NEXT_PUBLIC_MAP_KEY}&submodules=geocoder&callback=initMap`
참고 - NAVER Cloud Platform NAVER Map's Enterprise API - Hello, World
이슈: 지도가 렌더링되는 페이지에 진입하여 현재위치 설정 후 지도가 뜨지 않고 인증 실패 화면이 뜬다. 새로고침 하면 지도가 또 제대로 뜬다.
해결: Application을 처음 등록할 때 Web 서비스 URL은 메인 도메인으로 입력해준다. 예를 들어 http://localhost:3000
이면 그대로 http://localhost:3000
로 등록해준다. 다른 페이지에서 지도를 띄운다고 http://localhost:3000/map
이런식으로 등록해주면 위 사진처럼 인증 실패 화면이 뜬다.
또한, script 내의 URL을 clientId가 들어간 옛날 API URL인 "src=https://openapi.map.naver.com/openapi/v3/maps.js?clientId=${MAP_KEY}
" 로 했다면, 최신 버전인 ncpClientId가 들어간 src=https://openapi.map.naver.com/openapi/v3/maps.js?ncpClientId=${process.env.NEXT_PUBLIC_MAP_KEY}&submodules=geocoder&callback=initMap
로 바꾸어야한다.
참고 - 자주묻는질문: Web Dynamic Map에서 API 인증오류가 납니다. 어떻게 해야 하나요?
이슈: 지도가 한 번만 생성되어야 하는데 한 번 이상 렌더링 되어 겹침 현상. 줌인/줌아웃했을 때 지도 하나는 줌이 되고, 나머지는 줌 컨트롤이 안됨.
해결:
const [myLocation, setMyLocation] = useState<{latitude: number, longitude: number} | ''>('');
if(typeof myLocation !== 'string') {지도 생성 및 클릭이벤트 걸어주기}
클릭해서 주소 찾기 함수
에 한번, 검색해서 주소 찾기 함수
에 한 번씩 생성했었는데, 주소를 검색한 후에 클릭 이벤트가 안 먹혀서 종일 삽질했었다...이슈: 주소 검색 시 제출 두 번 되는 이슈
해결: button의 type을 submit으로 하면 인풋에 값이 있는 상태에서 엔터를 눌러 submit을 할 때, 버튼까지 같이 눌려 두번 submit 된다. 그러면 함수도 두번 실행되기 때문에 type은 button으로 수정.
이슈: input이 빈 값이고, 커서가 들어간 상태에서 엔터 클릭 시 submit 됨.
해결: input이 form에 한 개밖에 없을 때는 자동으로 submit이 된다. submit을 막으려면 input을 두 개 만들어 하나는 시각적으로 안 보이게 하거나, onKeyDown 이벤트에 input 값이 빈 문자열일 때 preventDefault()하기.
// first solution
<form>
<input className='hidden' type='text' />
<input type='text' />
</form>
// second solution
<form>
<input onKeyDown={(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.code === 'Enter' && (e.target as HTMLInputElement).value === '') {
e.preventDefault();
}
}} type='text' />
</form>