카카오맵 구현기(2): 마커 렌더링까지

윤뿔소·2023년 2월 14일
2

팀 프로젝트: 맛피

목록 보기
3/8

카카오맵 구현기(1)을 보고오면 더 좋습니다! 너무 길어져서 포스트의 기능 분리를 조금 했습니다^^

마커 구현하기

카카오맵에 마커를 표출하는 기능을 만들어보자!
전편에서 말했다싶이

  1. 드래그로 지도를 볼 수 있는 맵
  2. 검색한 맛집 정보를 볼 수 있는 페이지, '맛플레이스 디테일 페이지'로 넘어가면 맵 이동 후 마커 찍히기
  3. 검색한 데이터에 관련한 맛집 데이터의 좌표에 마커 찍기
  4. 로그인하면 자신이 맛집을 픽한 그룹, '맛 픽커'의 마커들이 찍히기

전편에서는 1번을 구현했고, 이제 2, 3, 4번을 구현할 차례다.

전제: DB에 담을 장소 데이터 관리

그 전에 장소 데이터를 어떻게 할 지가 관건이었다.
9, 10일 기획안으로 장소 데이터가 없다면 카카오맵 API의 장소 정보 데이터를 가져와 DB에 저장해 우리 데이터로 따로 쓸 계획이었다.

22/01/12
API를 이용하여 정보 데이터를 가져올 수 있다는 걸 알았다. 위/경도 좌표와 잘하면 id로 장소도 불러올 수 있으니 정보를 좀 가져와 담아 쓰기로 했다.

22/01/14
혹시나하고 정책을 살피던 중 발견해버렸다. 장소 정보 자체를 DB나 다른 곳에 저장하지말라는 정책이 있다는 것을.. 카카오데브 포럼에서 찾아보니 비슷한 답변들이 달렸다. 그래서 혹시 몰라 문의도 넣었지만 똑같은 답변이 달렸다. 아마 카카오 측에서 예민하게 생각하는 것 같았다.
22/01/15
그래서 결국 팀원과 회의 후 장소 정보를 공공데이터를 불러와 가공하여 우리 데이터로 사용하자고 결론이 났다. 좌표가 우리가 생각하는 기능의 방식이 좀 달랐고, 카테고리 같은 것도 우리가 쓸 수 있게 가공하자고 협의했다.
정말 아쉬웠다. 카카오의 방대한 데이터를 못쓰게 돼버렸으니 초반 기획했던 것과 많이 달라졌다. 사실 당연하다. 데이터를 무료로 꿀꺽하고 싶다니 좀 바보 같은 생각이었다. 이를 계기로 더 깊게 생각하게 됐고, 다음 지도를 이용한 앱을 만들 때 이러한 지식들을 사용해야겠다.

또 나온 주제가 있었는데 바로 검색이다. 검색은 검색 마커에서 기술하겠다.

맛플레이스 디테일 페이지 마커

맛플레이스 디테일 마커는 맛플레이스 디테일 페이지에 들어가면 관련 마커가 지도에 찍혀 어디에 있는지 가시적으로 보일 수 있게 할 수 있는 기능이다.

이 것은 맛집 디테일 페이지로 들어가면 그 맛집의 정보를 get하는 fetch한 데이터가 있을 거고, 그 데이터에 좌표를 가져오는 식으로 할 것이다. 그 좌표로 마커 구현 및 중심 좌표를 옮겨서 나오는 카메라 액션을 구현할 것이다.

마커 구현

우선 맛플레이스 디테일 페이지에 get해오는 코드가 있다.(참고: useAxios 사용기) 이 코드의 응답 데이터를 리코일의 상태로 선언해 값이 들어가거나 변하면(참고: 리코일 사용기), 마커 찍는 컴포넌트에 영향을 줘 마커가 찍히게 되는 것이다!

import { useState } from "react";
import { MapMarker } from "react-kakao-maps-sdk";
import { useRecoilValue } from "recoil";
import styled from "styled-components";
import { placeInfoState, placeInfoStatusState } from "../../store/placeInfoAtoms";

const InfoWindowContainer = styled.div`
  width: 2000%;
  height: 100%;
  display: flex;
  align-items: center;
`;

const PlaceName = styled.div`
  padding: 5px;
  font-size: 16.5px;
  font-weight: 500;
  text-align: center;
  color: #874356;
`;

const PlaceDetailMarker = () => {
  const placeInfo = useRecoilValue(placeInfoState);
  const placeInfoStatus = useRecoilValue(placeInfoStatusState);
  const [isVisible, setIsVisible] = useState({
    id: -1,
    isVisible: false,
  });

  return (
    <>
      {placeInfo && placeInfoStatus === "Success" ? (
        <MapMarker
          key={placeInfo.id}
          position={{ lat: placeInfo.latitude, lng: placeInfo.longitude }}
          clickable={true}
          onMouseOver={() => setIsVisible({ id: placeInfo.id, isVisible: true })}
          onMouseOut={() => setIsVisible({ id: -1, isVisible: false })}
        >
          {isVisible.isVisible && isVisible.id === placeInfo.id && (
            <InfoWindowContainer>
              <PlaceName>{placeInfo.name}</PlaceName>
            </InfoWindowContainer>
          )}
        </MapMarker>
      ) : null}
    </>
  );
};

export default PlaceDetailMarker;

코드를 보면 저렇게 MapMarker에 옵션을 넣어 이벤트를 지정하고, 내용도 삽입 할 수 있다. 편해!
placeInfoState라는 상태를 넣어서 페이지가 들어가면 여기 마커도 찍히게 구현했다. placeInfoStatus를 넣어서 데이터가 들어오면 렌더링 되게 조건도 추가했다!

22/01/29
근데 이후에 mouse over 이벤트가 아닌 계속 float하게 만들어서 선택된 장소를 좀 더 잘 구별갈 수 있게 UI를 바꿨다.

카메라 액션 구현

위 마커와 마찬가지로 장소 데이터 관련 상태가 변하면 카메라 액션도 변하게 만들 것이다. 바로 지도 중심 위/경도 좌표를 불러와 그 중심좌표를 장소 데이터 좌표로 변하게 하면 끝!

import { useEffect, useState } from "react";
import { Map } from "react-kakao-maps-sdk";
import { useRecoilValue } from "recoil";
import styled from "styled-components";
import { placeInfoState, placeInfoStatusState } from "../../store/placeInfoAtoms";
import MapMarkerComponent from "./MapMarkerComponent";

const MapContainer = styled(Map)`
  width: 100%;
  height: 100vh;
`;

const KakaoMap = () => {
  const placeInfo = useRecoilValue(placeInfoState);
  const placeInfoStatus = useRecoilValue(placeInfoStatusState);
  useEffect(() => {}, [placeInfo]);
  const readjustLat = placeInfo.latitude - 0.0003;
  const readjustLng = placeInfo.longitude - 0.0009;

  return (
    <>
      {placeInfo && placeInfoStatus === "Success" ? (
        <MapContainer center={{ lat: readjustLat, lng: readjustLng }} level={3} isPanto={true}>
          <MapMarkerComponent />
        </MapContainer>
      ) : (
        <MapContainer
          center={{ lat: 37.5554522671854, lng: 126.92415641617547 }}
          level={8}
          isPanto={false}
        >
          <MapMarkerComponent />
        </MapContainer>
      )}
    </>
  );
};

export default KakaoMap;

문제: 카메라 액션 버그

문제가 하나 있다. 딱 웹페이지를 실행시키고, 처음 맛플레이스 디테일 페이지로 이동할 때, 맛플레이스 좌표가 아닌 자꾸 홍대 부근 지역으로 이동한다. 다시 강력 새로고침하고 다른 곳에 지정해야지 된다. 왜그런지 모르겠다.. 카카오 API의 버근가 싶기도 하다. 카카오데브 포럼에 검색을 해봐도 나오지 않았다. 디버깅을 시도 해야봐야겠다.

22/01/31
해결했다!@!!@!@!@ 문제의 요인은 맛플레이스 데이터를 담은 리코일 상태의 좌표가 0으로 기본값이 정해져있는 부분이었다.

const defaultPlaceInfo: PlaceInfo = {
  id: -1,
  tel: "",
  img: "",
  ...
  // 여기!
  longitude: 0,
  latitude: 0,
  posts: [],
};

카카오맵 API의 특성인건지 좌표가 0으로 기본값이 들어가면 엉뚱한 곳으로 좌표를 잡는다. 저 부분을 null로 바꿨더니 됐다. ㅠㅠ 안그래도 마감 바쁜데 이거 때메 엄청 고생했다.

const defaultPlaceInfo: PlaceInfo = {
  id: -1,
  tel: "",
  img: "",
  ...
  // 여기!
  longitude: null,
  latitude: null,
  posts: [],
};

또 좌표 이동 코드에도 살짝의 수정이 있었다. KakaoMap 컴포넌트만의 좌표를 가지는 상태를 선언하고, 기본값을 null로 꼭 만들고, 여기서 조정하도록 수정했다.

import { useEffect, useState } from "react";
import { Map } from "react-kakao-maps-sdk";
import { useRecoilValue } from "recoil";
import styled from "styled-components";
import { placeInfoState, placeInfoStatusState } from "../../store/placeInfoAtoms";
import PickerMarker from "./PIckerMarker";
import PlaceDetailMarker from "./PlaceDetailMarker";
import SearchMarker from "./SearchMarker";

const MapContainer = styled(Map)`
  width: 100%;
  height: 100vh;
`;

const KakaoMap = () => {
  const placeInfoStatus = useRecoilValue(placeInfoStatusState);
  const { latitude, longitude } = useRecoilValue(placeInfoState);

  const [centerMove, setCenterMove] = useState({
    lat: 37.55867270361961,
    lng: 126.86212630618877,
  });

  const [centerInfo, setCenterInfo] = useState({ level: 0, center: { lat: null, lng: null } });

  useEffect(() => {
    if (placeInfoStatus === "Success") {
      setCenterMove({
        lat: latitude,
        lng: longitude,
      });
    }
    if (placeInfoStatus === "Loading" || placeInfoStatus === "Idle") {
      setCenterMove({ lat: null, lng: null });
    }
  }, [placeInfoStatus, latitude, longitude]);

  return (
    <>
      <MapContainer
        center={{
          lat: centerMove.lat || 37.55867270361961,
          lng: centerMove.lng || 126.86212630618877,
        }}
        level={9}
        isPanto={true}
        onCenterChanged={(map) =>
          setCenterInfo({
            level: map.getLevel(),
            center: {
              lat: map.getCenter().getLat(),
              lng: map.getCenter().getLng(),
            },
          })
        }
      >
        <PlaceDetailMarker />
        <SearchMarker />
        <PickerMarker />
      </MapContainer>
    </>
  );
};

export default KakaoMap;

맛플레이스 검색 마커

1. input에 입력하여 검색할 수 있는 기능 구현

이 기능은 input에 검색해 엔터를 누르면 키워드와 관련된 맛플레이스 리스트가 뜨고 그 리스트에 맞는 마커들을 표시하는 기능이다.
검색 마커는 맛플레이스 마커완 달리 되게 쉬웠다.

  1. 입력값을 body에 담아 axios 통신을 하여 useAxios를 거친 뒤 받아온 배열 데이터를 가져온다.
  2. 검색 페이지와 카카오맵 컴포넌트와 검색 마커 컴포넌트 3개에 영향을 주는 데이터니 전역으로 선언하기위해 리코일 아톰에 넣어준다.
  3. 검색 마커 컴포넌트에서 데이터가 왔다면, 렌더링되게 조건 설정하고 카카오맵 컴포넌트에 연결 해준다.
import { useState } from "react";
import { MapMarker } from "react-kakao-maps-sdk";
import styled from "styled-components";
import { useRecoilValue } from "recoil";
import { searchResultsState, searchStatusState } from "../../store/searchPlaceAtoms";
import { useNavigate } from "react-router";
import {
  curruntLocationPlacesState,
  curruntLocationStatusState,
} from "../../store/curruntLocationPlacesAtom";

const InfoWindowContainer = styled.div`
  display: flex;
  align-items: center;
`;

const PlaceName = styled.div`
  padding: 5px;
  font-size: 15px;
  text-align: center;
`;

const SearchMarker = () => {
  const navigate = useNavigate();
  const searchResults = useRecoilValue(searchResultsState);
  const searchStatus = useRecoilValue(searchStatusState);

  const [isVisible, setIsVisible] = useState({
    id: -1,
    isVisible: false,
  });

  const clickHandler = (id: number) => {
    navigate(`/places/${id}`);
  };

  return (
    <>
      {searchResults && searchStatus === "Success"
        ? searchResults.map((result) => (
            <MapMarker
              key={result.id}
              position={{ lat: result.latitude, lng: result.longitude }}
              clickable={true}
              onMouseOver={() => setIsVisible({ id: result.id, isVisible: true })}
              onMouseOut={() => setIsVisible({ id: -1, isVisible: false })}
              onClick={() => clickHandler(result.id)}
            >
              {isVisible.isVisible && isVisible.id === result.id && (
                <InfoWindowContainer>
                  <PlaceName>{result.name}</PlaceName>
                </InfoWindowContainer>
              )}
            </MapMarker>
          ))
        : null}
    </>
  );
};

export default SearchMarker;

맛플레이스 검색 마커는 되게 쉬웠다. API 대로 데이터를 가져올 그릇만 먼저 만들고, 나중에 API가 만들어지고 나면 연결하고 조금만 손봐주면 되니까 말이다.

어려웠던 부분

좀 어려웠던 부분은 onMouseOver 이벤트를 만들 때인데 검색 후 마커가 중첩되어있던 부분이 있지 않나? 근데 그냥 boolean으로 true, false로 처리해버리니까 맛플레이스 제목이 나오는 infoWindow가 여러개 같이 나와버리는 불상사가 나왔다. 하나만 나오게 먼저 onMouseOver 들어간 마커의 id를 넣어서 먼저 onMouseOver된다면 하나만 뜨게, 나오기 전까지 그것만 나오게 지정했다.

검색 관련 페이지

import styled from "styled-components";
import { useNavigate } from "react-router-dom";
import PlaceSearchResult from "../../components/PlaceSearchResult";
import { useEffect, useState } from "react";
import { searchResultsState, searchStatusState } from "../../store/searchPlaceAtoms";
import { useRecoilState } from "recoil";
import useAxios from "../../hooks/useAxios";
import { getSearchPlaceAxios } from "../../api/axiosAPI/search/placeSearchAxios";
import CurruntLocationPlacesButton from "../../components/CurruntLocaionPlacesButton";
import {
  curruntLocationPlacesState,
  curruntLocationStatusState,
} from "../../store/curruntLocationPlacesAtom";

... css 중략

interface PlaceData {
  id: number;
  tel: string;
  address: string;
  name: string;
  starAvg: number;
  postCount: number;
  longitude: number;
  latitude: number;
}

const SearchDetailPlace: React.FC = () => {
  const navigate = useNavigate();

  const [keyword, setKeyword] = useState("");

  const {
    axiosData: getSearch,
    responseData: searchData,
    status: searchAxiosStatus,
  } = useAxios<PlaceData[]>(() => getSearchPlaceAxios(keyword), [keyword], true);
  const [searchResults, setSearchResults] = useRecoilState(searchResultsState);
  const [searchStatus, setSearchStatus] = useRecoilState(searchStatusState);

  useEffect(() => {
    if (searchStatus === "Loading" && searchAxiosStatus === "Success") {
      setSearchResults(searchData);
      setSearchStatus("Success");
    }
  }, [searchStatus, searchAxiosStatus]);

  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    setKeyword(e.target.value);
  };

  const handleKeyPress = (event: React.KeyboardEvent<HTMLInputElement>) => {
    if (event.key === "Enter" && keyword.length !== 0) {
      setCurruntLocationStatus("Idle");
      setCurruntLocationPlaces([]);
      getSearch();
      setSearchStatus("Loading");
    } else if (event.key === "Enter" && keyword.length === 0) {
      return alert("검색어를 입력해주세요!");
    }
  };

  const [curruntLocationPlaces, setCurruntLocationPlaces] = useRecoilState(
    curruntLocationPlacesState
  );
  const [curruntLocationStatus, setCurruntLocationStatus] = useRecoilState(
    curruntLocationStatusState
  );

  return (
    <SearchWrapper>
      <label htmlFor="input-title">
        <h1>맛플레이스 검색</h1>
      </label>
      <input
        id="input-title"
        placeholder="검색어를 입력하세요"
        value={keyword}
        onChange={handleChange}
        onKeyDown={handleKeyPress}
      />

      {curruntLocationPlaces && curruntLocationStatus === "Success" ? (
        <SearchResultPlaceBox>
          {curruntLocationPlaces.map((place) => (
            <PlaceSearchResult key={place.id} place={place} />
          ))}
        </SearchResultPlaceBox>
      ) : null}
      {curruntLocationPlaces.length === 0 && curruntLocationStatus === "Success" ? (
        <NoneResultMessage>검색 결과가 없습니다!</NoneResultMessage>
      ) : null}

      {searchResults && searchStatus === "Success" ? (
        <SearchResultPlaceBox>
          {searchResults.map((place) => (
            <PlaceSearchResult key={place.id} place={place} />
          ))}
        </SearchResultPlaceBox>
      ) : null}
      {searchResults.length === 0 && searchStatus === "Success" ? (
        <NoneResultMessage>검색 결과가 없습니다!</NoneResultMessage>
      ) : null}

      <CurruntLocationPlacesButton />

      <PostPlaceBox>
        <button
          onClick={() => {
            navigate("/newplaces");
          }}
        >
          나만의 맛플레이스가 없다면?
        </button>
      </PostPlaceBox>
    </SearchWrapper>
  );
};
export default SearchDetailPlace;

위 리코일 아톰인 curruntLocationPlacesState는 후술할 현재 위치 기반으로 검색 관련 상태이다. 이제 얘기해보겠다.

2. 현재 위치 기반 맛플레이스 리스트 관련 마커 및 버튼 구현

능력자 백엔트 팀장님이 어느 좌표를 기점으로 몇 KM 이하 맛플레이스들을 리스트로 반환하는 기능을 만드셨다.
https://${host}/places?longitude=126.9019532&latitude=37.5170112&round=1
longitude에는 경도를, latitude에는 위도를 넣고 km가 기준인 round에 카카오맵 지도 확대 레벨에 관련해 km를 넣으면 그 안에 맛플레이스들을 반환한다는 것이다.

옳다구나! 재밌겠다! 하고 기능 구현을 해봤다. 깃헙

0. 카카오맵 좌표 리코일 상태로 공유하기

당연히 카카오맵 정중앙 좌표를 알아야 그 좌표 주변 맛플레이스들을 검색할 것이다.

...
import { curruntLocationState } from "../../store/curruntLocationPlacesAtom";
import { placeInfoState, placeInfoStatusState } from "../../store/placeInfoAtoms";
import PickerMarker from "./PIckerMarker";
import PlaceDetailMarker from "./PlaceDetailMarker";
import SearchMarker from "./SearchMarker";

...

const KakaoMap = () => {
  const token = localStorage.getItem("Authorization");
  const placeInfoStatus = useRecoilValue(placeInfoStatusState);
  const { latitude, longitude } = useRecoilValue(placeInfoState);
  const setCurruntLocation = useSetRecoilState(curruntLocationState);

... // center 관련 코드

  return (
    <>
      <MapContainer
        center={{
          lat: centerMove.lat || 37.56667437551163,
          lng: centerMove.lng || 126.95764417493172,
        }}
        level={7}
        isPanto={true}
        onCenterChanged={(map: getCenterType) =>
    	  // 여기!
          setCurruntLocation({
            level: map.getLevel(),
            center: {
              lat: map.getCenter().getLat(),
              lng: map.getCenter().getLng(),
            },
          })
        }
      >
        <PlaceDetailMarker />
        <SearchMarker />
        <PickerMarker />
      </MapContainer>
    </>
  );
};

export default KakaoMap;

curruntLocationStatesetCurruntLocation 함수를 불러와 현 좌표(center) 및 확대 레벨(level)을 넣고 현재 좌표를 리코일 아톰에 넣어 '현 위치 찾기' 버튼을 누를 시 이 데이터를 기반으로 확대 레벨 및 좌표를 설정해줄 것이다.

아톰 curruntLocationState 기본값은 이러하다.

import { atom } from "recoil";

export const curruntLocationPlacesState = atom({
  key: "curruntLocationPlacesState",
  default: [],
});

export const curruntLocationState = atom({
  key: "curruntLocationState",
  default: {
    level: 7,
    center: {
      lat: 37.56667437551163,
      lng: 126.95764417493172,
    },
  },
});

export const curruntLocationStatusState = atom({
  key: "curruntLocationStatusState",
  default: "Idle",
});

1. 버튼 구현

먼저 버튼을 만들어서 Search 페이지에 연결 시켜줄 것이다.

import { useEffect } from "react";
import { useRecoilState, useRecoilValue, useSetRecoilState } from "recoil";
import styled from "styled-components";
import { CurrentLocaionSearchAxios } from "../api/axiosAPI/places/PlacesAxios";
import { PlaceData } from "../api/axiosAPI/search/placeSearchAxios";
import useAxios from "../hooks/useAxios";
import {
  curruntLocationPlacesState,
  curruntLocationState,
  curruntLocationStatusState,
} from "../store/curruntLocationPlacesAtom";
import { searchResultsState, searchStatusState } from "../store/searchPlaceAtoms";

const CurrentLocaionSearchButton = styled.button`
  @media screen and (max-height: 580px) {
    bottom: -36vh;
  }
  @media screen and (min-width: 801px) {
    &:hover {
      background-color: rgba(198, 93, 123, 1);
    }
  }
  ... // CSS 코드 중략

const CurruntLocationPlacesButton = () => {
  const curruntLocation = useRecoilValue(curruntLocationState);
  const { center, level } = curruntLocation;
  const { lng, lat } = center;
  const arrLevelMeter = [0, 0.1, 0.25, 0.55, 0.8, 2.0, 3.5, 5.5];

  const setSearchResults = useSetRecoilState(searchResultsState);
  const setSearchStatus = useSetRecoilState(searchStatusState);
  const setCurruntLocationPlaces = useSetRecoilState(curruntLocationPlacesState);
  const [curruntLocationStatus, setCurruntLocationStatus] = useRecoilState(
    curruntLocationStatusState
  );

  const {
    axiosData: getCurrentLocaionPlace,
    responseData: CurrentLocaionPlaceData,
    status: getCurrentLocaionStatus,
  } = useAxios<PlaceData[]>(
    () => CurrentLocaionSearchAxios(lng, lat, arrLevelMeter[level]),
    [curruntLocationStatus, lng, lat, level],
    true
  );

  const CurrentLocaionSearchHandler = () => {
    setSearchStatus("Idle");
    setSearchResults([]);
    getCurrentLocaionPlace();
    setCurruntLocationStatus("Loading");
  };

  useEffect(() => {
    if (curruntLocationStatus === "Loading" && getCurrentLocaionStatus === "Success") {
      setCurruntLocationPlaces(CurrentLocaionPlaceData);
      setCurruntLocationStatus("Success");
    }
  }, [curruntLocationStatus, getCurrentLocaionStatus]);

  return (
    <>
      {level < 8 ? (
        <CurrentLocaionSearchButton onClick={CurrentLocaionSearchHandler}>
          현재 위치에서 검색
        </CurrentLocaionSearchButton>
      ) : null}
    </>
  );
};

export default CurruntLocationPlacesButton;
export const CurrentLocaionSearchAxios = async (
  lng: number,
  lat: number,
  level: number
): Promise<PlaceData[]> => {
  const response = await axios.get(`${url}?longitude=${lng}&latitude=${lat}&round=${level}`);
  return response.data;
};

핵심은 리코일 아톰 curruntLocationState의 값을 가져와 좌표와 확대 레벨에 맞게 axios 통신을 하는 것이다. 그 통신한 결과물을 담기 위해 위의 input에 검색하는 기능 관련 리코일 searchResultsState을 초기화 시키고, curruntLocationStatusState을 가져와 담았다.

그러면 input 검색 기능이 가진 리스트 및 마커 구현을 이 리코일 아톰의 데이터로 교체만 해주면 된다!

2. 마커 연결

위에서 얘기했듯이, 교체만 해주는 작업을 진행했다. 아톰 불러와서 데이터를 가져오고, status가 바뀌면 교체 작업을 진행하는 식으로 코드를 작성했다.

import { useState } from "react";
import { MapMarker } from "react-kakao-maps-sdk";
import styled from "styled-components";
import { useRecoilValue } from "recoil";
import { searchResultsState, searchStatusState } from "../../store/searchPlaceAtoms";
import { useNavigate } from "react-router";
import {
  curruntLocationPlacesState,
  curruntLocationStatusState,
} from "../../store/curruntLocationPlacesAtom";

const InfoWindowContainer = styled.div`
  width: 2000%;
  height: 100%;
  display: flex;
  align-items: center;
`;

const PlaceName = styled.div`
  padding: 5px;
  font-size: 15px;
  text-align: center;
`;

const SearchMarker = () => {
  const navigate = useNavigate();
  const searchResults = useRecoilValue(searchResultsState);
  const searchStatus = useRecoilValue(searchStatusState);
  // update!
  const curruntLocationPlaces = useRecoilValue(curruntLocationPlacesState);
  const curruntLocationStatus = useRecoilValue(curruntLocationStatusState);

  const [isVisible, setIsVisible] = useState({
    id: -1,
    isVisible: false,
  });

  const clickHandler = (id: number) => {
    navigate(`/places/${id}`);
  };

  return (
    <>
      // update!
      {curruntLocationPlaces && curruntLocationStatus === "Success"
        ? curruntLocationPlaces.map((result) => (
            <MapMarker
              key={result.id}
              position={{ lat: result.latitude, lng: result.longitude }}
              clickable={true}
              onMouseOver={() => setIsVisible({ id: result.id, isVisible: true })}
              onMouseOut={() => setIsVisible({ id: -1, isVisible: false })}
              onClick={() => clickHandler(result.id)}
            >
              {isVisible.isVisible && isVisible.id === result.id && (
                <InfoWindowContainer>
                  <PlaceName>{result.name}</PlaceName>
                </InfoWindowContainer>
              )}
            </MapMarker>
          ))
        : null}

      {searchResults && searchStatus === "Success"
        ? searchResults.map((result) => (
            <MapMarker
              key={result.id}
              position={{ lat: result.latitude, lng: result.longitude }}
              clickable={true}
              onMouseOver={() => setIsVisible({ id: result.id, isVisible: true })}
              onMouseOut={() => setIsVisible({ id: -1, isVisible: false })}
              onClick={() => clickHandler(result.id)}
            >
              {isVisible.isVisible && isVisible.id === result.id && (
                <InfoWindowContainer>
                  <PlaceName>{result.name}</PlaceName>
                </InfoWindowContainer>
              )}
            </MapMarker>
          ))
        : null}
    </>
  );
};

export default SearchMarker;

Status 상태가 Idle이면 없어지고, Success면 렌더링되게 한다! 굿!

맛픽커 마커

이 기능은 로그인 하면 내가 고른 맛플레이스들을 픽을 해서 맛픽커 페이지의 리스트에 넣을 수 있는 기능에 더해 각자 리스트에 맞는 마커가 나오도록 할 수 있는 기능이다.

이 마커는 그래도 비교적 간단하다. 로그인 하면 토큰이 생기고, 토큰이 로컬스토리지에 있다면, 서버에 엔드포인트 /pickers에 Get 요청을 보낸다. 가져온 리스트를 각각 배치해서 마커 컴포넌트에서 렌더링하면 된다.

0. 마커 디자인하기

이게 되게 은근히 오래 걸렸다. 내가 디자인 하는 사람은 아니다 보니 어떻게 할 까 고민이 많았다. 보통 맛집 하나만을 보지 않고, 먹을 거를 추가한다 싶으면

  1. 음식점
  2. 카페
  3. 술집

이렇게 나누는 게 최소한의 효율이라 생각했다. 그래서 그렇게 디자인을 해봤다.

피그마에서 직접 svg를 찾아 손수 가져온 다음 색을 넣고 디자인해서 다시 SVG파일로 내보내기를 한다음 프로젝트에 썼다. 은근 노가다더라.

노란색 마커인 맛집 마커는 왜 2개냐면 지도에 렌더링 됐을 때, 지도 특성상 노란색 계열이 많이 쓰였다. 그래서 잘 안보이기에 노란색 하나로는 지도용, 하나는 페이지의 리스트에서 통일성을 위해 쓰는 용 이렇게 2개로 만들어 사용했다.

1. axios 통신

export const getAllPickersPlaces = async () => {
  const response = await axios.get("/pickers");
  return response.data;
};

간단한 axios 코드다. 이 코드를 useAxios(참고)에 갔다쓰면 된다. 토큰이 조건이기에 deps에는 토큰을 집어 넣었다.

응답 값으로는 맛 픽을 한 맛플레이스의 id, 맛픽커 페이지의 리스트의 마커 id 등이 온다. 그래서 마커 id랑 맛플레이스 id를 사용해 각 리스트마다 마커를 다르게 렌더링하고, 클릭 이벤트를 추가해 바로 맛플레이스 디테일 페이지로 넘어가게 할 것이다.

2. 마커 구현

import { useEffect, useState } from "react";
import { MapMarker } from "react-kakao-maps-sdk";
import styled from "styled-components";
import { useNavigate } from "react-router";
import { getAllPickersPlaces } from "../../api/axiosAPI/groups/PickersAxios";
import useAxios from "../../hooks/useAxios";

const InfoWindowContainer = styled.div`
  width: 2000%;
  height: 100%;
  display: flex;
  align-items: center;
`;

const PlaceName = styled.div`
  padding: 5px;
  font-size: 15px;
  text-align: center;
`;

interface Place {
  id: number;
  name: string;
  latitude: number;
  longitude: number;
  groupImgIndex: number;
}
const markerImg = [
  "https://user-images.githubusercontent.com/94962427/215420707-f35b10a7-f81f-40e7-a975-f7938089555f.svg",
  "https://user-images.githubusercontent.com/94962427/214733289-7588880b-0492-429f-9e7e-8dbc883a88a3.svg",
  "https://user-images.githubusercontent.com/94962427/214733318-efc109a4-439d-4b3a-b17e-ab478ff16102.svg",
  "https://user-images.githubusercontent.com/94962427/214733548-640ad950-b4ce-42cd-ad04-7b37eb4eaf8f.svg",
];

const PickerMarker = () => {
  const navigate = useNavigate();
  const token = localStorage.getItem("Authorization");
  const {
    axiosData: getPickerPlace,
    responseData: pickerPlaces,
    status,
  } = useAxios(getAllPickersPlaces, [token]);

  const [releaseMarker, setReleaseMarker] = useState(false);
  const [isVisible, setIsVisible] = useState({
    id: -1,
    isVisible: false,
  });

  const clickHandler = (id: number) => {
    navigate(`/places/${id}`);
  };

  useEffect(() => {
    if (token && status === "Idle" && !releaseMarker) {
      getPickerPlace();
      setReleaseMarker(true);
    }
  }, [token, status, releaseMarker]);

  return (
    <>
      {token && pickerPlaces
        ? pickerPlaces.map((place: Place) => (
            <MapMarker
              key={place.id}
              image={{
                src: markerImg[place.groupImgIndex],
                size: { width: 25, height: 25 },
              }}
              position={{ lat: place.latitude, lng: place.longitude }}
              clickable={true}
              onMouseOver={() => setIsVisible({ id: place.id, isVisible: true })}
              onMouseOut={() => setIsVisible({ id: -1, isVisible: false })}
              onClick={() => clickHandler(place.id)}
            >
              {isVisible.isVisible && isVisible.id === place.id && (
                <InfoWindowContainer>
                  <PlaceName>{place.name}</PlaceName>
                </InfoWindowContainer>
              )}
            </MapMarker>
          ))
        : null}
    </>
  );
};

export default PickerMarker;

위 1번에서 얘기했던 기능을 구현하기 위해 tokenpickerPlaces가 있으면 렌더링 되게 했고, 어떤 맛집인지 구분하기위해 mouseOver 이벤트도 추가했고, 클릭 이벤트도 추가해 /places/${id}로 가게 했다.

결론

이 부분이 제일 어려웠다. 카카오맵 API를 조작하여 마커를 구현하는 것, input 검색 데이터와 현 위치 검색 데이터의 관리, 디테일 페이지로 넘어갈 때마다 마커 조작 및 카메라 액션 조작(얘가 제일 헬;;), 로그인 상태에 관련해 렌더링되는 맛픽커 마커까지.. 진짜 얘때메 2주는 날린 거 같았다..

그래도 이거 하면서 useAxiosskip, deps를 더 다양하게 썼고, 비동기 조작도 익숙해졌으며, 특히 리코일을 자주 쓰게 되면서 전역 상태를 선언하는 이유에 대해서 확실히 알았다. 이렇게 페이지-axios-마커표시-카카오맵 구조의 트리에서 리코일이 없었다면 props로 다 연결시켜줘야했었을 것이다. 만약 그랬다면 props drilling이 반복적으로 생겨 구현 뿐만 아니라 관리/보수가 힘들어 졌을 것이다.

고맙다! 리코일아! 고맙다! 상태관리야!

profile
코뿔소처럼 저돌적으로

7개의 댓글

comment-user-thumbnail
2023년 2월 14일

맛피의 지도를 태연님께서 구현하셨던 거였군요! 멋지게 구현하셨던데 이렇게 팁까지 공유주시다니 감사합니다.

답글 달기
comment-user-thumbnail
2023년 2월 18일

세세하게 설명 해주시고 나중에 지도 구현할때 도움이 많이 될꺼같아요!

답글 달기
comment-user-thumbnail
2023년 2월 18일

프로젝트 정말 열심히 하셨군요 반성 많이 하고 갑니다 흑흑

답글 달기
comment-user-thumbnail
2023년 2월 18일

진짜 고생하셨네요 멋지십니다 .. !! 👍🏻

답글 달기
comment-user-thumbnail
2023년 2월 18일

전 포스팅 보고 마커 부분 궁금했는데 여기서 또 완벽한 기승전결.. 상세한 설명... 이 게시글 많이 참고해보게 될 거 같아요 감사합니당 :0

답글 달기
comment-user-thumbnail
2023년 2월 19일

지도에 엄청 다양한 기능을 넣으신 것 같아요..!! 특히 검색 기능이랑 현재 위치에서 몇 km 이내 마커만 띄워주는 기능이 부럽습니다👍.....

1개의 답글