

짜잔 ~ 이건 카카오맵 API를 활용한 솔직할지도입니다 !
| 기본 마커 | 검색, 감정 추가 마커 |
|---|---|
![]() | ![]() |
감정마다 다른 이미지가 노출되는게 상당히 중요했다. (아주 귀엽잖아요)
카카오맵 공식 문서를 참고하여 마커의 이미지를 변경했다.
총 3가지의 마커 종류 중 클릭 이벤트로 실행되는 함수가 같은 기본 마커와 감정 추가 마커를 하나로 분류했다.
<>
{type === "center" ? (
// 지도 중심 좌표 마커
<MapMarker
zIndex={99999}
position={position} // 마커를 표시할 위치
onClick={() => handleAdd(position)}
image={{
src: `/icon-marker-act.svg`,
size: {
width: 28,
height: 40,
},
options: {
offset: {
x: 14,
y: 20,
},
},
}}
/>
) : (
// 기본, 검색 시 마커
<MapMarker
position={position} // 마커를 표시할 위치
onClick={(marker) => handleClick(marker, type)}
image={{
src: type === "default" ? `/emotion${emotion}.svg` : `/icon-marker.svg`,
size: {
width: type === "default" ? 50 : 28,
height: type === "default" ? 50 : 40,
},
options: {
offset: {
x: type === "default" ? 25 : 14,
y: type === "default" ? 25 : 20,
},
},
}}
/>
)}
</>
마커를 클릭하면 실행되는 이벤트는 다음과 같다.
// 1️⃣ 기본 마커, 검색 마커 클릭시
const handleClick = (marker: kakao.maps.Marker, type: string) => {
if (type !== "search") {
// 📍 기본 마커
setAddMode(false);
setIsEmotionAddMarker(false);
setIsActBottomSheet(true);
if (windowWidth < 1024) setBottomSheet();
} else {
// 📍 검색 마커
const { Ma: lat, La: lng } = marker.getPosition();
setAddMode(true);
setLatlng({ lat, lng });
map.panTo(marker.getPosition());
setIsEmotionAddMarker(true);
if (setCenterMarker) setCenterMarker({ lat, lng });
}
};
// 2️⃣ 감정 추가 마커
const handleAdd = (position: Latlng) => {
setAddMode(true);
setAddModeStep("step1");
setLatlng(position);
setIsActBottomSheet(true);
if (windowWidth < 1024) setBottomSheet();
};
마커에는 카카오맵이 제공하는 기본 이벤트가 존재한다.
공식 문서에서 안내된대로 getPosition() 이벤트를 추가하여 작성하였다.
현재 위치는 카카오맵 api가 제공해주는 Map 컴포넌트를 통해 얻을 수 있다.
<Map
center={{ lat: position.lat, lng: position.lng }}
style={{ width: "100%", height: "100vh" }}
onCreate={(map) => setMap(map)}
onBoundsChanged={(map) => {
const bounds = map.getBounds();
setBounds({
sw: bounds.getSouthWest().toString(),
ne: bounds.getNorthEast().toString(),
})
}}
onDragEnd={(map) => {
// @ts-ignore
const { Ma: lat, La: lng } = map.getCenter();
setCenterMarker({ lat, lng });
}}
onClick={(_, mouseEvent) => {
// @ts-ignore
const { Ma: lat, La: lng } = mouseEvent.latLng;
setCenterMarker({ lat, lng });
map!.setCenter(new kakao.maps.LatLng(lat, lng));
}}
>
너무 감사하게도 jaeseokim 님께서 리액트 버전을 만들면서 타입 정의를 깔끔하게 해두셨다. 커맨드를 눌러 Map 컴포넌트를 따라가보면, Map 컴포넌트에서 사용 가능한 메서드가 정리되어있다. 평소에 문서를 잘 열어봅시다 ~

onCreatesetBounds, getCenter)리액트 버전은 카카오맵 공식문서와는 조금 달랐다. 그 부분이 리액트 카카오맵 문서에는 자세히 나오지 않았다. map 객체를 얻기 위해 useRef를 사용하는 사람도 있고 onCreate 메서드를 사용하는 사람도 있었다.
하지만 전 둘다 안되는데요 ?

그래서 꼼수를 썼다 ! 깃에서 이렇게 원하는 키워드를 검색하면 최근에 사람들이 코드를 어떤 식으로 작성하고 있는지 확인할 수 있다. 모르는 개발자님 감사합니다 ~
많은 사람들이 상태 관리를 통해 map을 관리하고 있었고 그렇게 작성하니 무사히 완료 !
onBoundsChanged현재 보고 있는 지도 영역 내의 데이터만 필터링 하기 위해선 현재 지도의 바운드 값이 필요했다. 따라서 사용자가 지도를 탐색하여 바운드가 변경되면 해당 바운드를 상태 값으로 관리했다.
자. 지도의 바운드 값은 준비됐는데 이제 누가 필터링 할래 ?
문과인 나는 또 당황해버렸다. 카카오맵은 지도의 바운드를 sw, ne 값으로 전달한다. 이제 이걸 .. 어떻게 데이터 좌표와 비교할... 까요 .. ?

이렇게 하면 됩니다 ~ ! (짜잔) 사알짝 gpt의 도움을 받아서 리턴문을 작성했습니다.
이렇게 바운드 내에 포함된 좌표의 데이터만 필터링 성공 !
const filterDataFn = () => {
if (bounds?.sw && bounds?.ne) {
const swLatLng = bounds.sw.slice(1, -1).split(", ").map(Number);
const neLatLng = bounds.ne.slice(1, -1).split(", ").map(Number);
const [swLat, swLng] = swLatLng;
const [neLat, neLng] = neLatLng;
const newFilteredData = data!.filter((marker) => {
const lat = marker.latlng.lat;
const lng = marker.latlng.lng;
return lat >= swLat && lat <= neLat && lng >= swLng && lng <= neLng;
});
setFilteredData(newFilteredData);
}
};

현재 좌표 내의 데이터만 호출하는 것은 성공했지만, 바운드를 콘솔로 찍어보면 이렇게나 리렌더링이 많이 발생하고 있었다. 당장은 테스트이고 데이터가 몇 건 없지만 실제 사용자가 있다면 ,, 상상하기 싫다 !
따라서 bounds가 연속적으로 변할 때 마지막으로 변한 값만 유효하도록 debounce 기능을 추가했다. 타입을 추가하여 debounce 함수를 작성하였다. callback 매개변수를 통해 함수를 넘겨받고 limit 으로 지정한 시간 뒤에 실행되도록 설정 완.
// debounce.ts
export function debounce<T extends (...args: any[]) => void>(callback: T, limit = 500): T {
let timeout: ReturnType<typeof setTimeout>;
return function (this: ThisParameterType<T>, ...args: Parameters<T>) {
clearTimeout(timeout);
timeout = setTimeout(() => callback.apply(this, args), limit);
} as T;
}
useEffect(() => {
debounce(() => filterDataFn(), 500);
}, [bounds, data, position]);
처음엔 데이터를 필터링하는 함수가 디바운스 되도록 설정했다.
하지만 이게 웬걸 ... ? 도무지 필터링 된 데이터가 반환되지가 않았다.
이때는 이유를 찾지 못하고 다른 방법 시도 ... !
const handleBounds = (bounds: kakao.maps.LatLngBounds) => {
setBounds({
sw: bounds.getSouthWest().toString(),
ne: bounds.getNorthEast().toString(),
});
};
const debouncedHandleBounds = debounce(handleBounds);
filterDataFn 함수 실행이 안돼? 그럼 지도 좌표가 변경될 때마다 리렌더링이 유발되는 bounds를 디바운스 해서 넘겨버리겠스 ~ ! 하는 마인드였는데요 ? 이 조차도 적용이 안되세요 ( .. )
원인은 바로 !! useState로 관리되는 bounds 값이 변경될 때마다 debounce 함수가 초기화되면서 반환을 하지 못하기 때문이다. (두둥) 따라서 리액트가 제공하는 useCallback 메서드를 활용했다.
useCallback리액트에서 제공하는 useCallback 은 부모 컴포넌트가 재호출 되어도 리스너가 수정되지 않고 유지되어 기존 DOM을 재사용한다.
이때 리턴값은 최초에는 fn 함수, 이후 리렌더링에선 dependencies가 변하지 않았다면 이전에 반환한 함수(캐시된 함수), dependencies가 변했다면 새로 생성된 fn 함수를 반환한다. 따라서 debounce 함수 내부의 setTimeout 타이머 넘버가 그대로 유지될 수 있다.
const handleBounds = useCallback((bounds: kakao.maps.LatLngBounds) => {
setBounds({
sw: bounds.getSouthWest().toString(),
ne: bounds.getNorthEast().toString(),
});
}, []);
const debouncedHandleBounds = useCallback(debounce(handleBounds), [handleBounds]);
해당 내용을 적용해서 코드를 변경하면 !! ? 이렇게 debounce 함수 적용 성공 !
역시나 리액트 카카오맵 api에서 코드를 제공한다. 해당 코드를 함수로 만들어서 키워드 검색 이벤트에 연결했다. 검색시 추가로 적용되어야 하는 1️⃣ 추가 마커 비활성화 2️⃣ 바텀시트 비활성화를 적용했다.
검색 결과가 없는 경우엔 모달을 통해 사용자에게 안내하고 검색 상태값을 초기화한다.
const handleSearch = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setIsEmotionAddMarker(false);
setIsActBottomSheet(false);
const ps = new kakao.maps.services.Places();
// 키워드 검색 및 지도 반경 이동
ps.keywordSearch(search, (data, status, _pagination) => {
if (status === kakao.maps.services.Status.OK) {
const bounds = new kakao.maps.LatLngBounds();
let markers = [];
for (var i = 0; i < data.length; i++) {
markers.push({
latlng: {
lat: +data[i].y,
lng: +data[i].x,
},
content: data[i].place_name,
});
bounds.extend(new kakao.maps.LatLng(+data[i].y, +data[i].x));
}
setSearchedData(markers);
// 검색된 장소 위치를 기준으로 지도 범위를 재설정
map!.setBounds(bounds);
} else if (status === "ZERO_RESULT") {
// 검색 결과가 없는 경우
openModal({
title: "알림",
content: `'${search}' 검색 결과가 없습니다.`,
button: "닫기",
});
setSearchedData([]);
setSearch("");
}
});
};