[NextJS 지도 개발 #2] SWR로 전역 상태 관리하기

김유진·2023년 4월 23일
1

Nextjs

목록 보기
5/9
post-thumbnail

현재 data를 fetching하고 관리하는 라이브러리 중 인기가 많은 것은 단언 SWR와 React Query라고 할 수 있겠다! 리액트 쿼리에 대한 글을 먼저 작성하려고 했는데 어쩌다 보니 순서가 꼬여 SWR에 대한 소개글을 먼저 작성하게 되었다. ㅎㅎ

SWR이 유용한 이유

  1. 선언적인 코드를 작성할 수 있어서 개발자도 코드의 의도를 확인하는 데 있어 직관적으로 이해할 수 있다.
  2. 동일한 API 요청이 여러 번 호출된 경우, 한 번만 실행된다.
  3. Global State와 Server State를 분리하여 관리할 수 있다.

SWR 시작하기

JSON데이터를 받아오는 RESTful API를 이용한 통신을 사용한다고 가정해보자. 데이터를 어떻게 받아오게 될까?

const fetcher = (...args) => fetch(...args).then(res => res.json())

일단 먼저 fetcher 함수를 만들어 두고..

import useSWR from 'swr'
 
function Profile () {
  const { data, error, isLoading } = useSWR('/api/user/123', fetcher)
 
  if (error) return <div>failed to load</div>
  if (isLoading) return <div>loading...</div>
 
  // 데이터 렌더링
  return <div>hello {data.name}!</div>
}

정말 직관적으로 코드를 작성할 수 있다!
만약 받아온 데이터를 여러 곳에서 재사용하고 싶다면 어떻게 코드를 작성해야 할까? 그에 관련된 데이터 hook을 작성하면 된다.

function useUser (id) {
  const { data, error, isLoading } = useSWR(`/api/user/${id}`, fetcher)
 
  return {
    user: data,
    isLoading,
    isError: error
  }
}

UseUser라는 훅을 만들어 두고 해당 데이터가 필요할 때마다 컴포넌트에서 사용할 수 있도록 하면 된다.

function Avatar ({ id }) {
  const { user, isLoading, isError } = useUser(id)
 
  if (isLoading) return <Spinner />
  if (isError) return <Error />
  return <img src={user.avatar} />
}

이렇게 데이터를 관리하게 되면 최상위 컴포넌트에서 데이터를 받아 자식 컴포넌트에 Props로 넘겨주는 일을 하지 않아도 된다. 데이터는 이제 해당 컴포넌트에서 필요한 데이터 단위로 범위를 제한할 수 있고, 모든 컴포넌트는 서로에게 독립적이다.

useSWR의 리턴 값

const { data, error, isLoading, isValidating, mutate } = useSWR(key, fetcher, options)

파라미터

  • key : 요청을 위한 고유한 키 문자열(또는 함수, 배열, null)
  • fetcher : 옵션이다. 데이터를 가져오기 위한 함수를 반환하는 Promise
  • options: 옵션이다. SWR hook을 위한 옵션 객체이다.

반환 값

  • data : fetcher가 이행한 주어진 키에 대한 데이터. 만약 로드되지 않았다면 undefined로 남아 있다.
  • error : fetcher가 던진 에러
  • isLoading: 진행 중인 요청이 존재하고, 로드된 데이터가 없는 경우 반환
  • isValidating: 요청이나 갱신 로딩의 여부 확인
  • mutate(data?, options?) : 캐시된 데이터를 뮤테이트 하기 위한 함수이다.

전역 설정

SWRConfig 컨텍스트는 모든 SWR hook에 대한 전역 설정을 제공합니다.

<SWRConfig value={options}>
  <Component/>
</SWRConfig>

모든 SWR hook은 동일한 fetcher를 사용하여 JSON 데이터를 로드하고 기본적으로 3초마다 갱신한다.

import useSWR, { SWRConfig } from 'swr'
 
function Dashboard () {
  const { data: events } = useSWR('/api/events')
  const { data: projects } = useSWR('/api/projects')
  const { data: user } = useSWR('/api/user', { refreshInterval: 0 }) // 오버라이드
 
  // ...
}
 
function App () {
  return (
    <SWRConfig
      value={{
        refreshInterval: 3000,
        fetcher: (resource, init) => fetch(resource, init).then(res => res.json())
      }}
    >
      <Dashboard />
    </SWRConfig>
  )
}

뮤테이션

mutate API를 사용하여, 데이터를 변경하는 방법이 존재한다.
모든 키를 변경할 수 있는 global mutate API가 존재하고, 해당 SWR hook의 데이터만을 변경할 수 있는 bound mutate API가 존재한다.

Global Mutate

global mutator를 가져오는 권장 방법은 useSWRConfig Hook을 사용하는 것이다.

import { useSWRConfig } from "swr"
 
function App() {
  const { mutate } = useSWRConfig()
  mutate(key, data, options)
}

전역적으로도 가져올 수 있따.

import { mutate } from "swr"
 
function App() {
  mutate(key, data, options)
}

Bound Mutate

import useSWR from 'swr'
 
function Profile () {
  const { data, mutate } = useSWR('/api/user', fetcher)
 
  return (
    <div>
      <h1>My name is {data.name}.</h1>
      <button onClick={async () => {
        const newName = data.name.toUpperCase()
        // API에 대한 요청을 종료하여 데이터를 업데이트 합니다.
        await requestUpdateUsername(newName)
        // 로컬 데이터를 즉시 업데이트 하고 다시 유효성 검사(refetch)를 합니다.
        // NOTE: key는 미리 바인딩되어 있으므로 useSWR의 mutate를 사용할 때 필요하지 않습니다.
        mutate({ ...data, name: newName })
      }}>Uppercase my name!</button>
    </div>
  )
}

현재 키를 기반으로 데이터를 변경한다. useSWR 함수에 전달된 키와 연결된 키는 캐시에서 데이터를 찾을 때 사용되고, 이렇게 찾은 데이터는 첫번째 인자로 반환된다.

Mock데이터를 전역 상태로 관리하도록 만들기

우리가 만들고자 하는 서비스는 네이버 지도 API를 사용하여 만든 지도 화면에 맛집 관련된 핑을 얹어서 확인할 수 있게끔 만드는 것이다.
맛집 페이지에 대한 mock JSON데이터를 만들어 저장해둔 뒤에 데이터를 전역으로 불러와 사용할 예정이다.

먼저 useMap 파일을 하나 생성하여 /stores라는 키로 전역 데이터를 관리할 예정이다.

import { useCallback } from 'react';
import { Store } from '../types/store';
import { mutate } from 'swr';

export const STORE_KEY = '/stores';

const useStores = () => {
  const initializeStores = useCallback((stores: Store[]) => {
    mutate(STORE_KEY, stores);
  }, []);

  return {
    initializeStores,
  };
};
export default useStores;

bound Mutate를 이용하였으므로 /stores 키를 가지는 것에 대한 뮤테이션을 진행할 수 있다. 두번째 인자로 넘겨준 stores는 데이터를 사용하여 클라이언트 캐시를 업데이트 하거나 클라이언트에서 서버로 데이터를 보내, 서버에서 데이터를 변경하는 작업을 위하여 사용된다.
이제 initializeStores에 mock데이터들이 담겼다.
랜딩 페이지에 아래 코드를 추가하자.

export async function getStaticProps() {
  const stores = (await import('../public/stores.json')).default;
  return {
    props: { stores },
    revalidate : 60 * 60,
  };
}

mock 데이터를 아예 가져와서 stores라는 변수에 저장한다. 검증은 1시간으로 주었는데, Mock데이터는 변하지를 않으니까 사실 안줘도 괜찮다.
이제 랜딩페이지에 이 받아온 staticprops를 넘겨서 전역 상태로 관리해보자.

import useStores from "@/hooks/useStore";
const Home: NextPage<Props> = ({stores}) => {
  const { initializeStores } = useStores();

  useEffect(() => {
    initializeStores(stores); //전역 상태 업데이트
  }, [initializeStores, stores]);
  ...
}

useEffect를 이용하고 useStore을 이용하여 staticprops를 전역 변수로 저장하는 코드이다. 이제 index.tsx파일에 해당 작업을 하면서 Mock데이터를 useStore가 있다면 언제든지 이용할 수 있다.

맵에 Marker 그리기

맵에 마커를 표현하려면 맵이 전역 상태로 관리되어 마커도 맵에 접근할 수 있어야 한다.
그렇기 때문에 맵을 전역상태로 만들어줄 수 있는 onLoad props를 이용할 수 있다.
먼저 Map 부분을 만드는 MapSection 컴포넌트이다.

import useMap from '@/hooks/useMap';
import Map from './Map';
import { NaverMap } from '@/types/map';

const MapSection = () => {
    const { initializeMap } = useMap();
    const onLoadMap = (map: NaverMap ) => {
        initializeMap(map);
    };
    return (
        <Map onLoad = {onLoadMap} />
    );
};

export default MapSection;

여기서 initializeMapuseMap에서 SWR을 이용하여 Mutate하여 /map이라는 키로 생성된 map 객체를 전역 데이터로 관리한다.

const useMap = () => {
  const { data: map } = useSWR(MAP_KEY);

  const initializeMap = useCallback((map: NaverMap) => {
    mutate(MAP_KEY, map);
  }, []);
  ..
}

onLoad 함수 내부에는 이렇게 생겼으며

if (onLoad) {
  onLoad(map);
}

props로 넘겨준 함수를 통하여 map 객체를 외부로 emit처리하여 전역 객체로 관리할 수 있게 되는 것이다. 이제 마커 컴포넌트를 완성하여보자.
마커들을 나타내는 Markers 컴포넌트이다.

import React from 'react';
import useSWR from 'swr';
import { MAP_KEY } from '../../hooks/useMap';
import { STORE_KEY } from '../../hooks/useStore';
import { NaverMap } from '@/types/map';
import type { Store } from '../../types/store';
import Marker from './Marker';

const Markers = () => {
  const { data: map } = useSWR<NaverMap>(MAP_KEY);
  const { data: stores } = useSWR<Store[]>(STORE_KEY);

  if (!map || !stores) return null;
  return (
    <>
      {stores.map((store) => {
        return (
          <Marker
            map={map}
            coordinates={store.coordinates}
            key={store.nid}
          />
        );
      })}
    </>
  );
};
export default Markers;

useSWR이라는 훅을 불러와서 키를 넘겨주면 그에 해당하는 데이터를 받아와 타입을 지정하고 사용할 수 있다. 만약 둘 중 하나라도 없다면 null을 반환하고, 만약 있다면 store정보를 돌리면서 마커 컴포넌트를 반환하는데, 이때 map 객체와 위치 경도를 제공한다.

그럼 Marker 컴포넌트를 보자.

import { useEffect } from 'react';
import type { Marker } from '../../types/map';

const Marker = ({ map, coordinates,  onClick }: Marker): null => {
  useEffect(() => {
    let marker: naver.maps.Marker | null = null;
    if (map) {
      marker = new naver.maps.Marker({map: map, position: new naver.maps.LatLng(...coordinates)});
    }
    if (onClick) { naver.maps.Event.addListener(marker, 'click', onClick);
       }
	return () => {
      marker?.setMap(null);
    };
}, [map]); 
  return null;
};

export default Marker;

Marker는 네이버에서 기본적으로 주어지는 것을 사용하였으며, map 객체와 위치인 coordinates 는 필수적으로 필요하다.
더욱 자세한 정보는 아래 링크를 확인하자.
https://navermaps.github.io/maps.js.ncp/docs/tutorial-2-Marker.html

코드 동작은 map객체가 있을 대, 네이버의 Marker class를 이용하여 새로운 Marker 인스턴스를 생성하고, 마커를 찍은 것을 리턴한다.

Marker에 UI 적용하기

icon을 Marker의 prop으로 받을 수 있도록 타입을 지정한다.

import { Coordinates } from "./store";

export type NaverMap = naver.maps.Map;

export type Marker = {
    map: NaverMap;
    coordinates: Coordinates;
    icon: ImageIcon;
    onClick?: () => void;
  };
  
export type ImageIcon = {
    url: string;
    size: naver.maps.Size;
    origin: naver.maps.Point;
    scaledSize?: naver.maps.Size;
}

그리고 Markers의 prop으로 새롭게 icon을 넘겨주면 된다.
그럼 어떤 아이콘을 어떨 때 넘겨 주어야 하는지 지정해야 한다. 이미지 파일을 불러와 작업을 하도록 해보자.

해당 스프라이트를 네이버 기본 아이콘을 대체하고 싶다. 마커 하나 당 가로 길이는 64px, 세로 길이는 54px이고 개수는 13개이다.
그런데 크기가 너무 커서 2/3 크기로 사용하고 싶으므로 기존 크기에서 2/3을 곱하여 사용하여 보자.

export function generateStoreMarkerIcon(
  markerIndex: number,
): ImageIcon {
  return {
    url: '/markers.png',
    size: new naver.maps.Size(SCALED_MARKER_WIDTH, SCALED_MARKER_HEIGHT),
    origin: new naver.maps.Point(SCALED_MARKER_WIDTH * markerIndex, 0),
    scaledSize: new naver.maps.Size(
      SCALED_MARKER_WIDTH * NUMBER_OF_MARKER,
      SCALED_MARKER_HEIGHT
    ),
  };
}

반환 타입은 ImageIcon으로 설정하고, 몇번째 스프이트를 선택할 것인지 그 값을 받아 와서 스프라이트의 사이즈를 조절하여 집어넣는다.

스프라이트 사용에 대하여 더욱 자세한 내용은 아래 링크를 참고하자.
https://navermaps.github.io/maps.js.ncp/docs/tutorial-8-marker-retina-sprite.example.html

이제 마커까지 완료되었다. 마커를 클릭하였을 때 발생하는 이벤트를 완성해보자.

0개의 댓글