[LG CNS AM Inspire Camp 1기] React (15) - [LAB] 근처 카페 검색 웹 어플리케이션 끄적이기 (4)

정성엽·2025년 1월 13일
0

LG CNS AM Inspire 1기

목록 보기
25/53
post-thumbnail

INTRO

이제부터 코드를 리팩토링하는 과정을 정리할 예정이다.

지금까지 4편의 포스팅을 작성한 목적이기도 하다.

참고로 전역 관리를 위해 Context API 대신 Zustand를 사용했으니 이 부분은 참고하자


15. 리팩토링 아이디어

어떻게 코드를 수정할지 조금 고민했다.

Context API를 사용해서 전역으로 관리할까?

커스텀 훅으로 만들어서 사용할까?

커스텀 훅 내부에서 상태를 관리할 경우, 커스텀 훅 내부의 상태가 변경되더라도 이를 사용하고 있는 모든 컴포넌트가 리렌더링되지는 않는다.

따라서, 다른 컴포넌트에서도 상태 변수를 사용해야하는 경우에는 전역으로 상태를 관리하는 방법이 적절해보였다.

지금 내가 작성한 코드에서 지도 인스턴스는 독립적으로 동작하기 때문에 커스텀훅으로 만드는게 더 나을 것 같다.

NaverMap.jsx (리팩토링 이전)

const NaverMap = ({ searchKeyword }) => {
  const mapElement = useRef(null);
  const mapInstance = useRef(null);
  const { naver } = window;
  const [address, setAddress] = useState({
    x: 127.105399,
    y: 37.3595704,
  });
  const [cafes, setCafes] = useState([]);
  const [selectedCafe, setSelectedCafe] = useState(null);

  const onSuccessGeolocation = (position) => {
    setAddress({
      x: position.coords.longitude,
      y: position.coords.latitude,
    });
  };

  const onErrorGeolocation = () => {
    alert("위치 정보를 가져오는데 실패했습니다.");
  };

  useEffect(() => {
    if (navigator.geolocation) {
      navigator.geolocation.getCurrentPosition(
        onSuccessGeolocation,
        onErrorGeolocation
      );
    }
  }, []);

  useEffect(() => {
    if (searchKeyword) {
      naver.maps.Service.geocode(
        {
          query: searchKeyword,
        },
        function (status, res) {
          if (res.v2.addresses.length === 0) {
            alert("검색 결과가 없습니다.");
          } else {
            const resAddress = res.v2.addresses[0];
            const x = parseFloat(resAddress.x);
            const y = parseFloat(resAddress.y);
            setAddress({ x, y });
          }
        }
      );
    }
  }, [searchKeyword]);

  useEffect(() => {
    if (!mapElement.current) return;

    if (mapInstance.current) {
      mapInstance.current.destroy();
    }

    mapInstance.current = new naver.maps.Map(mapElement.current, {
      center: new naver.maps.LatLng(address.y, address.x),
      zoom: 16,
    });
  }, [address]);

  useEffect(() => {
    const timer = setTimeout(() => {
      naver.maps.Service.reverseGeocode(
        {
          location: new naver.maps.LatLng(address.y, address.x),
        },
        function (status, res) {
          if (status !== naver.maps.Service.Status.ERROR) {
            if (res.result.items.length > 0) {
              const item = res.result.items[0];
              const address = item.address;
              const roadAddress = item.roadAddress;

              const fetchCafes = async () => {
                const res = await searchLocal(
                  `${roadAddress || address} 근처 카페`
                );
                const filteredCafe = res.items
                  .filter((item) => item.category.includes("음식점"))
                  .map((item) => {
                    const point = new naver.maps.Point(
                      parseFloat(item.mapx) / 10000000,
                      parseFloat(item.mapy) / 10000000
                    );
                    return { ...item, mapx: point.x, mapy: point.y };
                  });
                console.log(filteredCafe);
                setCafes(filteredCafe);
              };

              fetchCafes();

              console.log("현재 위치 주소:", roadAddress || address);
            }
          } else {
            alert("주소를 찾을 수 없습니다");
          }
        }
      );
    }, 300);

    return () => clearTimeout(timer);
  }, [address]);

  useEffect(() => {
    cafes.forEach((cafe) => {
      addMarker(cafe.title, cafe.mapx, cafe.mapy, cafe);
    });
  }, [cafes]);

  const addMarker = (name, lat, lng, cafe) => {
    let newMarker = new naver.maps.Marker({
      position: new naver.maps.LatLng(lng, lat),
      map: mapInstance.current,
      title: name,
      clickable: true,
    });
    newMarker.setTitle(name);
    naver.maps.Event.addListener(newMarker, "click", function (e) {
      setSelectedCafe(cafe); 
    });
  };

  return (
    <div>
      <div ref={mapElement} style={{ width: "100%", height: 400 }}>
        NaverMap
      </div>
      {selectedCafe && (
        <div>
          {...클릭한 카페 상세 정보}
        </div>
      )}
   	 </div>
  );
};

export default memo(NaverMap);

16. 전역 상태 관리 (Zustand)

우선 2가지 상태 변수에 대해 전역 상태 관리를 수행하도록 코드를 수정했다.

2가지 상태 변수는 지도가 표시하는 address, 그리고 cafes 이다.

이 두 변수는 다른 컴포넌트에서도 사용해야할 확장성을 가지고 있다고 판단했다.

그래서 전역 상태 변수 관리 라이브러리인 Zustand 를 사용했다.

cafeStore.js

const cafeStore = create((set) => ({
  cafes: [],
  selectedCafe: null,
  setCafes: (cafes) => set({ cafes }),
  setSelectedCafe: (cafe) => set({ selectedCafe: cafe }),
})

export default cafeStore;

mapStore.js

const mapStore = create((set) => ({
  address: { x: 127.105399, y: 37.3595704 },
  setAddress: (address) => set({ address }),
})

export default mapStore;

처음 사용하는 전역 라이브러리이지만 직관적이라 이해하기 편했다.


17. 커스텀 훅 (useNaverMap)

다음으로 커스텀 훅을 만들어서 지도 변경 로직을 처리하도록 했다.

useNaverMap.js

const useNaverMap = () => {
  const mapElement = useRef(null);
  const mapInstance = useRef(null);
  const markers = useRef([]);
  const { address, setAddress } = useMapStore();
  const { setCafes, setSelectedCafe } = useCafeStore();
  const { naver } = window;

  const onSuccessGeolocation = (position) => {
    setAddress({
      x: position.coords.longitude,
      y: position.coords.latitude,
    });
  };

  const onErrorGeolocation = () => {
    alert("위치 정보를 가져오는데 실패했습니다.");
  };

  function updateMapInstance({ x, y }) {
    if (!mapElement.current) return;

    if (mapInstance.current) {
      mapInstance.current.destroy();
    }

    mapInstance.current = new naver.maps.Map(mapElement.current, {
      center: new naver.maps.LatLng(y, x),
      zoom: 16,
    });
  }

  function clearMarkers() {
    markers.current.forEach((marker) => marker.setMap(null));
    markers.current = [];
  }

  function addMarker(name, lat, lng, cafe) {
    const marker = new naver.maps.Marker({
      position: new naver.maps.LatLng(lng, lat),
      map: mapInstance.current,
      title: name,
      clickable: true,
    });

    marker.setTitle(name);
    naver.maps.Event.addListener(marker, "click", function () {
      setSelectedCafe(cafe);
    });

    return marker;
  }

  function addMarkers(cafes) {
    clearMarkers();
    cafes.forEach((cafe) => {
      const marker = addMarker(cafe.title, cafe.mapx, cafe.mapy, cafe);
      markers.current.push(marker);
    });
  }

  async function updateCafes({ x, y }) {
    try {
      const locationResult = await new Promise((resolve, reject) => {
        naver.maps.Service.reverseGeocode(
          {
            location: new naver.maps.LatLng(y, x),
          },
          function (status, res) {
            if (status === naver.maps.Service.Status.ERROR) {
              reject(new Error("주소를 찾을 수 없습니다"));
              return;
            }
            resolve(res);
          }
        );
      });

      if (locationResult.result.items.length > 0) {
        const item = locationResult.result.items[0];
        const address = item.address;
        const roadAddress = item.roadAddress;

        const res = await searchLocal(`${roadAddress || address} 근처 카페`);
        const filteredCafe = res.items
          .filter((item) => item.category.includes("음식점"))
          .map((item) => {
            const point = new naver.maps.Point(
              parseFloat(item.mapx) / 10000000,
              parseFloat(item.mapy) / 10000000
            );
            return { ...item, mapx: point.x, mapy: point.y };
          });

        setCafes(filteredCafe);
        addMarkers(filteredCafe);
      }
    } catch (error) {
      alert(error.message);
    }
  }

  function updateAddress(query) {
    if (!query) return;

    naver.maps.Service.geocode({ query }, function (status, res) {
      if (res.v2.addresses.length === 0) {
        alert("검색 결과가 없습니다.");
        return;
      }

      const resAddress = res.v2.addresses[0];
      const x = parseFloat(resAddress.x);
      const y = parseFloat(resAddress.y);
      setAddress({ x, y });
    });
  }

  useEffect(() => {
    if (address.x && address.y) {
      updateMapInstance(address);
      updateCafes(address);
    }
  }, [address]);

  function initialize() {
    if (navigator.geolocation) {
      navigator.geolocation.getCurrentPosition(
        onSuccessGeolocation,
        onErrorGeolocation
      );
    }
  }

  return { mapElement, initialize, updateAddress };
};

export default useNaverMap;

복잡해 보이는데 기존에 있던 코드를 useNaverMap쪽으로 옮겨왔다.

그리고 훅을 사용하는 곳에서는 오직 초기화, 키워드로 인한 주소 변경 로직만 사용할 수 있도록 제한했다.

따라서 NaverMap.jsx 에서 다음과 같이 사용할 수 있다.

NaverMap.jsx

const NaverMap = ({ searchKeyword }) => {
  const { mapElement, initialize, updateAddress } = useNaverMap();
  const { selectedCafe, setSelectedCafe } = useCafeStore(); // return에서 조건부 연산에 필요

  useEffect(() => {
    updateAddress(searchKeyword);
  }, [searchKeyword]);

  useEffect(() => {
    initialize();
  }, []);

  return (...);

18. 문제점

위처럼 리팩토링을 해보니 맨처음에 설정했던 목표가 애매해졌다.

커스텀 훅에서 전역 상태를 구독하고 로직에 따라 전역 상태를 직접 변경시킨다면 전역 상태와 커스텀 훅으로 분리한 이유가 없다.

왜냐하면 커스텀 훅 내부에서 선언된 상태는 독립적으로 업데이트된다는 특징이 있다.

그런데 커스텀 훅 내부에서 전역 상태를 구독해버리고, 이 전역 상태가 커스텀 훅에 의해서 변경이 된다면 커스텀 훅을 사용하는 모든 컴포넌트가 리렌더링 되어버린다.

즉, 커스텀 훅에서 모든 로직을 다 처리하고 있는 상황이다.

따라서 철저하게 업데이트 로직과 지도 인스턴스 관리 로직을 분리할 필요가 있다.


19. useNaverMap 수정

리팩토링의 기본 아이디어는 다음과 같다.

매개변수로 받아서 처리하자

useNaverMap을 수정해야하는 경우, 모두 매개변수로 전달받아서 지도 인스턴스를 다시 생성하도록 수정하면 된다.

수정된 코드는 다음과 같다.

useNaverMap.js

const useNaverMap = () => {
  const mapElement = useRef(null);
  const mapInstance = useRef(null);
  const markers = useRef([]);

  const { naver } = window;

  const initializeMap = (center) => {
    if (!mapElement.current) return;

    if (mapInstance.current) {
      mapInstance.current.destroy();
    }

    mapInstance.current = new naver.maps.Map(mapElement.current, {
      center: new naver.maps.LatLng(center.y, center.x),
      zoom: 16,
    });
  };

  const addMarker = (markerInfo) => {
    const marker = new naver.maps.Marker({
      position: new naver.maps.LatLng(markerInfo.y, markerInfo.x),
      map: mapInstance.current,
      title: markerInfo.title,
      clickable: true,
    });
    markers.current.push(marker);
    return marker;
  };

  const clearMarkers = () => {
    markers.current.forEach((marker) => marker.setMap(null));
    markers.current = [];
  };

  const updateCurrentPosition = (x, y) => {
    if (!mapInstance.current) return;
    mapInstance.current.panTo(new naver.maps.LatLng(y, x));
  };

  return {
    naver,
    mapElement,
    initializeMap,
    addMarker,
    clearMarkers,
    updateCurrentPosition,
  };
};

export default useNaverMap;

객체지향적으로 작성하니 코드가 훨씬 간결해진 모습을 볼 수 있다.

기존에는 없었던 updateCurrentPosition 함수도 추가할 수 있었는데 간단하게 확장이 가능했다.


20. 전역 상태 관리 수정

마찬가지로 외부에서 매개변수를 전달받아 상태변수가 업데이트 되도록 코드를 수정해보자

cafeStore.js

import { create } from "zustand";
import { searchLocal } from "../api/search/searchAPI";

const cafeStore = create((set) => ({
  cafes: [],
  selectedCafe: null,
  setCafes: (cafes) => set({ cafes }),
  setSelectedCafe: (cafe) => set({ selectedCafe: cafe }),
  updateCafes: async ({ x, y }, naver) => {
    try {
      const locationResult = await new Promise((resolve, reject) => {
        naver.maps.Service.reverseGeocode(
          {
            location: new naver.maps.LatLng(y, x),
          },
          function (status, res) {
            if (status === naver.maps.Service.Status.ERROR) {
              reject(new Error("주소를 찾을 수 없습니다"));
              return;
            }
            resolve(res);
          }
        );
      });

      if (locationResult.result.items.length > 0) {
        const item = locationResult.result.items[0];
        const address = item.address;
        const roadAddress = item.roadAddress;

        const res = await searchLocal(`${roadAddress || address} 카페`);
        const filteredCafe = res.items
          .filter((item) => item.category.includes("음식점"))
          .map((item) => {
            const point = new naver.maps.Point(
              parseFloat(item.mapx) / 10000000,
              parseFloat(item.mapy) / 10000000
            );
            return { ...item, mapx: point.x, mapy: point.y };
          });
        set({ cafes: filteredCafe });
      }
    } catch (error) {
      alert(error.message);
    }
  },
}));

export default cafeStore;

mapStore.js

import { create } from "zustand";

const onErrorGeolocation = () => {
  alert("위치 정보를 가져오는데 실패했습니다.");
};

const mapStore = create((set) => ({
  address: { x: 127.105399, y: 37.3595704 },
  updateInitialLocation: () => {
   navigator.geolocation.getCurrentPosition((position) => {
      set({
        address: {
          x: position.coords.longitude,
          y: position.coords.latitude,
        },
      });
    }, onErrorGeolocation);
  },
  setAddress: (address) => set({ address }),
  updateAddressFromSearch: (naver, query) => {
    if (!query) return;

    naver.maps.Service.geocode({ query }, function (status, res) {
      if (res.v2.addresses.length === 0) {
        alert("검색 결과가 없습니다.");
        return;
      }
      const resAddress = res.v2.addresses[0];
      set({
        address: {
          x: parseFloat(resAddress.x),
          y: parseFloat(resAddress.y),
        },
      });
    });
  },
  updateAddressFromLocation: (position) => {
    set({
      address: {
        x: position.coords.longitude,
        y: position.coords.latitude,
      },
    });
  },
}));

export default mapStore;

Zustand를 사용하니 전역 상태 변수를 업데이트하는 로직을 모두 한 공간에서 정의할 수 있었다.

이 과정을 통해 상태 변수 업데이트 로직은 모두 Zustand가 선언된 공간에서 처리되고, 지도 인스턴스 수정은 매개 변수를 받아서 표시하도록 코드를 리팩토링할 수 있었다.


21. NaverMap.jsx

마지막으로 위에서 수정한 리팩토링 코드를 NaverMap.jsx에서는 필요한 내용만 불러와서 사용할 수 있다.

코드는 다음과 같다.

NaverMap.jsx

const NaverMap = ({ searchKeyword }) => {
  const { address, updateInitialLocation, updateAddressFromSearch } =
    useMapStore();
  const { cafes, selectedCafe, setSelectedCafe, updateCafes } = useCafeStore();
  const {
    naver,
    mapElement,
    initializeMap,
    addMarker,
    clearMarkers,
    updateCurrentPosition,
  } = useNaverMap();

  updateInitialLocation();

  useEffect(() => {
    if (searchKeyword) {
      updateAddressFromSearch(naver, searchKeyword);
    }
  }, [searchKeyword]);

  useEffect(() => {
    if (address.x && address.y) {
      initializeMap(address);
      updateCafes({ x: address.x, y: address.y }, naver);
    }
  }, [address]);

  useEffect(() => {
    clearMarkers();
    cafes.forEach((cafe) => {
      addMarker({
        x: cafe.mapx,
        y: cafe.mapy,
        title: cafe.title,
      });
    });
  }, [cafes, addMarker, clearMarkers, setSelectedCafe]);

  return (
    <div className="mapWrapper">
      {...카페 상세 정보}
    </div>
  );
};

export default memo(NaverMap);

개인적으로 맘에 드는 코드를 작성할 수 있었다.


22. Result View

지금까지 만든 어플리케이션이 어떻게 동작하는지도 기록해두려고 한다.

나는 디자인 실력이 말도안되서 스타일링은 AI 모델을 사용했다.


OUTRO

리팩토링 과정을 기록해두고 싶어서 작성한 포스팅이다.

최종적으로 작성한 코드에서 아쉬운 점은 바로 Naver Search API에 있다.

검색 결과가 5개 밖에 검색이 되지 않는 상황에서 진짜 카페는 1~2개 밖에 없다. (스터디 카페 등등 제외..)

(한 30개정도 검색이 되었다면 화면에 마커가 가득찼을텐데..)

어쨌든 코드를 시작부터 리팩토링 코드처럼 작성할 수 있도록 조금 더 많이 만들어봐야겠다.

전체 코드

profile
코린이

0개의 댓글

관련 채용 정보