결과 코드가 궁금한 사람은 3. 구현 성공! 부분으로 바로 넘어가기
1. 사전 준비
1-1) API 키 발급받기
카카오 개발자 센터에 가서 API 키를 발급받자.
메인화면에서 '내 애플리케이션' 메뉴로 들어가 내 프로젝트 명과 정보를 등록해 키를 발급받는다.
그리고 내 애플리케이션의 플랫폼 메뉴로 들어가 API를 호출할 곳의 주소를 설정해줘야 한다.
로컬에서 React로 개발할 것이므로 localhost:3000을 설정하자.
(만약 여기에 등록되지 않은 주소에서 호출하게 되면 API가 제대로 호출되지 않는다)
1-2) 라이브러리 불러오는 script 삽입하기
public 폴더의 index.html로 가서 script를 삽입한다.
<head>
<title>React App</title>
<script
type="text/javascript"
src="//dapi.kakao.com/v2/maps/sdk.js?appkey=%REACT_APP_KAKAO_API_KEY%&libraries=services&autoload=false"
></script>
</head>
- git에 올려도 유출 걱정이 없도록 프로젝트의 최상단에 (src 아래 X) .env 파일을 생성하고 key를 변수에 담아놓자. html 파일에서는 %변수명%으로 참조한다.
- libraries=services는 services라는 라이브러리를 불러온다는 의미이다. 다른 라이브러리를 사용하고 싶으면 문서의 라이브러리 불러오기 부분을 참조하라
- 마지막에 &autoload=false 옵션을 준다. React는 index.html에 삽입된 script는 해당 script를 사용하지 않는 페이지여도 모든 페이지에서 script를 로딩하기 때문이다. 이 API를 사용하는 곳에서만 스크립트를 로딩하도록 해 API 요청 낭비를 막는다.
.env 파일 입력값
REACT_APP_KAKAO_API_KEY=발급받은 key // 앞이 REACT_ 로 시작하기만 하면 됨. key 대입할 때 따옴표 필요 없음
2. ❗ React에 직접 javascript 코드 집어넣지 마세요 ❗ - 나의 실패담
첫 번째 시도 - map 객체가 중복해서 생성됨
이 두 게시글을 참고해 React에서 kakao 키워드 맵을 불러오는 것 까지는 성공했다.
[#. React] Kakao 카카오 지도 API를 이용해서 키워드로 장소 검색하기, 검색한 장소 마커 띄우기, 검
키워드 검색 후 마커만 띄울 거라면 이 글을 참고하면 된다 앞글에 가서 kakao developers에서 앱키 발급 후 세팅만 하고 오자 developer0809.tistory.com/90 [#. React] Kakao 카카오 지도 API를 이용해서 키워드로
developer0809.tistory.com
[React] 카카오 맵 API로 지도 검색 앱 구현하기 with TypeScript
카카오에서 제공하는 MAP API를 활용해 지도 검색 앱을 구현해보자. 📎 Demo카카오 앱 키 (📎 앱 키 생성 방법)Client: ReactTypeScript, SCSSnpx create-react-app my-app --template typescript루트
velog.io
script에서 autoload=false로 설정을 해줬기 때문에, script를 다 불러올 때까지 기다렸다가 코드를 실행하기 위해 다음과 같은 형식으로 키워드 지도 불러오는 코드를 작성했다.
const [keyword, setKeyword] = useState();
const [tmp, setTmp] = useState();
useEffect(() => {
window.kakao.maps.load(() => {
// kakao map 코드
}
}, [keyword]);
onChange로 입력되는 검색어는 tmp에 넣고, 키워드를 submit 할 때 setKeyword(tmp)를 해서 useEffect가 실행되는 구조였다. 그런데 keyword가 바뀌어서 useEffect가 새로 실행될 때마다 스크롤로 사이즈를 축소할 때 전에 검색했던 지역이 지도 배경에 비치는 것이었다.
(카카오 맵에서 스크롤로 축소를 해봤다면 알겠지만 원래는 kakao 워터마크가 박힌 베이지색 이미지가 배경으로 깔린다)
useEffect가 실행될 때마다 이전 map이 unmount되지 않은게 원인인 듯 했다.
두 번째 시도 - map 생성하는 부분과 그 외의 코드를 별개의 useEffect에 집어넣기 (반만 성공)
keyword 입력마다 새로운 지도 객체가 생성되는 게 문제라면 별개의 useEffect에 지도 생성 부분과 그 외의 부분을 분리해서 집어넣으면 해결 될 것이라고 생각했다. 실제로도 그 문제는 해결이 됐다.
하지만 여전히 한계는 존재했는데, 지도 객체를 새로 생성하지 않게 되니 지도에 이전 검색어의 marker가 지워지지 않고 그대로 남아있다는 문제점이 드러났다. 이전에는 새로운 지도 객체의 아래에 깔려있었기 때문에 드러나지 않았던 문제였다.
결국 반만 성공한 코드
import React, { ChangeEvent, FormEvent, useEffect, useState } from 'react';
import '../../styles/kakaomap.css';
declare global {
interface Window {
kakao: any;
}
}
interface placeType {
place_name: string;
road_address_name: string;
address_name: string;
phone: string;
place_url: string;
}
function KakaoMap() {
const [searchInput, setSearchInput] = useState('');
const [keyword, setKeyword] = useState('이태원 맛집');
const [selectedPlace, setSelectedPlace] = useState();
const [places, setPlaces] = useState<string | any[]>();
const [map, setMap] = useState<any>(null);
const handleKeywordChange = (e: ChangeEvent<HTMLInputElement>) => {
setSearchInput(e.target.value);
};
const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
setKeyword(searchInput);
};
useEffect(() => {
window.kakao.maps.load(() => {
const kakao = window.kakao;
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);
setMap(map);
});
}, []);
useEffect(() => {
if (!map) return;
console.log('map load');
window.kakao.maps.load(() => {
let markers: any[] = [];
const kakao = window.kakao;
// 장소 검색 객체를 생성
const ps = new kakao.maps.services.Places();
// 검색 결과 목록이나 마커를 클릭했을 때 장소명을 표출할 인포윈도우를 생성
const infowindow = new kakao.maps.InfoWindow({ zIndex: 1 });
// 키워드로 장소를 검색합니다
searchPlaces();
// 키워드 검색을 요청하는 함수
function searchPlaces() {
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('placesList'),
resultEl = document.getElementById('menu_wrap'),
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 했을 때는 인포윈도우를 닫기
// click 했을 때는 해당 장소의 정보를 state에 저장
(function (marker, place) {
const title = place.place_name;
kakao.maps.event.addListener(marker, 'mouseover', function () {
displayInfowindow(marker, title);
});
kakao.maps.event.addListener(marker, 'mouseout', function () {
infowindow.close();
});
kakao.maps.event.addListener(marker, 'click', function () {
console.log('선택된 장소', place);
setSelectedPlace(place);
});
itemEl.onmouseover = function () {
displayInfowindow(marker, title);
};
itemEl.onmouseout = function () {
infowindow.close();
};
itemEl.onclick = function () {
console.log('선택된 장소', place);
setSelectedPlace(place);
};
})(marker, places[i]);
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 = `<span class="markerbg marker_${index + 1}"></span>
<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;
}
// 마커를 생성하고 지도 위에 마커를 표시하는 함수
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;">' + title + '</div>';
infowindow.setContent(content);
infowindow.open(map, marker);
}
// 검색결과 목록의 자식 Element를 제거하는 함수
function removeAllChildNods(el: HTMLElement) {
while (el.hasChildNodes()) {
el.lastChild && el.removeChild(el.lastChild);
}
}
});
}, [map, keyword]);
return (
<div className="map_wrap">
<div
id="map"
style={{
width: '100%',
height: '100%',
position: 'relative',
overflow: 'hidden',
}}
></div>
<div id="menu_wrap" className="bg_white">
<div className="option">
<div>
<form onSubmit={handleSubmit}>
키워드 :{' '}
<input
type="text"
value={searchInput}
onChange={handleKeywordChange}
id="keyword"
size={15}
/>
<button type="submit">검색하기</button>
</form>
</div>
</div>
<hr />
<ul id="placesList"></ul>
<div id="pagination"></div>
</div>
</div>
);
}
export default KakaoMap;
3. 구현 성공! - react-kakao-maps-sdk 패키지 이용
React로 카카오맵을 쉽게 구현할 수 있도록 도와주는 오픈소스 라이브러리이다.
Tutorial | react-kakao-maps-sdk docs
Usage
react-kakao-maps-sdk.jaeseokim.dev
kakao api 예제에 나온 js 방식의 코드를 그대로 사용하면 생기는 단점
- DOM 직접 조작으로 인해 이전 객체를 삭제하는 코드를 작성해도, useEffect로 인해 새로운 변수로 대체되어서 그러는지, 삭제나 변경이 잘 반영되지 않는다. (위에서 작성한 코드에 분명 removeMarkers 함수를 실행했는데도 불구하고! 마커 삭제가 먹히지 않아 결국 이 라이브러리를 사용하게 되었다...)
- typescript도 사용하고 있었기 때문에 빨간 줄이 뜨는 곳에는 데이터 타입을 일일히 조사하기 귀찮아서 많이 any 처리를 하게 된다.
react-kakao-maps-sdk 라이브러리 장점
- 미리 제공하는 컴포넌트와 useState를 이용해 Array.map()으로 렌더링하거나 state 삭제/변경으로 간편하게 marker와 데이터를 관리할 수 있다. DOM 조작을 하지 않아 이전 마커가 남아있는 등의 부작용이 없다.
- Typescript를 지원한다.
설치하기
최종 결과 코드
키워드로 장소검색하기 | react-kakao-maps-sdk docs
'이태원 맛집'으로 장소를 검색하고 검색결과를 지도 위에 마커로 표시합니다. 마커를 클릭하면 인포윈도우에 장소명을 표시합니다.
react-kakao-maps-sdk.jaeseokim.dev
이 예제를 참고해서 만들었다.
여기 예제에 내가 덧붙인 기능은 다음과 같다.
- 이미지 sprite 불러와서 1, 2, 3, ... 마커에 숫자 붙이기
- 검색 결과 목록으로 표출하기
- 마커에 mouse over 이벤트 덧붙이기
- 마커나 목록에서 장소 클릭하면 selectedPlace state에 선택한 장소의 정보 들어가게 하기
- 목록에서 장소 클릭하면 그 곳으로 이동하기 -> map.panTo() 함수 이용
import React, { useState, useEffect } from 'react';
import { Map, MapMarker, useMap } from 'react-kakao-maps-sdk';
import '../../styles/kakaomap.css';
function KakaoKeywordMap() {
const [map, setMap] = useState<any>();
const [markers, setMarkers] = useState<any[]>([]);
const [places, setPlaces] = useState<any[]>([]);
const [searchInput, setSearchInput] = useState('이태원 맛집');
const [keyword, setKeyword] = useState('이태원 맛집');
const [selectedPlace, setSelectedPlace] = useState();
const markerImageSrc =
'https://t1.daumcdn.net/localimg/localimages/07/mapapidoc/marker_number_blue.png';
const imageSize = { width: 36, height: 37 };
const spriteSize = { width: 36, height: 691 };
const handleKeywordChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setSearchInput(e.target.value);
};
const handleSearchSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setKeyword(searchInput);
};
useEffect(() => {
if (!map) return;
const ps = new kakao.maps.services.Places();
ps.keywordSearch(keyword, (data, status, _pagination) => {
if (status === kakao.maps.services.Status.OK) {
setPlaces(data);
/* 이하는 function displayPlaces(places) 함수와 비슷한 내용 */
// 검색된 장소 위치를 기준으로 지도 범위를 재설정하기위해
// LatLngBounds 객체에 좌표를 추가합니다
const bounds = new kakao.maps.LatLngBounds();
let markers = [];
for (var i = 0; i < data.length; i++) {
// @ts-ignore
markers.push({
position: {
lat: data[i].y,
lng: data[i].x,
},
content: data[i].place_name,
});
// @ts-ignore
bounds.extend(new kakao.maps.LatLng(data[i].y, data[i].x));
}
setMarkers(markers);
// 검색된 장소 위치를 기준으로 지도 범위를 재설정합니다
map.setBounds(bounds);
}
});
}, [map, keyword]);
여러개 마커에 mouseOver 이벤트 등록을 위해 Marker 컴포넌트를 EventMarker라는 wrapper로 감싸는 부분
- 여기 참고: https://react-kakao-maps-sdk.jaeseokim.dev/docs/sample/overlay/multipleMarkerEvent
const EventMarkerContainer = ({ position, content, i }: any) => {
const map = useMap();
const [isVisible, setIsVisible] = useState(false);
return (
<MapMarker
position={position}
image={{
src: markerImageSrc,
size: imageSize,
options: {
spriteSize: spriteSize,
spriteOrigin: new kakao.maps.Point(0, i * 46 + 10),
offset: new kakao.maps.Point(13, 37),
},
}}
onClick={(marker) => {
map.panTo(marker.getPosition());
setSelectedPlace(places[i]);
}}
onMouseOver={() => setIsVisible(true)}
onMouseOut={() => setIsVisible(false)}
>
{isVisible && <div style={{ color: '#000' }}>{content}</div>}
</MapMarker>
);
};
render할 html 부분
- 원래는 <Map /> 컴포넌트 안에 목록(menu_wrap 부분)을 넣으려고 했는데 아무리 해도 Map의 children으로 들어가지지 않아서 그냥 바깥으로 뺐다. 그래서 목록을 지도 위에 보여주기 위해 둘을 map_wrap 요소로 감쌌다.
css를 최대한 공식 문서에 나온 대로 하기 위해 요소의 구조와 class도 최대한 비슷하게 작성했다.
return (
<div className="map_wrap">
<Map // 로드뷰를 표시할 Container
center={{
lat: 37.566826,
lng: 126.9786567,
}}
style={{
width: '100%',
height: '500px',
}}
level={3}
onCreate={setMap}
>
{markers.map((marker, i) => (
<EventMarkerContainer
key={`EventMarkerContainer-${marker.position.lat}-${marker.position.lng}`}
position={marker.position}
content={marker.content}
i={i}
/>
))}
</Map>
<div id="menu_wrap" className="bg_white">
<div className="option">
<div>
<form onSubmit={handleSearchSubmit}>
키워드 :{' '}
<input
type="text"
value={searchInput}
onChange={handleKeywordChange}
id="keyword"
size={15}
/>
<button type="submit">검색하기</button>
</form>
</div>
</div>
<hr />
<ul id="placesList">
{places.map((item, i) => (
<li
key={i}
className="item"
onClick={() => {
map.panTo(
new kakao.maps.LatLng(
markers[i].position.lat,
markers[i].position.lng
)
);
setSelectedPlace(item);
}}
>
<span className={`markerbg marker_${i + 1}`}></span>
<div className="info">
<h5>{item.place_name}</h5>
{item.road_address_name ? (
<>
<span>{item.road_address_name}</span>
<span className="jibun gray">{item.address_name}</span>
</>
) : (
<span>{item.address_name}</span>
)}
<span className="tel">{item.phone}</span>
</div>
</li>
))}
</ul>
<div id="pagination"></div>
</div>
</div>
);
}
export default KakaoKeywordMap;
CSS 파일은 공식 문서에 있는 것 복붙해서 import
https://apis.map.kakao.com/web/sample/keywordList/
추가로 등록을 원하는 이벤트가 있다면 >오버레이 목록을 참조하면 된다.
'프로젝트 과정 기록' 카테고리의 다른 글
[JS] Chartjs 막대그래프 그리기 (별점 분포 차트) (0) | 2023.08.03 |
---|---|
[React] 회원가입 유효성 검사 구현하며 겪은 시행착오와 react-hook-form 사용 (0) | 2023.07.31 |
[CSS] Navbar 아랫부분의 그림자가 가려지지 않게 하는 법 +) 가상클래스, react-router-dom Outlet, MUI (0) | 2023.07.13 |
[MUI] material-ui <Link> 컴포넌트에 React-router-dom의 Link 적용하는 법 (0) | 2023.07.12 |
[MUI] material ui, emotion으로 global style 설정하기 (0) | 2023.07.12 |