무한 스크롤 vs 페이지네이션, 내가 내린 선택과 후회
최근 회사에서 관리자 페이지를 리뉴얼하면서 "페이지네이션을 할까, 무한 스크롤을 할까" 고민이 생겼습니다. 처음에는 단순하게 생각했는데, 막상 구현하다 보니 생각보다 복잡한 문제들이 많더라고요. 특히 백엔드 API와의 협업에서 예상치 못한 이슈들을 겪으면서 배운 것들을 공유해봅니다.
처음 마주친 상황
우리 관리자 페이지에는 주문 목록, 사용자 목록, 상품 목록 등 데이터가 많은 테이블들이 있었어요. 기존에는 단순한 테이블에 20개씩 보여주고 페이지 번호로 넘기는 구조였는데, 새로 디자인된 UI는 좀 더 모던한 느낌이었거든요.
React로 구현하면서 처음에는 간단하게 생각했어요:
const [currentPage, setCurrentPage] = useState(1);
const [data, setData] = useState([]);
const [totalCount, setTotalCount] = useState(0);
useEffect(() => {
fetchData(currentPage);
}, [currentPage]);
첫 번째 선택: 전통적인 페이지네이션
처음에는 가장 익숙한 페이지네이션을 선택했어요. 백엔드에서 총 개수도 주고, 페이지 정보도 주니까 구현하기 쉬워 보였거든요.
interface PaginationResponse<T> {
data: T[];
totalCount: number;
currentPage: number;
totalPages: number;
hasNext: boolean;
hasPrev: boolean;
}
const OrderList = () => {
const [response, setResponse] = useState<PaginationResponse<Order>>();
const fetchOrders = async (page: number) => {
const result = await api.get(`/orders?page=${page}&limit=20`);
setResponse(result);
};
return (
<div>
{response?.data.map(order => <OrderCard key={order.id} order={order} />)}
<Pagination
current={response?.currentPage}
total={response?.totalPages}
onChange={setCurrentPage}
/>
</div>
);
};
예상치 못한 성능 문제
처음에는 잘 돌아갔어요. 그런데 데이터가 늘어나면서 문제가 생기기 시작했습니다. 특히 검색 기능을 추가하고 나서부터요.
어느 날 백엔드 개발자가 찾아와서 "어드민에서 COUNT 쿼리 때문에 DB 성능이 안 좋다"고 하더라고요. 알고 보니 총 개수를 계산하는 게 엄청 무거운 작업이었던 거죠.
// 이렇게 하면 백엔드에서 COUNT(*) 쿼리가 날아감
const fetchData = (page: number, searchTerm: string) => {
return api.get(`/orders?page=${page}&search=${searchTerm}&limit=20`);
// 뒤에서는 SELECT COUNT(*) FROM orders WHERE ... 이 실행됨
};
특히 검색 조건이 복잡할 때 COUNT 쿼리가 몇 초씩 걸리는 경우도 있었어요. 사용자는 그냥 첫 번째 페이지만 보려는 건데 전체 개수를 세느라 기다려야 하는 상황이었죠.
두 번째 시도: 무한 스크롤
그래서 무한 스크롤로 바꿔보기로 했어요. 총 개수를 안 세도 되니까 백엔드 부담도 줄고, UX도 더 좋을 것 같았거든요.
const useInfiniteScroll = (fetchMore: () => void) => {
useEffect(() => {
const handleScroll = () => {
if (
window.innerHeight + document.documentElement.scrollTop
>= document.documentElement.offsetHeight - 1000
) {
fetchMore();
}
};
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
}, [fetchMore]);
};
const OrderList = () => {
const [orders, setOrders] = useState<Order[]>([]);
const [hasNext, setHasNext] = useState(true);
const [loading, setLoading] = useState(false);
const fetchMore = useCallback(async () => {
if (loading || !hasNext) return;
setLoading(true);
const lastId = orders[orders.length - 1]?.id;
const newOrders = await api.get(`/orders?lastId=${lastId}&limit=20`);
setOrders(prev => [...prev, ...newOrders.data]);
setHasNext(newOrders.hasNext);
setLoading(false);
}, [orders, loading, hasNext]);
useInfiniteScroll(fetchMore);
return (
<div>
{orders.map(order => <OrderCard key={order.id} order={order} />)}
{loading && <Spinner />}
</div>
);
};
무한 스크롤의 함정들
처음에는 괜찮아 보였는데, 실제 사용하다 보니 여러 문제들이 생겼어요:
1. 검색할 때마다 리셋 문제
검색 조건이 바뀔 때마다 기존 데이터를 다 지우고 새로 시작해야 하는데, 이게 생각보다 복잡했어요:
const handleSearch = useCallback((searchTerm: string) => {
setOrders([]); // 기존 데이터 클리어
setHasNext(true);
// 새로 검색 시작
fetchOrders(searchTerm);
}, []);
2. 브라우저 뒤로가기 문제
사용자가 상세 페이지 갔다가 뒤로 오면 스크롤 위치도 사라지고, 로드했던 데이터도 다 사라져서 처음부터 다시 로딩해야 했어요.
3. 특정 항목으로 바로 가기 어려움
"100번째 주문을 보여줘" 같은 요구사항이 생겼을 때 무한 스크롤로는 답이 없었어요.
절충안: Cursor 기반 페이지네이션
결국 찾은 해답은 cursor 기반 페이지네이션이었어요. 페이지 번호 대신 마지막 아이템의 ID를 기준으로 다음 데이터를 가져오는 방식이죠.
interface CursorPaginationProps {
cursor?: string;
hasNext: boolean;
hasPrev: boolean;
}
const OrderList = () => {
const [orders, setOrders] = useState<Order[]>([]);
const [pagination, setPagination] = useState<CursorPaginationProps>({
hasNext: true,
hasPrev: false
});
const fetchPage = async (cursor?: string, direction: 'next' | 'prev' = 'next') => {
const response = await api.get(`/orders?cursor=${cursor}&direction=${direction}&limit=20`);
setOrders(response.data);
setPagination({
cursor: response.cursor,
hasNext: response.hasNext,
hasPrev: response.hasPrev
});
};
return (
<div>
{orders.map(order => <OrderCard key={order.id} order={order} />)}
<div className="pagination">
<button
disabled={!pagination.hasPrev}
onClick={() => fetchPage(pagination.cursor, 'prev')}
>
이전
</button>
<button
disabled={!pagination.hasNext}
onClick={() => fetchPage(pagination.cursor, 'next')}
>
다음
</button>
</div>
</div>
);
};
현재 사용하는 하이브리드 방식
지금은 상황에 따라 다르게 적용하고 있어요:
관리자 페이지
정확한 페이지 이동이 필요한 곳은 cursor 페이지네이션 + React Query를 조합해서 사용해요:
const useOrdersPagination = (cursor?: string) => {
return useQuery({
queryKey: ['orders', cursor],
queryFn: () => fetchOrders(cursor),
keepPreviousData: true,
});
};
일반 사용자용 목록
UX가 중요한 곳은 무한 스크롤 + Intersection Observer API를 사용합니다:
const useIntersectionObserver = (
ref: RefObject<Element>,
callback: () => void
) => {
useEffect(() => {
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
callback();
}
},
{ threshold: 0.1 }
);
if (ref.current) {
observer.observe(ref.current);
}
return () => observer.disconnect();
}, [ref, callback]);
};
백엔드와의 협업에서 배운 점
이 과정에서 백엔드 개발자와 많은 대화를 나눴어요:
- COUNT 쿼리는 정말 비싸다: 특히 조건이 복잡하거나 JOIN이 많으면 더더욱
- OFFSET은 페이지가 뒤로 갈수록 느려진다:
OFFSET 10000
은 앞의 10000개를 다 스캔해야 함 - Cursor 방식이 성능상 가장 좋다: 인덱스를 효율적으로 사용할 수 있음
지금 내가 내린 결론
완벽한 정답은 없는 것 같아요. 하지만 지금은 이런 기준으로 선택하고 있습니다:
- 데이터가 많고 성능이 중요하다면: Cursor 페이지네이션
- 사용자 경험이 중요하다면: 무한 스크롤
- 관리 기능에서 정확한 위치가 필요하다면: 전통적 페이지네이션 (단, 총 개수는 캐싱)
그리고 무엇보다 백엔드 개발자와 미리 충분히 논의하는 게 중요하더라고요. 프론트에서는 간단해 보이는 기능이 백엔드에서는 엄청난 부하를 일으킬 수 있거든요.
혹시 비슷한 고민을 해보신 분들이 있다면, 어떤 방식을 선택하셨나요? 각각의 장단점에 대한 경험도 궁금합니다!