페이지네이션 상태복원하기(feat.Next-Approuter)
서론
많은 아이템들을 사용자에게 제공할때, 크게 2가지로 갈린다. 페이지 기반 or 커서 기반이다. 개발적인 관점을 떠나, 유저경험으로서 두가지 중 어떠한 것이 더 편할까를 고민했을 때 나는 고민없이 무한스크롤(커서 기반)이 더 편했다. 그래서 이 무한스크롤로 개발을 시작했고, 이 과정속에서 마주한 커다란 고민들을 지혜롭게 해결하고, 이 양자택일의 선택에서 고른 모든사안에 대한 근거와 이유를 내 프로젝트와 함께 작성하기 위해 기록한다.
나의 고민
사용자들은 LIST 무한스크롤을 통해서 나열된 아이템을 클릭하여 페이지를 이동하고 다시 뒤로가기를 눌러 원래의 LIST로 돌아올 확률은 매우 높다. 따라서 프론트 개발자는 이 LIST의 상태를 사용자에게 복원시켜서 유저경험이 자연스럽게 이어지도록 신경써야한다. 문제는 이걸 어떻게 하느냐다.
MATE프로젝트에는(아래의 사진) 4가지의 필터링 기능이 존재한다. 카테고리 / 검색어 / 태그 / 조회필터링
이 개발이라는 큰 카테고리 하위에서 조회필터링(인기순) 과 태그로 검색은 높은확률로 쓰일 것이고 이 기능을 위해 프로젝트를 만들기도 하였다. 따라서 유저가 조합한 검색 결과는 반드시 유지되어야 생각했었고 이를 URL에 저장하여 브라우저 히스토리에 남기려고 하였다. 그리고 이게 가장 큰 문제가 되었다.
기술 스택 및 버전
{
// ...
"dependencies": {
"@tanstack/react-query": "^5.69.0",
"next": "15.2.4",
"react": "^19.0.0",
},
}
사진으로 파악하기


구현
필요 함수
데이터 페칭 관련:
- useProductSearch 훅: React Query의 useInfiniteQuery를 래핑한 커스텀 훅
- fetchNextPage: 다음 페이지 데이터를 가져오는 함수
- getNextPageParam: 다음 페이지 번호를 결정하는 함수
검색 파라미터 관리:
- handleCategoryChange, handleSortChange, handleSearchChange: 각각 카테고리, 정렬, 검색어 변경
- handleTagAdd, handleTagRemove: 태그 추가/제거
- updateUrlParams: URL 파라미터 업데이트
무한스크롤 감지:
- IntersectionObserver: 스크롤 하단 도달 감지
- observerRef: 감지 대상 DOM 요소 참조
코드
app/products/page.tsx - SRC
import { Metadata } from 'next';
import {
QueryClient,
dehydrate,
HydrationBoundary,
} from '@tanstack/react-query';
import apiClient from '@/utils/api/api';
import { productURL, tagURL } from '@/service/endpoints/endpoints';
import {
ProductListResponseSchema,
PopularTagsResponseSchema,
ProductListItem,
} from '@/schemas/api/product.schema';
import { INITIAL_SEARCH_PARAMS } from './constants/productConstants';
import ProductListClient from './page-components/ProductListClient';
import { queryKeys } from '@/lib/react-query/queryKeys';
type PageData = {
items: ProductListItem[];
current_page: number;
has_next: boolean;
};
export const metadata: Metadata = {
title: '프로덕트 | MATE',
description: '다양한 개발자와 디자이너의 프로덕트들을 확인해보세요.',
};
export default async function ProductsPage() {
const queryClient = new QueryClient();
// 인기 태그 미리 불러오기
await queryClient.prefetchQuery({
queryKey: queryKeys.tag.mostTag(),
queryFn: async () => {
const response = await apiClient.get(tagURL.mostTag, {
schema: PopularTagsResponseSchema,
});
return response;
},
});
// 초기 제품 목록 미리 불러오기
await queryClient.prefetchInfiniteQuery({
queryKey: queryKeys.products.list(INITIAL_SEARCH_PARAMS),
queryFn: async ({ pageParam = 0 }) => {
const response = await apiClient.post(productURL.srch, {
params: {
...INITIAL_SEARCH_PARAMS,
page: pageParam,
size: 4,
},
schema: ProductListResponseSchema,
});
const {
content = [],
current_page = 0,
has_next = false,
} = response || {};
return {
items: content,
current_page,
has_next,
};
},
initialPageParam: 0,
getNextPageParam: (lastPage: PageData | undefined) => {
if (!lastPage) return undefined;
return lastPage.has_next ? lastPage.current_page + 1 : undefined;
},
});
const dehydratedState = dehydrate(queryClient);
return (
<HydrationBoundary state={dehydratedState}>
<ProductListClient />
</HydrationBoundary>
);
}
app/products/page-components/ProductListClient.tsx
(무한스크롤 로직 및 검색 로직 분리x)
'use client';
import { useState, useEffect, useRef, useCallback } from 'react';
import { useSearchParams, useRouter } from 'next/navigation';
import useProductSearch from '@/hooks/query/useProductSearch';
import { ProductSearchBody } from '@/schemas/api/product.schema';
import usePopularTags from '@/hooks/query/usePopularTags';
import CategoryTabs from './list/CategoryTabs';
import SearchBar from './list/SearchBar';
import SortSelector from './list/SortSelector';
import TagSelector from './list/TagSelector';
import ProductGrid from './list/ProductGrid';
import {
CATEGORY_OPTIONS,
INITIAL_SEARCH_PARAMS,
SORT_OPTIONS,
} from '../constants/productConstants';
const ProductListClient = () => {
const router = useRouter();
const searchParams = useSearchParams();
const observerRef = useRef<HTMLDivElement>(null);
const [showMobileTagFilter, setShowMobileTagFilter] = useState(false);
// URL 파라미터에서 초기 검색 조건 설정
const [searchParams2, setSearchParams] = useState<ProductSearchBody>({
category:
(searchParams.get('category') as string) ||
INITIAL_SEARCH_PARAMS.category,
sort: (searchParams.get('sort') as string) || INITIAL_SEARCH_PARAMS.sort,
tag: searchParams.get('tag')
? searchParams.get('tag')?.split(',') || []
: [],
title: searchParams.get('title') || '',
size: 4,
page: 0,
});
// 인기 태그 가져오기
const { data: popularTags = [] } = usePopularTags();
// 제품 검색 결과 가져오기
const {
data: productsData,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isLoading,
isError,
} = useProductSearch(searchParams2);
// URL 파라미터 업데이트 함수
const updateUrlParams = useCallback(() => {
const params = new URLSearchParams();
if (searchParams2.category) {
params.set('category', searchParams2.category);
}
if (searchParams2.sort) {
params.set('sort', searchParams2.sort);
}
if (Array.isArray(searchParams2.tag) && searchParams2.tag.length > 0) {
params.set('tag', searchParams2.tag.join(','));
}
if (searchParams2.title) {
params.set('title', searchParams2.title);
}
router.push(`/products?${params.toString()}`);
}, [router, searchParams2]);
// 검색 파라미터 변경 시 URL 업데이트
useEffect(() => {
updateUrlParams();
}, [searchParams2, updateUrlParams]);
// 무한 스크롤 처리
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) {
console.log('다음 페이지 불러오기...');
fetchNextPage();
}
},
{ threshold: 0.1 }
);
if (observerRef.current) {
observer.observe(observerRef.current);
}
return () => {
if (observerRef.current) {
observer.unobserve(observerRef.current);
}
};
}, [fetchNextPage, hasNextPage, isFetchingNextPage]);
// 카테고리 변경 핸들러
const handleCategoryChange = (category: string) => {
setSearchParams((prev) => ({
...prev,
category,
}));
};
// 정렬 옵션 변경 핸들러
const handleSortChange = (sort: string) => {
setSearchParams((prev) => ({
...prev,
sort,
}));
};
// 검색어 변경 핸들러
const handleSearchChange = (title: string) => {
setSearchParams((prev) => ({
...prev,
title,
}));
};
// 태그 추가 핸들러
const handleTagAdd = (tag: string) => {
if (!Array.isArray(searchParams2.tag) || searchParams2.tag.includes(tag))
return;
setSearchParams((prev) => ({
...prev,
tag: [...(Array.isArray(prev.tag) ? prev.tag : []), tag],
}));
// 모바일에서 태그 추가 후 자동으로 태그 필터 닫기 (UX 개선)
if (window.innerWidth < 1024) {
setShowMobileTagFilter(false);
}
};
// 태그 제거 핸들러
const handleTagRemove = (tag: string) => {
if (!Array.isArray(searchParams2.tag)) return;
setSearchParams((prev) => ({
...prev,
tag: Array.isArray(prev.tag) ? prev.tag.filter((t) => t !== tag) : [],
}));
};
// 모든 제품 목록 데이터 병합
const products = Array.isArray(productsData?.pages)
? productsData.pages.flatMap((page) => page?.items || [])
: [];
// 선택된 태그 수 계산
const selectedTagCount = Array.isArray(searchParams2.tag)
? searchParams2.tag.length
: 0;
// 태그 필터 토글
const toggleMobileTagFilter = () => {
setShowMobileTagFilter(!showMobileTagFilter);
};
return (
<div className='max-w-screen-xl mx-auto p-4'>
<div className='flex flex-col space-y-6'>
{/* 카테고리 탭 */}
<CategoryTabs
activeCategory={searchParams2.category || CATEGORY_OPTIONS.DEVELOP}
onCategoryChange={handleCategoryChange}
/>
{/* 검색 컨트롤 */}
<div className='flex flex-col gap-4 sm:flex-row'>
<div className='flex-1'>
<SearchBar
value={searchParams2.title || ''}
onChange={handleSearchChange}
/>
</div>
<div className='flex items-center justify-between sm:justify-start gap-2'>
<SortSelector
value={searchParams2.sort || SORT_OPTIONS.CREATE}
onChange={handleSortChange}
/>
{/* 모바일 태그 필터 토글 버튼 */}
<button
type='button'
onClick={toggleMobileTagFilter}
className='lg:hidden flex items-center px-3 py-2 bg-selection hover:bg-hover text-textLight rounded transition-colors'
aria-expanded={showMobileTagFilter}
aria-controls='mobile-tag-filter'
>
<svg
className='w-4 h-4 mr-1'
fill='none'
viewBox='0 0 24 24'
stroke='currentColor'
>
<path
strokeLinecap='round'
strokeLinejoin='round'
strokeWidth={2}
d='M7 7h10M7 12h10M7 17h10'
/>
</svg>
태그
{selectedTagCount > 0 && (
<span className='ml-1 w-5 h-5 flex items-center justify-center bg-active text-white rounded-full text-xs'>
{selectedTagCount}
</span>
)}
</button>
</div>
</div>
{/* 모바일 태그 필터 (접을 수 있는 패널) */}
<div
id='mobile-tag-filter'
className={`lg:hidden transition-all duration-300 overflow-hidden ${
showMobileTagFilter ? 'max-h-96 opacity-100' : 'max-h-0 opacity-0'
}`}
>
<TagSelector
popularTags={popularTags || []}
selectedTags={
Array.isArray(searchParams2.tag) ? searchParams2.tag : []
}
onTagAdd={handleTagAdd}
onTagRemove={handleTagRemove}
compact={true} // 모바일용 컴팩트 모드
/>
</div>
{/* 선택된 태그 표시 */}
{Array.isArray(searchParams2.tag) && searchParams2.tag.length > 0 && (
<div className='flex flex-wrap gap-2'>
{searchParams2.tag.map((tag) => (
<div
key={tag}
className='flex items-center bg-selection text-textLight rounded-md px-2 py-1'
>
<span className='mr-1'>{tag}</span>
<button
type='button'
onClick={() => handleTagRemove(tag)}
className='hover:text-red-400 focus:outline-none'
aria-label={`태그 삭제: ${tag}`}
>
<svg
className='w-3 h-3'
fill='none'
viewBox='0 0 24 24'
stroke='currentColor'
>
<path
strokeLinecap='round'
strokeLinejoin='round'
strokeWidth={2}
d='M6 18L18 6M6 6l12 12'
/>
</svg>
</button>
</div>
))}
</div>
)}
<div className='flex flex-col lg:flex-row gap-6'>
{/* 메인 컨텐츠 영역 */}
<div className='flex-1 order-2 lg:order-1'>
{isLoading ? (
<div className='flex justify-center items-center h-64'>
<div className='text-textLight'>로딩 중...</div>
</div>
) : isError ? (
<div className='flex justify-center items-center h-64 bg-red-50 text-red-500 rounded-lg'>
<div className='text-center'>
<p className='typo-head3 mb-2'>
데이터를 불러오는 중 오류가 발생했습니다
</p>
<p className='typo-body2'>잠시 후 다시 시도해 주세요</p>
</div>
</div>
) : products.length === 0 ? (
<div className='flex justify-center items-center h-64 bg-bgLight rounded-lg'>
<div className='text-textLight text-center'>
<p className='typo-head3 mb-2'>검색 결과가 없습니다</p>
<p className='typo-body2'>
다른 검색어나 필터를 시도해 보세요
</p>
</div>
</div>
) : (
<ProductGrid products={products} />
)}
{/* 무한 스크롤용 감지 div */}
{!isLoading && !isError && products.length > 0 && (
<div ref={observerRef} className='h-10' />
)}
{/* 로딩 상태 표시 */}
{isFetchingNextPage && (
<div className='flex justify-center items-center h-20'>
<div className='text-textLight'>로딩 중...</div>
</div>
)}
</div>
{/* 사이드바 - 태그 선택 (데스크톱에서만 표시) */}
<div className='hidden lg:block lg:w-72 w-full order-1 lg:order-2'>
<TagSelector
popularTags={popularTags || []}
selectedTags={
Array.isArray(searchParams2.tag) ? searchParams2.tag : []
}
onTagAdd={handleTagAdd}
onTagRemove={handleTagRemove}
compact={false} // 데스크탑용 전체 모드
/>
</div>
</div>
</div>
</div>
);
};
export default ProductListClient;
hooks/query/useProductSearch.ts
'use client';
import { useInfiniteQuery } from '@tanstack/react-query';
import { productURL } from '@/service/endpoints/endpoints';
import apiClient from '@/utils/api/api';
import { queryKeys } from '@/lib/react-query/queryKeys';
import {
ProductSearchBody,
ProductListResponseSchema,
} from '@/schemas/api/product.schema';
// 제품 검색 훅
const useProductSearch = (body: ProductSearchBody) => {
const searchParams = body || {};
return useInfiniteQuery({
queryKey: queryKeys.products.list(searchParams),
queryFn: async ({ pageParam = 0 }) => {
const searchBody = {
...searchParams,
page: pageParam,
size: searchParams.size || 4,
};
try {
const response = await apiClient.post(productURL.srch, {
params: searchBody,
schema: ProductListResponseSchema,
});
// 기본값 설정
const {
content = [],
current_page = 0,
has_next = false,
} = response || {};
return {
items: content,
current_page,
has_next,
};
} catch (error) {
console.error('API 요청 실패:', error);
return {
items: [],
current_page: 0,
has_next: false,
};
}
},
getNextPageParam: (lastPage) => {
if (!lastPage) return undefined;
return lastPage.has_next ? lastPage.current_page + 1 : undefined;
},
initialPageParam: 0,
});
};
export default useProductSearch;
로직 흐름
- URL 파라미터에서 검색 조건을 읽어서 searchParams2 상태 초기화
- useProductSearch가 첫 번째 페이지(page: 0) 데이터 요청
- 서버에서 응답받은 데이터로 ProductGrid 렌더링
- IntersectionObserver가 하단 감지 요소 관찰 시작
- 유저가 페이지 하단으로 스크롤
- observerRef 요소가 뷰포트에 들어옴 (threshold: 0.1)
- IntersectionObserver 콜백 실행
- hasNextPage가 true이고 현재 로딩 중이 아니면 fetchNextPage 호출
- getNextPageParam이 다음 페이지 번호 계산 (current_page + 1)
- 새 페이지 데이터 요청하고 기존 데이터에 추가
그리고 검색 조건이 변경시,
- 사용자가 카테고리/정렬/검색어/태그 변경
- 해당 핸들러가 searchParams2 상태 업데이트
- useEffect가 상태 변화 감지하여 updateUrlParams 실행
- URL이 새로운 파라미터로 업데이트
- useProductSearch가 새로운 조건으로 첫 페이지부터 다시 요청
문제
현재 URL에서 파라미터로 4개의 검색필터링을 담고있다. URL 상태관리를 통해 얻을 수 있는 장점들이 존재하기 떄문이다.
공유 가능성
- 사용자가 특정 검색 조건의 URL을 복사해서 다른 사람에게 공유 가능
- 예: /products?category=develop&sort=like&tag=react,typescript
북마크 지원
- 사용자가 자주 보는 검색 조건을 북마크로 저장 가능
- 나중에 북마크 클릭하면 정확한 상태로 복원
새로고침 시 상태 유지
- F5 새로고침해도 검색 조건 그대로 유지
- 브라우저 탭 닫았다가 다시 열어도 동일한 상태
브라우저 히스토리 관리
- 뒤로가기/앞으로가기 버튼으로 이전 검색 조건들 탐색 가능
- 검색 과정이 브라우저 히스토리에 기록됨
SEO 최적화
- 검색엔진이 다양한 필터 조건의 페이지들을 크롤링 가능
- 각 검색 조건별로 고유한 URL 생성
딥링크 지원
- 외부에서 특정 검색 조건으로 바로 진입 가능
- 마케팅 캠페인에서 특정 카테고리/태그로 유도 가능
서버 사이드 렌더링 지원
- SSR/SSG 시 URL 파라미터 기반으로 초기 데이터 fetch 가능
- 첫 페이지 로드 시 올바른 상태로 렌더링
Analytics 추적
- Google Analytics 등에서 어떤 검색 조건이 인기 있는지 추적 가능
- 사용자 행동 패턴 분석 용이
따라서 URL에 상태를 저장하기 떄문에 브라우저 히스토리추적으로 이전 검색조건을 유지할 수 있게된다.

위의 상황은 해당 태그가 2개밖에 없어 스크롤이 어차피 최상단이 였기 때문에 유저경험상 차이점을 느낄 수 없다. 그런데 만약 해당 태그에 반환하는 아이템이 여러개라면 어떻게될까?

스크롤 제일 하단의 아이템을 눌러 페이지를 이동하고, 브라우저 뒤로가기를 클릭하니 0.4초정도 찰나의 이전 스크롤위치를 보여주었다가 스크롤이 최상단으로 순간이동하게된다.사실 이는 검색필터링이 설정이 되던, 검색필터링이 설정이 되지 않던간에 스크롤 후, 아이템을 클릭하고 뒤로가기하면 스크롤은 무조건 최상단으로 위치하게 된다.
디버깅(해결 과정)
일단 이 결정적인 원인은 코드 내부 URL 파라미터 업데이트 함수때문이다.
// URL 파라미터 업데이트 함수
const updateUrlParams = useCallback(() => {
const params = new URLSearchParams();
if (searchParams2.category) {
params.set('category', searchParams2.category);
}
if (searchParams2.sort) {
params.set('sort', searchParams2.sort);
}
if (Array.isArray(searchParams2.tag) && searchParams2.tag.length > 0) {
params.set('tag', searchParams2.tag.join(','));
}
if (searchParams2.title) {
params.set('title', searchParams2.title);
}
router.push(`/products?${params.toString()}`); // <-------
}, [router, searchParams2]);
// 검색 파라미터 변경 시 URL 업데이트
useEffect(() => {
updateUrlParams();
}, [searchParams2, updateUrlParams]);
이 Next.js의 router.push()를 통해서 유저의 입력값을(검색조건) URL에 실시간으로 동기화 하기 위함이다.
그리고 이 기본 동작은 URL 이 변경되면 자동으로 스크롤을 맨 위로 이동하게 된다.

스크롤이 맨 위로 이동하는 것은 메소드 문제가 아닌 웹의 기본적인 UX 패턴 때문이다. 페이지가 변경되면 스크롤이 상단에 있는 것이 맞는 동작이기 때문이다. 이는 스크린 리더의 접근성도 마찬가지이고 브라우저의 기본 동작과도 일치하기 때문이다. 정리하면 "URL이 바뀌면 = 새로운 페이지" 이다. 그러나 지금처럼 SPA로서 URL을 상태관리 용도로 쓴다면 웹의 기본동작과 내가 원하는 동작이 맞지 않기 떄문이다.
따라서 어떤 추가의 기능이 필요할 것 같지만 rotuer.push의 옵션으로 가볍게 해결가능하다.
다음과같이 scroll의 옵션을 false로 설정하면 검색조건들이 존재한 상태에서 페이지 이동을 하고 뒤로이동해도 스크롤의 위치가 복원된다.

그럼 끝!
인줄 알았으나 개발모드와 프로덕션에서 차이가 생긴다. 프로덕션 서버에서는 뒤로가기시 스크롤을 제일 위로 찍고 다시 내려온다...

그리고 개발서버 역시 다시확인하니 이제 다시 뒤로가기가 안되었다. 위의 사진처럼 천장을찍고 내려왔던게 선녀처럼 보이기 시작한다. 전체적인 수정이 필요했다.
디버깅 2
뒤로가기 시, 페이지가 상단에올라가는것은 rotuer.push() 가 호출되었다는 소리인데, 이것이 호출되려면 검색파라미터가 변경되었을 때의 사이드페익트 조절을 위한useEffect문이 돌았다는 의미이다.
이 과정에서 'popstate'이벤트에 대해 알아야하는데, popstate는 브라우저의 뒤로가기나 앞으로가기의 기능을 호출할때 발생한다.
window.addEventListener('popstate', function(event) {
// 브라우저의 뒤로가기/앞으로가기 버튼이 클릭되었을 때 발생
});
여기서 Next.js의 router 내부동작에서, 이러한 popstate를 감지하고 관련된 params훅을 업데이트하는데 이떄 여기서 useSearchParams()훅이 새로운 값을 반환하게 되고 useEffect문이 돌아 컴포넌트를 리렌더링 시킨다. 그래서 나는 먼저 이 useEffect문을 제거하고, 검색조건핸들러에서 직접 URL을 변경시키는 작업을 선택했다. 따라 url을 직접 변경하는것은 next.js의 router메소드로 관리하고, 스크롤은 브라우저에게 위임했다.
if (typeof window !== 'undefined' && 'scrollRestoration' in history) {
history.scrollRestoration = 'auto';
}
전체코드
'use client';
import { useState, useEffect, useRef, useCallback } from 'react';
import { useSearchParams, useRouter } from 'next/navigation';
import useProductSearch from '@/hooks/query/useProductSearch';
import { ProductSearchBody } from '@/schemas/api/product.schema';
import usePopularTags from '@/hooks/query/usePopularTags';
import CategoryTabs from './list/CategoryTabs';
import SearchBar from './list/SearchBar';
import SortSelector from './list/SortSelector';
import TagSelector from './list/TagSelector';
import ProductGrid from './list/ProductGrid';
import {
CATEGORY_OPTIONS,
INITIAL_SEARCH_PARAMS,
SORT_OPTIONS,
} from '../constants/productConstants';
const ProductListClient = () => {
const router = useRouter();
const searchParams = useSearchParams();
const observerRef = useRef<HTMLDivElement>(null);
const [showMobileTagFilter, setShowMobileTagFilter] = useState(false);
// 브라우저 스크롤 복원 즉시 활성화
if (typeof window !== 'undefined' && 'scrollRestoration' in history) {
history.scrollRestoration = 'auto';
}
// URL 파라미터에서 초기 검색 조건 설정
const [searchParams2, setSearchParams] = useState<ProductSearchBody>({
category:
(searchParams.get('category') as string) ||
INITIAL_SEARCH_PARAMS.category,
sort: (searchParams.get('sort') as string) || INITIAL_SEARCH_PARAMS.sort,
tag: searchParams.get('tag')
? searchParams.get('tag')?.split(',') || []
: [],
title: searchParams.get('title') || '',
size: 4,
page: 0,
});
// 인기 태그 가져오기
const { data: popularTags = [] } = usePopularTags();
// 제품 검색 결과 가져오기
const {
data: productsData,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isLoading,
isError,
} = useProductSearch(searchParams2);
// URL 파라미터 업데이트 함수 (사용자 액션일 때만)
const updateUrlParams = useCallback(
(updatedParams: ProductSearchBody) => {
const params = new URLSearchParams();
if (updatedParams.category) {
params.set('category', updatedParams.category);
}
if (updatedParams.sort) {
params.set('sort', updatedParams.sort);
}
if (Array.isArray(updatedParams.tag) && updatedParams.tag.length > 0) {
params.set('tag', updatedParams.tag.join(','));
}
if (updatedParams.title) {
params.set('title', updatedParams.title);
}
const newUrl = `/products?${params.toString()}`;
// Next.js router 사용 (가장 안정적)
router.push(newUrl, { scroll: false });
},
[router]
);
// 무한 스크롤 처리
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) {
console.log('다음 페이지 불러오기...');
fetchNextPage();
}
},
{ threshold: 0.1 }
);
if (observerRef.current) {
observer.observe(observerRef.current);
}
return () => {
if (observerRef.current) {
observer.unobserve(observerRef.current);
}
};
}, [fetchNextPage, hasNextPage, isFetchingNextPage]);
// 카테고리 변경 핸들러
const handleCategoryChange = (category: string) => {
const updatedParams = { ...searchParams2, category };
setSearchParams(updatedParams);
updateUrlParams(updatedParams); // 즉시 URL 업데이트
};
// 정렬 옵션 변경 핸들러
const handleSortChange = (sort: string) => {
const updatedParams = { ...searchParams2, sort };
setSearchParams(updatedParams);
updateUrlParams(updatedParams); // 즉시 URL 업데이트
};
// 검색어 변경 핸들러
const handleSearchChange = (title: string) => {
const updatedParams = { ...searchParams2, title };
setSearchParams(updatedParams);
updateUrlParams(updatedParams); // 즉시 URL 업데이트
};
// 태그 추가 핸들러
const handleTagAdd = (tag: string) => {
if (!Array.isArray(searchParams2.tag) || searchParams2.tag.includes(tag))
return;
const updatedParams = {
...searchParams2,
tag: [
...(Array.isArray(searchParams2.tag) ? searchParams2.tag : []),
tag,
],
};
setSearchParams(updatedParams);
updateUrlParams(updatedParams); // 즉시 URL 업데이트
// 모바일에서 태그 추가 후 자동으로 태그 필터 닫기 (UX 개선)
if (window.innerWidth < 1024) {
setShowMobileTagFilter(false);
}
};
// 태그 제거 핸들러
const handleTagRemove = (tag: string) => {
if (!Array.isArray(searchParams2.tag)) return;
const updatedParams = {
...searchParams2,
tag: Array.isArray(searchParams2.tag)
? searchParams2.tag.filter((t) => t !== tag)
: [],
};
setSearchParams(updatedParams);
updateUrlParams(updatedParams); // 즉시 URL 업데이트
};
// 모든 제품 목록 데이터 병합
const products = Array.isArray(productsData?.pages)
? productsData.pages.flatMap((page) => page?.items || [])
: [];
// 선택된 태그 수 계산
const selectedTagCount = Array.isArray(searchParams2.tag)
? searchParams2.tag.length
: 0;
// 태그 필터 토글
const toggleMobileTagFilter = () => {
setShowMobileTagFilter(!showMobileTagFilter);
};
return (
<div className='max-w-screen-xl mx-auto p-4'>
<div className='flex flex-col space-y-6'>
{/* 카테고리 탭 */}
<CategoryTabs
activeCategory={searchParams2.category || CATEGORY_OPTIONS.DEVELOP}
onCategoryChange={handleCategoryChange}
/>
{/* 검색 컨트롤 */}
<div className='flex flex-col gap-4 sm:flex-row'>
<div className='flex-1'>
<SearchBar
value={searchParams2.title || ''}
onChange={handleSearchChange}
/>
</div>
<div className='flex items-center justify-between sm:justify-start gap-2'>
<SortSelector
value={searchParams2.sort || SORT_OPTIONS.CREATE}
onChange={handleSortChange}
/>
{/* 모바일 태그 필터 토글 버튼 */}
<button
type='button'
onClick={toggleMobileTagFilter}
className='lg:hidden flex items-center px-3 py-2 bg-selection hover:bg-hover text-textLight rounded transition-colors'
aria-expanded={showMobileTagFilter}
aria-controls='mobile-tag-filter'
>
<svg
className='w-4 h-4 mr-1'
fill='none'
viewBox='0 0 24 24'
stroke='currentColor'
>
<path
strokeLinecap='round'
strokeLinejoin='round'
strokeWidth={2}
d='M7 7h10M7 12h10M7 17h10'
/>
</svg>
태그
{selectedTagCount > 0 && (
<span className='ml-1 w-5 h-5 flex items-center justify-center bg-active text-white rounded-full text-xs'>
{selectedTagCount}
</span>
)}
</button>
</div>
</div>
{/* 모바일 태그 필터 (접을 수 있는 패널) */}
<div
id='mobile-tag-filter'
className={`lg:hidden transition-all duration-300 overflow-hidden ${
showMobileTagFilter ? 'max-h-96 opacity-100' : 'max-h-0 opacity-0'
}`}
>
<TagSelector
popularTags={popularTags || []}
selectedTags={
Array.isArray(searchParams2.tag) ? searchParams2.tag : []
}
onTagAdd={handleTagAdd}
onTagRemove={handleTagRemove}
compact={true} // 모바일용 컴팩트 모드
/>
</div>
{/* 선택된 태그 표시 */}
{Array.isArray(searchParams2.tag) && searchParams2.tag.length > 0 && (
<div className='flex flex-wrap gap-2'>
{searchParams2.tag.map((tag) => (
<div
key={tag}
className='flex items-center bg-selection text-textLight rounded-md px-2 py-1'
>
<span className='mr-1'>{tag}</span>
<button
type='button'
onClick={() => handleTagRemove(tag)}
className='hover:text-red-400 focus:outline-none'
aria-label={`태그 삭제: ${tag}`}
>
<svg
className='w-3 h-3'
fill='none'
viewBox='0 0 24 24'
stroke='currentColor'
>
<path
strokeLinecap='round'
strokeLinejoin='round'
strokeWidth={2}
d='M6 18L18 6M6 6l12 12'
/>
</svg>
</button>
</div>
))}
</div>
)}
<div className='flex flex-col lg:flex-row gap-6'>
{/* 메인 컨텐츠 영역 */}
<div className='flex-1 order-2 lg:order-1'>
{isLoading ? (
<div className='flex justify-center items-center h-64'>
<div className='text-textLight'>로딩 중...</div>
</div>
) : isError ? (
<div className='flex justify-center items-center h-64 bg-red-50 text-red-500 rounded-lg'>
<div className='text-center'>
<p className='typo-head3 mb-2'>
데이터를 불러오는 중 오류가 발생했습니다
</p>
<p className='typo-body2'>잠시 후 다시 시도해 주세요</p>
</div>
</div>
) : products.length === 0 ? (
<div className='flex justify-center items-center h-64 bg-bgLight rounded-lg'>
<div className='text-textLight text-center'>
<p className='typo-head3 mb-2'>검색 결과가 없습니다</p>
<p className='typo-body2'>
다른 검색어나 필터를 시도해 보세요
</p>
</div>
</div>
) : (
<ProductGrid products={products} />
)}
{/* 무한 스크롤용 감지 div */}
{!isLoading && !isError && products.length > 0 && (
<div ref={observerRef} className='h-10' />
)}
{/* 로딩 상태 표시 */}
{isFetchingNextPage && (
<div className='flex justify-center items-center h-20'>
<div className='text-textLight'>로딩 중...</div>
</div>
)}
</div>
{/* 사이드바 - 태그 선택 (데스크톱에서만 표시) */}
<div className='hidden lg:block lg:w-72 w-full order-1 lg:order-2'>
<TagSelector
popularTags={popularTags || []}
selectedTags={
Array.isArray(searchParams2.tag) ? searchParams2.tag : []
}
onTagAdd={handleTagAdd}
onTagRemove={handleTagRemove}
compact={false} // 데스크탑용 전체 모드
/>
</div>
</div>
</div>
</div>
);
};
export default ProductListClient;
정확히 말하자면 Parmas를 상태로 관리하고 태그가 변경되면 useEffect문으로 router.push를 호출하여 중앙에서 관리하였지만, 분산형으로 관리하고 사이드이펙트를 없에기위해 중앙집중적인 useEffect를 제거하고 각각의 검색조건 핸들러에 상태변경 핸들러를 달았더니 말끔하게 해결되었다. 다만 기존코드는 아래 URL 처럼 최초의 리스트 페이지에 접근할떄 useEffect로 쿼리를 완벽하게 셋팅할 수 있었지만
https://mate.springbud.site/products?category=DEVELOP&sort=CREATE
MATE | 프리랜서 허브
MATE - 빠른 프리랜서 매칭 플랫폼!
mate.springbud.site
지금 chlchdml List페이지에 접근할 떄는
https://mate.springbud.site/products
MATE | 프리랜서 허브
MATE - 빠른 프리랜서 매칭 플랫폼!
mate.springbud.site
이렇게 쿼리가 세팅되지 않는다. 그러나 이는 사용자 경험이나 router에 전혀 영향을 미치지 않았기 때문에 감수할 수 있는 부분이였다.
자 이제 다시 잘된다.
그러나 또 문제가 발생 ㅜㅜㅜ 개발서버에서 잘되던 녀석이 프로덕션서버에선 되지않는다. 진짜 그지같은경우다.
또 문제는 아예 안될거면 안되는게좋은데 낮은확률로 안된다. 진짜 이걸 영상녹화를 하고싶은데 녹화만 하면 잘되다가 녹화를끄면 불쑥불쑥 한번식 스크롤이 초기화가되어 상단으로 올라간다. 여기서 키보드 다 때려 부술뻔했다.
디버깅 3 - 성공
클라이언트페이지에서 모든조건을 다 확인했었는데도 내가 아는선 + AI를 잔뜩 돌려도 문제되는 부분이 없었다. 그렇다면 남은건 서버컴포넌트에서 prefetch를 하고 캐싱값을 통해 빠르게 초기값을 세팅하는 부분일 텐데 여기서 얻어걸렸다.
이 prefetch는 서버컴포넌트에서 미리 데이터를 패칭해서 캐싱하고 해당 캐싱된값을 통해 초기 로딩속도를 개선할 수 있다. 그러나 이게 rotuer.push 에서 스크롤 복원과 충돌되었을 가능성을 알게되었다. 뒤로가기 popstate이벤트일때, prefetch된 데이터로 즉시 렌더링을 수행하다 간헐적으로 스크롤 위치를 손실하게 될 수 있기 때문이란다. 이말은 즉 브라우저의 스크롤 복원이 준비도 되기전에 렌더링이 완료되어 스크롤 복원 타이밍을 놓칠 경우를 의미한다.
아래의 글은 클로드의 의견인데, 진위 여부 관련 자료를 아직 찾지 못해 일단 첨부만한다.
타이밍 경쟁 상황 (Race Condition)
브라우저 스크롤 복원 메커니즘: 브라우저는 DOM이 충분히 렌더링되고 레이아웃이 안정화된 후에 스크롤 위치를 복원합니다. 하지만 prefetch된 데이터는 이 "충분히 렌더링" 시점을 예상보다 훨씬 빠르게 만듭니다.
React 렌더링 사이클과의 충돌:
prefetch 없이: 데이터 로딩 → 렌더링 → 레이아웃 → 스크롤 복원 (충분한 시간) prefetch 있을 때: 즉시 렌더링 → 레이아웃 → 스크롤 복원 시점 놓침
무한스크롤에서 더 심각한 이유
렌더링 복잡성: 무한스크롤로 여러 페이지가 로딩된 상태에서는 DOM 요소가 매우 많습니다. 이 많은 요소들이 한 번에 복원되면서 브라우저의 스크롤 복원 알고리즘이 혼란을 겪을 수 있어요.
Virtual Scrolling 없는 경우: 모든 아이템이 실제 DOM에 존재하므로 레이아웃 계산이 복잡해집니다. 브라우저가 정확한 스크롤 위치를 계산하기 전에 React가 렌더링을 완료해버리는 거죠.
동적 컨텐츠 높이: 무한스크롤의 각 아이템 높이가 동적이면, 브라우저가 스크롤 위치를 계산할 때 실제 높이와 예상 높이가 달라질 수 있습니다.
간헐적 발생 이유
네트워크 상태에 따른 변동:
네트워크가 빠를 때: prefetch 효과가 극대화되어 문제 발생 확률 높음 네트워크가 느릴 때: 자연스럽게 렌더링이 지연되어 문제 발생 확률 낮음
브라우저별 차이: 각 브라우저의 스크롤 복원 구현이 미묘하게 달라서, 같은 코드도 브라우저에 따라 다르게 동작할 수 있습니다.
디바이스 성능: 고성능 디바이스에서는 렌더링이 매우 빨라서 문제가 더 자주 발생하고, 저성능 디바이스에서는 자연스럽게 지연되어 문제가 덜 발생합니다.
사실 내가 여기서 파악한건 디바이스 성능과 브라우저별 차이 였다. 내 휴대폰으로 해봤을 떈, 전혀 문제가 없었지만 데스크톱으로 웹을 실행했었을 떄는 간헐적으로 발생했었다. 따라서 SRC에서 prefetch문을 삭제하였더니 왠걸 다시는 스크롤이 상단으로 올라가지 않았다. 드디어 완벽하게 디버깅을 해냈다.
맺는말
스크롤의 위치는 완벽하게 복원했으나 성공한 디버깅3의 과정에서 이론이 아직 완벽하지않아 여전히 찜찜한 상태이긴 하다. 또 개발과정에서 개발서버와 프로덕션서버의 차이를 전혀 인지하지못한 상태로 개발했다는 것이 조금 아쉽게 와닿았다.앞으로는 개발할때 이러한 민감한 타이밍 방식에 대해 깊히 고민하고 코딩을 하려한다.
추가적으로 당장은 해야할 것이 많아 이 해당과정에서 얻은 자세한 것들은 조금 더 천천히 작성해보려고한다.
1. useParmas() 동작방식 및 컴포넌트 리렌더링 과정 및 관련 훅들 업데이트,
2. 브라우저의 기본 url 히스토리 관리와, 넥스트의 url 관리
등에 대해 시각이 조금 튼거 같고 공부해야겠다는 생각이 들었다. 일단 만들었으니까 한잔하자.