Next.js
TypeScript
React
Web Development
Performance
왜 하이브리드 페이지네이션인가?
블로그에 검색 기능을 추가하면서 고민했던 가장 큰 문제는 "어떻게 페이지네이션을 처리할 것인가"였습니다. 서버 사이드와 클라이언트 사이드, 각각의 장단점이 명확했기 때문에 상황에 따라 다른 전략을 사용하기로 결정했습니다.
문제 상황
초기 구현의 한계
처음에는 단순하게 생각했습니다. "현재 페이지의 5개 포스트에서 검색하면 되겠지?"
// 잘못된 접근
const filteredPosts = initialPosts.filter(post =>
post.title.includes(searchQuery)
); // 현재 페이지의 5개만 검색
문제점:
- 검색 결과가 9개여도 현재 페이지에 없으면 찾을 수 없음
- 전체 포스트를 검색할 수 없어 사용자 경험 저하
- 페이지네이션이 검색 결과 개수와 맞지 않음
해결 전략: 하이브리드 접근
핵심 아이디어
검색어가 없을 때는 서버 사이드, 있을 때는 클라이언트 사이드
// 서버에서 데이터 준비
const initialPosts = await getPosts(page, 5, tag); // 현재 페이지만
const allPosts = await getAllPosts(tag); // 검색용 전체 데이터
1. 검색어 없을 때 - 서버 사이드 페이지네이션
export async function getPosts(
page: number = 1,
limit: number = 5,
tag?: string
) {
const from = (page - 1) * limit;
const to = from + limit - 1;
let query = supabase
.from("posts")
.select("*")
.eq("published", true)
.range(from, to)
.order("created_at", { ascending: false });
if (tag) {
query = query.contains("tags", [tag]);
}
return query;
}
장점:
- 필요한 데이터만 전송 (5개만)
- 서버 리소스 효율적 사용
- 대량의 포스트에도 안정적인 성능
2. 검색어 있을 때 - 클라이언트 사이드 필터링
const hasSearchQuery = search.trim().length > 0;
// 검색할 데이터 소스 결정
const postsToFilter = hasSearchQuery ? allPosts : initialPosts;
// 클라이언트 사이드 필터링
const filteredPosts = postsToFilter.filter(post => {
const searchLower = search.toLowerCase();
const matchTitle = post.title.toLowerCase().includes(searchLower);
const matchExcerpt = post.excerpt?.toLowerCase().includes(searchLower);
const matchTags = post.tags?.some(tag =>
tag.toLowerCase().includes(searchLower)
);
return matchTitle || matchExcerpt || matchTags;
});
// 클라이언트 사이드 페이지네이션
const searchTotalPages = Math.ceil(filteredPosts.length / 5);
const paginatedPosts = filteredPosts.slice(
(searchPage - 1) * 5,
searchPage * 5
);
장점:
- 즉각적인 검색 결과 (서버 요청 없음)
- 타이핑할 때마다 실시간 필터링
- 전체 포스트에서 검색 가능
구현 세부사항
페이지네이션 상태 관리
"use client";
export default function PostListSection({
initialPosts,
allPosts,
totalPages,
currentPage
}) {
const [search, setSearch] = useState("");
const [searchPage, setSearchPage] = useState(1);
// 검색 여부에 따라 다른 페이지네이션 사용
const hasSearchQuery = search.trim().length > 0;
const effectiveTotalPages = hasSearchQuery ? searchTotalPages : totalPages;
const effectiveCurrentPage = hasSearchQuery ? searchPage : currentPage;
// ...
}
검색어 변경 시 페이지 리셋
const handleSearchChange = (value: string) => {
setSearch(value);
// 검색 시작하거나 검색어 삭제 시 첫 페이지로
if (value.trim().length === 0 ||
(search.trim().length === 0 && value.trim().length > 0)) {
setSearchPage(1);
}
};
이유: "React" 검색 후 2페이지 → "Next.js" 검색 시에도 2페이지에 머무르는 문제 방지
데이터 흐름 다이어그램
검색어 없음:
┌──────────────┐
│ 서버 요청 │ → initialPosts (5개)
│ ?page=2 │ → totalPages
└──────────────┘
↓
서버 사이드 페이지네이션
검색어 있음:
┌──────────────┐
│ 서버 요청 │ → allPosts (전체)
│ (초기 로드) │
└──────────────┘
↓
클라이언트 필터링
↓
클라이언트 사이드 페이지네이션
성능 고려사항
ISR 캐싱으로 초기 로딩 최적화
// app/blog/page.tsx
export const revalidate = 60; // 60초 캐싱
export default async function BlogPage({ searchParams }) {
const allPosts = await getAllPosts(tag); // 캐싱됨
// ...
}
트레이드오프
장점:
- 검색 시 즉각적인 반응
- 일반 탐색 시 최소한의 데이터 전송
단점:
allPosts를 항상 로드하므로 초기 데이터 크기 증가- 포스트가 매우 많을 때 (100개 이상) 클라이언트 메모리 사용 증가
향후 개선 방향
1. 조건부 데이터 로딩
// 검색 버튼을 클릭했을 때만 allPosts 로드
const [allPostsLoaded, setAllPostsLoaded] = useState(false);
const handleSearchClick = async () => {
if (!allPostsLoaded) {
const posts = await fetchAllPosts();
setAllPosts(posts);
setAllPostsLoaded(true);
}
};
2. 서버 사이드 검색으로 전환
// Supabase Full-text search 활용
const { data } = await supabase
.from("posts")
.select()
.textSearch("title,excerpt", searchQuery);
결론
하이브리드 페이지네이션은 "한 가지 방법만 고집하지 않는다"는 유연한 사고의 결과입니다.
핵심 원칙:
- 검색: 사용자 경험 우선 (즉각적인 반응)
- 일반 탐색: 성능 우선 (필요한 것만)
상황에 맞는 최적의 전략을 선택하는 것, 그것이 좋은 아키텍처 설계의 시작입니다.