👀
제대로 된 페이지네이션 구현하기
Kioschool
2025-01-11
문제 1 - 쿼리 스트링
페이지네이션을 구현하기 위해 useSearchParams
를 사용하여 쿼리 스트링을 관리하고 있다. 페이지네이션은 사용자가 페이지를 클릭할 때마다 URL이 바뀌어야 하며, 그에 따라 API 요청도 발생해야 한다.
super-admin/user?page=0
에서 page
키 값은 API 통신을 하는 커스텀 훅에서 바꿔주고 있었다.
1const response = superAdminApi2 .get<PaginationResponse<User>>("/users", { params })3 .then((res) => {4 searchParams.set("page", params.page.toString());5 setSearchParams(searchParams);6 return res.data;7 });
이렇게 하다 보니, 문제가 발생했다. 바로 URL이 두 번 바뀌게 되는 것이었다.
관리 페이지(/super-admin/manage
)에서 전체 사용자 조회 페이지(/super-admin/user?page=0
)로 넘어가는 순간을 살펴보자.
사용자 조회 클릭 시 super-admin/manage
→ super-admin/user
로 한 번 바뀌고, 0번 page의 사용자 데이터를 fetch 해오고 setSearchParams로 super-admin/user
→ super-admin/user?page=0
으로 또 한 번 더 바뀐다.
이렇게 총 두 번 바뀌게 된다. 따라서 사용자가 전체 사용자 조회 화면에서 뒤로 가기 버튼을 한 번 눌렀을 때, super-admin/user?page=0
→ super-admin/user
로 URL이 바뀌게 되어 의도한 대로 관리 페이지로 돌아갈 수 없게 된다.
해결 방법
위 문제는 setSearchParams
에 옵션 객체로 replace: true
를 넘겨주면 해결할 수 있다.
1setSearchParams(searchParams, { replace: true });
이렇게 함으로써 history 스택에 새 항목을 추가하는 대신 현재 항목을 대체할 수 있다.
문제 2 - 페이지네이션의 API 중복 호출 문제
발생한 문제
페이지네이션 구현 중 API가 두 번씩 요청되는 현상이 발생했다.
문제의 근본적인 원인은 아래 코드에서 볼 수 있는 순환 구조다
1useEffect(() => {2 const nowPage = Number(searchParams.get("page"));3 const searchValue = userInputRef.current?.value || "";45 fetchAndSetWorkspaces(nowPage, pageSize, searchValue, true);6}, [searchParams]);
1// fetch와 searchParams set을 동시에 하고 있다.2const fetchAllWorkspaces = (page: number, size: number, name?: string, replace?: boolean) => {3 // ...45 const response = superAdminApi6 .get<PaginationResponse<Workspace>>('/workspaces', { params })7 .then((res) => {8 searchParams.set('page', params.page.toString());9 setSearchParams(searchParams, { replace });10 return res.data;11 })1213 return response;14};
이 코드의 문제점을 단계별로 살펴보면:
useEffect
가searchParams
를 의존성 배열에 포함- 내부에서
fetchAndSetWorkspaces
함수 호출 fetchAndSetWorkspaces
는 API 호출 후setSearchParams
로 URL 쿼리를 업데이트searchParams
가 변경되면 다시useEffect
트리거- 그 결과 API가 또다시 호출됨

예상되는 결과와 실제 발생한 현상
이론적으로는 이런 구조가 무한 루프를 만들어야 한다
useEffect
실행 → fetchAndSet 호출 →setSearchParams
로searchParams
변경 →useEffect
다시 실행 → 무한 반복...
하지만 실제로는 API가 단 2번만 호출되고 멈췄다. 왜 그럴까?
그 이유는 useSearchParams
훅의 내부 구현 때문이다. 이 훅에서 반환하는 searchParams
와 setSearchParams
는 각각 useMemo
와 useCallback
으로 최적화되어 있다. 따라서 동일한 쿼리 파라미터 값에 대해서는 참조가 유지된다.
실제 발생한 호출 과정
- 컴포넌트 마운트 →
useEffect
실행 fetchAndSetWorkspaces
호출 [첫 번째 API 호출]- API 응답 후
setSearchParams
로 URL 쿼리 업데이트 searchParams
변경 감지 →useEffect
재실행fetchAndSetWorkspaces
두 번째 호출 [두 번째 API 호출]- 두 번째 API 응답 후
setSearchParams
호출하지만, 이미 같은 값으로 설정되어 있어searchParams
의 참조는 변경되지 않음 - 의존성 배열의 값 변화 없음 → 더 이상
useEffect
실행 안 됨
이렇게 해서 무한 루프 대신 API가 단 두 번만 호출되는 현상이 발생했다.
해결 방법
지금은 useSearchParams
의 최적화 덕분에 API 요청이 2번 이루어지는 부작용만 있지만 이대로 방치하면 나중에는 심각한 결과를 초래할 수 있다. 따라서 fetchAndSetWorkspaces
에서는 fetch만 하고, setSearchParams
는 각 컴포넌트에서 관리하도록 책임을 분리했다.
1 useEffect(() => {2 const nowPage = Number(searchParams.get('page'));3 const searchValue = searchParams.get('name') || '';45 fetchAllWorkspaces(nowPage, pageSize, searchValue);6 // 사실 searchParams는 useMemo로 동결되어 관리되기 때문에, toString()을 해줄 필요는 없다.7}, [searchParams.toString()]);
1function SuperAdminSearchBar() {2 const fetchContentsByName = (e: React.KeyboardEvent<HTMLInputElement>) => {3 // 검색어로 검색하면 0번 page로 이동4 searchParams.set('page', '0');56 // name의 유무에 따라 name params 추가 및 삭제7 if (inputRef.current?.value === '') {8 searchParams.delete('name');9 } else {10 searchParams.set('name', String(inputRef.current?.value));11 }1213 // fetchAndSet대신 set만 수행14 setSearchParams(searchParams);15 };
1const fetchAllWorkspaces = (page: number, size: number, name?: string) => {2 // fetch만 수행하고, setSearchParams는 각 컴포넌트에서 관리하도록 책임 분리3 const response = superAdminApi4 .get<PaginationResponse<Workspace>>('/workspaces', { params })5 .then((res) => {6 return res.data;7 })89 return response;10 };

책임을 분리하여 좀 더 나은 코드가 되었고, 모든게 정상적으로 작동한다!
후기
문제 정의부터 해결책 적용까지 과정을 통해 useSearchParams
훅의 동작 원리를 깊이 이해할 수 있었고, 책임 분리의 중요성을 다시 한번 느꼈다. 점점 더 깊게 생각하는 태도를 갖추려고 노력하는 중이다.
참고링크