무한 스크롤 vs 페이지네이션, 내가 내린 선택과 후회

2025. 7. 7. 23:09·Frontend Development
728x90

최근 회사에서 관리자 페이지를 리뉴얼하면서 "페이지네이션을 할까, 무한 스크롤을 할까" 고민이 생겼습니다. 처음에는 단순하게 생각했는데, 막상 구현하다 보니 생각보다 복잡한 문제들이 많더라고요. 특히 백엔드 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]);
};

백엔드와의 협업에서 배운 점

이 과정에서 백엔드 개발자와 많은 대화를 나눴어요:

  1. COUNT 쿼리는 정말 비싸다: 특히 조건이 복잡하거나 JOIN이 많으면 더더욱
  2. OFFSET은 페이지가 뒤로 갈수록 느려진다: OFFSET 10000은 앞의 10000개를 다 스캔해야 함
  3. Cursor 방식이 성능상 가장 좋다: 인덱스를 효율적으로 사용할 수 있음

지금 내가 내린 결론

완벽한 정답은 없는 것 같아요. 하지만 지금은 이런 기준으로 선택하고 있습니다:

  • 데이터가 많고 성능이 중요하다면: Cursor 페이지네이션
  • 사용자 경험이 중요하다면: 무한 스크롤
  • 관리 기능에서 정확한 위치가 필요하다면: 전통적 페이지네이션 (단, 총 개수는 캐싱)

그리고 무엇보다 백엔드 개발자와 미리 충분히 논의하는 게 중요하더라고요. 프론트에서는 간단해 보이는 기능이 백엔드에서는 엄청난 부하를 일으킬 수 있거든요.

혹시 비슷한 고민을 해보신 분들이 있다면, 어떤 방식을 선택하셨나요? 각각의 장단점에 대한 경험도 궁금합니다!

728x90
저작자표시 비영리 변경금지 (새창열림)

'Frontend Development' 카테고리의 다른 글

JavaScript의 this 바인딩: 상황별 동작 원리  (1) 2025.07.07
웹 성능 최적화의 핵심: preconnect, preload, prefetch 가이드  (1) 2025.07.04
CSS 쌓임 맥락의 모든 것: Z-Index가 작동하지 않는 이유  (2) 2025.07.03
JavaScript 매개변수 전달의 비밀: Call by Value와 참조의 모든 것  (8) 2025.07.02
Next.js Server Action: 서버와 클라이언트를 연결하는 새로운 방식  (2) 2025.07.02
'Frontend Development' 카테고리의 다른 글
  • JavaScript의 this 바인딩: 상황별 동작 원리
  • 웹 성능 최적화의 핵심: preconnect, preload, prefetch 가이드
  • CSS 쌓임 맥락의 모든 것: Z-Index가 작동하지 않는 이유
  • JavaScript 매개변수 전달의 비밀: Call by Value와 참조의 모든 것
Kun Woo Kim
Kun Woo Kim
안녕하세요, 김건우입니다! 웹과 앱 개발에 열정적인 전문가로, React, TypeScript, Next.js, Node.js, Express, Flutter 등을 활용한 프로젝트를 다룹니다. 제 블로그에서는 개발 여정, 기술 분석, 실용적 코딩 팁을 공유합니다. 창의적인 솔루션을 실제로 적용하는 과정의 통찰도 나눌 예정이니, 궁금한 점이나 상담은 언제든 환영합니다.
  • Kun Woo Kim
    WhiteMouseDev
    김건우
  • 깃허브
    포트폴리오
    velog
  • 전체
    오늘
    어제
  • 공지사항

    • [인사말] 이제 티스토리에서도 만나요! WhiteMouse⋯
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
    • 분류 전체보기 (111) N
      • Frontend Development (46) N
      • Backend Development (25) N
      • Algorithm (33)
        • 백준 (11)
        • 프로그래머스 (17)
        • 알고리즘 (5)
      • Infra (1)
      • 자료구조 (3)
  • 링크

    • Github
    • Portfolio
    • Velog
  • 인기 글

  • 태그

    frontend development
    tailwindcss
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.3
Kun Woo Kim
무한 스크롤 vs 페이지네이션, 내가 내린 선택과 후회
상단으로

티스토리툴바