- Published on
TanStack Query로 무한 스크롤과 서버 상태를 어떻게 관리할까?
- Authors

- Name
- GW
개요
게시글 목록, 댓글 목록, 검색 결과처럼 많은 데이터를 보여주는 서비스에서는 무한 스크롤(Infinite Scroll) 을 자주 사용합니다.
처음에는 단순히 페이지 번호를 증가시키면서 데이터를 가져오면 될 것처럼 보입니다.
const [page, setPage] = useState(1);
const [posts, setPosts] = useState<Post[]>([]);
const fetchPosts = async () => {
const response = await getPosts(page);
setPosts((prev) => [...prev, ...response.posts]);
};
하지만 기능이 늘어나면 단순한 데이터 조회만으로는 해결하기 어려운 문제가 생깁니다.
예를 들어
- 이전 페이지 데이터는 언제까지 유지할까?
- 이미 조회한 페이지를 다시 요청하지 않으려면?
- 필터나 정렬이 바뀌면 기존 데이터는 어떻게 관리해야 할까?
- 좋아요 같은 사용자 인터랙션 이후에는 언제 서버와 다시 동기화할까?
결국 문제는 데이터를 가져오는 것(Fetching) 이 아니라,
서버 상태(Server State)를 어떻게 캐싱하고 동기화할 것인가
로 바뀌게 됩니다.
TanStack Query(구 React Query)는 흔히 데이터 Fetching 라이브러리로 알려져 있지만,
공식 문서에서는 비동기 상태(Async State) 를 관리하기 위한 라이브러리라고 설명합니다.
즉,
서버 데이터를 가져오는 것보다 서버 상태를 캐시에 저장하고, 최신 상태와 동기화하는 것
이 TanStack Query의 핵심 역할입니다.
이번 글에서는 실제 프로젝트에서 사용했던 경험을 바탕으로
- Query Key는 어떻게 설계해야 하는지
- Infinite Query는 내부적으로 어떻게 캐시되는지
- staleTime과 gcTime은 어떻게 다르게 사용해야 하는지
- 낙관적 업데이트는 왜
cancelQueries부터 시작하는지 - TanStack Query는 어떤 방식으로 렌더링을 최적화하는지
등을 함께 살펴보겠습니다.
왜 단순한 page 상태 관리만으로는 부족할까?
무한 스크롤을 직접 구현하면 보통 다음과 같은 구조가 됩니다.
const [page, setPage] = useState(1);
const [posts, setPosts] = useState<Post[]>([]);
const [loading, setLoading] = useState(false);
const fetchPosts = async () => {
setLoading(true);
const response = await getPosts(page);
setPosts((prev) => [...prev, ...response.posts]);
setLoading(false);
};
처음에는 크게 문제가 없어 보입니다.
하지만 기능이 하나씩 추가되면서 점점 복잡해집니다.
예를 들어 게시글 목록에서 흔히 발생하는 흐름을 살펴보겠습니다.
목록 진입
↓
1페이지 조회
↓
스크롤
↓
2페이지 조회
↓
스크롤
↓
3페이지 조회
↓
좋아요 클릭
↓
목록 갱신
↓
상세 페이지 이동
↓
뒤로가기
↓
목록 복원
이 과정에서 여러 가지 고민이 생깁니다.
이전 페이지 데이터는 어떻게 유지할까?
이미 받아온 1~3페이지 데이터를 계속 유지할 것인지, 아니면 다시 요청할 것인지 결정해야 합니다.
중복 요청은 어떻게 막을까?
스크롤 이벤트가 연속으로 발생하면 같은 페이지를 여러 번 요청할 수도 있습니다.
이를 직접 제어하지 않으면 네트워크 요청이 중복될 수 있습니다.
로딩 상태는 어떻게 관리할까?
초기 진입인지, 다음 페이지를 불러오는 중인지, 새로고침인지에 따라 UI는 모두 달라집니다.
초기 로딩
↓
전체 Skeleton
다음 페이지 조회
↓
하단 Loading Indicator
새로고침
↓
기존 목록 유지 + Background Fetch
이러한 상태를 모두 직접 관리하려면 코드가 빠르게 복잡해집니다.
좋아요를 누르면 어떻게 해야 할까?
예를 들어 게시글에 좋아요를 눌렀다고 가정해 보겠습니다.
좋아요 성공
↓
현재 페이지만 수정?
모든 페이지 수정?
API를 다시 호출?
캐시만 수정?
단순히 데이터를 가져오는 것보다 데이터를 언제 다시 가져와야 하는지가 더 어려운 문제가 됩니다.
즉,
Fetch
↓
State 저장
↓
끝
이 아니라,
데이터 조회
↓
캐시 저장
↓
언제 최신 데이터를 다시 가져올까?
↓
언제 기존 데이터를 재사용할까?
↓
언제 캐시를 버릴까?
를 함께 고민해야 합니다.
이것이 TanStack Query가 해결하려는 문제입니다.
TanStack Query는 무엇을 해결할까?
TanStack Query는 서버 상태를 Cache 중심으로 관리합니다.
즉,
Server
↓
TanStack Query Cache
↓
UI
라는 구조를 기본으로 합니다.
여기서 중요한 점은
TanStack Query는 서버 데이터를 소유(Own)하는 것이 아니라 서버와 동기화(Synchronize)
하는 역할을 한다는 것입니다.
이 내용은 이전 글인 TanStack Query와 Zustand의 역할을 어떻게 나눌까? 에서도 이야기했듯이 TanStack Query의 핵심 철학입니다.
예를 들어 게시글 목록을 조회하면
API 요청
↓
TanStack Query Cache 저장
↓
컴포넌트는 Cache를 구독
↓
UI 렌더링
이런 흐름으로 동작합니다.
따라서 같은 Query를 사용하는 다른 컴포넌트도 다시 API를 호출하는 것이 아니라, 이미 존재하는 Cache를 공유하게 됩니다.
TanStack Query가 단순한 Fetch 라이브러리가 아니라, Server State Manager라고 불리는 이유도 여기에 있습니다.
Query Key는 왜 중요할까?
TanStack Query에서 가장 중요한 개념 중 하나는 Query Key 입니다.
TanStack Query는 Query Key를 기준으로
- 캐시를 저장하고
- 캐시를 조회하며
- 캐시를 갱신하고
- 캐시를 무효화합니다.
즉, Query Key는 Cache를 식별하는 키 역할을 합니다.
예를 들어 게시글 목록을 조회한다고 가정해 보겠습니다.
useQuery({
queryKey: ['posts'],
queryFn: fetchPosts,
});
여기까지는 문제가 없습니다.
하지만 필터 기능이 추가되면 어떨까요?
전체 게시글 → 질병 / 건강관리 / 예방접종 등
만약 Query Key가 그대로 유지되면
['posts'];
라면 TanStack Query는 모두 같은 데이터라고 판단합니다.
따라서 필터가 변경되어도 기존 캐시를 그대로 사용할 수 있습니다.
이 문제를 해결하려면 필터도 Query Key에 포함해야 합니다.
useQuery({
queryKey: ['posts', { category }],
queryFn: () => fetchPosts(category),
});
그러면
['posts', { category: 'all' }] → 별도의 Cache 1
['posts', { category: 'health' }] → 별도의 Cache 2
['posts', { category: 'disease' }] → 별도의 Cache 3
처럼 각각 독립적인 서버 상태로 관리됩니다.
TanStack Query 공식 문서에서도 Query Function이 의존하는 값은 Query Key에 포함해야 한다고 설명합니다.
즉,
Query Key는 단순한 문자열이 아니라 서버 상태를 구분하는 식별자입니다.
Infinite Query는 데이터를 어떻게 캐시할까?
일반적인 useQuery와 useInfiniteQuery의 가장 큰 차이 중 하나는 캐시에 저장되는 데이터의 구조입니다.
일반적인 Query가 하나의 데이터를 캐시한다면,
const { data } = useQuery({
queryKey: ['posts'],
queryFn: fetchPosts,
});
Infinite Query는 여러 페이지를 하나의 Query Cache 안에서 관리합니다.
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({
queryKey: ['posts'],
queryFn: ({ pageParam }) => fetchPosts(pageParam),
initialPageParam: null,
getNextPageParam: (lastPage) => lastPage.nextCursor,
});
TanStack Query 공식 문서에 따르면 Infinite Query의 data는 크게 다음과 같은 구조를 가집니다.
{
pages: [...],
pageParams: [...]
}
*TanStack Query - Infinite Queries
pages에는 지금까지 가져온 각 페이지의 데이터가 저장되고, pageParams에는 각 페이지를 요청할 때 사용한 pageParam이 저장됩니다.
예를 들어 다음과 같이 데이터를 가져왔다고 가정해 보겠습니다.
첫 번째 요청 (cursor = null)
↓
Page 1
posts: [A, B, C], nextCursor: 10
↓
두 번째 요청 (cursor = 10)
↓
Page 2
posts: [D, E, F], nextCursor: 20
↓
세 번째 요청 (cursor = 20)
↓
Page 3
posts: [G, H, I]
여기서 cursor는 현재 페이지의 데이터를 가져오기 위해 서버에 전달하는 값이고, nextCursor는 서버가 응답으로 내려주는 다음 페이지를 요청할 때 사용할 cursor 값입니다.
그러면 Infinite Query의 데이터는 개념적으로 다음과 같은 형태가 됩니다.
{
pages: [
{ posts: [A, B, C], nextCursor: 10 },
{ posts: [D, E, F], nextCursor: 20 },
{ posts: [G, H, I], nextCursor: null },
],
pageParams: [null, 10, 20],
}
즉, TanStack Query가 모든 게시글을 하나의 배열로 합쳐서 저장하는 것이 아니라 각 페이지의 경계를 유지한 상태로 캐시합니다.
UI에서 하나의 목록으로 보여주고 싶다면 필요한 시점에 이를 합칠 수 있습니다.
const posts = data?.pages.flatMap((page) => page.posts) ?? [];
이 구조를 이해하는 것은 중요합니다.
나중에 낙관적 업데이트를 적용하거나 특정 게시글을 캐시에서 수정할 때도 단순한 배열이 아니라 pages 내부의 각 페이지를 순회해야 하기 때문입니다.
예를 들어 특정 게시글을 찾아 수정한다면 개념적으로 다음과 같은 형태가 됩니다.
queryClient.setQueryData(['posts'], (oldData) => {
if (!oldData) {
return oldData;
}
return {
...oldData,
pages: oldData.pages.map((page) => ({
...page,
posts: page.posts.map((post) => (post.id === targetId ? { ...post, liked: true } : post)),
})),
};
});
Infinite Query의 캐시를 직접 수정할 때는 pages와 pageParams 구조를 유지해야 합니다.
다음 페이지는 어떻게 결정할까?
Infinite Query에서 중요한 또 하나의 개념이 pageParam입니다.
useInfiniteQuery({
queryKey: ['posts'],
queryFn: ({ pageParam }) => fetchPosts(pageParam),
initialPageParam: null,
getNextPageParam: (lastPage) => lastPage.nextCursor,
});
예를 들어 서버가 다음과 같이 응답한다고 가정해 보겠습니다.
{
"posts": [],
"nextCursor": 30
}
그러면
getNextPageParam: (lastPage) => lastPage.nextCursor;
를 통해 nextCursor의 값인 30이 다음 요청의 pageParam으로 전달됩니다.
queryFn: ({ pageParam }) => fetchPosts(pageParam);
그리고 더 이상 데이터가 없다면 undefined 또는 null을 반환하여 다음 페이지가 없다는 것을 표현할 수 있습니다.
getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined;
이 값을 기반으로 hasNextPage도 결정됩니다.
즉, Infinite Query는 단순히 page = page + 1을 수행하는 것이 아니라,
이전 페이지의 응답을 기반으로 다음 페이지를 가져오기 위한 정보를 결정하는 구조
라고 이해할 수 있습니다.
이러한 구조 덕분에 단순한 페이지 번호 기반 Pagination뿐 아니라 Cursor 기반 Pagination도 자연스럽게 처리할 수 있습니다.
Infinite Query가 다시 Fetch되면 어떻게 될까?
여기서 조금 더 중요한 문제가 있습니다.
이미 다음과 같이 여러 페이지를 가져온 상태라고 가정해 보겠습니다.
Page 1
↓
Page 2
↓
Page 3
↓
Page 4
그런데 이 Query가 stale(상한, 낡은) 상태가 되어 다시 Fetch되어야 한다면 어떻게 될까요?
단순하게 생각하면 Page 4만 다시 가져와도 될 것처럼 보입니다.
하지만 TanStack Query는 stale한 Infinite Query를 refetch할 때 첫 번째 페이지부터 각 페이지를 순차적으로 다시 가져옵니다.
*Infinite Query의 데이터를 다시 불러와야 할 때는 어떤 일이 일어날까
기존 Cache
Page 1
Page 2
Page 3
↓
Refetch 발생
↓
Page 1 요청
↓
Page 2 요청
↓
Page 3 요청
왜 이렇게 동작할까요?
Cursor 기반 Pagination을 생각해 보면 이유를 이해할 수 있습니다.
예를 들어 처음 데이터를 가져왔을 때 다음과 같은 관계였다고 가정해 보겠습니다.
Page 1
nextCursor = A
↓
Page 2
nextCursor = B
↓
Page 3
그런데 그동안 서버의 데이터가 변경되었다면 Page 1에서 반환하는 Cursor 자체가 달라질 수 있습니다.
기존
Page 1
↓ cursor A
Page 2
↓ cursor B
Page 3
서버 데이터 변경 후
Page 1
↓ cursor X
Page 2
↓ cursor Y
Page 3
이 상황에서 예전에 저장해 둔 Cursor를 그대로 사용하면 데이터가 중복되거나 일부 데이터가 누락될 가능성이 있습니다.
그래서 TanStack Query는 Infinite Query를 refetch할 때 첫 페이지부터 순차적으로 다시 조회하여 새로운 Cursor 체인을 따라갑니다.
공식 문서에서도 stale cursor로 인해 레코드가 중복되거나 누락되는 것을 방지하기 위한 동작이라고 설명하고 있습니다.
이 동작은 데이터의 일관성을 유지하는 데 도움이 되지만, 다른 문제를 만들기도 합니다.
캐시된 페이지가 3개라면 Refetch 시
↓
3개 페이지 순차 요청
캐시된 페이지가 30개라면 Refetch 시
↓
30개 페이지 순차 요청
즉, 사용자가 많은 페이지를 불러온 상태라면 refetch에 필요한 네트워크 요청도 함께 증가할 수 있습니다.
이 문제는 뒤에서 살펴볼 maxPages와도 연결됩니다.
staleTime은 데이터의 최신성을 결정한다
TanStack Query의 캐시를 이해할 때 가장 많이 헷갈리는 개념 중 하나가 staleTime입니다.
staleTime은 간단히 말하면
캐시된 데이터를 얼마 동안 Fresh한 데이터로 판단할 것인가
를 결정합니다.
TanStack Query의 기본 staleTime은 0입니다.
즉, Query가 성공적으로 데이터를 가져오더라도 기본 설정에서는 바로 stale 상태로 간주됩니다.
여기서 중요한 점은
stale이 되었다고 해서 데이터가 즉시 삭제되거나 바로 refetch되는 것은 아니라는 것입니다.
stale은 단순히
이 데이터는 최신이라고 보장하는 Fresh 기간이 끝났다.
라는 의미에 가깝습니다.
그리고 stale 상태의 Query는 특정 조건에서 Background Refetch의 대상이 될 수 있습니다.
기본 설정에서는 대표적으로
- 새로운 Query Instance가 Mount될 때
- Window가 다시 Focus될 때
- Network가 다시 연결될 때
와 같은 상황에서 stale query가 background refetch될 수 있습니다.
예를 들어 사용자가 게시글 목록을 보고 다른 브라우저 탭으로 이동했다가 다시 돌아왔다고 가정해 보겠습니다.
게시글 조회
↓
Cache 저장
↓
staleTime = 0으로 설정 시, 즉시 Stale 상태
↓
다른 브라우저 탭으로 이동했다가 다시 돌아옴
↓
Window가 Focus됨
↓
Background Refetch 가능
데이터가 자주 변경되는 서비스라면 이러한 동작이 유용할 수 있습니다.
하지만 모든 데이터가 항상 즉시 최신 상태일 필요는 없습니다.
예를 들어 게시글 목록이 몇 초 또는 몇 분 동안 동일한 데이터를 보여줘도 문제가 없다면 staleTime을 조정할 수 있습니다.
useInfiniteQuery({
queryKey: ['posts'],
queryFn: ({ pageParam }) => fetchPosts(pageParam),
initialPageParam: null,
getNextPageParam: (lastPage) => lastPage.nextCursor,
staleTime: 60 * 1000,
});
이 경우 데이터를 가져온 뒤 1분 동안은 Fresh 상태로 유지됩니다.
데이터 Fetch
↓
staleTime 1분 동안 Fresh 상태 유지
↓
staleTime 1분 경과 시, Stale 상태로 변경
↓
특정 Refetch 조건에서 Background Refetch 가능
따라서 staleTime은 단순한 성능 옵션이 아니라
이 데이터가 얼마 동안 최신이라고 믿을 수 있는가?
라는 데이터 정책에 가깝습니다.
예를 들어
- 실시간성이 중요한 데이터라면: 짧은
staleTime - 변경 빈도가 낮은 데이터라면: 긴
staleTime
처럼 데이터의 성격에 따라 다르게 설정할 수 있습니다.
무조건 staleTime을 길게 설정하는 것이 좋은 것도 아니고, 기본값인 0이 항상 나쁜 것도 아닙니다.
중요한 것은 서버 데이터의 변경 빈도와 사용자가 기대하는 최신성에 맞게 결정하는 것입니다.
staleTime과 gcTime은 무엇이 다를까?
staleTime과 함께 자주 등장하는 옵션이 gcTime입니다.
두 옵션은 이름 때문에 비슷해 보이지만 역할은 완전히 다릅니다.
staleTime: 데이터를 언제 Stale로 판단할 것인가?gcTime: 사용되지 않는 Cache를 언제 제거할 것인가?
TanStack Query에서 어떤 Query를 사용하는 컴포넌트가 모두 Unmount되면 해당 Query는 Inactive 상태가 됩니다.
하지만 Inactive가 되었다고 Cache가 바로 제거되는 것은 아닙니다.
기본적으로 Inactive Query는 캐시에 남아 있다가 gcTime이 지나면 Garbage Collection 대상이 됩니다.
TanStack Query의 기본 gcTime은 5분입니다.
예를 들어 다음과 같은 상황을 생각해 보겠습니다.
게시글 목록 진입
↓
데이터 Fetch 발생
↓
Cache 저장
↓
게시글 상세 페이지로 이동
↓
게시글 목록 컴포넌트가 Unmount됨
↓
Query가 Inactive 상태가 됨
↓
Inactive 상태가 되어도 Cache는 바로 제거되지 않음
↓
기본 gcTime 5분 동안 유지
↓
gcTime 5분 경과 시, Garbage Collection 대상이 됨
↓
Cache 제거
따라서 사용자가 gcTime이 지나기 전에 다시 목록으로 돌아오면 기존 Cache를 활용할 수 있습니다.
여기서 staleTime과 함께 생각하면 조금 더 재미있습니다.
예를 들어
staleTime: 60 * 1000, // 1분
gcTime: 5 * 60 * 1000, // 5분
으로 설정했다고 가정해 보겠습니다.
그리고 오후 1시에 데이터를 가져왔다고 해보겠습니다.
13:00
데이터 Fetch 발생
↓
Fresh 상태
13:01
staleTime 1분 경과
↓
Stale 상태 (Cache는 그대로 존재)
13:03
사용자가 다른 페이지로 이동
↓
컴포넌트가 Unmount되면서, Query를 사용하는 곳이 없어져서 Inactive 상태가 됨
↓
이때부터 gcTime 5분 카운트 시작
13:08
5분 동안 다시 사용되지 않음
↓
Cache에서 데이터 제거
여기서 중요한 점은 staleTime과 gcTime의 기준이 되는 시점이 다르다는 것입니다.
staleTime은 데이터를 가져온 시점을 기준으로 동작합니다.
데이터를 가져온 시점에서 staleTime 1분 경과 시,
↓
Fresh → Stale 상태로 변경
즉, staleTime은 "이 데이터를 언제까지 최신이라고 판단할 것인가?" 를 결정합니다.
데이터가 Stale 상태가 되더라도 Cache에서 바로 제거되는 것은 아닙니다.
단지 TanStack Query가 해당 데이터를 더 이상 최신이라고 보장하지 않는다는 의미입니다.
반면 gcTime은 Query가 Inactive 상태가 된 시점부터 동작합니다.
Query가 Inactive된 시점에서 gcTime 5분 경과 시,
↓
Cache에서 제거
즉, gcTime은 "아무도 이 데이터를 사용하지 않을 때 Cache에 얼마나 오래 보관할 것인가?" 를 결정합니다.
여기서 사용자가 gcTime이 지나기 전에 다시 해당 페이지로 돌아오는 경우를 생각해 보겠습니다.
13:03에 다른 페이지로 이동했다가 13:05에 다시 돌아왔다고 가정해 보겠습니다.
13:03
Query가 Inactive 상태가 됨
↓
gcTime 5분 카운트 시작
13:05
사용자가 다시 페이지로 돌아옴
↓
아직 gcTime 5분이 지나지 않음
↓
Cache에 기존 데이터가 존재
↓
기존 Cache 데이터 즉시 사용
따라서 서버의 응답을 처음부터 기다리지 않고 Cache에 남아 있는 데이터를 바로 사용할 수 있습니다.
다만 이 예제에서 데이터는 이미 13:01에 staleTime이 지나 Stale 상태가 되었습니다.
따라서 기본 설정에서는 아래와 같이 Background Refetch가 발생할 수 있습니다.
기존 Cache 데이터를 먼저 사용해서 보여줌
↓
동시에 Background Refetch 발생
↓
서버에 최신 데이터 요청
↓
응답받은 최신 데이터로 Cache 갱신
staleTime은 데이터의 최신성(Freshness) 에 대한 설정이고,
gcTime은 사용하지 않는 Cache의 수명에 대한 설정입니다.
이 둘을 구분하는 것이 TanStack Query의 캐시 정책을 이해하는 데 매우 중요합니다.
무한 스크롤에서는 Cache가 계속 커질 수 있다
Infinite Query의 구조를 다시 살펴보겠습니다.
{
pages: [
page1,
page2,
page3,
page4,
...
],
pageParams: [
cursor1,
cursor2,
cursor3,
cursor4,
...
]
}
사용자가 계속 스크롤하면 새로운 페이지가 pages에 추가됩니다.
Page 1
↓
Page 2
↓
Page 3
↓
Page 4
↓
...
↓
Page 30
기본적으로 maxPages를 설정하지 않았다면 Infinite Query가 저장할 수 있는 페이지 수에는 제한이 없습니다.
따라서 사용자가 매우 많은 페이지를 조회하는 화면에서는 두 가지를 고려해야 합니다.
페이지 증가
↓
Cache에 보관하는 데이터 증가
↓
Memory 사용량 증가 가능
그리고 앞에서 살펴본 것처럼 Infinite Query가 refetch될 때는 저장되어 있는 페이지들을 첫 페이지부터 순차적으로 다시 가져옵니다.
따라서
Cache Page 증가
↓
Refetch 대상 Page 증가
↓
Network 요청 증가
문제도 함께 발생할 수 있습니다.
TanStack Query 공식 문서에서도 많은 페이지를 저장할 경우 메모리 사용량과 refetch 시 네트워크 사용량을 고려해야 한다고 설명합니다.
이를 위해 TanStack Query v5에서는 maxPages 옵션을 사용할 수 있습니다.
maxPages로 Infinite Query의 Cache 크기 제한하기
예를 들어 다음과 같이 설정할 수 있습니다.
const postsQuery = useInfiniteQuery({
queryKey: ['posts'],
queryFn: ({ pageParam }) => fetchPosts(pageParam),
initialPageParam: null,
getNextPageParam: (lastPage) => lastPage.nextCursor,
getPreviousPageParam: (firstPage) => firstPage.previousCursor,
maxPages: 3,
});
maxPages: 3으로 설정하면 Infinite Query의 pages에는 최대 3개의 페이지가 유지됩니다.
예를 들어 다음 페이지를 계속 가져온다고 가정해 보겠습니다.
Page 1
↓
Page 1, 2
↓
Page 1, 2, 3
↓
Page 2, 3, 4
↓
Page 3, 4, 5
최대 페이지 수에 도달한 이후 새로운 페이지를 가져오면 Fetch 방향에 따라 반대쪽의 오래된 페이지가 제거됩니다.
따라서 무한정 페이지를 Cache에 보관하는 대신 일정 범위만 유지할 수 있습니다.
maxPages 없을 시
↓
Page 1 ~ Page 30까지 모두 Cache
↓
Refetch 시 최대 30개 Page 순차 조회
maxPages = 3 설정 시
↓
최근 범위의 3개 Page만 Cache
↓
Refetch 시 최대 3개 Page 순차 조회
이를 통해
- Infinite Query가 보관하는 데이터의 양을 제한하고
- Refetch 시 다시 요청해야 하는 페이지 수도 제한할 수 있습니다.
다만 maxPages를 무조건 설정하는 것이 좋은 것은 아닙니다.
페이지를 Cache에서 제거한다는 것은 사용자가 이전 영역으로 이동했을 때 해당 페이지를 다시 가져와야 할 수도 있다는 의미이기 때문입니다.
즉,
많은 Page Cache
↓
Memory / Refetch 비용 증가
↓
이전 데이터 즉시 접근 가능
적은 Page Cache
↓
Memory / Refetch 비용 감소
↓
제거된 Page 재요청 가능
이라는 트레이드오프가 존재합니다.
또한 maxPages를 사용하면서 양방향으로 페이지를 가져와야 한다면 getNextPageParam과 getPreviousPageParam을 적절하게 정의해야 합니다.
따라서 maxPages 역시 단순히 성능을 높여주는 옵션이라기보다
얼마나 많은 데이터를 Cache에 유지할 것인지 결정하는 정책
으로 보는 것이 적절합니다.
Infinite Query의 Cache 전략을 정리하면
지금까지 살펴본 내용을 정리하면 Infinite Query의 성능은 단순히 useInfiniteQuery를 사용하는 것만으로 결정되지 않습니다.
다음과 같은 요소들이 함께 영향을 줍니다.
Query Key
↓
어떤 데이터를 같은 Cache로 볼 것인가?
staleTime
↓
얼마 동안 Fresh하다고 판단할 것인가?
gcTime
↓
사용하지 않는 Cache를 얼마 동안 유지할 것인가?
pages
↓
현재 몇 개의 Page를 Cache하고 있는가?
maxPages
↓
최대 몇 개의 Page를 유지할 것인가?
Refetch
↓
얼마나 많은 Page를 다시 요청해야 하는가?
결국 TanStack Query의 캐시를 설계한다는 것은 단순히
staleTime: 60000;
같은 옵션 값을 넣는 것이 아닙니다.
데이터의 최신성, 네트워크 비용, 메모리 사용량, 그리고 사용자가 기대하는 UX 사이의 균형을 결정하는 것
이라고 볼 수 있습니다.
다음으로는 게시글의 좋아요와 같은 사용자 인터랙션에서 이러한 Cache를 어떻게 안전하게 수정할 수 있는지 살펴보겠습니다.
특히 낙관적 업데이트에서 단순히 setQueryData만 호출하면 왜 문제가 될 수 있는지, cancelQueries → Snapshot → Rollback → invalidateQueries 흐름이 필요한 이유를 자세히 살펴보겠습니다.
낙관적 업데이트는 단순히 UI를 먼저 바꾸는 것이 아니다
커뮤니티 서비스에서는 좋아요, 북마크처럼 사용자의 인터랙션에 즉각적인 피드백이 필요한 경우가 많습니다.
예를 들어 사용자가 좋아요 버튼을 눌렀다고 가정해 보겠습니다.
가장 단순한 방법은 서버 요청이 성공한 이후 UI를 변경하는 것입니다.
좋아요 클릭
↓
API 요청
↓
서버 응답 대기
↓
요청 성공
↓
UI 변경
하지만 네트워크 응답에 시간이 걸린다면 사용자는 버튼을 눌렀음에도 아무런 변화가 없는 것처럼 느낄 수 있습니다.
이를 개선하기 위해 사용할 수 있는 방법이 낙관적 업데이트(Optimistic Update) 입니다.
낙관적 업데이트에서는 서버 요청의 성공을 기다리지 않고 먼저 UI를 변경합니다.
좋아요 클릭
↓
UI 즉시 변경
↓
API 요청
↓
성공하면 유지
사용자 입장에서는 버튼을 누르자마자 화면이 변경되기 때문에 훨씬 빠르게 반응하는 것처럼 느껴집니다.
하지만 낙관적 업데이트의 핵심은 단순히 "UI를 먼저 변경하는 것" 에 있지 않습니다.
서버 요청은 실패할 수도 있고, 같은 데이터를 대상으로 이미 다른 Refetch가 진행 중일 수도 있습니다.
따라서 더 중요한 문제는
낙관적으로 변경한 Cache와 실제 Server State의 일관성을 어떻게 유지할 것인가?
입니다.
TanStack Query에서는 낙관적 업데이트를 크게 두 가지 방법으로 구현할 수 있습니다.
- Mutation의
variables를 이용해 UI에서 임시 결과를 보여주는 방법 onMutate에서 Query Cache 자체를 직접 변경하는 방법
여러 컴포넌트가 같은 서버 상태를 사용하고 있다면 Cache를 직접 변경하는 방법을 사용할 수 있습니다.
이번 글에서는 Cache를 직접 변경하는 방법을 중심으로 살펴보겠습니다.
왜 cancelQueries부터 시작할까?
게시글 목록에 좋아요를 적용한다고 가정해 보겠습니다.
먼저 단순하게 Cache만 수정할 수도 있습니다.
queryClient.setQueryData(['posts'], (oldData) => {
// 좋아요 상태 변경
});
하지만 여기에는 한 가지 문제가 있습니다.
이미 ['posts'] Query의 Refetch가 진행 중일 수 있기 때문입니다.
게시글 Refetch 시작
↓
좋아요 클릭
↓
setQueryData로 Cache 변경
↓
좋아요 UI 즉시 반영
↓
기존 Refetch 완료
↓
Refetch 결과가 Cache에 반영
이 경우 먼저 시작된 Refetch의 응답이 낙관적으로 변경한 Cache를 덮어쓸 수 있습니다.
그래서 TanStack Query 공식 Optimistic Updates 예제에서는 onMutate에서 먼저 cancelQueries를 호출합니다.
onMutate: async (_, context) => {
await context.client.cancelQueries({
queryKey: ['posts'],
});
};
공식 문서에서도 그 이유를 진행 중인 Refetch가 Optimistic Update를 덮어쓰는 것을 방지하기 위해서라고 설명합니다.
*Optimistic Updates - Via the cache
흐름을 정리하면 다음과 같습니다.
Mutation 시작
↓
진행 중인 Query 취소
↓
기존 Cache 저장
↓
Cache 낙관적 변경
↓
Mutation 요청
여기서 중요한 점은 cancelQueries 자체가 낙관적 업데이트를 수행하는 것은 아니라는 것입니다.
낙관적으로 변경하려는 Cache와 경쟁할 수 있는 기존 Refetch를 먼저 정리하는 역할을 합니다.
실패했을 때를 위해 Snapshot을 저장한다
진행 중인 Query를 취소했다면 기존 Cache를 저장합니다.
const previousPosts = context.client.getQueryData(['posts']);
왜 기존 값을 저장해야 할까요?
낙관적 업데이트는 말 그대로
이 요청은 성공할 것이다.
라고 먼저 가정하는 방식이기 때문입니다.
실제 요청은 실패할 수 있습니다.
좋아요 클릭
↓
Cache 변경 (liked: false → true)
↓
API 요청
↓
실패
이때 UI를 그대로 두면 실제 서버에는 좋아요가 반영되지 않았지만 화면에는 좋아요가 적용된 상태가 됩니다.
따라서 Mutation 이전의 Cache를 저장해 두었다가 실패하면 복구해야 합니다.
onMutate: async (_, context) => {
await context.client.cancelQueries({
queryKey: ['posts'],
});
const previousPosts = context.client.getQueryData(['posts']);
// Optimistic Update
return { previousPosts };
};
이렇게 저장해 둔 값을 onError에서 다시 사용할 수 있습니다.
onError: (_, __, onMutateResult, context) => {
context.client.setQueryData(['posts'], onMutateResult?.previousPosts);
};
전체 흐름은 다음과 같습니다.
Mutation
↓
cancelQueries
↓
기존 Cache Snapshot
↓
Optimistic Update
↓
API 요청
↓
실패
↓
Snapshot으로 Rollback
즉, Snapshot은 단순한 백업이 아니라 Optimistic Update 실패 시 Cache의 일관성을 복구하기 위한 기준점입니다.
Infinite Query에서 낙관적 업데이트하기
앞에서 Infinite Query의 Cache가 다음과 같은 구조를 가진다고 살펴봤습니다.
{
pages: [
page1,
page2,
page3,
],
pageParams: [
cursor1,
cursor2,
cursor3,
],
}
따라서 특정 게시글의 좋아요 상태를 변경하려면 각 Page 내부에서 해당 게시글을 찾아야 합니다.
예를 들어 다음과 같이 구현할 수 있습니다.
const toggleLikeMutation = useMutation({
mutationFn: togglePostLike,
onMutate: async (postId, context) => {
await context.client.cancelQueries({
queryKey: ['posts'],
});
const previousPosts = context.client.getQueryData(['posts']);
context.client.setQueryData(['posts'], (oldData) => {
if (!oldData) {
return oldData;
}
return {
...oldData,
pages: oldData.pages.map((page) => ({
...page,
posts: page.posts.map((post) =>
post.id === postId
? {
...post,
liked: !post.liked,
likeCount: post.liked ? post.likeCount - 1 : post.likeCount + 1,
}
: post
),
})),
};
});
return { previousPosts };
},
onError: (_, __, onMutateResult, context) => {
context.client.setQueryData(['posts'], onMutateResult?.previousPosts);
},
onSettled: (_, __, ___, ____, context) => {
return context.client.invalidateQueries({ queryKey: ['posts'] });
},
});
핵심 흐름만 보면 다음과 같습니다.
좋아요 클릭
↓
cancelQueries 실행
↓
현재 Infinite Query Cache 저장 (Snapshot)
↓
pages 순회
↓
해당 Post 탐색
↓
liked, likeCount 변경
↓
UI 즉시 반영
↓
API 요청
그리고 요청이 실패하면
API 실패
↓
Snapshot 복원
↓
UI Rollback
합니다.
성공했는데 왜 invalidateQueries를 다시 호출할까?
여기서 한 가지 의문이 생길 수 있습니다.
낙관적 업데이트로 이미 Cache를 수정했고 서버 요청도 성공했다면,
왜 마지막에 다시 invalidateQueries를 호출할까요?
onSettled: (_, __, ___, ____, context) => {
return context.client.invalidateQueries({ queryKey: ['posts'] });
};
이유는 클라이언트가 예상한 결과와 실제 서버의 최종 상태가 항상 같다고 보장할 수 없기 때문입니다.
예를 들어 클라이언트에서는
likeCount
10 → 11
이라고 예상했지만, 그 사이 다른 사용자도 좋아요를 누르는 등 서버의 최종 상태가 달라질 수 있습니다.
따라서 Optimistic Update는 사용자에게 빠른 피드백을 제공하기 위한 임시 상태로 보고,
Mutation이 끝난 이후 Query를 invalidate하여 서버의 실제 상태와 다시 동기화할 수 있습니다.
Optimistic Cache
↓
빠른 UI 피드백
↓
Mutation 완료
↓
invalidateQueries 실행
↓
Refetch 실행
↓
Server State와 재동기화
이 관점에서 보면 낙관적 업데이트의 목적은
서버의 실제 데이터가 변경된 것을 확인한 뒤 화면을 업데이트하는 것이 아니라, 서버 응답을 기다리는 동안 성공할 것이라고 예상하고 화면을 먼저 업데이트하는 것
이라고 볼 수 있습니다.
Structural Sharing은 무엇일까?
TanStack Query에는 별도로 설정하지 않아도 적용되는 렌더링 최적화가 있습니다.
그중 하나가 Structural Sharing입니다.
*Render Optimizations - Structural Sharing
일반적으로 네트워크에서 JSON 데이터를 다시 받아오면 새로운 JavaScript 객체가 만들어집니다.
예를 들어 서버에서 동일한 데이터를 다시 받아도
const previous = {
id: 1,
name: '철수',
};
const next = {
id: 1,
name: '철수',
};
previous === next; // false
객체의 내용은 같지만 참조는 다릅니다.
TanStack Query는 새로운 데이터를 Cache에 저장할 때 기존 데이터와 비교하여 변경되지 않은 부분의 참조를 가능한 한 유지합니다.
예를 들어 개념적으로 다음과 같은 데이터가 있다고 가정해 보겠습니다.
const previous = {
user: {
id: 1,
name: '철수',
},
posts: [
{ id: 1, title: 'A' },
{ id: 2, title: 'B' },
],
};
새로운 데이터를 가져왔는데 두 번째 게시글만 변경되었습니다.
const next = {
user: {
id: 1,
name: '철수',
},
posts: [
{ id: 1, title: 'A' },
{ id: 2, title: '수정된 B' },
],
};
TanStack Query는 변경되지 않은 부분의 기존 참조를 가능한 한 유지하고, 변경된 부분만 새로운 값으로 교체합니다.
개념적으로 보면 다음과 같습니다.
Previous Data vs New Data 비교
↓
변경되지 않은 부분은 기존 Reference 유지
↓
변경된 부분은 새로운 Reference 사용
개념은 알겠는데, "실제 라이브러리 내부에서는 이 불변성과 참조 공유를 어떻게 처리할까?"
궁금해서 TanStack Query의 소스 코드를 열어보았습니다.
핵심은 바로 replaceEqualDeep이라는 재귀 함수입니다.
이 함수는 이전 데이터(a)와 새 데이터(b)를 자식 요소까지 재귀적으로 비교하며 동작합니다.
- 값이 같은 부분: 새 객체를 만드는 대신 기존 데이터의 참조(Reference)를 그대로 재사용합니다.
- 값이 바뀐 부분: 변경된 자식만 새로 교체하여 복사본을 만듭니다.
- 전체가 같은 경우: 객체를 새로 만들지 않고 이전 데이터 객체 자체를 그대로 반환합니다.
결과적으로 데이터 구조를 깊게 탐색하면서 바뀌지 않은 자식 노드들의 레퍼런스를 최대한 보존하는 방식으로 Structural Sharing을 구현하고 있습니다.
이것이 중요한 이유는 React에서 참조 동일성(Referential Identity) 이 여러 최적화(useMemo, useCallback, React.memo 등)와 연결되어 있기 때문입니다.
TanStack Query 공식 문서에서도 Structural Sharing을 통해 가능한 많은 Reference를 유지함으로써 값의 안정성을 높인다고 설명합니다.
다만 Structural Sharing은 기본적으로 JSON-compatible 데이터에서 동작합니다.
Date, Map, 클래스 인스턴스 등 JSON-compatible하지 않은 값은 기본 Structural Sharing의 이점을 동일하게 기대할 수 없습니다.
대부분의 일반적인 REST API 응답처럼 JSON 기반 데이터를 다룬다면 기본 설정을 그대로 활용할 수 있습니다.
즉, Structural Sharing은 TanStack Query가 Refetch 등으로 새로운 데이터를 받아왔을 때,
기존 Cache 데이터와 비교해서 내용이 바뀌지 않은 객체는 기존 참조를 그대로 유지하는 최적화입니다.
실제로 바뀌지 않은 데이터의 참조가 불필요하게 변경되는 것을 막아서 React의 렌더링 및 메모이제이션 최적화가 더 효과적으로 동작하도록 하기 위해서입니다.
TanStack Query는 사용한 속성만 추적한다
TanStack Query에는 Structural Sharing 외에도 렌더링을 줄이기 위한 또 다른 최적화가 있습니다.
예를 들어 다음 Query가 있다고 가정해 보겠습니다.
const query = useQuery({
queryKey: ['posts'],
queryFn: fetchPosts,
});
Query 객체에는 다양한 값이 존재합니다.
data
isPending
isFetching
isError
isStale
...
이 값들 중 일부는 비교적 자주 변경될 수 있습니다.
예를 들어 Background Refetch가 발생하면 isFetching은 변경될 수 있습니다.
하지만 컴포넌트가 data만 사용하면 isFetching이 변경될 때마다 컴포넌트까지 다시 Render할 필요는 없습니다.
TanStack Query는 이를 위해 Tracked Properties라는 최적화를 사용합니다.
공식 문서에 따르면 TanStack Query는 Proxy를 사용하여 컴포넌트가 실제로 접근한 속성을 추적하고, 사용한 속성이 변경되었을 때만 해당 컴포넌트의 Re-render를 유발합니다.
*Render Optimizations - Tracked Properties
예를 들어
const { data } = useQuery({
queryKey: ['posts'],
queryFn: fetchPosts,
});
처럼 data만 사용한다면 isFetching과 같이 사용하지 않은 속성의 변경 때문에 불필요하게 Re-render되는 것을 줄일 수 있습니다.
다만 다음과 같은 Object Rest Destructuring은 이 최적화를 방해할 수 있습니다.
const { data, ...rest } = useQuery({
queryKey: ['posts'],
queryFn: fetchPosts,
});
TanStack Query 공식 문서에서도 Object Rest Destructuring을 사용할 경우 Tracked Properties 최적화가 비활성화될 수 있다고 설명하며, 이를 방지하기 위한 ESLint 규칙도 제공합니다.
select로 구독 범위를 줄일 수도 있다
컴포넌트가 Query 데이터 전체를 사용할 필요가 없는 경우에는 select를 활용할 수도 있습니다.
예를 들어 게시글 전체가 아니라 게시글 개수만 필요한 컴포넌트가 있다고 가정해 보겠습니다.
const { data: postCount } = useQuery({
queryKey: ['posts'],
queryFn: fetchPosts,
select: (posts) => posts.length,
});
이 컴포넌트가 관심 있는 것은 게시글의 내용이 아니라 게시글의 개수입니다.
TanStack Query 공식 문서에 따르면 select를 사용하면 Query Data의 일부만 선택하여 구독할 수 있습니다.
따라서 게시글의 제목이 변경되더라도 게시글 개수가 동일하다면 해당 변경 때문에 컴포넌트가 다시 Render될 필요가 없습니다.
*Render Optimizations - select
이처럼 TanStack Query의 성능 최적화는 단순히 "Cache를 사용해서 API 요청을 줄인다"에서 끝나지 않습니다.
Structural Sharing → 변경되지 않은 데이터 Reference 유지
Tracked Properties → 실제로 사용하는 Query 상태만 추적
select → 필요한 데이터 범위만 구독
처럼 React의 Re-render 비용까지 고려한 최적화도 함께 제공합니다.
마치며
처음에는 무한 스크롤을 구현하는 것이 단순히 다음 페이지를 가져오는 문제라고 생각하기 쉽습니다.
하지만 실제 서비스에서는 단순히 데이터를 가져오는 것뿐만 아니라, 가져온 서버 상태를 어떻게 관리할 것인지 함께 고민해야 합니다.
- Query Key → 어떤 데이터를 같은 서버 상태로 볼 것인가?
- Infinite Query → 여러 Page를 어떤 구조로 관리할 것인가?
- staleTime → 데이터를 얼마 동안 Fresh하다고 판단할 것인가?
- gcTime → 사용하지 않는 Cache를 얼마 동안 유지할 것인가?
- maxPages → 얼마나 많은 Page를 Cache에 유지할 것인가?
- Optimistic Update → 서버 응답 전에 Cache를 어떻게 안전하게 변경할 것인가?
- Structural Sharing → 변경되지 않은 데이터의 Reference를 어떻게 유지할 것인가?
- Tracked Properties / select → 필요한 상태와 데이터만 어떻게 구독할 것인가?
결국 TanStack Query를 잘 사용한다는 것은 단순히 useQuery나 useInfiniteQuery의 API를 사용하는 것에서 끝나지 않습니다.
서버 상태의 최신성, Cache의 수명, 네트워크 비용, 메모리 사용량 그리고 사용자 경험 사이의 균형을 설계하는 것
이라고 생각합니다.
프로젝트에서도 무한 스크롤과 서버 상태 캐싱을 적용하면서 단순히 데이터를 가져오는 것보다 언제 기존 데이터를 재사용하고, 언제 서버와 다시 동기화할 것인지를 결정하는 것이 중요했습니다.
또한 좋아요와 같은 사용자 인터랙션에는 낙관적 업데이트를 적용하여 서버 응답을 기다리지 않고 즉각적인 UI 피드백을 제공하면서도, 실패 시 Rollback과 이후 재동기화를 통해 서버 상태와의 일관성을 유지했습니다.
TanStack Query는 이러한 서버 상태 관리에 필요한 여러 기능을 제공하지만, 라이브러리를 사용한다고 해서 자동으로 최적의 Cache 전략이 만들어지는 것은 아닙니다.
중요한 것은 데이터의 특성과 서비스의 사용 패턴을 이해하고, 그에 맞는 Cache 정책과 동기화 전략을 설계하는 것입니다.