이전 시간에 사용자 행동을 예상해보는 블로그 글을 작성해 봤었다.
사용자 행동을 왜 예상해보는지 이해가 안 갈수도, 궁금할 수도 있는데,
첫 번째로는
외부 API를 무엇을 "기준"으로 공부해야 할지, 알 수있었다.
(막상 해야지라고 생각했을 때는 너무 방대하고 어떻게 풀어나갈지 모르겠었다.... )
두 번째로는
서비스를 사용하는 사용자가 혼란스러워 하지 않아야 하므로.
물론 0번째 순위이지만, 우선 내가 기획하고, 개발하는 상황이므로...
아무튼 알아보자 KakaoMap
react-kakao-map-sdk에서 지원하는 Map
컴포넌트가 있다. 이 Map 컴포넌트 안에서 데이터를 시각화하고 UX를 제어할 것이다.
카카오맵을 제어하고, 조작하는데 반드시 필요한것이 Map 객체이다.
Map객체는 <Map/>
의 props를 통해 반환 받을 수 있다.
cf) Map의 중심좌표는 필수이므로 적당히 정해주자
import { Map } from 'react-kakao-maps-sdk';
// 생략...
const KakaoMap = () => {
//... 생략
const [kakaoMap, setKakaoMap] = useState<kakao.maps.Map>()
const handleKakaoMap = (map: kakao.maps.Map) => setKakaoMap(map)
console.log(kakaoMap) //확인하는 습관은 좋은 습관
return <Map
...
onCreate={handleKakaoMap}// map 객체를 반환받아 state로 관리하자
>
//... 생략
이렇게 해서 kakaoMap의 객체를 state안에 넣어주면 된다.
cf) 나중에는 전역으로 관리할 예정~~
위의 코드를 화면으로 나타내면 아래와 같다.
검색창도 없는 생 지도화면만 보인다. 마우스로 드래그하면 지도만 움직인다.
그렇다면 내가 예상한 사용자의 행동예측으로 무엇을, 어떻게 보여줄 수 있을까?
저번 시간에 case별로 분류 한것을 공통적인 것만 나열해 봤다.
[V] 현재 자신의 위치만이 지도상에 표시되어있기 -> geoLocation으로 처리하면 됨(예제들 많음)
[ ] 키워드를 검색했을 시, 유저가 보는 지도상 중심좌표로부터 생성, 시각화
[ ] 특정 장소를 검색했을 시, 생성, 데이터 시각화
[ ] 지도가 움직일 때 ,드래그 또는 지도의 zoom level등이 변했을 때, 이전 데이터는 삭제,드래그된 중심좌표를 기점으로 데이터 표시
검색 창이 필요하고, 검색 받은 입력값을 가지고 지도에 데이터를 표시 해주면 되겠다.
카카오맵의 내장 라이브러리에는 keyword 라이브러리가 있다. keyword라이브러리로 검색 후, 그것을 지도에 Marker(데이터)표시 예시가 훌륭히 되어있는데, 참고하면서 하면 매우 쉽다.
React-키워드로 장소검색하기
KakaoMap Docs/키워드로 장소검색하고 목록으로 표출
키워드를 통해 지도가 움직이고, 움직인 지도에서 데이터를 보여줄때는 Marker라는 카카오맵의 component를 통해서 보여준다.
Marker는 객체 배열인것을 인지하자(당연히 1개이상이므로~~)
따라서 키워드로 입력해서 장소가 있으면(성공) map을 돌려 보여주고, 없으면(실패) 빈배열로 반환해주면 좋을 것 같다.
그래서 나는 함수 안에 Promise
를 사용해서 성공,실패로 나누어 데이터를 제어할 것이다.
Promise를 사용하면 성공, 실패 case를 내가 제어 할 수 있고, 굳이 try { } catch{}
로 감싸주고, 처리하기에는 복잡하다 생각했기 때문이다.
쉽게 풀어서 말하자면, 원하는 데이터가 없을 때도
resolve
메소드 안에[]
빈배열만 넣어서 값을 내보내겠다는 거다.
interface I_CustomMarkerProps extends MapMarkerProps {
id: string
position: { lng: number; lat: number } // marker를 배열 돌릴 때 key값을 넣으려면 type custom 해야함
}
//...
const DEFAULT_SEARCH_VALUES = 'hospital'
const searchPlaces = async (map: kakao.maps.Map | null, searchValue = DEFAULT_SEARCH_VALUES) => {
if (!map) return [];
return new Promise<I_CustomMarkerProps[]>(res => {
const psInstance = new kakao.maps.services.Places(searchValue === DEFAULT_SEARCH_VALUES ? map : undefined);
psInstance.keywordSearch(
searchValue,
(data, status, _pagination) => {
if (status === kakao.maps.services.Status.OK) {
const bounds = new kakao.maps.LatLngBounds();
const resMarkers = data.map(marker => {
// any 는 react-kakao-map엣 @ts-ignore이라 지정해서 넣어줬습니다.
bounds.extend(new kakao.maps.LatLng(Number(marker.y), Number(marker.x)));
return {
id: marker.id,
position: {
lat: Number(marker.y), //29line에서 @ts-ignore해놔서 any로 타입 지정
lng: Number(marker.x),
},
place: marker.place_name,
address: marker.address_name,
phone: marker.phone,
};
});
if (searchValue !== DEFAULT_SEARCH_VALUES) {
map.setBounds(bounds);
}
res(resMarkers);
} else if (status === kakao.maps.services.Status.ZERO_RESULT) {
//....실패했을 때 logic
res([]);
}
},
// option사용해야함
{
useMapBounds: true,
category_group_code: CATETGORY_CODE[category_type],
radius: map?.getLevel() >= 3 ? 3000 : 100,
useMapCenter: true,
page: 5,
},
);
});
};
공식문서 참와, react-kakao-map의 docs를 참고해서 만들었다.
여기서 중요한 것은 DEFAULT_SEARCH_VALUES
와 option
부분이다. 차근차근 뜯어보자
const DEFAULT_SEARCH_VALUES = 'hospital'// 나중에 조건부로 'hosptial` | 'walk' 해줄 것이다. 탭클릭에 따라서
const searchPlaces = async (map: kakao.maps.Map | null, searchValue = DEFAULT_SEARCH_VALUES) => {
if (!map) return []
return new Promise<I_CustomMarkerProps[]>((res) => {
const psInstance = new kakao.maps.services.Places(searchValue === DEFAULT_SEARCH_VALUES ? map : undefined)
//... 생략
searchValue
부분에 DEFAULT_SEARCH_VALUES
를 넣어둔 이유는 내가 사용자의 행동과, 우리가 만들 서비스 기능을 합친 부분이기에 넣어 두었다.
searchValue에 디폴트값을 나중에는 동물병원관련 keyword를 넣어둘 것을 염두했기에 우선 적으로 넣어주었다.
그러고 나서 psInstance
를 보면 조건부를 해주었는데 이유는 아래와 같다.
map 객체가 인자값으로 들어가 있지 않으면 중심좌표가 없기 때문에 keyword 검색을 해도 일반적인 장소들만 나옴, 다시 말하면, 현재 사용자에게 보여지는 지도상에 있는 위치가 아니라 사용자에게 보여지지 않는 장소가 나온다는 이야기이다.
map이 있으면 현재 user가 보고있는 지도상의 위치의 근방으로 keyword검색으로 장소들이 나옴
뿐만아니라 위의 keyword검색으로 장소들이 일반 사람들이 카카오맵을 사용 할 때처럼 사용되려면 조건이 있다.
이걸 가능하게 하는 것이 keywordSearch의 두번째 인자값
option에서 `useMapCenter`, `useMapBound` 를 사용해야 가능함
const psInstance = new kakao.maps.services.Places(searchValue === DEFAULT_SEARCH_VALUES ? map : undefined)
psInstance가 undefined일 경우는 아래 사이트의 예시를 읽으면 바로 이해가 될것이다.
React-키워드로 장소검색하기
const psInstance = new kakao.maps.services.Places() // 이렇게 되어있음
if (searchValue !== DEFAULT_SEARCH_VALUES) {
map.setBounds(bounds)
}
위에 undefined일 경우에서 보면, 알 수 있는 코드이다.
psInstance에서 undefined 즉 map이 안들어 가있으면 검색된 장소를 아우르는 곳으로 이동시켜야 하니까
{
useMapBounds: true, // useMapBounds을 사용하면 현재 user가 보고있는 map의 boundary영역을 기점으로 keyword가 검색된다.
category_group_code: CATETGORY_CODE[category_type],
radius: map?.getLevel() >= 3 ? 3000 : 100, // 중심좌표로부터 떨어진 거리의 위치 장소범위임
useMapCenter: true, // useMapBounds와 같이 사용하자~~ user가 보고있는 map의 중심좌표 사용 결정여부 -> useMapBounds와 같이 사용하여
page: 5,
},
주석에 써놨다.
여기서 특이한 것은 category_group_code
일텐데 keyword로 검색 장소(들)을 찾을 때 넣어주면 좋을 option이다.
병원,음식, 숙박등 관련된 코드들이 내장 되어있는데 카카오데브를 참고하면 된다.
카카오데브_카테고리
string
으로 넣을 수도 string[]
로 넣을 수도 있다.
나는 3가지의 케이스에서 동일하게 배열 형태로 값을 내보내고 싶었다.
동일하게 보냄으로서 내가 신경써줘야 할 부분이 줄어들고, 사용자에게 자신이 검색한 내용의 데이터가 없다라는 것을 UI로 쉽게 보여줄 수 있기 때문이다.
- 성공했을 때 I_CustomMarkerProps[ ]
- 검색에는 성공했으나 데이터가 없을 때 [ ]
- 실패했을 때 [ ]
탭으로 분기처리를 할 것을 기본 베이스로, keyword 함수를 카카오 내장 API를 사용해서 함수를 만들었는데 어디다가 갔다 붙여야 할까???
Map
컴포넌트에는 onIdle이라는 props가 있다.
카카오맵을 사용자가 사용하여 이벤트가 발생 할 때 계속해서 map객체를 반환하는데 여기다 넣으면
이벤트가 일어나면 실시간으로 사용자와 상호작용한다.
따라서
const [markers,setMarkers]=useState<I_CustomerMarker>([])
//... 생략
const handleIdleMap = async (map: kakao.maps.Map) => {
setKakaoMap(map);
const markers = await searchPlaces(map);
if (!markers) return setMarkers([]);
if (markers) setMarkers(markers);
};
<Map
center={currentPosition}
style={MAP_STYLE}
level={INITIAL_ZOOM}
onCreate={kakaoMapHandler}
onIdle={handleIdleMap}
/**
onIdle은 맵의 움직임을 동적으로 감지합니다.
따라서 중심좌표의 변경, 줌level등을 동적으로 사용자의 인터렉션에 따라 감지 할수 있습니다.
활용 예시로는
중심좌표가 변화하면 그에 따른 유저가 원하는 location을 중심좌표 주변으로 검색할수 있겠끔 해줄수 있습니다.
*/
>
<CustomMarker position={{ lat: 33.5563, lng: 126.79581 }}></CustomMarker>
</Map>
이렇게 사용하지만, 나중가서 나는 onDragEnd
로 변경하게 된다.
setKakaoMap
이 보일 텐데....
맵객체는 실시간 변경됨에 따라서 map객체의 정보 또한 바뀌므로 계속해서 넣어주었다.
필요한가? 라고 생각이 들수도 있지만,
marker를 클릭 시 map의 중심좌표를 바꿀 때 map.setBound(marker.latLng)
,등 필요해진다.
코드들은
useState
로 상태값을 관리 했지만, 내가 실제 프로젝트에서 사용 할 때는
zustand
전역객체로 관리 하였고, Hook, api 폴더로 정리하였다.
import Skeleton from '@/components/ui/skeleton';
import useKakaoLoader from '@/hooks/client/map/kakao-map/useKakaoLoader';
import useKakaoMapStore, {
I_CustomMarkerProps,
setCurrentLocation,
setCurrentPosition,
setKakaoMap,
setMarkers,
} from '@/store/map/kakako-map/kakaoMap-store';
import useSearchLocationStore from '@/store/map/search-location/search-store';
import { CSSProperties, useEffect } from 'react';
import { Map } from 'react-kakao-maps-sdk';
import CustomMarker from './custom-marker/CustomMarker';
const MAP_STYLE: CSSProperties = { width: '1264px', height: '600px', position: 'relative', overflow: 'hidden' };
const INITIAL_ZOOM = 3;
interface I_Category_code {
hospital: ['HP8', 'PM9'];
walk: ['AT4', 'CT1'];
}
const CATETGORY_CODE: I_Category_code = {
hospital: ['HP8', 'PM9'],
walk: ['AT4', 'CT1'],
};
const KakaoMap = () => {
const [loading, error] = useKakaoLoader();
const { map: kakaoMap, currentPosition } = useKakaoMapStore();
const category_type = useSearchLocationStore(state => state.category_type);
const DEFAULT_SEARCH_VALUES = category_type === 'hospital' ? '동물병원' : '공원';
const kakaoMapHandler = (map: kakao.maps.Map) => {
if (!kakaoMap) {
setKakaoMap(map);
}
};
const handleIdleMap = async (map: kakao.maps.Map) => {
setKakaoMap(map);
const markers = await searchPlaces(map);
if (!markers) return setMarkers(null);
if (markers) setMarkers(markers);
};
/**
* 초기 위치값 설정 useEffect
*/
useEffect(() => {
if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition(position => {
const { coords } = position;
setCurrentPosition({
lat: coords.latitude,
lng: coords.longitude,
});
setCurrentLocation({
lat: coords.latitude,
lng: coords.longitude,
});
});
}
}, []);
const searchPlaces = async (map: kakao.maps.Map | null, searchValue = DEFAULT_SEARCH_VALUES) => {
if (!window.kakao) return [];
if (!map) return [];
return new Promise<I_CustomMarkerProps[]>(res => {
const psInstance = new kakao.maps.services.Places(searchValue === DEFAULT_SEARCH_VALUES ? map : undefined); // map 객체가 인자값으로 들어가 있지 않으면 중심좌표가 없기 때문에 keyword 검색을 해도 일반적인 장소들만 나옴, map이 있으면 현재 user가 보고있는 지도상의 위치의 근방으로 keyword검색으로 장소들이 나옴 <- 이걸 가능하게 하는 것이 keywordSearch의 두번째 인자값 option에서 useMapCenter, useMapBound를 사용해야 가능함
psInstance.keywordSearch(
searchValue,
(data, status, _pagination) => {
if (status === kakao.maps.services.Status.OK) {
const bounds = new kakao.maps.LatLngBounds();
const resMarkers = data.map(marker => {
// any 는 react-kakao-map엣 @ts-ignore이라 지정해서 넣어줬습니다.
bounds.extend(new kakao.maps.LatLng(Number(marker.y), Number(marker.x)));
return {
id: marker.id,
position: {
lat: Number(marker.y), //29line에서 @ts-ignore해놔서 any로 타입 지정
lng: Number(marker.x),
},
place: marker.place_name,
address: marker.address_name,
phone: marker.phone,
};
});
if (searchValue !== DEFAULT_SEARCH_VALUES) {
map.setBounds(bounds); //psInstance에서 undefined 즉 map이 안들어 가있으면 검색된 장소를 아우르는 곳으로 이동시켜야 하니까
}
res(resMarkers);
} else if (status === kakao.maps.services.Status.ZERO_RESULT) {
//....실패했을 때 logic
res([]);
}
},
{
useMapBounds: true, // useMapBounds을 사용하면 현재 user가 보고있는 map의 boundary영역을 기점으로 keyword가 검색된다.
category_group_code: CATETGORY_CODE[category_type],
radius: map?.getLevel() >= 3 ? 3000 : 100, // 중심좌표로부터 떨어진 거리의 위치 장소범위임
useMapCenter: true, // useMapBounds와 같이 사용하자~~ user가 보고있는 map의 중심좌표 사용 결정여부 -> useMapBounds와 같이 사용하여
page: 5,
},
);
});
};
if (loading || error) return <Skeleton type="map" />;
return (
<Map
center={currentPosition}
style={MAP_STYLE}
level={INITIAL_ZOOM}
onCreate={kakaoMapHandler}
onIdle={handleIdleMap}
/**
onIdle은 맵의 움직임을 동적으로 감지합니다. 따라서 중심좌표의 변경, 줌level등을 동적으로 사용자의 인터렉션에 따라 감지 할수 있습니다. 활용 예시로는 중심좌표가 변화하면 그에 따른 유저가 원하는 location을 중심좌표 주변으로 검색할수 있겠끔 해줄수 있습니다.
*/
>
<CustomMarker position={{ lat: 33.5563, lng: 126.79581 }}></CustomMarker>
</Map>
);
};
export default KakaoMap;
위의 코드는 복잡 해 보이지만,위에서 작성한 체크리스트를 보면 이렇게 동작되는 함수겠구나 라고 알 수 있다.
[V] 현재 자신의 위치만이 지도상에 표시되어있기 -> geoLocation으로 처리하면 됨(예제들 많음)
[V] 키워드를 검색했을 시, 유저가 보는 지도상 중심좌표로부터 생성, 시각화
[V] 특정 장소를 검색했을 시, 생성, 데이터 시각화
[V] 지도가 움직일 때 ,드래그 또는 지도의 zoom level등이 변했을 때, 이전 데이터는 삭제,드래그된 중심좌표를 기점으로 데이터 표시