co-cherry
TanStack Query 본문
TanStack Query
React 애플리케이션에서 서버 상태(Server State)를 관리하기 위한 비동기 상태 관리 라이브러리
서버에서 데이터를 가져오고, 캐싱하고, 동기화하고, 업데이트 하는 모든 과정을 대신 관리해준다.
Q. 왜 필요할까?
useEffect + fetch 로 직접 서버 데이터를 관리하면 매번 반복되는 코드를 작성해야 함
const [data, setData] = useState(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(null);
useEffect(() => {
setIsLoading(true);
fetch('/api/todos')
.then(res => res.json())
.then(data => setData(data))
.catch(err => setError(err))
.finally(() => setIsLoading(false));
}, []);
- 페이지 이동 시 매번 새로 요청
- 같은 데이터를 여러 컴포넌트에서 각자 중복 요청
- 데이터 자동 갱신 없음
- *loading/error/data를 매번 직접 선언해야 함
*Bolierplate 반복적으로 재사용되는 표준화된 코드
TanStack Query를 사용하면 위 코드를 아래와 같이 표현 가능하다.
const { data, isLoading, error } = useQuery({
queryKey: ['todos'],
queryFn: () => fetch('/api/todos').then(res => res.json()),
});
- caching 같은 Key로 요청 시 캐시에서 즉시 반환
- 중복 요청 제거 동일한 Key 요청은 하나로 합쳐 처리
- 자동 re-fetching 윈도우 포커스, 네트워크 재연결 시 데이터 자동 갱신
- 자동 재시도 요청 실패 시 기본 3회 재시도
- 낙관 업데이트 캐시된 데이터를 먼저 보여 주고 뒤에서 갱신
핵심 요소
QueryClient + QueryClientProvider 캐시를 관리하는 중앙 저장소
App 바깥에 QueryClient와 QueryClientProvider를 사용
const queryClient = new QueryClient()
function App() {
return (
<QueryClientProvider client={queryClient}>
<Todos />
</QueryClientProvider>
)
}
QueryClient 생성 시, defaultOptions로 모든 쿼리에 공통 적용될 전역 설정을 할 수 있다.
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 0, // 기본값 0 = 호출 즉시 stale 상태
gcTime: 1000 * 60 * 5, // 기본값 5분
retry: 0, // 요청 실패 시 재시도 횟수
retryDelay: 3000, // 재시도 간격 (3초)
refetchOnWindowFocus: false, // 윈도우 포커스 시 리패칭 여부
refetchOnReconnect: false, // 네트워크 재연결 시 리패칭 여부
refetchOnMount: false, // 컴포넌트 마운트 시 리패칭 여부
},
},
});
캐시 관리
- staleTime fresh → stale 로 변하는 데 걸리는 시간, 이 시간 동안은 캐시된 데이터를 그대로 사용하고 서버에 재요청 X
- gcTime 데이터가 사용되지 않은 상황(inactive)에서 메모리에 유지될 수 있는 시간
데이터가 자주 변경되지 않는 영역의 경우, staleTime을 길게 설정해 불필요한 서버 요청을 줄이고 캐시를 적극적으로 활용
실시간성이 요구되거나 자주 변경되는 데이터가 표시되는 영역의 경우, staleTime을 짧게 설정하거나 기본값인 0을 활용
gcTime은 메모리 사용량을 고려해 데이터를 일정 시간 동안 유지한 후, 삭제하도록 설정
useQuery 단일 데이터 요청(GET), 데이터 fetching, caching, 상태 관리 등의 기능을 제공
const { isPending, isError, data, error } = useQuery({
queryKey: ['todos'],
queryFn: getTodoList,
})
if (isPending) return <span>로딩중...</span>
if (isError) return <span>Error: {error.message}</span>
return (
<ul>
{data.map(todo => <li key={todo.id}>{todo.title}</li>)}
</ul>
)
[주요 옵션]
- queryKey 캐시를 식별한 유일한 키, 캐싱/데이터 갱신/중복 요청 방지에 사용
- queryFn Promise를 반환하는 데이터 요청 함수(비동기 fetching), 실제 API 호출 로직을 포함
- enabled false로 설정하면 자동 실행되지 않음, 특정 조건에서만 실행할 때 사용
- plcaeholderData 실제 데이터가 도착하기 전까지 임시로 보여 줄 데이터
[주요 반환값]
- isPending 아직 데이터를 한 번도 불러오지 않은 상태(isLoading 대체)
- isFetching 백그라운드 re-fetching 포함, 현재 데이터를 가져오는 중이면 true
- isError / error 요청 실패 상태 및 에러 객체
- isSuccess / data 요청 성공 상태 및 데이터
Q. refetch가 일어나는 조건?
전제 조건: 데이터가 stale 상태일 때
- 컴포넌트가 마운트 될 때
- 다른 탭/창에 갔다가 다시 돌아왔을 때
- 네트워크가 재연결 됐을 때
useMutation 데이터 생성/수정/삭제(POST·PUT·DELETE)
const mutation = useMutation({
mutationFn: (newTodo) => axios.post('/api/todos', newTodo),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['todos'] })
},
})
// 실행
mutation.mutate({ title: '할 일 추가' })
[주요 옵션]
- mutationFn 실제 API 요청이 이루어지는 함수, Promise 반환
- onMutate API 요청 전에 실행, 낙관적 업데이트나 로딩 처리에 활용
- onSuccess 요청 성공 시 실행
- onError 요청 실패 시 실행, 롤백 처리에 활용
- onSettled 성공/실패 무관하게 응답이 오면 항상 실행
Invalidation 캐시 무효화, 서버에서 데이터가 변경됐을 때, 클라이언트의 캐시를 stale 처리하고 refetching 발생시킴
const queryClient = useQueryClient()
// 1. 모든 쿼리 무효화
queryClient.invalidateQueries()
// 2. 특정 키로 시작하는 모든 쿼리 무효화
queryClient.invalidateQueries({ queryKey: ['team'] })
// 'team', ['team', 1], ['team', 'twins'] 모두 무효화됨
// 3. predicate로 조건에 맞는 쿼리만 무효화
queryClient.invalidateQueries({
predicate: (query) =>
query.queryKey[0] === 'team' &&
query.queryKey[1]?.name === 'LG Twins',
})
mutation 성공 후, 관련 쿼리를 invalidate 하는 것이 일반적이다.
const deleteMutation = useMutation({
mutationFn: (id) => axios.delete(`/api/todos/${id}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['todos'] }) // 삭제 후 목록 갱신
},
})
Query Key 설계 전략
Query Key TanStack Query가 캐시를 식별하는 유일한 키
key에 들어갈 수 있는 것들
- 문자열(내부적으로 배열로 변환): useQuery({ queryKey: ['todos'], queryFn: getTodos })
- 배열 + 숫자(같은 키를 쓰면서 id로 구별 가능): useQuery({ queryKey: ['todo', 5], queryFn: ... })
- 배열 + 객체(필터, 옵션 등 조건 포함): useQuery({ queryKey: ['todo', 5, { preview: true }], queryFn: ... })
변수에 의존한다면 반드시 Key에 포함시키는 것이 좋다.
또, 변수가 바뀔 때 refetch를 직접 호출하는 것보다, queryKey에 변수를 포함시켜 자동으로 재호출되게 하는 것이
시간차 문제와 코드 복잡도 측면에서 유리하다.
// ❌ todoId가 바뀌어도 재호출 안 됨
useQuery({
queryKey: ['todos'],
queryFn: () => getTodo(todoId),
})
// ✅ todoId가 바뀔 때마다 자동 재호출
useQuery({
queryKey: ['todos', todoId],
queryFn: () => getTodo(todoId),
})
Query Key Factory
key를 각 컴포넌트마다 직접 작성하면
- 오타가 나도 에러가 발생하지 않아 디버깅이 어려움
- Key 구조를 바꾸려면 사용된 모든 곳을 찾아서 수정해야 함
- 무효화 범위를 잘못 잡아 원하지 않는 캐시가 남거나 너무 많이 날아갈 수 있음
→ Query Key Factory를 이용해 key를 한 곳에서 계층적으로 관리할 수 있음
1. 단일 파일에서 전체 관리
- queryKeys.users.queryKey → ['users']
- queryKeys.todos.queryKey → ['todos'] // todos 전체
- queryKeys.todos.detail(todoId) → { queryKey: ['todos', 'detail', '1'] } // todos 안의 detail
- queryKeys.todos.list(filters) → { queryKey: ['todos', 'list', { filters: { status: 'done' } }], queryFn: ... } // todos 안의 list
import { createQueryKeyStore } from '@lukemorales/query-key-factory'
type TodoFilters = {
status?: 'done' | 'active'
page?: number
}
export const queryKeys = createQueryKeyStore({
users: null, // 단순 Key만 필요한 경우
todos: {
// queryKey만 반환 — queryFn 따로 작성해야 함
detail: (todoId: string) => [todoId],
// queryKey + queryFn 같이 반환 — 컴포넌트에서 바로 사용 가능
list: (filters: TodoFilters) => ({
queryKey: [{ filters }],
queryFn: (ctx) => api.getTodos({ filters, page: ctx.pageParam }),
}),
},
})
| detail | list | |
| 반환 | queryKey만 | queryKey + queryFn |
| queryFn | 따로 작성 필요 | 포함되어 있음 |
| 사용 상황 | Key만 필요할 때 | Key와 요청 함수를 같이 묶을 때 |
2. 도메인별로 분리 후 병합
도메인이 많아질수록 유지보수에 이 방법이 더 적합함
// src/queries/users.ts
import { createQueryKeys } from '@lukemorales/query-key-factory'
export const usersKeys = createQueryKeys('users')
// src/queries/todos.ts
import { createQueryKeys } from '@lukemorales/query-key-factory'
export const todosKeys = createQueryKeys('todos', {
// queryKey만 반환 — queryFn 따로 작성해야 함
detail: (todoId: string) => [todoId],
// queryKey + queryFn 같이 반환 — 컴포넌트에서 바로 사용 가능
list: (filters: TodoFilters) => ({
queryKey: [{ filters }],
queryFn: (ctx) => api.getTodos({ filters, page: ctx.pageParam }),
}),
})
// src/queries/index.ts — 도메인별로 만든 Key를 한 곳에 병합
import { mergeQueryKeys } from '@lukemorales/query-key-factory'
export const queryKeys = mergeQueryKeys(usersKeys, todosKeys)
장점
- 오타 방지 및 자동 완성
- Key 구조 변경이 쉬움
- 무효화 범위를 정밀하게 제어 가능
- Single source of truth
Optimistic Update 패턴 심화
Optimistic Update 서버 응답을 기다리지 않고 UI를 먼저 업데이트 한 뒤, 실패하면 롤백하는 패턴
- onMutate 요청 전, 캐시 저장 + UI 즉시 업데이트
- onError 요청 실패 시, 롤백
- onSetteld 성공/실패 무관하게 서버 데이터로 최종 동기화
const mutation = useMutation<Todo, Error, Todo>({
mutationFn: (updatedTodo) => updateTodo(updatedTodo),
onMutate: async (updatedTodo) => {
// 1. 진행 중인 리패칭 취소 (race condition 방지)
await queryClient.cancelQueries({
queryKey: todoKeys.detail(updatedTodo.id)
})
// 2. 현재 캐시 저장 (실패 시 롤백용)
const previousTodo = queryClient.getQueryData<Todo>(
todoKeys.detail(updatedTodo.id)
)
// 3. 캐시를 낙관적으로 업데이트 (서버 응답 전에 UI 반영)
queryClient.setQueryData(
todoKeys.detail(updatedTodo.id),
updatedTodo
)
return { previousTodo }
},
onError: (err, updatedTodo, context) => {
// 4. 실패 시 저장해뒀던 값으로 롤백
queryClient.setQueryData(
todoKeys.detail(updatedTodo.id),
context?.previousTodo
)
},
onSettled: (data, error, updatedTodo) => {
// 5. 성공/실패 무관하게 서버 데이터로 최종 동기화
queryClient.invalidateQueries({
queryKey: todoKeys.detail(updatedTodo.id)
})
},
})
Q. cancelQueries를 호출하는 이유?
사용자가 클릭한 후 캐시를 A → B로 업데이트 중에 백그라운드에서 리패칭을 진행한다면,
리패칭 응답이 나중에 도착해 캐시를 다시 A로 덮어쓰는 문제가 발생할 수 있으므로 진행 중인 리패칭을 먼저 취소 후 업데이트
흐름
onMutate → 현재 캐시 저장(롤백용) + UI 즉시 업데이트
↓
서버 요청 전송
↓
onError → 실패 시 저장해둔 캐시로 롤백
onSettled → 성공/실패 무관하게 서버 데이터로 최종 동기화
이때 onMutate에서 저장한 롤백용 데이터를 onError에서 꺼내 쓰려면 어딘가에 임시로 보관해야 한다.
이때 이 역할을 하는 것이 context다.
onMutate에서 return { previousTodos }로 반환한 값이 context에 담기고,
에러 발생 시 onError의 세 번째 인자로 전달되어 롤백에 사용된다.
onMutate: async (newTodo) => {
const previousTodos = queryClient.getQueryData(...) // 현재 데이터 저장
...
return { previousTodos } // ← context에 담김
},
onError: (error, _todo, context) => { // ← context로 전달받음
queryClient.setQueryData(..., context?.previousTodos) // 꺼내서 롤백
}
Q. 언제 쓰는 것이 좋은가?
| 써야 할 때 | 쓰지 말아야 할 때 |
| 좋아요, 북마크 | 결제 처리 |
| 체크박스 완료 처리 | 회원가입 |
| 간단한 텍스트 수정 | 서버 연산 결과가 필요한 경우 |
Prefetching & Infinite Query 최적화
Prefetching 사용자가 실제 데이터를 요청하기 전에 미리 캐시에 적재해두는 기법
비동기 요청은 데이터 양이 클수록 받아오는 속도가 느리고 시간이 오래 걸리기 때문에 데이터를 미리 받아와 캐싱해두는 것
const queryClient = useQueryClient()
useEffect(() => {
const nextPage = currentPage + 1
if (nextPage < maxPage) {
queryClient.prefetchQuery({
queryKey: ['posts', nextPage],
queryFn: () => fetchPosts(nextPage),
staleTime: 1000 * 10, // 10초 이내 캐시가 있으면 재요청 안 함
})
}
}, [currentPage])
*이미 캐싱된 데이터가 있으면 다시 가져오지 않는다.
InfiniteQuery
무한 스크롤이나 더보기처럼 특정 조건에서 데이터를 추가적으로 받아오는 기능을 구현할 때 사용
const {
data,
hasNextPage,
isFetching,
isFetchingNextPage,
fetchNextPage,
} = useInfiniteQuery({
queryKey: todoKeys.todos.getAll().queryKey,
queryFn: ({ pageParam }) => fetchTodos({ page: pageParam }),
initialPageParam: 1,
getNextPageParam: (lastPage, allPages) => {
return lastPage.hasMore ? allPages.length + 1 : undefined
// undefined 반환 시 → hasNextPage가 false가 되어 더 이상 불러오지 않음
},
})
// data.pages를 flatMap으로 펼쳐서 사용
const allTodos = data?.pages.flatMap((page) => page.todos)
return (
<div>
<ul>
{allTodos?.map((todo) => (
<li key={todo.id}>{todo.title}</li>
))}
</ul>
<button
disabled={!hasNextPage}
onClick={() => fetchNextPage()}
>
{isFetchingNextPage ? '로딩 중...' : '더 불러오기'}
</button>
</div>
)
주요 옵션
- initialPageParam 첫 페이지를 가져올 때 사용할 기본 페이지 매개변수(필수)
- getNextPageParam 페이지를 증가시키는 함수(필수)
getNextPageParam: (lastPage, allPages) => {
// lastPage — 가장 최근에 가져온 페이지 데이터
// allPages — 현재까지 가져온 모든 페이지 데이터
return lastPage.hasMore ? allPages.length + 1 : undefined
}
- maxPages 저장할 최대 페이지 수, 최대 페이지 수에 도달하면 새 페이지를 가져올 때 방향에 따라 첫 번째 또는 마지막 페이지 제거, 0 또는 undefined이면 무제한
주요 반환값
- data.pages 모든 페이지 데이터를 포함하는 배열
- data.pageParams 모든 페이지 매개변수를 포함하는 배열
- fetchNextPage 다음 페이지를 fetch
- fetchPreviousPage 이전 페이지를 fetch
- isFetchingNextPage 다음 페이지 fetch 중이면 true
- hasNextPage 다음 페이지가 있으면 true
prefetchInfiniteQuery
일반 쿼리처럼 Infinite Query도 prefetch 할 수 있다.
기본적으로 첫 번째 페이지만 prefetch되며, 그 이상 prefetch 하려면 pages 옵션을 활용해야 한다.
(단, 이 경우 getNextPageParam을 반드시 제공해야 함)
await queryClient.prefetchInfiniteQuery({
queryKey: todoKeys.todos.getAll().queryKey,
queryFn: ({ pageParam }) => fetchTodos({ page: pageParam }),
initialPageParam: 0,
getNextPageParam: (lastPage) => lastPage.nextCursor,
pages: 3, // 첫 3페이지를 미리 prefetch
})
prefetchQuery vs prefetchInfiniteQuery
- pages : 3 으로 설정하면 3 페이지를 순차적으로 요청하기 때문에 네트워크 요청이 3번 발생
→ 너무 많은 페이지를 미리 적재하면 오히려 불필요한 요청이 늘어날 수 있으므로 적절한 수를 설정하는 것이 중요
// prefetchQuery — 일반 단일 페이지 데이터 미리 적재
queryClient.prefetchQuery({
queryKey: todoKeys.todos.getAll().queryKey,
queryFn: () => fetchTodos(),
})
// prefetchInfiniteQuery — 무한 스크롤용 데이터 미리 적재
// pages 옵션으로 여러 페이지를 한 번에 적재 가능
queryClient.prefetchInfiniteQuery({
queryKey: todoKeys.todos.getAll().queryKey,
queryFn: ({ pageParam }) => fetchTodos({ page: pageParam }),
initialPageParam: 0,
getNextPageParam: (lastPage) => lastPage.nextCursor,
pages: 3, // 1, 2, 3페이지 미리 적재
})
Error/Loading 바운더리 패턴
기존 useQuery의 isLoading / isError 로 분기 처리 시, 매번 모든 컴포넌트마다 아래와 같이 중복 코드가 발생
function TodoList() {
const { data, isLoading, isError } = useQuery(...)
if (isLoading) return <Spinner />
if (isError) return <ErrorMessage />
return <ul>{data?.map(todo => <li>{todo.title}</li>)}</ul>
}
function UserProfile() {
const { data, isLoading, isError } = useQuery(...)
if (isLoading) return <Spinner /> // 또 반복
if (isError) return <ErrorMessage /> // 또 반복
return <div>{data?.name}</div>
}
선언적 방식으로 전환
- 선언형 : "이 구간은 로딩·에러가 있으면 이 경계에서 처리한다. 성공 상태만 그려라"
컴포넌트는 입력이 같으면 동일한 UI를 산출하고 경계(Boundary)는 입력이 준비되지 않았거나 실패한 경우를 처리
Suspense + ErrorBoundary 를 사용하면 로딩과 에러 처리를 상위 컴포넌트에서 한 번에 선언할 수 있다.
- Suspense 컴포넌트 트리에서 준비되지 않은 데이터를 만났을 때, 해당 부분을 fallback UI로 대체하는 경계 역할
- ErrorBoundary 자바 스크립트 에러가 컴포넌트 트리 전체를 망가뜨리는 것을 방지하고 에러 발생 구간만 fallback UI로 대체
- 로딩 상태는 Suspense 가 지역적 처리 / 에러 상태는 상위 ErrorBoundary가 전역적으로 포착 / 성공 상태만 컴포넌트가 집중
// 컴포넌트는 성공 케이스만 작성
function TodoList() {
const { data } = useSuspenseQuery({
queryKey: todoKeys.todos.getAll().queryKey,
queryFn: fetchTodos,
})
return <ul>{data.map(todo => <li>{todo.title}</li>)}</ul>
}
// 로딩과 에러는 바깥에서 한 번에 선언
<ErrorBoundary fallback={<ErrorMessage />}>
<Suspense fallback={<Spinner />}>
<TodoList />
</Suspense>
</ErrorBoundary>
컴포넌트가 늘어나도 Suspense 와 ErrorBoundary는 한 번만 선언하면 됨
<ErrorBoundary fallback={<ErrorMessage />}>
<Suspense fallback={<Spinner />}>
<TodoList />
<UserProfile /> // 추가해도 로딩/에러 처리 따로 안 해도 됨
<TeamList /> // 추가해도 로딩/에러 처리 따로 안 해도 됨
</Suspense>
</ErrorBoundary>
또는 특정 기능별 에러 처리를 통해 에러 영향 범위를 제한하고 각 섹션별 복구 전략을 제공할 수 있다.
function Dashboard() {
return (
<div>
{/* TodoList에서 에러가 나도 나머지는 정상 동작 */}
<ErrorBoundary fallback={<TodoErrorFallback />}>
<Suspense fallback={<TodoSkeleton />}>
<TodoList />
</Suspense>
</ErrorBoundary>
{/* UserProfile에서 에러가 나도 나머지는 정상 동작 */}
<ErrorBoundary fallback={<ProfileErrorFallback />}>
<Suspense fallback={<ProfileSkeleton />}>
<UserProfile />
</Suspense>
</ErrorBoundary>
{/* TeamList에서 에러가 나도 나머지는 정상 동작 */}
<ErrorBoundary fallback={<TeamErrorFallback />}>
<Suspense fallback={<TeamSkeleton />}>
<TeamList />
</Suspense>
</ErrorBoundary>
</div>
)
}
[참고 출처]
https://github.com/ssi02014/react-query-tutorial
GitHub - ssi02014/react-query-tutorial: 😃 TanStack Query(aka. react query) 에서 자주 사용되는 개념 정리
😃 TanStack Query(aka. react query) 에서 자주 사용되는 개념 정리 - ssi02014/react-query-tutorial
github.com
구) React Query / 현) Tanstack Query 이제 제대로 사용해보자
🌳 React Query (Tanstack Query) - v4기준 수정중
velog.io
React Query의 캐시타임(Cache Time)과 스테일타임(Stale Time), 포커스 속성에 대한 이해
React Query의 캐시타임(Cache Time)과 스테일타임(Stale Time), 포커스 속성에 대한 이해
velog.io
https://www.yolog.co.kr/post/optimistic-update/
개발자 매튜 | 실제 서비스에서 낙관적 업데이트(Optimistic Update)를 활용하여, 유저의 답답함 줄이
낙관적 업데이트(Optimistic Update)란 무엇인가? `낙관적 업데이트(Optimistic Update)`는 API 호출과 같은 비동기 작업이 완료되기 전에 사용자 인터페이스(UI)를 먼저 업데이트하여 사용자가 즉각적인 반
www.yolog.co.kr
https://tanstack.com/query/v4/docs/framework/react/community/lukemorales-query-key-factory
Query Key Factory | TanStack Query React Docs
Typesafe query key management with auto-completion features. Focus on writing and invalidating queries without the hassle of remembering how you've set up a key for a specific query! Installation You...
tanstack.com
1-1편 React의 선언적 경계 패턴: Suspense와 ErrorBoundary
“선언적” 철학을 경계로 구현한다
velog.io
'React' 카테고리의 다른 글
| useMemo, useCallback을 활용한 상세 페이지 구현하기 (w. TMDB) (0) | 2026.04.12 |
|---|---|
| Tanstack query 활용한 무한 스크롤 페이지 (w.TMDB) (0) | 2026.03.29 |
| 상태 관리 전략 비교 (0) | 2026.03.22 |
| 커스텀 훅과 책임 분리 (0) | 2026.03.15 |
| 컴포넌트 설계 원칙 정리와 중복 로직 리팩토링 (0) | 2026.03.05 |
