#refactoring

SWR, 서버 데이터를 앱 데이터처럼 관리해보자

(자료 출처 : https://github.com/vercel/swr)

프론트엔드는 웹과 사용자가 맞닿아 있는 부분을 개발하는 영역이다. 따라서 프론트 개발자에게 서버의 데이터를 어떻게 사용자에게 보여줄 것인가?라는 문제는 정말 중요하다고 볼 수 있다.

그런데 서버의 데이터를 보여주기 전에 관리부터 해야 할 텐데 다들 어떻게 관리해왔을까? 서버 데이터를 앱에서 관리하는 방법에 대해서 많은 개발자가 고민하였고, 그중 일부는 데이터를 관리하기 위해 Redux를 사용하기도 하였다.

하지만 Redux로 서버 데이터를 관리하기에는 불편함이 컸다. 그래서 오늘은 데이터를 관리함에 있어 기존 Redux의 단점과 이를 해소할 수 있는 SWR에 대해서 알아보도록 하자.

😭 Redux. 서버 데이터 관리가 불편해요

👎 fetch 후 Redux 상태에 담는 코드가 복잡하다.

fetch 후 데이터를 어딘가에 저장해야 하므로 Redux를 활용해 전역으로 상태를 관리하는 상황이 많이 발생한다.

다음은 해당 기능을 수행하는 Hook이다.

//action.js
export const GET_USER_DATA = 'user/GET_USER_DATA';
export const GET_USER_DATA_SUCCESS = 'user/GET_USER_DATA_SUCCESS';
export const GET_USER_DATA_ERROR = 'user/GET_USER_DATA_ERROR';

export const thunkLoadUserData = () => async (dispatch) => {
  dispatch({ type: GET_USER_DATA });

  try {
    const items = await fetch(...);

    dispatch({ type: GET_USER_DATA_SUCCESS, payload: items });
  } catch (error) {
    dispatch({ type: GET_USER_DATA_ERROR, payload: error });
  }
};

//reducer.js
const reducer = (state = initialState, action) => {
  switch (action.type) {
    case GET_USER_DATA:
      return {
        ...state,
        isLoading: true,
        error: null,
      };
    case GET_USER_DATA_SUCCESS:
      return {
        ...state,
        items: [...action.payload],
        isLoading: false,
      };
    case GET_USER_DATA_ERROR:
      return {
        ...state,
        error: action.payload,
      };

      ...
  }
}

정보를 fetch하여 전역 상태에 담는 하나의 동작만을 간단히 구현하기 위해서 액션 타입을 작성하고, 그에 대응되는 thunk 함수와 reducer 코드를 작성해야 한다.

여기서 request와 관련된 동작을 추가로 구현할 때마다 위와 비슷한 코드를 반복적으로 작성해야 한다. 이런 작업을 반복하면서 개발자는 번거로움을 느끼고 코드는 점점 복잡해진다.

👎 상태 초기화를 위한 코드가 반복된다.

장바구니 목록을 3개의 페이지에서 사용한다면 아래와 같은 코드를 작성해야 할 것이다.

// Page1.jsx
const Page1 = () => {
  useEffect(() => {
    dispatch(thunkLoadUserData());
  }, []); // 유저 데이터 초기화

  return <Profile />;
};

const Profile = () => {
  const userData = useSelector(({ user }) => user);
  // 유저 데이터를 리덕스에서 가져옴

  return (
    <div>
      <h2>{userData.name}</h2>
      <p>{userData.age}</p>
    </div>
  );
};

여기서 문제점은 유저 데이터를 초기화하는 코드와 해당 데이터를 사용하기 위해 가져오는 코드가 분리된다는 것이다.

현재 예제는 간단하여 Profile에서 유저 데이터를 초기화하여도 되지만 Page1이 커지면 커질수록 page 하위 컴포넌트 여기저기에서 유저 데이터를 가져올 일이 많아지므로 결국에는 Page1에서 유저 데이터를 초기화하여야 한다.

이렇게 데이터 초기화 코드와 사용 코드가 분리되었을 때의 문제점은 다음과 같다.

  1. 데이터를 사용하는 컴포넌트 or 상위 컴포넌트에서 반드시 데이터 초기화 코드가 강제된다.
  2. 만약 유저 데이터를 Page1, Page2, Page3에서 모두 사용하고 있다면 사용자가 어떤 페이지 url로 가장 먼저 접근할지 개발자는 알 수 없으니, 모든 페이지에서 상태 초기화 코드를 작성하여야 한다.

이렇듯 개발자가 상태 초기화에 대해 관심을 가져야 하며 초기화 코드가 반복된다. 여기서 추가로 1분마다, 또는 네트워크가 재연결되었을 때 데이터를 갱신시켜주어야 한다면? 이제 코드는 점점 복잡해지기 시작한다.

이런 문제들이 발생하는 건 태생부터 Redux가 서버 상태 관리를 위한 라이브러리가 아니기 때문이다. Redux는 추적 가능한 상태 관리 라이브러리다. 그래서 비동기적인 작업에 약한 Redux는 미들웨어로 이를 해결하는 등 여러 가지 대안이 나오고 있지만, 여전히 불편하다. 그럼 SWR은 어떤 녀석이길래 이 문제를 해결해 준다는 것일까?

😎 SWR. 넌 누구냐!

SWR은 stale-while-revalidate의 약자다. 이는 HTTP 리소스에 대한 명세 - RFC 5861에서 정의한 ‘stale-while-revalidate’ 전략에서 따온 것이다.

그렇다면 ‘stale-while-revalidate’ 전략은 무엇일까? 명세 RFC 5861에서는 다음과 같이 표현하고 있다.

‘stale-while-revalidate’는 캐시가 가지고 있는 stale response가 유효한 지 백그라운드에서 재검증하는 동안 해당 stale response를 즉시 리턴하여 네트워크 지연 시간을 숨기는 전략입니다.

‘stale-while-revalidate’ 이라는 이름처럼 SWR는 내부적으로 request를 보냈을 때 우선은 캐시로부터 stale(오래된) 정보를 먼저 리턴하고 백그라운드에서 fetch request(revalidate)를 보내는 전략을 사용하고 있다.

좀 어렵게 다가올 수 있다. 그리고 이런 의문이 들 수 있다.

SWR이 그래서 대체 뭐가 좋은데?

사용하는 개발자 입장에서 와닿는 것은 결국 라이브러리의 편의성이기 때문에 SWR을 왜 사용하는지에 대한 주제로 빠르게 넘어가 보자.

🙄 SWR. 그래서 왜 사용하나요?

1. 서버 데이터를 앱 데이터처럼 사용

SWR도 캐싱을 통해 데이터를 전역 상태로 관리한다고 볼 수 있다. 하지만 Redux와 다른 점은 서버 데이터를 앱 데이터처럼 사용할 수 있다는 점이다. (Redux의 상태 추적은 SWR 단독으로는 불가)

const fetcher = url => fetch(url).then(r => r.json());

function App() {
  const { data, error } = useSWR('/api/data', fetcher);
  // ...
}

useSWR은 첫 번째 인자로 key, 두 번째 인자로 fetcher를 받는다. 여기서 key는 각 fetch의 캐시 데이터를 구분하기 위한 고유한 값으로 사용되는데 일반적으로 API의 endpoint가 사용된다. 그 이유는 key 값이 기본적으로 fetcher의 첫 번째 인자로 전달되어 사용이 편리하기 때문이다. 위 경우로 예를 들자면 fetcher의 첫 번째 인자로 /api/data가 전달되는 셈이다.

key만 알고 있다면 앱 어디서든지 /api/data 에 대한 캐시 데이터를 가져올 수 있다. 이는 캐시 데이터를 전역 상태로 관리할 수 있게 해주어 매우 편리하다.

2. 데이터 갱신을 위한 re-fetching을 간단히 구현할 수 있다.

const { data, error } = useSWR('https://api.github.com/repos/vercel/swr', fetchCurrentTime, {
  refreshInterval: 1000,
});

이렇게 작성만 하면 1초마다 한 번씩 자동으로 데이터를 갱신할 수 있다.

또한, 사용자가 브라우저에 Focus를 하면 정보를 갱신하는 것이 default 설정으로 들어가 있고 이 외에도 특정 상황에 정보를 갱신할 수 있는 옵션들이 매우 많다. (참고)

직접 구현했다면 상당히 복잡했을 기능을 한 줄의 옵션으로 바로 적용이 가능하니 굉장한 이점이라고 할 수 있다.

3. fetch data가 캐시 후 앱 전역으로 공유되기 때문에 불필요한 request를 줄일 수 있다.

function useUser(id) {
  const { data, error } = useSWR(`/api/user/${id}`, fetcher);

  return {
    user: data,
    isLoading: !error && !data,
    isError: error,
  };
}

function Content() {
  const { user, isLoading } = useUser(1);
  if (isLoading) return <Spinner />;
  return <h1>Welcome back, {user.name}</h1>;
}

function Avatar() {
  const { user, isLoading } = useUser(1);
  if (isLoading) return <Spinner />;
  return <img src={user.avatar} alt={user.name} />;
}

위는 SWR를 사용해 response를 받아오는 예시 코드이다. 코드를 보면 SWR을 통해 request를 보내고 user의 정보를 받아오는 useUser()가 여기저기서 호출되고 있다. 하지만 SWR은 request의 중복을 자동으로 제거해주기 때문에 useUser()가 여러 번 호출된다 해도 한 번만 request가 이루어진다. 이는 의미 없는 request를 줄여 서버의 부하를 줄여준다.

그리고 정보에 대한 갱신이 필요하다면 mutate(key) 메서드를 사용해 추가적인 request를 수행할 수 있다.

출처

REDUX를 넘어 SWR로(1)

Redux 말고 SWR

Keeping things fresh with stale-while-revalidate