현재 data를 fetching하고 관리하는 라이브러리 중 인기가 많은 것은 단언 SWR와 React Query라고 할 수 있겠다! 리액트 쿼리에 대한 글을 먼저 작성하려고 했는데 어쩌다 보니 순서가 꼬여 SWR에 대한 소개글을 먼저 작성하게 되었다. ㅎㅎ
- 선언적인 코드를 작성할 수 있어서 개발자도 코드의 의도를 확인하는 데 있어 직관적으로 이해할 수 있다.
- 동일한 API 요청이 여러 번 호출된 경우, 한 번만 실행된다.
- Global State와 Server State를 분리하여 관리할 수 있다.
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로 넘겨주는 일을 하지 않아도 된다. 데이터는 이제 해당 컴포넌트에서 필요한 데이터 단위로 범위를 제한할 수 있고, 모든 컴포넌트는 서로에게 독립적이다.
const { data, error, isLoading, isValidating, mutate } = useSWR(key, fetcher, 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 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)
}
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 함수에 전달된 키와 연결된 키는 캐시에서 데이터를 찾을 때 사용되고, 이렇게 찾은 데이터는 첫번째 인자로 반환된다.
우리가 만들고자 하는 서비스는 네이버 지도 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
가 있다면 언제든지 이용할 수 있다.
맵에 마커를 표현하려면 맵이 전역 상태로 관리되어 마커도 맵에 접근할 수 있어야 한다.
그렇기 때문에 맵을 전역상태로 만들어줄 수 있는 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;
여기서 initializeMap
은 useMap
에서 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 인스턴스를 생성하고, 마커를 찍은 것을 리턴한다.
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
이제 마커까지 완료되었다. 마커를 클릭하였을 때 발생하는 이벤트를 완성해보자.