페이지네이션 로직 구현기

2025-01-11

front

notion image

문제 1 - 쿼리 스트링


기존에는 pagination 페이지에서 쿼리 스트링에 page 값을 저장하여 불러오는 방식으로 구현되어 있었다.
http://localhost:3000/super-admin/user?page=0
해당 page 키 값은 API 통신을 하는 커스텀 훅에서 바꿔주고 있었다.
const response = superAdminApi .get<PaginationResponse<User>>('/users', { params }) .then((res) => { searchParams.set('page', params.page.toString()); setSearchParams(searchParams); return res.data; })
이렇게 하다 보니, 문제가 발생했다. 바로 url이 2번 바뀌게 되는 것이었다.
 
관리 페이지에서 전체 사용자 조회 페이지로 넘어가는 순간을 살펴보자. url들은 다음과 같다
관리 페이지 URL: http://localhost:3000/super-admin/manage
사용자 조회 URL: http://localhost:3000/super-admin/user?page=0
 
사용자 조회 클릭 시 super-admin/manage super-admin/user 로 한 번 바뀌고, 0번 page의 사용자 데이터를 fetch 해오고 setSearchParamssuper-admin/usersuper-admin/user?page=0 으로 또 한 번 더 바꿔준다.
이렇게 총 2번이 바뀌게 된다. 따라서 사용자가 전체 사용자 조회 화면에서 뒤로 가기 버튼을 “한 번”눌렀을 때, super-admin/user?page=0super-admin/user 로 url이 바뀌게 되므로 의도한 대로 관리 페이지로 나갈 수 없게 된다.
 

해결 방법


위 문제는 setSearchParams에 option 객체로 replace 파라미터를 true로 넘겨주면 해결할 수 있다.
(searchParams 는 URLSearchParams 객체를 반환한다.)
setSearchParams(searchParams, { replace: true })
이렇게 함으로써 history 스택에 새 항목을 추가하는 대신 현재 항목을 대체할 수 있다.
 
이렇게 문제 1은 해결할 수 있었지만, 개발을 하다 보니 또 다른 문제를 발견했다.
 

문제 2 - API 중복 요청


아뿔싸! api가 2번씩 요청이 가고 있었다.
 
그 원인을 파악해보니 로직 자체에 문제가 있었다.
SuperAdminWorkspace 컴포넌트에서 useEffect에서 workspace들을 fetch하고 있다. 그리고 searchParams을 의존성 배열에 넣어놓은 상태다.
// SuperAdminWorkspace 컴포넌트 useEffect(() => { const nowPage = Number(searchParams.get('page')); const searchValue = userInputRef.current?.value || ''; fetchAndSetWorkspaces(nowPage, pageSize, searchValue, true); }, [searchParams]);
이때는 searchParams가 객체인줄 몰랐다. . . 나중에 searchParams.toString()으로 문자열을 의존성 배열에 넣도록 바꿨다. - 사실 이 조치는 필요없다. 그 이유는 searchParamsuseMemo를 이용하고 있기 때문이다.
 
그리고 fetchAllWorkspaces에서는 쿼리 스트링을 바꾸고 있어서 searchParams가 변하게 되고, 다시 useEffect 내의 fetchAndSetWorkspaces이 호출된다.
const fetchAllWorkspaces = (page: number, size: number, name?: string, replace?: boolean) => { . . . const response = superAdminApi .get<PaginationResponse<Workspace>>('/workspaces', { params }) .then((res) => { searchParams.set('page', params.page.toString()); setSearchParams(searchParams, { replace }); return res.data; }) return response; };
이렇게 되면 다음과 같은 무한 API 호출 및 렌더링이 발생하게 된다.
 
useEffect실행 → fetchAndSet호출 → setSearchParams에서 searchParams변경 → useEffect실행 → fetchAndSet호출 → . . .
 
그러나 그런 일은 일어나지 않았다.
 
그 이유가 뭘까?
그것은 바로 useSearchParams에서 반환되는 searchParams와 setSearchParams는 각각 useMemo와 useCallBack이 적용되어 있기 때문이다. (
🔎
useSearchParams 분석
)
 
그래서 나는 운이 좋게도? 2번의 중복된 API호출이 발생하였다.
해당 API 호출의 과정은 다음과 같다.
 
컴포넌트가 마운트 됐을 때 useEffect실행 → fetchAndSet함수 실행[1]setSearchParamssearchParams변경 (캐싱 시작) → useEffect실행 → fetchAndSet함수 실행[2]setSearchParamsseachParams변경 (이전과 동일한 값이므로 캐싱 유지)
 

해결 방법


위 문제는 책임의 소재를 확실히 분리함으로써 해결할 수 있었다.
fetch하는 건 오직 useEffect에서만 하고, fetch를 해야하는 경우(다른 페이지 이동, 검색어 입력)에는 searchParams를 변경하여 useEffect를 트리깅 하는 방식으로 바꿨다.
 
실제 사용처를 살펴보자

BEFORE

useEffect(() => { const nowPage = Number(searchParams.get('page')); const searchValue = userInputRef.current?.value || ''; fetchAndSetWorkspaces(nowPage, pageSize, searchValue, true); }, [searchParams]);
useEffect 함수
<Pagination totalPageCount={workspaces.totalPages} paginateFunction={(page: number) => { fetchAndSetWorkspaces(page, pageSize, userInputRef.current?.value); }} />
Pagination 컴포넌트
const SuperAdminSearchBar = forwardRef<HTMLInputElement, SuperAdminSearchBarProps>((props, ref) => { . . . props.fetchContents(0, 6, ref.current?.value, false); };
검색창 컴포넌트
 
useEffect에서도 fetch를 하고, Pagination 컴포넌트, 심지어 SuperAdminSearchBar(검색바) 컴포넌트에서도 페이지 클릭 시 fetch를 하고 있다. 이런 구조는 정말 좋아 보이지 않는다.
지금은 운이 좋아서 API 요청이 2번 이루어지는 부작용만 있지만 나중에는 엄청 심각한 결과를 초래할 수 있기 때문이다.
 
그래서 나는 fetch하는 부분은 useEffect에서만 하고 기존에 fetch를 따로 하던 부분에서는 searchParams를 변경해주는 방식으로 바꿨다.

AFTER

useEffect(() => { const nowPage = Number(searchParams.get('page')); const searchValue = searchParams.get('name') || ''; fetchAndSetWorkspaces(nowPage, pageSize, searchValue); }, [searchParams.toString()]);
useEffect - searchParams를 원시타입으로 변환하여 관리하고 있다.
<Pagination totalPageCount={workspaces.totalPages} paginateFunction={(page: number) => { searchParams.set('page', page.toString()); setSearchParams(searchParams); }} />
Pagination 컴포넌트 - searchParams만 바꿔주고 있다.
function SuperAdminSearchBar() { . . . const fetchContentsByName = (e: React.KeyboardEvent<HTMLInputElement>) => { if (!(e.key === 'Enter' && inputRef && typeof inputRef !== 'function')) return; searchParams.set('page', '0'); if (inputRef.current?.value === '') { searchParams.delete('name'); } else { searchParams.set('name', String(inputRef.current?.value)); } setSearchParams(searchParams); };
SuperAdminSearchBar 컴포넌트 - searchParams만 바꿔주고 있다.
const fetchAllWorkspaces = (page: number, size: number, name?: string) => { const params: FetchAllWorkspacesParamsType = { page, size, name }; const response = superAdminApi .get<PaginationResponse<Workspace>>('/workspaces', { params }) .then((res) => { return res.data; })
fetch하는 함수에서 seachParams를 변경하는 코드 삭제
 
이를 통해 책임을 분리하고 좀 더 나은 코드가 되었고, 모든게 정상적으로 작동한다!

후기


해당 문제를 해결하면서 useSeachParams 훅에 대해 분석할 수 있었다. 그리고 문제를 정의하고 해당 문제를 해결하는 과정을 통해 몰랐던 내용들을 많이 배울 수 있었다.
점점 더 깊게 생각하는 태도를 갖추려고 노력하고 있다.
내 코드가 chill 한 코드가 되는 그날까지
내 코드가 chill 한 코드가 되는 그날까지
 
참고링크
GitHubGitHubreact-router/packages/react-router/lib/hooks.tsx at main · remix-run/react-router
A Visual Guide to React Rendering - useMemo | Alex Siodrenko
방황하는 하이에나들을 위한 블로그방황하는 하이에나들을 위한 블로그[리액트] 깊이알아보는 useState, 리렌더링핵심 작동원리