Zustand와 React 19를 이용한 무한 스크롤: Async 함정
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 피드백을 위해 useState로 loading 상태를 노출할 수 있지만, 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의 새로운 배칭 동작에서만 나타날 수 있는 미묘한 버그를 제거합니다.