👀

제대로 된 페이지네이션 구현하기

Kioschool

2025-01-11

문제 1 - 쿼리 스트링

페이지네이션을 구현하기 위해 useSearchParams를 사용하여 쿼리 스트링을 관리하고 있다. 페이지네이션은 사용자가 페이지를 클릭할 때마다 URL이 바뀌어야 하며, 그에 따라 API 요청도 발생해야 한다.

super-admin/user?page=0에서 page 키 값은 API 통신을 하는 커스텀 훅에서 바꿔주고 있었다.

1const response = superAdminApi
2 .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/managesuper-admin/user 로 한 번 바뀌고, 0번 page의 사용자 데이터를 fetch 해오고 setSearchParamssuper-admin/usersuper-admin/user?page=0 으로 또 한 번 더 바뀐다.

이렇게 총 두 번 바뀌게 된다. 따라서 사용자가 전체 사용자 조회 화면에서 뒤로 가기 버튼을 한 번 눌렀을 때, super-admin/user?page=0super-admin/user 로 URL이 바뀌게 되어 의도한 대로 관리 페이지로 돌아갈 수 없게 된다.

해결 방법

위 문제는 setSearchParams에 옵션 객체로 replace: true를 넘겨주면 해결할 수 있다.

1setSearchParams(searchParams, { replace: true });

이렇게 함으로써 history 스택에 새 항목을 추가하는 대신 현재 항목을 대체할 수 있다.

문제 2 - 페이지네이션의 API 중복 호출 문제

발생한 문제

페이지네이션 구현 중 API가 두 번씩 요청되는 현상이 발생했다.

문제의 근본적인 원인은 아래 코드에서 볼 수 있는 순환 구조다

SuperAdmin.tsx
1useEffect(() => {
2 const nowPage = Number(searchParams.get("page"));
3 const searchValue = userInputRef.current?.value || "";
4
5 fetchAndSetWorkspaces(nowPage, pageSize, searchValue, true);
6}, [searchParams]);
fetchAllWorkspaces
1// fetch와 searchParams set을 동시에 하고 있다.
2const fetchAllWorkspaces = (page: number, size: number, name?: string, replace?: boolean) => {
3 // ...
4
5 const response = superAdminApi
6 .get<PaginationResponse<Workspace>>('/workspaces', { params })
7 .then((res) => {
8 searchParams.set('page', params.page.toString());
9 setSearchParams(searchParams, { replace });
10 return res.data;
11 })
12
13 return response;
14};

이 코드의 문제점을 단계별로 살펴보면:

  1. useEffectsearchParams를 의존성 배열에 포함
  2. 내부에서 fetchAndSetWorkspaces 함수 호출
  3. fetchAndSetWorkspaces는 API 호출 후 setSearchParams로 URL 쿼리를 업데이트
  4. searchParams가 변경되면 다시 useEffect 트리거
  5. 그 결과 API가 또다시 호출됨
API 중복 호출 문제1

예상되는 결과와 실제 발생한 현상

이론적으로는 이런 구조가 무한 루프를 만들어야 한다

useEffect 실행 → fetchAndSet 호출 → setSearchParamssearchParams 변경 → useEffect 다시 실행 → 무한 반복...

하지만 실제로는 API가 단 2번만 호출되고 멈췄다. 왜 그럴까?

그 이유는 useSearchParams 훅의 내부 구현 때문이다. 이 훅에서 반환하는 searchParamssetSearchParams는 각각 useMemouseCallback으로 최적화되어 있다. 따라서 동일한 쿼리 파라미터 값에 대해서는 참조가 유지된다.

실제 발생한 호출 과정

  1. 컴포넌트 마운트 → useEffect 실행
  2. fetchAndSetWorkspaces 호출 [첫 번째 API 호출]
  3. API 응답 후 setSearchParams로 URL 쿼리 업데이트
  4. searchParams 변경 감지 → useEffect 재실행
  5. fetchAndSetWorkspaces 두 번째 호출 [두 번째 API 호출]
  6. 두 번째 API 응답 후 setSearchParams 호출하지만, 이미 같은 값으로 설정되어 있어 searchParams의 참조는 변경되지 않음
  7. 의존성 배열의 값 변화 없음 → 더 이상 useEffect 실행 안 됨

이렇게 해서 무한 루프 대신 API가 단 두 번만 호출되는 현상이 발생했다.

해결 방법

지금은 useSearchParams의 최적화 덕분에 API 요청이 2번 이루어지는 부작용만 있지만 이대로 방치하면 나중에는 심각한 결과를 초래할 수 있다. 따라서 fetchAndSetWorkspaces에서는 fetch만 하고, setSearchParams는 각 컴포넌트에서 관리하도록 책임을 분리했다.

SuperAdminWorkspace.tsx
1 useEffect(() => {
2 const nowPage = Number(searchParams.get('page'));
3 const searchValue = searchParams.get('name') || '';
4
5 fetchAllWorkspaces(nowPage, pageSize, searchValue);
6 // 사실 searchParams는 useMemo로 동결되어 관리되기 때문에, toString()을 해줄 필요는 없다.
7}, [searchParams.toString()]);
SuperAdminSearchBar.tsx
1function SuperAdminSearchBar() {
2 const fetchContentsByName = (e: React.KeyboardEvent<HTMLInputElement>) => {
3 // 검색어로 검색하면 0번 page로 이동
4 searchParams.set('page', '0');
5
6 // name의 유무에 따라 name params 추가 및 삭제
7 if (inputRef.current?.value === '') {
8 searchParams.delete('name');
9 } else {
10 searchParams.set('name', String(inputRef.current?.value));
11 }
12
13 // fetchAndSet대신 set만 수행
14 setSearchParams(searchParams);
15 };
fetchAllWorkspaces
1const fetchAllWorkspaces = (page: number, size: number, name?: string) => {
2 // fetch만 수행하고, setSearchParams는 각 컴포넌트에서 관리하도록 책임 분리
3 const response = superAdminApi
4 .get<PaginationResponse<Workspace>>('/workspaces', { params })
5 .then((res) => {
6 return res.data;
7 })
8
9 return response;
10 };
API 중복 호출 문제2

책임을 분리하여 좀 더 나은 코드가 되었고, 모든게 정상적으로 작동한다!

후기

문제 정의부터 해결책 적용까지 과정을 통해 useSearchParams 훅의 동작 원리를 깊이 이해할 수 있었고, 책임 분리의 중요성을 다시 한번 느꼈다. 점점 더 깊게 생각하는 태도를 갖추려고 노력하는 중이다.

참고링크

© castle_bell · All rights reserved