요약

팀프로젝트로 React를 활용해 기술블로그를 개발하던 중 페이지 이동시 페이지가 깜빡이며 SPA처럼 동작하지 않는다는 것을 확인하였습니다. 처음 개발을 하면서는 해당 문제에 대해 중요하게 생각하진 않았는데 이후 코드 리뷰를 받으며 실제 동작을 테스트해보니 SPA의 기능에 문제가 있다는 것을 인지하였습니다

 

이후 동작을 나눠보고 어떤 것이 문제를 일으키는지 테스트를 해보았고 그 중에서 SWR에서 문제가 발생한다는 것을 확인하였습니다. 원인을 찾고자 공식문서를 보며 코드를 다시 작성해보았고 다시 작성한 코드는 문제가 없다는 것을 확인 후 원래 코드와 비교하며 해결하였습니다.

 

문제는 suspense 속성을 줬기 때문이라는 것을 확인하였고 suspense는 데이터가 올 때까지 보여주는 로딩 상태를 정의하는 것이라고 이해하였습니다.

 

 

개요

학교 동아리 활동으로 블로그를 개발하던 중 카테고리 페이지를 작업하게 되었습니다. 어떻게 구현되는지 조사해 여러 카테고리 메뉴를 사용하는 사이트들을 참고해봤습니다. 대표적으로 쿠팡, G마켓이 있었고 몇번 동작하면서 구현방식을 확인해보니 카테고리 메뉴를 누르면 페이지 이동하면서 URL 파라미터로 카테고리 이름을 가져온 뒤 그 데이터를 바탕으로 서버에 요청을 보내는 방식을 사용 중 이라는 것을 확인하였습니다

 

이러한 방식으로 구현하면 나중에 카테고리 메뉴로 직접 들어가도 원하는 아티클, 그리고 페이지를 찾아볼 수 있을거라고 생각되어 UX를 높이기 위해 이와 같은 방식으로 진행하였습니다

 

import React, { useEffect, useState } from 'react';
import axios from 'axios';
import { useParams, useNavigate, useSearchParams } from 'react-router-dom';

const Category = () => {
  const params = useParams();
  const navigate = useNavigate();
  const [searchParams] = useSearchParams();
  const currentParamsPageNumber = searchParams.get('page');
  const nowParamsPageNumber = () => {
    return currentParamsPageNumber === null
      ? 0
      : parseInt(currentParamsPageNumber);
  };

// 서버에서 받은 데이터 처리
  const { data } = useGetPostListData(
    params.categoryName === undefined ? 'all' : params.categoryName,
    nowParamsPageNumber(),
  );
  const handlePostData = () => {
    return data?.body.data.content === undefined ? [] : data?.body.data.content;
  };

//카테고리메뉴에 넘겨줄 콜백함수
  const handleCategoryMenuNavigation = (categoryName: string) => {
    navigate(`/category/${categoryName}`); //수정사항
  }; //navigate를 통해 url변경 시킴!


//페이지네이션
  const handlePageNavigation = (nowPage: number) => {
    nowPage === nowParamsPageNumber()
      ? null
      : navigate(`/category/${params.categoryName}?page=${nowPage}`);
  };//navigate를 통해 url변경 시킴!

  const handleTotalPageData = () => {
    return data?.body.data.totalPages ? data.body.data.totalPages : 0;
  }; // 

  return (
    <LayoutContainer>
      <CategoryInner>
        <CategoryMenu
          type={params.categoryName === undefined ? 'all' : params.categoryName}
          onClick={handleCategoryMenuNavigation}
        />
        <BlogCardGridLayout PostData={handlePostData()} />
        <PageBarWrapper>
          {handleTotalPageData() && (
            <PageBar
              page={nowParamsPageNumber()}
              totalPage={10}
              onClick={handlePageNavigation}
            />
          )}
        </PageBarWrapper>
      </CategoryInner>
    </LayoutContainer>
  );
};

export default Category;

search params라는 훅을 사용하여 쿼리 정보를 가져오고  해당 데이터를 통해 서버에 데이터를 요청하는 방식입니다.

 

발생한 문제

아래 동작하는 영상을 보면 카테고리 메뉴 이동시 페이지 전체가 다시 렌더링 되면서 깜빡이는 현상이 발생해버렸습니다. 하지만 처음 데이터를 받아온 뒤 카테고리 메뉴를 이동시키게 되면 SWR의 기능인 캐싱으로 따로 데이터를 저장하여 깜빡이지 않는 현상을 볼 수 있었습니다.

 

 

처음에 테스트 해보면서 데이터가 캐싱되는데 크게 문제될까? 라고 생각하면서 넘어갔지만 나중에 리뷰하며 테스트 해볼 때 이러한 부분들이 React에 장점인 SPA를 크게 해친다고 생각했기 때문에 문제로 인식하고 해결하고자 했습니다.

 

문제 해결 과정

useNavigate가 문제인가

 

 

useNavigate hook causes waste rendering · Issue #7634 · remix-run/react-router

Version 6.0.0-beta.0 Test Case I create a pure component, there is no reason to execute again when i want to navigate to other page using useNavigate , I think useNavigate hook cause waste renderin...

github.com

useNavigate rerendering issue 로 검색해봤더니 useNavigate hook causes waste rendering라는 제목으로 벌써 인식된 이슈가 있었고 비슷한 문제가 아닐까 생각하게 되었습니다.

 

라우팅 되어있는 Query Params를 변경하기 위해 이러한 방식으로 구현하였는데 구현할 때 실제로 페이지가 제대로 동작하는지만 확인하고 다른 기능과 충돌이 발생해 로직을 놓치지 않았을까 다시 확인해보았지만 비슷하게 구현한 테스트 코드에서 같은 문제를 확인할 수 없었습니다.

 

useNavigate 테스트

정말 useNavigate가 리렌더링을 일으키는지 테스트를 해보았고 테스트 코드는 다음과 같습니다.

...
const testNavigation = () => {
	navigate('/');
}
<button onClick={testNavigation}>test</button>

navigate와 api 요청하는 코드가 문제라고 생각했기 때문에 api 요청코드를 일단 막아두고 테스트를 해봤는데, 홈으로 이동했을 때 페이지 자체에 리렌더링은 발생하지 않는다는 것을 확인했고 이로써 API 요청에 문제가 있다고 생각하여 API 요청보내는 코드에서 문제를 찾고자 헀습니다.

 

그전에 실질적으로 데이터 요청을 보내기 위해 내부구조를 고민해봤고 외부에서 카테고리 페이지로 직접적인 접근을 했을 때 등등.. UX를 고려하여 useLocation이라는 router-hook을 사용하여 pathname(/category/:categoryName)을 가공해서 categoryName이라는 변수에 searchParams라는 (?page=1)데이터를 가공해서 nowPage라는 변수에 넣어 초기 데이터를 설정하였습니다

링크를 타고 직접적으로 category page에 접근했을 때 동적으로 처리하기 위해서입니다.

 

이렇게 요청보낼 변수를 처리한 뒤 서버 상태 관리 라이브러리를 적용시키기 위해 기본구조를 만들어 useEffect로 구현된 서버 API 요청을 구현하고 제대로 동작하는지 확인해보았습니다

useEffect(() => {
    if (categoryName === 'all')
      instance
        .get(`/api/v1/post/list?page=${nowPage}`)
        .then(function (response) {
          setPostData(response.data.body.data.content);
        });
    else
      instance
        .get(`/api/v1/post/list/${categoryName}?page=${nowPage}`)
        .then(function (response) {
          setPostData(response.data.body.data.content);
        });
  }, [categoryName, nowPage]);

데이터가 리렌더링 없이 받아와지는 것을 확인했고 SWR문제라는 것을 확신하게 되었습니다

 

SWR 테스트

const { data } = useGetPostListData(categoryName, nowPage);

//실제 useEffect대신 SWR로 교체할 부분
-----------------------------------------------------------------

export function useGetPostListData(category: string, page = 0, size?: number) {
  const { data, error } = useSWR(
    [`post/list${url(category, page, size)}`],
    getPostListData,
    { suspense: true },
  );
  return {
    data: data && data,
  };
}


async function getPostListData(params: string) {
  const res = await API.getPostListData(params);
  return res.data;
}

const url = (category: string, page: number, size?: number) => {
  const pageSize = size === undefined ? 16 : size;
  if (category === 'all') return `?page=${page}&size=${pageSize}`;
  return `/${category}?page=${page}&size=${pageSize}`;
};

------------------------------------------------------------------
//class 형태로 axios 요청들을 모아놓음

getPostListData = (params: string) => {
    return axios.get<rowDetailPostDataType>(`${this.API}/api/v1/${params}`);
 };

//API 폴더에 있는 실제 요청보내는 코드
-----------------------------------------------------------------

 

동작하는 방식은 앞서 작성한 useEffect와 같고 테스트를 해보니 실제 문제현상이 일어나는 것을 확인하였습니다.

 

협업을 진행하면서 다른 사람이 작성한 코드를 이용했는데 처리되는 과정을 확인하고 일련의 동작과정 중 놓친 부분이 있고 그쪽에서 생긴 문제일 수도 있겠다 싶어 공식문서와 기존 소스코드를  참조하여 로직을 다시 정의하고 데이터를 받아왔습니다.

 

해결!!

코드 리팩토링

const instance = axios.create({
    baseURL: '<https://gdsc-dju.com>',
    timeout: 15000,
  });

  function useGetPostData(categoryName: string, nowPage: number) {
    const handleServerAPI = (categoryName: string, nowPage: number) => {
      if (categoryName === 'all') return `/api/v1/post/list?page=${nowPage}`;
      else return `/api/v1/post/list/${categoryName}?page=${nowPage}`;
    };

    const fetcher = (url: string) =>
      instance.get(url).then((response) => response.data);

    const { data, error } = useSWR(
      handleServerAPI(categoryName, nowPage),
      fetcher,
    );
    return {
      data,
      isLoading: !error && !data,
      isError: error,
    };
  }
const { data, isLoading, isError } = useGetPostData(categoryName, nowPage);

실제 SPA처럼 깔끔한 페이지 이동으로 처리되는 것을 확인할 수 있었습니다.

 

결국 문제가 뭐였던건가

export function useGetPostListData(category: string, page = 0, size?: number) {
  const { data, error } = useSWR(
    [`post/list${url(category, page, size)}`],
    getPostListData,
    { suspense: true },
  );
  return {
    data: data && data,
  };
}

기존의 코드와 구조적으로 큰 차이는 없어서 옵션값만 비교해봤는데 suspense에 관한 오류였습니다. 엄청 돌고 돌아서 온거같았는데...

 

suspense : 사용자 경험을 위해 동적 로딩이라고 생각할 수 있는데 쉽게말해 로딩화면을 보여준다고 생각할 수 있습니다.

그래서 코드를 짜신분에게 물어보니 저 깜빡이는 현상은 suspense 로딩화면이었고, 로딩화면이 하얀화면에 스피너가 돌아가는 것으로 동작하는 것인데 빠르게 로딩되다보니 깜빡이는 현상으로 보이는 것 뿐이었습니다.

 

그래서 결론적으로 저 옵션에 suspense를 제거하는 것으로 최종적으로 문제를 해결하였습니다..

이후에 suspense에 관해서 문서를 정리해볼 예정입니다

 

React Suspense 소개 (feat. React v18)

Engineering Blog by Dale Seo

www.daleseo.com

 

 

 

추가적으로 참고한 내용

데이터 캐시에 대해(참고)

SWR(서버 상태 관리 라이브러리)를 공부하면서 SWR의 기능 중 서버에서 받은 데이터를 캐시에 저장하는 기능이 있는데, 이러한 캐싱하는 것의 장점으로 처음 요청을 보내고 다른 페이지로 갔다가 다시 처음 페이지로 돌아갔을 경우 해당 데이터를 저장하고 있어 추가적인 서버와의 통신없이도 데이터를 빠르게 보여주는 방식이 있습니다.

 

하지만 이러한 방식을 사용한다면 실시간 데이터 처리는 할 수 없기 때문에 어떻게하면 좋을지 고민이 됐고 뿐만 아니라 해당 카테고리 데이터는 여러 곳에서 사용되기 때문에 만약 과도하게 SWR을 사용해서 서버와 통신할 경우 캐시 공간 낭비가 되지 않을까하는 궁금증도 들었지만 UX적인 부분과 성능면에서 훨씬 유의미하다고 생각했기 때문에 SWR 즉 서버 상태 관리 라이브러리를 사용하고자 했습니다

 

추가적으로 실시간 처리 혹은 꾸준한 데이터 갱신이 필요하다면 다양한 방법이 있으니 그걸 참고하여 구현한다면 해결될 문제라고 생각했기 때문에 합리적인 방식으로 코드를 구현했다고 생각했습니다.

 

SWR 이란 무엇인가? (#백엔드 데이터 #전역)

0. SWR의 용법 const { data, error, isValidating, mutate } = useSWR(key, fetcher, options) ✓ 파라미터 key: 요청을 위한 고유한 키 문자열(또는 함수 / 배열 / null) fetcher: (옵션) 데이터를..

basemenks.tistory.com

 

 

회고

라이브러리의 편리한 기능들이 많지만 그 기능을 제대로 사용하기 위해선 정확히 어떤 동작을 하는지 읽어보지만 말고 실제로 내가 원하는 기능을 하는지 간단하게 사용(테스트)하면서 확인한 뒤 적용시키는 것이 필요하다고 생각했습니다. 그리고 코드 리뷰를 하기 전 실제 테스트를 했을 때 페이지 전체가 리렌더링 되는 부분을 개의치 않게 넘어갔는데 사실 이러한 부분들이 리액트로 구현하면서, 다르게 말하자면 SPA로써 구현할 때 놓치지 말아야할 부분이었는데 놓친거 같아 이전에 썼던 여러 문서들을 다시 읽어보며 공부해보는 좋은 계기가 되었습니다. 결국 크게 돌아갔을지도 모르지만 스스로 많은 문서를 읽어보고 공부해본 계기가 된거같습니다

 

이런 과정들이 코드 리뷰를 진행하면서 나왔는데 앞으로도 자주 리뷰를 진행해보면서 성장해나갈 예정입니다.

복사했습니다!