Zustand와 React 19를 이용한 무한 스크롤: Async 함정

발행: (2025년 12월 15일 오후 01:01 GMT+9)
8 min read
원문: Dev.to

Source: Dev.to

2025년 **Solo SaaS Development – Design, Implementation, and Operations Advent Calendar 2025**의 15일 차.
어제 글에서는 “Mobile‑First Design”을 다루었습니다. 이 포스트에서는 Zustand와 React 19를 사용해 무한 스크롤을 구현하면서 마주친 함정들과 그 해결 방법을 설명합니다.

무한 스크롤이란?

무한 스크롤은 사용자가 페이지 하단에 가까워질 때 자동으로 다음 콘텐츠 묶음을 로드합니다—Twitter와 Instagram에서 익숙한 패턴이죠. 전통적인 페이지네이션(“다음” 클릭)과 비교하면 더 부드러운 경험을 제공하지만, 구현 과정에서 미묘한 버그가 숨어 있을 수 있습니다.

내 인디 프로젝트 Memoreru의 요구사항은 다음과 같습니다:

  • 공개, 팀, 비공개 세 가지 범위와 북마크 뷰를 전환
  • 각 뷰마다 독립적인 페이지네이션 상태 유지
  • SSR로 초기 데이터를 표시하고 클라이언트에서 추가 로드

간단해 보이지만, 개발 과정에서 여러 문제가 드러났습니다.

라이브러리와 아키텍처

스크롤 감지를 라이브러리에 위임하기

스크롤 감지와 로딩 상태 관리를 위해 **react-infinite-scroll-component**를 사용했습니다.

}        // Loading display
  scrollThreshold={0.6}               // Trigger at 60 % scroll
>
  {items.map(item => (
    
  ))}

이 라이브러리는 IntersectionObserver와 컨테이너 감지와 같은 번거로운 세부 사항을 추상화해 주어 핵심 기능에 집중할 수 있게 해줍니다. scrollThreshold를 0.6으로 설정하면 사용자가 하단에 도달하기 전에 로딩이 시작돼 체감 대기 시간이 줄어듭니다.

Zustand로 다중 스코프 관리하기

각 뷰(공개, 팀, 비공개, 북마크)마다 자체적인 “아이템 목록”, “현재 페이지”, “추가 여부”, “로딩” 플래그가 필요합니다. Zustand는 이를 위한 가볍고 보일러플레이트가 없는 스토어를 제공합니다.

interface ContentStore {
  // Items per scope
  publicItems: ContentItem[];
  privateItems: ContentItem[];
  teamItems: ContentItem[];
  bookmarkItems: ContentItem[];

  // Pagination state (per scope)
  pagination: {
    public: { page: number; hasMore: boolean };
    private: { page: number; hasMore: boolean };
    team: { page: number; hasMore: boolean };
    bookmarks: { page: number; hasMore: boolean };
  };

  // Loading state (per scope)
  loadingState: {
    public: boolean;
    private: boolean;
    team: boolean;
    bookmarks: boolean;
  };
}

Zustand의 최소한의 API 덕분에 스토어를 이해하기 쉬우면서도 사용자가 탭을 전환할 때 각 스코프의 상태를 유지할 수 있습니다.

함정 1: 동일한 데이터가 두 번 표시됨

근본 원인

중복 아이템이 나타난 이유는 API가 이미 가져온 아이템을 다시 반환했기 때문입니다(예: 페이지 1 이후에 새 아이템이 추가되어 페이지 2의 첫 번째 아이템이 페이지 1의 마지막 아이템과 겹치는 경우).

해결책 – ID 기반 중복 검사

새 아이템을 추가하기 전에 중복을 필터링합니다. Set을 사용해 O(1) 조회를 수행합니다.

const loadMoreItems = useCallback(async () => {
  const newItems = await fetchNextPage();

  setItems(prev => {
    const existingIds = new Set(prev.map(item => item.id));
    const uniqueNewItems = newItems.filter(item => !existingIds.has(item.id));
    return [...prev, ...uniqueNewItems];
  });
}, []);

Set 방식은 큰 리스트에서 Array.includes를 반복 호출하는 것보다 훨씬 확장성이 좋습니다.

함정 2: 데이터 순서가 뒤섞임

근본 원인

빠른 스크롤링은 여러 페이지 요청을 발생시키고, 그 응답이 순서대로 도착하지 않을 수 있습니다—전형적인 레이스 컨디션입니다:

Page 1 request → starts
Page 2 request → starts (fast scroll)
Page 2 response → arrives first
Page 1 response → arrives later

그 결과 페이지 1 데이터가 페이지 2 데이터 뒤에 추가됩니다.

해결책 – Ref로 로딩 상태 추적하기

React 상태 업데이트는 비동기이므로, 변경 가능한 ref가 동기적인 “로드 중?” 플래그를 제공합니다.

const loadingRef = useRef(false);

const loadMore = useCallback(async () => {
  if (loadingRef.current) return;   // Prevent overlapping requests
  loadingRef.current = true;

  try {
    const newItems = await fetchNextPage();
    // Append newItems safely...
  } finally {
    loadingRef.current = false;
  }
}, []);

UI 피드백을 위해 useStateloading 상태를 노출할 수 있지만, ref는 단순히 중복 요청을 방지합니다.

함정 3: SSR 데이터 사라짐

근본 원인

Next.js SSR을 통해 가져온 초기 데이터가 클라이언트 측 하이드레이션 후 사라지는 경우가 있습니다. 이는 이후 클라이언트 요청이 빈 배열을 반환해 사전 렌더링된 아이템을 덮어쓰기 때문입니다.

해결책 – SSR 데이터 보호하기

SSR 데이터가 로드되었는지 추적하고 빈 응답으로 덮어쓰는 것을 방지합니다.

const fetchData = useCallback(async () => {
  const items = await fetch(apiUrl).then(res => res.json());

  // Skip overwriting when SSR data exists and the new payload is empty
  if (store.isSSRDataLoaded && store.items.length > 0 && items.length === 0) {
    console.warn('Blocked overwriting SSR data');
    return;
  }

  updateStore(items);
}, []);

이상적으로는 SSR과 클라이언트 요청이 동일한 인증 및 필터 파라미터를 사용해야 하지만, 방어적인 검사는 견고성을 높여줍니다.

React 19 고려 사항

React 19는 보다 적극적인 자동 배칭을 도입했으며, 이로 인해 로컬 컴포넌트 상태와 Zustand 스토어 간의 상태 업데이트 순서가 바뀔 수 있습니다. 결정적인 순서가 필요할 때는 로컬 업데이트를 flushSync로 감싸세요.

import { flushSync } from 'react-dom';

const updateItems = useCallback((newItems) => {
  let mergedItems;

  flushSync(() => {
    setLocalState(prev => {
      mergedItems = [...prev, ...newItems];
      return mergedItems;
    });
  });

  // Now the Zustand store can safely use `mergedItems`
  useStore.setState({ items: mergedItems });
}, []);

flushSync를 사용하면 React 업데이트가 동기적으로 완료되도록 강제해 Zustand 변이 후에 배치되는 것을 방지합니다. 이는 React 19의 새로운 배칭 동작에서만 나타날 수 있는 미묘한 버그를 제거합니다.

Back to Blog

관련 글

더 보기 »

LLM 채팅 UI에서 240 FPS를 추구하기

요약: 나는 React UI에서 스트리밍 LLM 응답을 위한 다양한 최적화를 테스트하기 위해 벤치마크 스위트를 구축했다. 주요 요점: 1. 먼저 적절한 상태를 구축하고, 그 다음에 최적화를 적용한다…

새로운 React 19 Hooks (예시 포함)

React 19은 비동기 작업, 폼 관리 및 낙관적 UI 업데이트에 대한 보다 세밀한 제어를 제공하는 여러 새로운 hooks를 도입합니다. 이러한 hooks는 …