맛집 관련 프로젝트를 진행하면서 지도 API가 필요했다. 사용자가 글을 쓰는 과정에서 원하는 장소를 지도에서 찾고, 해당 장소의 위치 정보를 글에 첨부할 수 있도록 구현해야 했다. 기능 구현을 위해 네이버 지도 API와 카카오맵 API를 찾아보았는데 네이버 지도 API는 현재 키워드로 장소 검색 기능을 제공하지 않는 듯하여 카카오맵 API를 사용하게 되었다.
코드가 길기 때문에 지도 관련 주요 코드는 아래 카카오맵 API에서 제공하는 예제를 참고하길 권장한다.
나는 모바일웹을 만들고 있었고, 내가 원하는 UI/UX는 네이버 블로그 스마트 에디터에서 지도를 첨부하는 것과 유사한 것이었다.
1. 키워드로 검색을 하면 목록이 나오고 목록의 항목을 누르면 해당 장소의 지도와 위치 정보가 뜬다.
2. 위치 정보에서 X 버튼을 누르면 위치 정보가 초기화되고 다시 검색 화면으로 넘어간다.
3. 상단의 첨부를 클릭하면 작성 중인 글에 위치 정보(지도)가 첨부된다.
이를 구현하기 위해 카카오맵에서 기본으로 제공하는 코드를 일부 수정했다. 나는 현재 Vanilla JS로 SPA를 구현하는 프로젝트를 진행하고 있어 class를 이용하여 컴포넌트를 구현했다. UI 및 비즈니스 로직이 class 내부에서 구현되다 보니 이에 맞춰 변경된 부분들이 있다. 나와 같은 환경이 아니라면 본 포스팅의 코드는 전체적인 흐름과 구현 방식을 참고하는 용도로 보면 될 것 같다. (카카오맵 개발 환경 설정 및 일부 중요하지 않은 부분은 다루지 않는다.)
1. 키워드 장소 검색 예제에서 지도 영역을 제거하고 검색 결과 목록만 가져오기
카카오맵에서 제공하는 예제에서는 지도와 목록이 한 쌍으로 나와있다. 검색을 하면 지도에도 검색 결과에 해당하는 곳들에 마커(map-pin)가 찍힌다. 그러나 나는 텍스트로 이루어진 검색 목록만 필요했기 때문에 지도와 관련된 코드는 모두 제거하였다.
* 나는 Tailwind CSS를 사용하고 있어 일부 중복되는 스타일 코드는 제거하였다.
<div class='map_wrap'>
<form id='search'>
<input type='text' placeholder='장소명을 입력하세요.' value='' id='keyword'>
<button type='submit'>
<i class='ph ph-magnifying-glass text-18 block'></i>
</button>
</form>
<div id='menu_wrap'>
<ul id='placesList'></ul>
<div id='pagination' class='flex-center'></div>
</div>
</div>
.map_wrap {
position: relative;
width: 100%;
height: 100%;
}
.map_wrap form {
width: calc(100% - 24px);
border-radius: 8px;
padding: 5px 8px 5px 12px;
display: flex;
border: solid 1px #ddd;
margin: 0 12px 8px;
}
.map_wrap input {
font-size: 16px;
padding-right: 6px;
width: 100%;
}
.map_wrap form button {
padding: 4px;
}
#menu_wrap * {
font-size: 14px;
}
#menu_wrap {
width: 100%;
padding: 5px;
padding-left: 8px;
height: calc(100% - 112px);
overflow-y: auto;
}
#placesList .item {
position: relative;
border-bottom: 1px solid #eee;
overflow: hidden;
cursor: pointer;
min-height: 65px;
letter-spacing: -0.015em;
}
#placesList .item span {
display: block;
margin-top: 4px;
}
#placesList .item h5 {
font-weight: bold;
}
#placesList .item h5,
#placesList .item .info {
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
#placesList .item .info {
padding: 10px 0 10px 15px;
}
#placesList .info .gray {
color: #8a8a8a;
}
#placesList .info .jibun {
padding-left: 28px;
background: url(https://t1.daumcdn.net/localimg/localimages/07/mapapidoc/places_jibun.png)
no-repeat;
background-position: 0px 3px;
}
#placesList .info .tel {
color: #009900;
}
#pagination {
margin: 16px auto 16px;
text-align: center;
gap: 20px;
}
#pagination a {
font-size: 16px;
color: #888;
display: inline-block;
width: 28px;
border-bottom: solid 1px rgba(0, 0, 0, 0);
}
#pagination .on {
font-weight: bold;
cursor: default;
color: #222;
border-color: #222;
}
// 초기화
init() {
this.$input = document.getElementById('keyword');
// 장소 검색 객체 생성
this.ps = new kakao.maps.services.Places();
}
// 키워드 검색 요청
searchPlaces() {
const keyword = this.$input.value;
if (!keyword.replace(/^\s+|\s+$/g, '')) {
alert('키워드를 입력해주세요!');
return false;
}
// 장소 검색 객체를 이용한 키워드로 장소 검색 요청
this.ps.keywordSearch(keyword, this.placesSearchCB.bind(this));
}
// 장소 검색이 완료됐을 때 호출되는 콜백함수
placesSearchCB(data, status, pagination) {
if (status === kakao.maps.services.Status.OK) {
// 검색 성공 시 검색 목록 렌더링
this.displayPlaces(data);
// 페이지 번호 렌더링
this.displayPagination(pagination);
} else if (status === kakao.maps.services.Status.ZERO_RESULT) {
alert('검색 결과가 존재하지 않습니다.');
return;
} else if (status === kakao.maps.services.Status.ERROR) {
alert('검색 결과 중 오류가 발생했습니다.');
return;
}
}
// 검색 결과 목록 렌더링 함수
displayPlaces(places) {
const listEl = document.getElementById('placesList');
const menuEl = document.getElementById('menu_wrap');
const fragment = document.createDocumentFragment();
// 검색 결과 목록에 추가된 항목들을 제거
this.removeAllChildNods(listEl);
for (var i = 0; i < places.length; i++) {
const itemEl = this.getListItem(places[i]); // 검색 결과 항목 Element 생성
// TODO: 검색 결과 항목 클릭 시 지도 및 장소 정보 출력
((title) => {
itemEl.onclick = () => {
console.log(title);
};
})(places[i].place_name);
fragment.appendChild(itemEl);
}
// 검색 결과 항목들을 검색결과 목록 Element에 추가
listEl.appendChild(fragment);
menuEl.scrollTop = 0;
}
// 검색 결과 항목을 Element로 반환하는 함수
getListItem(places) {
const el = document.createElement('li');
let itemStr =
'<div class="info">' + ' <h5>' + places.place_name + '</h5>';
if (places.road_address_name) {
itemStr +=
' <span>' +
places.road_address_name +
'</span>' +
' <span class="jibun gray">' +
places.address_name +
'</span>';
} else {
itemStr += ' <span>' + places.address_name + '</span>';
}
itemStr += ' <span class="tel">' + places.phone + '</span>' + '</div>';
el.innerHTML = itemStr;
el.className = 'item';
return el;
}
// 검색 결과 목록 하단에 페이지 번호를 표시는 함수
displayPagination(pagination) {
const paginationEl = document.getElementById('pagination');
const fragment = document.createDocumentFragment();
// 기존에 추가된 페이지 번호 삭제
while (paginationEl.hasChildNodes()) {
paginationEl.removeChild(paginationEl.lastChild);
}
for (var i = 1; i <= pagination.last; i++) {
const el = document.createElement('a');
el.href = '#';
el.innerHTML = i;
if (i === pagination.current) {
el.className = 'on';
} else {
el.addEventListener(
'click',
(function (i) {
return function (e) {
e.preventDefault();
pagination.gotoPage(i);
};
})(i),
);
}
fragment.appendChild(el);
}
paginationEl.appendChild(fragment);
}
// 검색 결과 목록의 자식 Element를 제거하는 함수
removeAllChildNods(el) {
while (el.hasChildNodes()) {
el.removeChild(el.lastChild);
}
}
지도 및 marker와 관련된 부분들을 제거하고 전반적으로 UI도 수정하였다.
초기 렌더링 시 init을 수행하고, searchPlaces를 form의 submit 이벤트 핸들러로 등록하면 된다.
2. 검색 결과 목록의 항목 클릭 시 해당 항목의 지도 및 위치 정보 출력하기
위에서 구현한 장소 검색을 수행하면 검색 결과로 여러 정보를 담은 객체(place)가 반환되는데 여기서 주소를 추출해 해당 주소의 위치(지도)를 가져올 수 있다.
카카오맵 api에서 제공하는 '주소로 장소 표시하기' 예제를 활용한 코드이다.
<div class='map_wrap'>
( … )
${
id
? `<div id='location_info'>
<div class='border-y border-zinc-200'>
<div id='map_container' class='w-full h-240'>
<div id='map'></div>
</div>
<div class='flex justify-between items-center p-12 '>
<div class='flex-center gap-6'>
<i class='ph-fill ph-map-pin text-24 text-[#238CFA]'></i>
<div>
<p class='font-semibold tracking-tight'>${placeName ?? ''}</p>
<p class='text-12 text-zinc-400'>${address ?? ''}</p>
</div>
</div>
<button id='edit' aria-label='수정' class='px-4 text-zinc-500'>수정</button>
</div>
</div>
</div>`
: ``
}
</div>
#place_info {
width: 100%;
height: calc(100% - 62px);
position: absolute;
top: 60px;
background: white;
}
#map {
width: 100%;
height: 100%;
position: relative;
overflow: hidden;
}
.info_window__content {
width: 140px;
text-align: center;
font-size: 12px;
padding: 6px 0;
border-radius: 8px;
}
init() {
// 지도 객체, 좌표-주소 변환 객체 초기화 추가
// 사용자가 검색 결과 목록에서 선택한 항목이 있는 경우에만 수행
const { id } = this.state.locationInfo;
if (id) {
const mapContainer = document.getElementById('map'); // 지도를 그릴 영역
const mapOption = {
center: new kakao.maps.LatLng(33.450701, 26.570667),
level: 3,
};
this.map = new kakao.maps.Map(mapContainer, mapOption);
this.geocoder = new kakao.maps.services.Geocoder();
}
this.ps = new kakao.maps.services.Places();
this.$input = document.getElementById('keyword');
this.$locationInfo = document.getElementById('location_info');
}
// 검색 결과 목록 렌더링 함수
displayPlaces(places) {
const listEl = document.getElementById('placesList');
const menuEl = document.getElementById('menu_wrap');
const fragment = document.createDocumentFragment();
// 검색 결과 목록에 추가된 항목들을 제거
this.removeAllChildNods(listEl);
for (var i = 0; i < places.length; i++) {
const itemEl = this.getListItem(places[i]); // 검색 결과 항목 Element 생성
const { id, road_address_name, place_name } = places[i];
// 검색 결과 항목 클릭 시 지도 및 장소 정보 출력
((address, place_name) => {
itemEl.onclick = () => {
// ** 추가된 부분 **
this.setState({
...this.state,
locationInfo: { id, address, placeName: place_name },
});
this.geocoder.addressSearch(address, this.addressSearchCB.bind(this));
};
})(road_address_name, place_name);
fragment.appendChild(itemEl);
}
// 검색결과 항목들을 검색결과 목록 Element에 추가
listEl.appendChild(fragment);
menuEl.scrollTop = 0;
}
// 특정 주소의 좌표 검색 후 해당 위치로 지도 중심을 이동시키는 함수
addressSearchCB(result, status) {
if (status === kakao.maps.services.Status.OK) {
const coords = new kakao.maps.LatLng(result[0].y, result[0].x);
// 결과값으로 받은 위치 마커로 표시
const marker = new kakao.maps.Marker({
map: this.map,
position: coords,
});
// 인포 윈도우에 장소명 표시
const { placeName } = this.state.locationInfo;
const infowindow = new kakao.maps.InfoWindow({
content: `<div class='info_window__content'>${placeName}</div>`,
});
infowindow.open(this.map, marker);
// 지도의 중심을 결과값으로 받은 위치로 이동
this.map.setCenter(coords);
}
}
location_info는 검색 결과 목록에서 선택한 항목의 위치 정보와 지도를 포함하는 영역이다. 검색 결과 목록에서 항목을 클릭하기 전까지는 화면에 렌더링하지 않으며 클릭했을 때 위치 정보 state가 업데이트되면서 렌더링된다. 수정 버튼을 클릭했을 때 locationInfo를 초기화하면 다시 검색 화면으로 돌아갈 수 있다.
3. 사용자의 현재 위치를 중심으로 한 검색 결과 받아보기
카카오맵 API는 키워드에 대한 검색 결과를 최대 45개까지만 제공한다. '이마트'와 같이 전국적으로 많이 있는 장소의 경우, 검색 결과로 모든 곳의 정보를 받아볼 수는 없다. 따라서 현재 사용자 위치를 중심으로 하여 검색 결과를 받아볼 수 있도록 구현했다.
HTML5 GeoLocation을 이용하여 간편하게 구현할 수 있다. 카카오맵 API에도 HTML5 GeoLocation을 사용한 예제 가 있다. 참고해서 구현하면 된다.
앞서 수행한 결과와 다르게 현재 내 위치 중심으로 결과를 도출한다.
// 초기화 시 사용자 현재 위치 받아오기
init() {
(...)
this.getUserLocation();
}
getUserLocation() {
if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition((position) => {
const lat = position.coords.latitude;
const lon = position.coords.longitude;
this.userLocation = new kakao.maps.LatLng(lat, lon);
});
} else {
// 받아올 수 없는 경우
this.userLocation = new kakao.maps.LatLng(37.554678, 126.970606); // 서울역 좌표
}
}
// 키워드 검색 요청
searchPlaces() {
const keyword = this.$input.value;
this.$input.blur();
if (!keyword.replace(/^\s+|\s+$/g, '')) {
alert('장소명을 입력해 주세요!');
return false;
}
// 장소 검색 객체를 통한 키워드로 장소 검색 요청
this.ps.keywordSearch(keyword, this.placesSearchCB.bind(this), {
location: this.userLocation, // location 옵션 추가
});
}
keywordSearch 함수의 파라미터 중 option 객체에서 특정 지역을 중심으로 검색하도록 하는 location 값을 설정해 주면 해당 location을 중심으로 검색이 수행된다.
4. 포스팅에 위치 정보 및 지도 바로가기 링크 추가하기
포스팅 시 사용자가 위치 정보를 첨부하면 검색 결과 데이터에서 id, address, place_name을 추출하여 DB에 저장한다.
address와 place_name은 사용자에게 위치 정보를 보여주는 데 사용하고, id는 지도 바로가기 링크에 사용한다.
카카오맵 API에서는 좌표나 장소 ID를 이용하여 카카오맵에서 해당 위치를 표시한 상태의 URL을 만들 수 있다.
URL Pattern | 예시 |
/link/map/장소ID | https://map.kakao.com/link/map/18577297 |
<a href='${`https://map.kakao.com/link/map/${id}`}' class='flex items-center gap-4'>
<i class='ph-fill ph-map-pin text-28 block text-primary'></i>
<div>
<p class='font-medium'>${placeName}</p>
<p class='text-14 text-zinc-400'>${address}</p>
</div>
</a>
위 패턴의 URL을 사용했을 때 모바일에서는 다음과 같이 '설치 없이 지도 보기'를 클릭할 경우 장소 상세 정보 페이지로 이동하고 '카카오맵앱에서 열기'를 클릭할 경우 지도로 넘어간다.
PC 버전에서는 바로 지도 화면으로 넘어간다.
지도가 아닌 장소 상세 페이지만으로 충분하다면, 검색 결과 데이터 객체가 제공하는 'place_url' 프로퍼티를 이용할 수도 있다.
잘못된 부분이나 개선할 점이 있다면 코멘트 남겨주세요!
references
https://apis.map.kakao.com/web/guide/
'Develop > JavaScript' 카테고리의 다른 글
[JavaScript] Vanilla JavaScript에 PWA 적용하기 with Webpack, Workbox (0) | 2024.05.18 |
---|---|
[JavaScript] Firestore 검색 구현하기 with Aloglia (0) | 2024.04.19 |
Vanilla JavaScript 프로젝트 초기 설정하기 (ES6) (0) | 2024.02.06 |
[Jest] 자바스크립트 테스트 코드 작성해 보기 (0) | 2023.09.07 |
[JavaScript] localStorage 사용하기 (0) | 2023.02.22 |