Keepit의 핵심 기능 지도 구현하기

박재현 ( Jcurver )·2023년 1월 28일
0

들어가며

지도는 Keepit의 핵심 기능입니다. 그럼에도 불구하고 작업 기간을 관리하다보니 가장 개발 Task로 작업을 진행하게 되었습니다.

지도 기능 자체를 처음 구현해보기도 하고, 백엔드와의 interaction 보다는 kakao의 api와 function 그리고 webview를 주로 다뤄야 했습니다. 해당 기능 자체도 처음이기에 '혹시 잘 안되면 어떡하지'라는 큰 부담감을 가지고 코드를 짜나갔습니다.

결국 여러번에 걸친 리펙토링 끝에 지도를 완성했습니다.

Keepit 지도 시연 영상 보기

Webview와 React-Native간 통신

지도는 웹뷰로 구현이되어야 했습니다. kakao map은 ios, android, web 전용 api를 제공해주고 있어서 React-Native에서는 web용 api를 선택해야 했기 때문입니다. docs가 친절하게 설명되어 있고 블로그에서도 많이 다루어져서 어렵지 않게 통신을 구현할 수 있었습니다.

React-Native-Webview github docs에 있는 사용방법을 그대로 차용하였습니다.

const INJECTED_JAVASCRIPT = `(function() {
    window.ReactNativeWebView.postMessage(JSON.stringify(window.location));
})();`;

<WebView
  source={{ uri: 'https://reactnative.dev' }}
  injectedJavaScript={INJECTED_JAVASCRIPT}
  onMessage={this.onMessage}
/>;

Trouble Shooting

React-Native 내에서 Webview를 호출하는 경우 데이터의 흐름제어가 React 기반으로 순차적으로 진행되지 않아 상황에 맞게 커스텀이 필요하다.

주요 서비스인 지도 구현을 위해서 카카오맵과 웹뷰 그리고 지도 api를 사용했습니다. 그 과정에서 기존에 겪었던 에러와 결이 다른 에러들을 겪게 되었습니다. 특히 React-Native와 웹뷰가 서로 데이터를 주고받는 타이밍을 비동기적으로 관리하는데 어려움이 있었습니다.

조금 복잡하지만 한가지 상황을 예를 들어 설명해 보겠습니다.

  1. gps 버튼을 누른 경우
    gps버튼이 활성화 색으로 변한다.
    유저의 현재 위치로 지도가 부드럽게 이동한다.
    지도 확대 레벨이 조정된다.
    유저의 현재 위치에 파란색 점이 생긴다.
  2. 유저가 지도를 드래그하거나 지도 확대 레벨이 변경된 경우
    gps 버튼 색을 비활성화 시켜야 한다.

위 조건을 지켜야 한다고 생각해봅시다. 그때 아래와 같이 로직을 생각했습니다.

  1. 위 1번을 위한 로직을 Webview쪽 로직에 구현한다.
  2. 위 2번을 위해서 Webview 쪽에서 move 이벤트가 발생한 경우 React-Native쪽으로 이벤트를 보내고 이를 받아 버튼을 비활성화 처리한다.

이렇게 생각해볼 수 있습니다. 하지만 여기서 문제가 발생합니다. move 이벤트가 1번 로직에서 부드럽게 이동하며 이미 연속적으로 발생하고 있기에 2번 로직에 의해 gps버튼이 활성화되지 않습니다. 또한 move외에 click, double-click과 같은 이벤트가 동시에 호출되는 경우 각각의 이벤트가 따로 listen 되고 있기 때문에 이러한 이벤트가 같은 phase에 호출된 것인지 알 수 없었습니다.

이를 해결하기 위해서 useDebounce hook으로 RN쪽에서 데이터가 0.3초 동안 listen되지 않는 순간 Webview로부터 들어왔던 이벤트를 모두 취합해서 처리하여 문제를 해결했습니다. 실제로 네이버지도와 카카오맵에도 다양한 딜레이가 걸려있는데 왜 걸려있는지에 대해서 작업하며 자연스럽게 배울 수 있었습니다.

React-Native측으로 넘어오는 Webview Event처리는 다음과 같습니다.

  const [getWebviewData, setGetWebviewData] = useState({});
  const debounceGetWebviewData = useDebounce(getWebviewData, 300);

  const handleOnMessageFromWebView = (e) => {
    const data = JSON.parse(e.nativeEvent.data);
    if (data?.zoom_changed || data?.dragend) {
      setGpsClick(false);
      setZoomOutClick(false);
    }
    if (Number(data?.targetIndex) > -1) {
      setGetWebviewData((prev) => ({ ...prev, ...data }));
    }
    if (data?.click) {
      setGetWebviewData((prev) => ({ ...prev, click: true }));
    }
    if (data?.doubleClick) {
      setGetWebviewData((prev) => ({ ...prev, doubleClick: true }));
    } else {
      setGetWebviewData((prev) => ({ ...prev, doubleClick: false }));
    }
  };

  useEffect(() => {
    if (debounceGetWebviewData.targetIndex > -1) {
      setOnTopSheet(true);
      setOnBottomSheet(true);
      if (debounceGetWebviewData?.type === 'customMarkers') {
        setBottomSheetData(
          placeData.customMarkers[debounceGetWebviewData.targetIndex],
        );
      } else if (debounceGetWebviewData?.type === 'markers') {
        setBottomSheetData(
          placeData.markers[debounceGetWebviewData.targetIndex],
        );
      }
      setIsInputLoading(false);
    } else if (
      debounceGetWebviewData?.click &&
      !debounceGetWebviewData?.targetIndex &&
      !debounceGetWebviewData?.doubleClick
    ) {
      setOnTopSheet((d) => !d);
      setOnBottomSheet((d) => !d);
    }
    setGetWebviewData({});
  }, [
    debounceGetWebviewData?.targetIndex,
    debounceGetWebviewData?.click,
    debounceGetWebviewData?.doubleClick,
    debounceGetWebviewData?.type,
    searchWord,
    placeData.customMarkers,
    placeData.markers,
  ]);

코드 일부만 발췌했습니다.

  1. handleOnMessageFromWebView function에서 React-Native측으로 넘어오는 Webview Event를 받아서 setGetWebviewData로 state를 변경합니다.
  2. 변경된 state를 useDebounce hook으로 감싸서 연속적으로 들어온 이벤트를 한 번에 모아 listen할 수 있도록 합니다.
  3. 변경된 이벤트를 useEffect의 dependency array에 모두 담아 호출하고, 유저의 interaction 관점에서 발생할 수 있는 케이스별로 구분하여 if문을 통해 이벤트를 제어합니다.

이와같은 로직으로 작업하니, 비로소 원하는대로 작동하게 되었습니다.

Webview 구현하기 (Vanilla JS)

웹뷰를 적용하는 과정에서 Kakao map api를 사용하였습니다. IOS, Android, web 3가지 버전을 지원했고, React-Native를 사용하기에 선택지는 web뿐이었습니다. 기본적으로 모든 프레임워크에서 작동하기 위해서 모든 api와 function은 바닐라 javascript기반으로 구성되어 있었습니다.

Kakao maps api는 기본적으로 map 클래스 생성자를 통해서 지도를 생성하게 됩니다.
이후 map에 내장된 함수를 이용해서 상태를 변경합니다. 예컨대 map.setLevel(4)를 하면 지도의 확대 레벨이 4로 변경됩니다.

docs 설명이 꽤 친절한 편이라서 함수를 이용하는데에는 큰 어려움이 없었습니다.
지도의 확대 레벨이 7 이하인 경우 Custom Overlay를 적용하였고, 확대 레벨이 7이상인 경우 Marker Clusterer를 적용하도록 구현하였습니다.

클러스터링 숫자 크기에 따라서 클러스터러의 색상을 변경해줬고, 작성된 리뷰 수에 따라서 Custom Overlay에 숫자 태그를 붙여주었습니다. 또한 리뷰가 있는 마커와 없는 마커를 다르게 구분하고, 이미지를 추가하고, 마커 클릭시 상황에 맞는 이벤트를 부여하였습니다.

하나하나의 기능을 구현하는 것이 어렵지는 않았지만 각각의 기능들이 얽히면서 다양한 side effect를 발생시켜서 이런 부분을 제어하는게 다소 어려웠습니다. 그리고 생성자에 묶여있는 함수를 사용해야 했기에 구현 난이도는 높지 않았지만 기능이 제한적이라서 지도 이벤트를 원하는대로 커스텀하기에는 다소 어렵다고 느꼈습니다.

배운 점

이벤트를 한번에 처리하는 과정에서 useDebounce 훅을 정말 의미있게 사용해보았습니다.

작업을 다 마치고 추후에 검색을 하다가 알게 되었는데, 이벤트를 제어할 때 useDebounce와 useThrottle을 이미 범용적으로 사용하고 있다는 사실을 알 수 있었습니다.
lodash 라이브러리에서도 제공하고 있구요!

useDebounce와 useThorttle의 동작원리에 대해서 간단히 정리해보고 마무리하겠습니다.

useDebounce

주로 사용되는 케이스는 다음과 같습니다.

  • 검색어 입력 중 0.5초간 입력이 없을 때 검색결과 api 호출
  • 회원가입 중 0.5초간 입력이 없을 때 입력한 필드의 유효성 검사 api 호출
  • 유저가 작성중인 글에 5초간 입력이 없을 때 내용을 임시저장

즉, 유저의 interaction 또는 sideEffectEvent가 여러번 들어오는 경우 매 상황마다 페이지를 리렌더링을 하기에는 많은 리소스가 소모되고 UX에도 좋지 않은 경우 사용하게 된다는 것을 알 수 있습니다.

코드는 다음과 같이 구성되어있습니다.

import { useEffect, useState } from 'react'

function useDebounce<T>(value: T, delay?: number): T {
  const [debouncedValue, setDebouncedValue] = useState<T>(value)

  useEffect(() => {
    const timer = setTimeout(() => setDebouncedValue(value), delay || 500)

    return () => {
      clearTimeout(timer)
    }
  }, [value, delay])

  return debouncedValue
}

export default useDebounce

인자로 value와 delay(ms)를 받고, value가 변경된 뒤 delay 시간동안 value가 변경되지 않으면 value를 return합니다. delay시간 내에 value가 변경된 경우 timer가 초기화됩니다.

그리고 cleanup function으로 unmount시에 setTimeout을 메모리에서 해제해줍니다.

useThrottle

주로 사용되는 케이스는 다음과 같습니다.

  • 무한 스크롤 중에 사용자가 페이지 하단에 가까워지면 더 많은 콘텐츠를 요청합니다.
  • 설정된 간격으로 스크롤 위치를 확인하여 CSS 애니메이션을 트리거합니다.

즉, 이벤트가 너무 짧은 시간 간격으로 트리거 될 것이라고 예상되는 경우, useThrottle을 통해 이벤트가 일어나는 시간 텀을 고정값으로 늘려줍니다.

이벤트 발생 빈도를 줄여서 성능과 UX에 기여한다는 점이 useDebounce와 동일합니다.

코드는 다음과 같이 구성되어있습니다.

import { useEffect, useState } from 'react'

const useThrottle = (callbackFunc: () => void, time: number): any => {
  const [isWaiting, setIsWaiting] = useState(false)
  let Timer;

  useEffect(() => {
    if (!isWaiting) {
      callbackFunc()
      setIsWaiting(true) // 함수가 호출되자마자 true로 바꾸어 호출 중단

      Timer = setTimeout(() => {
        // 특정 시간 이후에 false로 바꾸어 재호출
        setIsWaiting(false)
      }, time)
    } else {
      return () => {
      	clearTimeout(timer)
    }}
  }, [callbackFunc, isWaiting, time])
}

export default useThrottle

useDebounce는 state 변경 시간을 감지해서 delay를 걸어줬다면, useThrottle은 function의 호출 시간을 감지해서 delay를 걸어줍니다.

conclusion

debounce와 throttle 모두 setTimeout이라는 Web API에 의존하여 실행됩니다. 그러나 setTimeout, setInterval은 정확한 지연시간을 보장해주지 않습니다.

실제로 useThrottle 예제를 몇 번 새로고침하다보면 count와 throttleCount의 숫자가 불규칙하게 변하는 것을 확인할 수 있습니다. virtual DOM에 의존하기 때문인데, 이러한 점이 마음에 들지 않아서 useRef를 이용하여 dom에 접근하여 해결한 useDebounce, useThrottle hook도 존재합니다. 또는 컴포넌드 리렌더링 시에 상태를 보완해주기 위해 useMemo로 한번 감싸주는 방법도 사용됩니다.

결국 사용 의도에 맞게 정교하게 커스텀 할 필요가 있고, 이러한 점들이 React를 깊이있게 공부해야 하는 이유 중 하나라고 느낄 수 있었습니다.

profile
FE developer / Courage is very important when it comes to anything.

0개의 댓글