[FE] React-Query를 SWR로 마이그레이션?

보투게더·2023년 10월 3일
1
post-thumbnail
post-custom-banner

결론적으로는 마이그레이션을 하지 않았다.
다만, 그 과정을 남겨놓고자 기록한다.

배경

보투게더 서비스는 리액트 쿼리를 통해 서버 데이터 상태를 관리하고 있다. 리액트 쿼리를 선택한 데에는 리액트쿼리가 아래의 기능을 지원했기 때문이었다.

  1. 캐싱 기능

    • 캐싱 시간을 설정하여 성능 향상이 가능
  2. 자동 데이터 업데이트

    • 페이지 포커즈 아웃 후 다시 포커즈 시 다시 fetch함
    • 일정 시간이 지나면 새데이터 fetch(캐싱기능과 관련)
  3. 무한 스크롤 기능 제공

  4. 낙관적 업데이트 가능

  5. suspense를 통한 로딩화면 구현

이러한 기능들은 직접 구현할 수도 있었겠지만, 본질적인 (컴포넌트 제작, 글 작성과 같은) 기능을 구현하는 것이 더욱 우선순위가 높다고 판단하여 리액트쿼리를 사용하기로 결정했다.


그런데 SWR?

그런데 2달동안 열심히 서비스를 만들고 나서 SWR이란 비슷한 서버 데이터 상태관리 라이브러리를 알게 되었다.

React-query vs SWR

둘의 공통점은 다음과 같다

  1. 서버 데이터 상태를 관리하는 라이브러리다.
  2. 캐싱기능을 제공한다.
  3. 쿼리키를 사용한다.
    • 다만, 리액트쿼리는 배열형태로, SWR은 url형태로 사용한다.
  4. 낙관적 업데이트가 가능하다.
  5. 페이지 다시 포커즈할때 데이터 업데이트가 된다.
  6. 무한스크롤이 가능하다.
  7. suspense가 적용된다.

둘의 차이점은 다음과 같다.

  1. SWR보다 react-query의 크기가 더 크다.
  2. 다운로드 수는 리액트쿼리가 더 많지만 점점 비슷해지는 추세이다.
  3. 개인적인 생각으로는 react-query가 더 기능이 많다.

결론은?

이 두 라이브러리에 대한 생각을 거듭하다보니 의문이 들었다.

  1. 프로젝트에서 하고자 했던 기능들은 swr로도 모두 구현이 가능했다. 그럼 꼭 리액트 쿼리를 사용해야 하는 이유가 있는가? 꼭 리액트 쿼리만을 사용할 필요는 없다.
  2. 번들이 더 큰 리액트쿼리의 다양한 기능을 사용하고 있는가? 지금까지의 기능은 SWR로도 가능할 것 같다.
  3. 마지막으로 [React][비교] SWR vs React Query vs Recoil selector ?, 이 블로그를 읽으면서 fetch 속도가 빠른 SWR를 사용해보면 게시글을 불러오는 시간이 단축될 지 궁금해졌다.

결과적으로는 기존 기능도 구현할 수 있는, 번들크기가 작고 fetch가 더 빠르다는 swr로 마이그레이션하면 어떤 변화가 나올지 궁금했다. 더불어 마이그레이션을 해본 경험이 없었기에 이번 기회에 한 라이브러리에서 다른 라이브러리로 마이그레이션해보는 경험을 해보고자 하였다.


무한스크롤 마이그레이션 도전

다만 무턱대고 모든 코드를 마이그레이션을 시도하는 것은 좋지 않아보였다. 얼마나 많은 시간과 노력이 드는 일인지 짐작되지 않았다. 때문에 가장 어려워 보이고, 주요한 기능인 무한스크롤을 먼저 바꾸며 SWR을 더 알아가보고 전체를 마이그레이션할 것인지 결정하기로 했다.

무한 스크롤은 PostList 안에서 이루어진다.
PostList라는 컴포넌트는 아래와 같은 영역이다. 게시글의 목록을 나타낸다.

PostList컴포넌트에 usePostList 훅을 사용해서 필요한 데이터를 가지고 와 분기로 렌더링을 한다.


무한스크롤 기존 코드(react-query)

// hook
// getPostList = postList를 fetch하는 함수
export const usePostList = (requiredOption, optionalOption) => {
  const { data, error, fetchNextPage, hasNextPage, isFetchingNextPage } =
    useInfiniteQuery<PostList>(
      [QUERY_KEY.POSTS],
      ({ pageParam = 0 }) =>
        getPostList({ ...requiredOption, pageNumber: pageParam }, optionalOption),
      {
        getNextPageParam: lastPage => {
          if (lastPage.postList.length !== POST_AMOUNT_PER_PAGE) return;

          return lastPage.pageNumber + 1;
        },
        suspense: true,
      }
    );

  const isPostListEmpty = data?.pages[0].postList.length === 0;

  return { data, fetchNextPage, hasNextPage, isFetchingNextPage, isPostListEmpty };
};
  
  
  
// component
export default function PostList() {
...관련없는 코드 생략

  const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isPostListEmpty } = usePostList(
    {
      postType,
      postSorting: selectedSortingOption,
      postStatus: selectedStatusOption,
      isLoggedIn: loggedInfo.isLoggedIn,
    },
    postOptionalOption
  );
  
  useEffect(() => {
    if (isIntersecting && hasNextPage) {
      fetchNextPage();
    }
  }, [isIntersecting, fetchNextPage, hasNextPage]);
  
  
  return (
    ...관련없는 코드 생략
      <S.PostListContainer>
        // post가 하나도 없을때 보여줄 컴포넌트
        {isPostListEmpty && (
          <EmptyPostList status={selectedStatusOption} keyword={postOptionalOption.keyword} />
        )}
        // postList
        {data?.pages.map((postListInfo, pageIndex) => (
          <React.Fragment key={pageIndex}>
            {postListInfo.postList.map((post, index) => {
              // 마지막 페이지의 8번째 게시글을 무한스크롤 targetRef로 지정
              // 이 컴포넌트에 도달하면 다음페이지 fetch
              if (index === 7) {
                return (
                  <div key={post.postId} ref={targetRef}>
                    <Post isPreview={true} postInfo={post} />
                  </div>
                );
              }
              return <Post key={post.postId} isPreview={true} postInfo={post} />;
            })}
            ...관련없는 코드 생략
        ))}
        // postList가 패치 중이라면 보여줄 스켈레톤
        {isFetchingNextPage && <Skeleton isLarge={false} />}
      </S.PostListContainer>
    </>
  );
}
  

usePostList을 사용하여 가지고 오는 데이터는 다음과 같다.

  • data.pages: fetch한 각 페이지의 postList들이 들어있는 배열(Post[][])
    • 1회 fetch 시 가지고 오는 data = Post[]
  • fetchNextPage: 다음 페이지를 fetch할 트리거 함수
  • hasNextPage: 다음 페이지가 있는지 확인하는 변수
  • isFetchingNextPage: 다음페이지가 fetch 중인지 나타내는 변수
  • isPostListEmpty: 총 postList가 비어있는지 확인하는 변수
    • 리액트쿼리에서 제공하는 변수는 아님. 렌더링을 위해 자체 제작.

이 값/함수는 컴포넌트 내에서 렌더링 혹은 트리거로 사용된다.

위 코드를 SWR로 수정해보았다.
그 결과는 아래와 같다.

(리액트쿼리를 사용하여 무한스크롤을 구현하고 싶다면
검색했을 때 좋은 자료가 많으니 참고하면 좋다.
지금은 리액트쿼리로 무한스크롤을 구현하는 게시물이 아니니
자세한 코드설명은 생략하도록 하겠다.)


무한스크롤 수정 코드(SWR)

// hook
//수정 1) 기존에 만든 postList를 fetch하는 함수(getPostList)를 사용하지 못함
const fetcher = (url: string) => fetch(url).then(res => res.json());

export const usePostListFeatSWR = (requiredOption, optionalOption) => {
  const { data, size, isLoading, setSize } = useSWRInfinite(
    //수정 2) url을 1번째 인수로 넣어 key로 사용하면서 fetch함수의 url로 사용
    //     > 그래서 함수 안에서 url이 만들어지는 기존 fetch 함수를 사용할 수 없었음
    index => makePostListUrl(index)({ ...requiredOption, pageNumber: 0 }, optionalOption),
    fetcher
  );

  //수정 3) 대체로 제공해주던 값/함수를 직접 제작
  const changedPostList = data
    ? data.map(postList => {
        return postList.map(post => transformPostResponse(post));
      })
    : [];
  //여기서 size는 호출된 page를 의미
  //ex. size = 3이면 현재 3페이지까지 fetch했다는 의미
  const isLoadingMore = isLoading || (size > 0 && data && typeof data[size - 1] === 'undefined');
  const isEmpty = data?.[0]?.length === 0;
  const isReachingEnd = isEmpty || (data && data[data.length - 1]?.length < POST_LIST_MAX_LENGTH);
  //size를 set해주면 알아서 refetch
  const fetchNextPage = () => setSize(size + 1);

  return {
    data: changedPostList,
    hasNextPage: !isReachingEnd,
    fetchNextPage,
    isFetchingNextPage: isLoadingMore,
    isPostListEmpty: data && isEmpty ,
  };
};


  
// component
export default function PostList() {
...관련없는 코드 생략

  const { 
  	data,
    hasNextPage,
    fetchNextPage,
    isFetchingNextPage,
    isPostListEmpty
  } = usePostListFeatSWR(
    {
      postType,
      postSorting: selectedSortingOption,
      postStatus: selectedStatusOption,
      isLoggedIn: loggedInfo.isLoggedIn,
    },
    postOptionalOption
  );
    
  useEffect(() => {
    //수정 4) isFetchingNextPage가 조건문에 들어가있지 않으면 무한 fetch가 일어남
    if (isIntersecting && hasNextPage && !isFetchingNextPage) {
      fetchNextPage();
    }
  }, [isIntersecting, fetchNextPage, hasNextPage, isFetchingNextPage]);
  
  
  return (
    ...관련없는 코드 생략
      <S.PostListContainer>
        // post가 하나도 없을때 보여줄 컴포넌트
        { isPostListEmpty && (
          <EmptyPostList status={selectedStatusOption} keyword={postOptionalOption.keyword} />
        )}
        // postList
        {data?.pages.map((postListInfo, pageIndex) => (
          <React.Fragment key={pageIndex}>
            {postListInfo.postList.map((post, index) => {
              // 마지막 페이지의 8번째 게시글을 무한스크롤 targetRef로 지정
              // 이 컴포넌트에 도달하면 다음페이지 fetch
              if (index === 7) {
                return (
                  <div key={post.postId} ref={targetRef}>
                    <Post isPreview={true} postInfo={post} />
                  </div>
                );
              }
              return <Post key={post.postId} isPreview={true} postInfo={post} />;
            })}
            ...관련없는 코드 생략
        ))}
        // postList가 패치 중이라면 보여줄 스켈레톤
        {isFetchingNextPage && <Skeleton isLarge={false} />}
      </S.PostListContainer>
    </>
  );
}

SWR로 수정하며 기존 코드를 최대한 수정하지 않는 방향으로 수정해보았다.

코드를 보며 어떻게 독자는 어떻게 느꼈는지 궁금하다.
막상 정리를 해보니 크게 바뀐 코드가 없다고도 느껴진다만,
우리 프론트엔드에선 이를 마이그레이션하지 않기로 결정했다.

내가 수정을 하며 느낀점을 아래에 정리해보았다.


SWR 마이그레이션요?

1. 리액트 쿼리에서 기본제공하는 값/함수를 비슷하게 기능하도록 만들 수 있지만 세심한 부분을 모두 커버하기엔 노력이 필요하다.
내가 애먹었던 부분 중 하나는 무한스크롤이 무한으로 불리는 오류였다. 리액트쿼리에서는 다음페이지가 fetch 중이라면 알아서 refetch를 하지 않았다. 때문에 당연하게 여겨서 신경쓰지 못해 디버깅하는데 시간이 좀 걸렸었다.
더불어 리액트쿼리에서 지원해주는 기능을 대체하는 코드는 SWR공식문서에서 예시로 만들어놓은 코드 자료가 있기 때문에 참고할 수 있었지만, 만약 없었더라면 직접 고안해내야 했다. SWR가 용량이 작은 만큼 지원 기능이 적다는 것을 체감할 수 있었다.

//언급한 무한스크롤 무한 refetch가 된 코드
        
//react-query
  useEffect(() => {
    if (isIntersecting && hasNextPage) {
      fetchNextPage();
    }
  }, [isIntersecting, fetchNextPage, hasNextPage]);
        
//SWR
  useEffect(() => {
    if (isIntersecting && hasNextPage && !isFetchingNextPage) {
      fetchNextPage();
    }
  }, [isIntersecting, fetchNextPage, hasNextPage, isFetchingNextPage]);

2. SWR 공식문서 외 자료가 적다
이건 미처 생각하지 못했던 부분이었다. 처음 리액트쿼리를 학습할 때에 비해 자료가 많이 부족했다. 아래가 정확한 지표는 아니지만 각 라이브러리에서 무한스크롤을 위해 사용하는 함수를 검색했을 때 나오는 검색량이 4배가 넘는다. 물론 SWR 관련 질좋은 자료도 많았지만 다운로드 수가 비슷한 것에 비해 양이 많이 차이가 났다.
나는 아직 새로운 것을 학습하는데에 에너지가 많이 들고 참고자료가 많이 필요한 편인데, 각자 다른 스타일로 코드를 작성한 것을 내 코드에 적용시키는 것이 어려웠고, 더불어 내가 필요한 정보(오류가 난 이유라던가)를 찾기 어려웠다고 느꼈다. 그렇다면 앞으로 SWR로 수정을 해나간다면 생각보다 더 오래걸릴 수 있겠다고 생각이 들었다.


- react-query

- SWR


3. SWR로 수정하는 경우, 기존 fetch함수 관련 로직이 수정되어야 했다.
SWR과 react-query는 쿼리키를 다르게 다룬다고 느꼈다.
react-query의 쿼리키는 배열형태로 받고 동일한 쿼리키를 가진 요청이 들어오면 캐싱되어있는 데이터가 있는지 확인하고 가진 캐싱된 데이터가 유효하다면 그것을 사용한다.
SWR은 쿼리키가 fetch 함수와 긴밀히 연관이 되어있다. 쿼리키 기본예제는 url을 넣는 것으로 설명되어있고, 찾아보면 배열로도 넣을 수 있지만 이 경우에는 fetch 함수에 인자로 넣기 위함으로 설명한다. SWR 역시 쿼리키로 캐싱을 하지만, 개인적으로 SWR의 쿼리키는 fetch와 연관성이 깊다고 느꼈다.
기존 코드로는 사용처에서 쿼리 훅을 부르고, 쿼리훅에서 연관된 fetch 함수를 부르고, fetch함수에서 fetch와 관련된 url 생성, 에러처리 등을 해서 직접 fetch를 했었다.
근데, 이렇게 훅 자체에서 fetch와 관련 인자들을 관리하는 느낌이 되니까 기존 설계와 어긋나며 대대적인 공사라는 느낌이 들었고(물론 생각보다 많이 안 고쳐도 됐을 수도 있다만), 더불어 쿼리키를 이용한 캐싱규칙들까지도 다시 설정하고 학습해야 할 것 같았다. 여기까지 생각하고 과연 이럴만한 가치가 있는 마이그레이션일까 고민이 들었다.


4. SWR과 리액트쿼리는 비슷한 서버 상태관리 라이브러리지만 다른 목적일지도 모른다.
이건 SWR로 마이그레이션 시도 중 뒤늦게 발견하게 된 주제인데, swr과 리액트쿼리를 무슨 기준으로 사용해야 하는 건지 아직도 고민을 하던 나에게 답이 되었다.

저희가 서비스를 개발하고 있는 당시에 비해 React-Query가 많이 발전하며 안정성을 찾아 현재는 SWR보다 React-Query가 많이 대중적이게 됐습니다. 하지만 저희는 SWR에서 React-Query로 전환을 고려하고 있지는 않습니다. 왜냐하면 저희는 refetch를 시키며 변동성이 빈번히 일어나는 일보다는 서버에서의 정보를 불러와서 사용하는 일이 주인 서비스이기 때문에 update에 최적화 된 React-Query로 전환할 이유는 아직 느끼지 못했습니다. -출처-

swr은 데이터를 조회하는데 최적화 되있기 때문에 개인적으로 post, put, delete는 기존의 네트워크 통신 라이브러리를 이용하는게 좋은것 같습니다. post, put, delete로 데이터 변경이 되었다면 mutate 호출 시 바인딩 된 컴포넌트에서 다시 렌더링을 수행합니다. -출처-

SWR은 상태관리용 라이브러리가 아닌 데이터 가져오기(fetch)용 라이브러리이다. -출처-

리액트 쿼리와 SWR의 mutate는 기능이 다르다. 리액트쿼리에서는 post, put, delete 등의 처리를 하기 위해서 사용하지만, SWR에서는 한 번 패치한 데이터를 다시 패치할 때 사용한다. 단적으로 여기서 라이브러리의 목적이 보이는 듯하다.


결론적으로는

SWR로 모든 코드를 수정하는 것은 좋은 선택이 아니라고 생각이 들었다.

마이그레이션을 해보는 경험을 쌓을 수 있고,
번들사이즈가 작고, fetch가 더 빠른 SWR를 사용해 얼마가 더 최적화되는지 확인해볼수도 있겠지만,

SWR로 마이그레이션을 한 결과를 정확하게 측정할 수 없는 환경(서버에 데이터가 계속 추가되며 fetch에 영향을 주는 상황, 다른 최적화 방법들도 구현되는 상황이라 동일조건환경이 아니었음)이었고,
SWR로 마이그레이션하는 것이 예상보다 더 많은 코드수정이 필요했으며 지원하지 않는 기능은 직접 구현을 했어야 했고,
사용자 인터렉션이 많은 우리 서비스에서 굳이 데이터 조회가 중심인 SWR로 옮기는 것은 목적에 맞는 라이브러리 사용이 아니겠다고 생각이 들었다.


결국, 무한스크롤을 마이그레이션 해본 경험으로 끝내기로 했다.

SWR에 대해 그래도 찾아보았다고 생각하고 시작한 도전이었는데,
마이그레이션을 하며 모르는 부분에 어려움도 많이 겪고, 새로운 정보도 많이 알았다.


이번 경험을 통해 배운 점은

하나의 기술에서 다른 기술로 전환하는 것은 생각보다도 더 어렵고 복잡한 일이다. 라이브러리가 유사하더라도 지원하는 기능이 다르고 추구하는 방향이 다르기에 쉽게 생각할 부분은 아니다. 때문에 처음 선택할 때 더 근거를 찾고 내 서비스와 알맞는 기술들을 선택해야 겠다.

그리고, 마이그레이션 시도를 지지해주고 같이 고민해준 프론트엔드팀에게 감사하다.



참고자료

react-query/SWR 비교

SWR 관련 문서

인용된 글의 출처는 인용 글 말미에 -출처-로 연결

profile
Fun from Choice! 오늘도 즐거운 한 표
post-custom-banner

1개의 댓글

comment-user-thumbnail
2023년 10월 4일

수아 덕분에 SWR과 React-Query의 차이점에 대해 학습할 수 있는 경험 해보아서 너무 좋았어요 블로그에 정리된 글을 보며 다시 한번 공부할 수 있어서 좋았습니다 -웃슷-

답글 달기