atomWithHash로 URL 기반 페이지네이션 구현하기
Jotai의 atomWithHash를 이용하여 페이지네이션을 구현해봅시다.
블로그 포스트가 늘어나면서 페이지네이션이 필요해졌습니다.
단순히 작동하는 것을 넘어, URL 상태와 동기화되고 재사용 가능하며 구조적으로 깔끔한 페이지네이션을 만들고 싶어졌어요.
왜 atomWithHash인가?
페이지네이션 상태를 관리하는 방법은 여러 가지가 있습니다.
고려한 방법들
1. useState 사용
const [currentPage, setCurrentPage] = useState(1);- 가장 간단하지만 새로고침 시 상태가 사라집니다.
- URL에 페이지 정보가 없어 공유가 불가능합니다.
2. URL Query Parameter
/blog?page=2- Next.js의
useSearchParams로 관리 - 이미 카테고리/태그 필터링에 query parameter를 사용 중이었습니다.
/blog?category=Frontend&tag=React&page=2처럼 URL이 길어지게 됩니다.
3. URL Hash
/blog#page=2- Query parameter와 분리되어 깔끔합니다.
- 필터링은 query, 페이지는 hash로 역할이 명확해집니다.
atomWithHash가 이 방식을 자동으로 처리해줍니다.
4. LocalStorage
- 브라우저에 저장되지만 공유가 불가능합니다.
- 서버 사이드 렌더링과 맞지 않습니다.
atomWithHash를 이용하다.
jotai-location의 atomWithHash는 URL 해시와 Jotai atom을 자동으로 양방향 동기화됩니다.
export const currentPageAtom = atomWithHash("page", 1);이 한 줄을 통해, 다음과 같은 동작을 기대할 수 있습니다.
- atom 값 변경 → URL 해시 자동 업데이트
- URL 해시 변경 → atom 값 자동 업데이트
- 브라우저 뒤로/앞으로가기 자동 지원
청크 기반 네비게이션을 선택한 이유
페이지가 많을 때 모든 페이지 번호를 보여주기에는 UX가 떨어져 보입니다.
저는 여러 방식을 생각을 해보았는데요,
고려한 방식들
1. 무한 스크롤
- 페이지 개념이 없어 URL로 특정 위치 공유 불가
- SEO에 불리
- 사용자가 “어디까지 봤는지” 파악 어려움
2. 생략 표시 (1 2 3 … 10)
- 중간 페이지로 바로 가기 어려움
- ”…”을 클릭할 수 없어 답답함
- 많이 쓰이지만 UX가 좋지 않다
3. 슬라이딩 윈도우 (3 4 [5] 6 7)
- 현재 페이지 중심으로 표시
- 페이지 이동 시 번호가 계속 바뀌어 혼란스러움
- 전체 중 어디쯤인지 파악 어려움
4. 청크 단위 (1 2 3 4 5 >)
- 5개씩 묶어서 표시
>버튼으로 다음 5개로 이동- 명확한 구간 구분
청크 단위 기반으로 진행
총 3페이지: 1 2 3
총 6페이지: 1 2 3 4 5 >
< 6
총 13페이지: 1 2 3 4 5 >
< 6 7 8 9 10 >
< 11 12 13청크 이동 로직:
- 페이지 5에서
>클릭 → 페이지 6으로 (다음 청크 첫 페이지) - 페이지 11에서
<클릭 → 페이지 10으로 (이전 청크 마지막 페이지가 아니라 첫 페이지)
이렇게 하면 “이전/다음 5개”라는 명확한 단위로 탐색할 수 있습니다.
Atom 설계 철학
Derived Atom 활용
export const totalPagesAtom = atom((get) => {
const posts = get(filteredPostsAtom);
return Math.ceil(posts.length / POSTS_PER_PAGE);
});- 필터가 변경되면 포스트 수가 변경됩니다.
- 전체 페이지 수도 자동으로 재계산 되어야 합니다.
- Derived atom은 의존하는 atom이 변경될 때 자동으로 재계산된다는 점을 고려하였습니다.
export const paginatedPostsAtom = atom((get) => {
const posts = get(filteredPostsAtom);
const currentPage = get(currentPageAtom);
const startIndex = (currentPage - 1) * POSTS_PER_PAGE;
return posts.slice(startIndex, startIndex + POSTS_PER_PAGE);
});- 컴포넌트가 “현재 페이지의 포스트”만 구독하면 됩니다.
- 다른 페이지로 이동해도 이 atom만 업데이트 됩니다.
- 슬라이싱 로직이 atom에 캡슐화되어 컴포넌트는 단순해집니다!
상태는 최소한으로, 계산은 자동으로
직접 관리하는 상태:
currentPageAtom- 사용자가 직접 변경
자동 계산되는 상태:
totalPagesAtom- filteredPosts 기반으로 계산paginatedPostsAtom- filteredPosts + currentPage 기반으로 계산
이렇게 하면 상태 불일치가 발생할 수 없습니다!
currentPage를 바꾸면 paginatedPostsAtom이 자동으로 업데이트 되거든요.
필터 변경 시 페이지 리셋
useEffect(() => {
setCurrentPage(1);
}, [searchParams]);- 사용자가 페이지 10에서 다른 카테고리로 필터링
- 새 필터 결과는 2페이지만 있습니다.
- 페이지 10 그대로면 빈 화면
왜 1페이지로?
- 새로운 필터 = 새로운 검색
- 처음부터 보는 게 자연스럽습니다.
정리
atomWithHash:
- URL 상태 동기화로 공유 가능성 확보
- 브라우저 네이티브 히스토리 활용
청크 기반 네비게이션:
- 명확한 구간으로 사용자 혼란 최소화
- 조건부 렌더링으로 깔끔한 UI
Derived Atom:
- 상태는 최소화, 계산은 자동화
- 상태 불일치 원천 차단
어떻게 해야 UX적으로 조금 더 좋을지 고민을 좀 해보았어요.
여러 시행착오도 있었지만, 조금 더 깔끔하게 구현해보고자 노력하였습니다.
이 페이지네이션이 10… 이상 넘게 찍히는 날 까지 꾸준히 작성 해보겠습니다.