- 발행일
무한 스크롤에서는 왜 이슈가 생긴 걸까 — 상태의 주인이 둘일 때
무한 스크롤에서는 왜 이슈가 생긴 걸까
[TripTune](/blog/웹 기술로 만드는 협업형 여행 계획 플랫폼 TripTune 개발)에는 무한 스크롤이 세 군데 있습니다 — 여행지 탐색 목록, 일정 목록 검색, 일정 편집의 여행 루트. 그리고 세 군데 모두에서 각기 다른 이슈를 겪었어요. 이 글의 원래 버전은 "Intersection Observer를 써라, 디바운스를 걸어라" 같은 일반론이었는데, 개고하면서 보니 실제로 겪은 이슈는 그런 게 아니었습니다. 전부 한 문장으로 수렴해요:
누적된 목록 데이터의 주인이 누구인지가 흐릿할 때 이슈가 났다.
무한 스크롤은 일반 목록과 달리 여러 페이지의 응답을 클라이언트가 누적합니다. 그 누적본을 누가 들고 있느냐(컴포넌트 state? 쿼리 캐시? 전역 스토어?)가 설계의 전부인데, 처음엔 그게 설계 결정인 줄도 몰랐습니다.
사례 1. 주인이 둘이었다 — react-query 캐시 vs zustand 스토어
일정 편집 화면의 여행 루트 목록이 제일 아팠습니다. 이 목록은 성격이 특이해요 — 서버에서 불러온 여행지 목록이면서, 동시에 사용자가 드래그로 순서를 바꾸고 드롭존으로 삭제하는 편집 대상입니다.
처음 구현은 서버 데이터니까 react-query(무한 스크롤 포함), 편집 상태니까 zustand — 교과서적 분리였습니다. 그런데 다음 페이지를 불러올 때마다 문제가 터졌어요. react-query가 캐시에 새 페이지를 누적하는 동안, 사용자는 zustand 쪽 목록의 순서를 바꾸고 있으니, 두 소유자의 데이터를 병합할 때마다 "서버 순서 vs 사용자가 만진 순서" 충돌이 났습니다. 페이지를 불러왔더니 방금 바꾼 순서가 되돌아가 있는 식으로요.
결론은 한쪽을 죽이는 것이었습니다. 이 화면에서 react-query를 걷어내고, zustand가 유일한 주인이 되도록 페이지 로드(useInView + fetch-and-merge)까지 스토어 액션으로 옮겼어요. 지금도 소스에 당시의 결정이 주석으로 남아 있습니다:
// ScheduleRoute.tsx — 실제 소스의 주석
// 무한 스크롤 준비
// 이전에는 react-query로 했었음
// 병합 이슈로 인하여 zustand만 살리기로!
"서버 상태는 react-query, 클라이언트 상태는 zustand"라는 원칙은 여전히 옳지만, 편집 가능한 무한 스크롤 목록은 서버 상태이자 클라이언트 상태라 원칙이 답을 안 줍니다. 이 화면에서 목록의 본질은 "서버 데이터의 캐시"가 아니라 "사용자가 편집 중인 문서" 였고, 그래서 주인은 스토어여야 했어요.
사례 2. 주인이 너무 약했다 — 페이지 번호를 useState로
일정 검색의 무한 스크롤은 반대 사례입니다. 처음엔 페이지 번호를 컴포넌트 useState로 들고 직접 누적했는데, 탭을 전환하거나 상세에 다녀오면 컴포넌트가 언마운트되며 누적본과 페이지 번호가 통째로 증발했습니다. 돌아올 때마다 1페이지부터 다시.
이건 사례 1과 반대로 목록의 본질이 순수한 서버 데이터 캐시니까, 주인을 컴포넌트에서 쿼리 캐시로 승격하는 게 답이었습니다. useInfiniteQuery로 옮기면서 검색어·탭을 쿼리 키에 넣으니(['scheduleListSearch', keyword, type]) 조건이 바뀌면 자동 리셋되고, 재방문 시 캐시가 그대로 살아 있었어요.
useInfiniteQuery({
queryKey: ['scheduleListSearch', keyword, type],
queryFn: ({ pageParam }) => fetchScheduleListSearch(pageParam || 1, keyword, type),
enabled: !!keyword && enabled, // 빈 검색어면 요청 자체를 차단
getNextPageParam: (lastPage) => {
if (!lastPage?.data) return undefined // 에러 응답 방어 — undefined = 끝
const { currentPage, totalPages } = lastPage.data
return currentPage < totalPages ? currentPage + 1 : undefined
},
})
getNextPageParam의 방어도 실전에서 넣은 것입니다. 서버가 에러 응답을 200에 실어 보내던 시기가 있었는데, lastPage.data가 없으면 다음 페이지 판정이 터지면서 무한 재요청으로 이어졌거든요.
정리 — 무한 스크롤 이슈의 진짜 질문
원래 이 글에 있던 일반론(Intersection Observer, 스로틀, 중복 요청 방지)은 틀린 말은 아니지만 우리가 겪은 이슈의 원인이 아니었습니다. 실제 원인은 매번 소유권이었어요.
| 목록의 본질 | 주인 | 우리 사례 |
|---|---|---|
| 순수한 서버 데이터 캐시 | 쿼리 캐시 (useInfiniteQuery) | 여행지 탐색·일정 검색 |
| 사용자가 편집 중인 문서 | 스토어 (zustand) — 페이지 로드도 스토어 액션으로 | 여행 루트 편집 |
| 컴포넌트 로컬 | (무한 스크롤에서는 거의 답이 아님) | 초기 구현의 실패 사례 |
라이브러리 선택 기준은 무한 스크롤 비교에, useInfiniteQuery·useSWR 구현 코드와 각 예제의 함정은 구현 편에 따로 정리했습니다. 원래 이 글 뒤에 붙어 있던 데이터 패칭 라이브러리 카탈로그(SWR·Apollo·RTK Query·Firebase)는 제목과 무관한 나열이라 걷어냈습니다.