카카오에서 제공하는 MAP API를 활용해 지도 검색 앱을 구현해보자.
📎 Demo
map-search-app
├── client
│ ├── public (정적 자원 관리)
│ │ ├── images (이미지 관리)
│ │ └── index.html (지도 API 코드 입력)
│ ├── src
│ │ ├── App.tsx (컴포넌트 구성)
│ │ ├── index.tsx (App.tsx와 index.html 연결)
│ │ ├── App.scss (컴포넌트 스타일)
│ │ ├── common.scss (공통 스타일)
│ │ ├── index.scss (글로벌 스타일)
│ │ └── components
│ │ └── views
│ │ └── LandingPage
│ │ ├── LandingPage.tsx
│ │ └── Sections
│ │ └── Map.tsx (지도 영역)
│ ├── package-lock.json
│ └── package.json
├── package-lock.json
└── package.json
npx create-react-app my-app --template typescript
루트 경로에 .env 파일 생성 후 발급 받은 JavaScript 앱 키를 환경 변수로 작성
REACT_APP_KAKAO_API_KEY=발급받은javascriptAPI키
<script type="text/javascript" src="//dapi.kakao.com/v2/maps/sdk.js?appkey=발급받은 APP KEY를 넣으시면 됩니다."></script>
실제 지도를 그리는 JavaScript API 코드를 public/index.html <head></head>
사이에 삽입한다.
발급받은 APP KEY는 하드 코딩하면 정보가 노출될 위험이 있으니, .env에서 작성한 환경 변수를 사용하면 된다. URI에 환경 변수를 적용하기 위해서는 %여기사이%
에 환경 변수 입력 후 넣으면 된다.
(public/index.html)
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta name="description" content="Web site created using create-react-app" />
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<!-- Kakao API 불러오기 -->
<script type="text/javascript" src="//dapi.kakao.com/v2/maps/sdk.js?appkey=%REACT_APP_KAKAO_API_KEY%&libraries=services"></script>
<title>MAP SEARCH APP</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
</body>
</html>
라이브러리는 services, clusterer, drawing 세 가지가 있다. 필요한 경우 아래 형식으로 파라미터를 추가하면 된다.
&libraries=services,clusterer,drawing
- services: 마커를 클러스터링 할 수 있는 클러스터러 라이브러리
- clusterer: 장소 검색 과 주소-좌표 변환 을 할 수 있는 services 라이브러리
- drawing: 지도 위에 마커와 그래픽스 객체를 쉽게 그릴 수 있게 그리기 모드를 지원하는 drawing 라이브러리
LandingPage 컴포넌트에 입력 폼을 구현하고, 입력 값을 Map 컴포넌트로 넘긴다.
(LandingPage.tsx)
import React, { useState } from 'react'
import Map from './Sections/Map';
export interface propsType {
searchKeyword: string
}
const LandingPage = ():JSX.Element => {
// 입력 폼 변화 감지하여 입력 값 관리
const [Value, setValue] = useState("");
// 제출한 검색어 관리
const [Keyword, setKeyword] = useState("");
// 입력 폼 변화 감지하여 입력 값을 state에 담아주는 함수
const keywordChange = (e: { preventDefault: () => void; target: { value: string }; }) => {
e.preventDefault();
setValue(e.target.value);
}
// 제출한 검색어 state에 담아주는 함수
const submitKeyword = (e: { preventDefault: () => void; }) => {
e.preventDefault();
setKeyword(Value);
}
// 검색어를 입력하지 않고 검색 버튼을 눌렀을 경우
const valueChecker = () => {
if (Value === "") {
alert ("검색어를 입력해주세요.")
}
}
return (
<div className="landing-page">
<div className="landing-page__inner">
<div className="search-form-container">
<form className="search-form" onSubmit={ submitKeyword }>
<label htmlFor="place" className="form__label">
<input type="text" id="movie-title" className="form__input" name="place" onChange={ keywordChange } placeholder="검색어를 입력해주세요. (ex: 강남 맛집)" required />
<div className="btn-box">
<input className="btn form__submit" type="submit" value="검색" onClick={ valueChecker }/>
</div>
</label>
</form>
</div>
{/* 제출한 검색어 넘기기 */}
<Map searchKeyword={ Keyword }/>
</div>
</div>
)
}
export default LandingPage
(Map.tsx)
import React, { useEffect } from 'react';
import { propsType } from '../LandingPage';
interface placeType {
place_name: string,
road_address_name: string,
address_name: string,
phone: string,
place_url: string
}
// head에 작성한 Kakao API 불러오기
const { kakao } = window as any;
const Map = (props: propsType) => {
// 마커를 담는 배열
let markers: any[] = [];
// 검색어가 바뀔 때마다 재렌더링되도록 useEffect 사용
useEffect(() => {
const mapContainer = document.getElementById("map");
const mapOption = {
center: new kakao.maps.LatLng(37.566826, 126.9786567), // 지도의 중심좌표
level: 3 // 지도의 확대 레벨
};
// 지도를 생성
const map = new kakao.maps.Map(mapContainer, mapOption);
// 장소 검색 객체를 생성
const ps = new kakao.maps.services.Places();
// 검색 결과 목록이나 마커를 클릭했을 때 장소명을 표출할 인포윈도우를 생성
const infowindow = new kakao.maps.InfoWindow({zIndex:1});
// 키워드로 장소를 검색합니다
searchPlaces();
// 키워드 검색을 요청하는 함수
function searchPlaces() {
let keyword = props.searchKeyword;
if (!keyword.replace(/^\s+|\s+$/g, "")) {
console.log("키워드를 입력해주세요!");
return false;
}
// 장소검색 객체를 통해 키워드로 장소검색을 요청
ps.keywordSearch(keyword, placesSearchCB);
}
// 장소검색이 완료됐을 때 호출되는 콜백함수
function placesSearchCB(data: any, status: any, pagination: any) {
if (status === kakao.maps.services.Status.OK) {
// 정상적으로 검색이 완료됐으면
// 검색 목록과 마커를 표출
displayPlaces(data);
// 페이지 번호를 표출
displayPagination(pagination);
} else if (status === kakao.maps.services.Status.ZERO_RESULT) {
alert('검색 결과가 존재하지 않습니다.');
return;
} else if (status === kakao.maps.services.Status.ERROR) {
alert('검색 결과 중 오류가 발생했습니다.');
return;
}
}
// 검색 결과 목록과 마커를 표출하는 함수
function displayPlaces(places: string | any[]) {
const listEl = document.getElementById('places-list'),
resultEl = document.getElementById('search-result'),
fragment = document.createDocumentFragment(),
bounds = new kakao.maps.LatLngBounds();
// 검색 결과 목록에 추가된 항목들을 제거
listEl && removeAllChildNods(listEl);
// 지도에 표시되고 있는 마커를 제거
removeMarker();
for ( var i=0; i<places.length; i++ ) {
// 마커를 생성하고 지도에 표시
let placePosition = new kakao.maps.LatLng(places[i].y, places[i].x),
marker = addMarker(placePosition, i, undefined),
itemEl = getListItem(i, places[i]); // 검색 결과 항목 Element를 생성
// 검색된 장소 위치를 기준으로 지도 범위를 재설정하기위해
// LatLngBounds 객체에 좌표를 추가
bounds.extend(placePosition);
// 마커와 검색결과 항목에 mouseover 했을때
// 해당 장소에 인포윈도우에 장소명을 표시
// mouseout 했을 때는 인포윈도우를 닫기
(function(marker, title) {
kakao.maps.event.addListener(marker, 'mouseover', function() {
displayInfowindow(marker, title);
});
kakao.maps.event.addListener(marker, 'mouseout', function() {
infowindow.close();
});
itemEl.onmouseover = function () {
displayInfowindow(marker, title);
};
itemEl.onmouseout = function () {
infowindow.close();
};
})(marker, places[i].place_name);
fragment.appendChild(itemEl);
}
// 검색결과 항목들을 검색결과 목록 Element에 추가
listEl && listEl.appendChild(fragment);
if (resultEl) {
resultEl.scrollTop = 0;
}
// 검색된 장소 위치를 기준으로 지도 범위를 재설정
map.setBounds(bounds);
}
// 검색결과 항목을 Element로 반환하는 함수
function getListItem(index: number, places: placeType) {
const el = document.createElement('li');
let itemStr = `
<div class="info">
<span class="marker marker_${index+1}">
${index+1}
</span>
<a href="${places.place_url}">
<h5 class="info-item place-name">${places.place_name}</h5>
${
places.road_address_name
? `<span class="info-item road-address-name">
${places.road_address_name}
</span>
<span class="info-item address-name">
${places.address_name}
</span>`
: `<span class="info-item address-name">
${places.address_name}
</span>`
}
<span class="info-item tel">
${places.phone}
</span>
</a>
</div>
`
el.innerHTML = itemStr;
el.className = 'item';
return el;
}
// 마커를 생성하고 지도 위에 마커를 표시하는 함수
function addMarker(position: any, idx: number, title: undefined) {
var imageSrc = 'https://t1.daumcdn.net/localimg/localimages/07/mapapidoc/marker_number_blue.png', // 마커 이미지 url, 스프라이트 이미지
imageSize = new kakao.maps.Size(36, 37), // 마커 이미지의 크기
imgOptions = {
spriteSize : new kakao.maps.Size(36, 691), // 스프라이트 이미지의 크기
spriteOrigin : new kakao.maps.Point(0, (idx*46)+10), // 스프라이트 이미지 중 사용할 영역의 좌상단 좌표
offset: new kakao.maps.Point(13, 37) // 마커 좌표에 일치시킬 이미지 내에서의 좌표
},
markerImage = new kakao.maps.MarkerImage(imageSrc, imageSize, imgOptions),
marker = new kakao.maps.Marker({
position: position, // 마커의 위치
image: markerImage
});
marker.setMap(map); // 지도 위에 마커를 표출
markers.push(marker); // 배열에 생성된 마커를 추가
return marker;
}
// 지도 위에 표시되고 있는 마커를 모두 제거합니다
function removeMarker() {
for ( var i = 0; i < markers.length; i++ ) {
markers[i].setMap(null);
}
markers = [];
}
// 검색결과 목록 하단에 페이지번호를 표시는 함수
function displayPagination(pagination: { last: number; current: number; gotoPage: (arg0: number) => void }) {
const paginationEl = document.getElementById('pagination') as HTMLElement;
let fragment = document.createDocumentFragment();
let i;
// 기존에 추가된 페이지번호를 삭제
while (paginationEl.hasChildNodes()) {
paginationEl.lastChild &&
paginationEl.removeChild(paginationEl.lastChild);
}
for (i=1; i<=pagination.last; i++) {
const el = document.createElement('a') as HTMLAnchorElement;
el.href = "#";
el.innerHTML = i.toString();
if (i===pagination.current) {
el.className = 'on';
} else {
el.onclick = (function(i) {
return function() {
pagination.gotoPage(i);
}
})(i);
}
fragment.appendChild(el);
}
paginationEl.appendChild(fragment);
}
// 검색결과 목록 또는 마커를 클릭했을 때 호출되는 함수
// 인포윈도우에 장소명을 표시
function displayInfowindow(marker: any, title: string) {
const content = '<div style="padding:5px;z-index:1;" class="marker-title">' + title + '</div>';
infowindow.setContent(content);
infowindow.open(map, marker);
}
// 검색결과 목록의 자식 Element를 제거하는 함수
function removeAllChildNods(el: HTMLElement) {
while (el.hasChildNodes()) {
el.lastChild &&
el.removeChild (el.lastChild);
}
}
}, [props.searchKeyword])
return (
<div className="map-container">
<div id="map" className="map"></div>
<div id="search-result">
<p className="result-text">
<span className="result-keyword">
{ props.searchKeyword }
</span>
검색 결과
</p>
<div className="scroll-wrapper">
<ul id="places-list"></ul>
</div>
<div id="pagination"></div>
</div>
</div>
)
}
export default Map
배포하기 전에 카카오 개발자 페이지에 가서 Web 플랫폼을 수정해야 한다.
로컬에서 작업하기 위해 http://localhost:3000
으로 설정해두었다면, 배포하는 사이트의 도메인으로 바꿔준다.