today's helen

seize!

web

TanStack Query 원리부터 제대로 쓰기

yooncandooit 2026. 7. 8. 15:56
반응형

useQuery 한 줄로 데이터를 가져오는 건 쉽죠! 하지만 Query Key는 왜 배열이어야 하는지, Optimistic Update에서 cancelQueries를 빠뜨리면 어떤 일이 생기는지, Suspense와 ErrorBoundary가 어떻게 맞물리는지에 대한 생각을 작성했습니다!


들어가며

대부분의 웹 프레임워크는 데이터를 가져오거나 업데이트하는 정해진 규칙이 없어요

이 때문에 개발자는 데이터를 처리하는 방법을 직접 고민해야하죠!

  1. React에서 useEffect를 사용하는 방법
  2. → 데이터를 가져오거나 업데이트하는 코드를 컴포넌트 내부에 작성하여 상태와 side effect가 연결됨
  3. 전역 상태 관리 라이브러리 사용
  4. → 서버에서 가져온 데이터를 전역 상태 관리 도구에 저장하고 여러 컴포넌트에서 사용할 수 있도록 함

그치만 위와 같은 방식 모두 반복적인 코드와 비효율성을 초래할 수 있어요.

그렇기 때문에 우리는 TanStack Query를 사용합니다.

TanStack Query란?
웹 어플리케이션에서 서버 상태 가져오기, 캐싱, 동기화 및 업데이트를 용이하게 만들어주는 라이브러리

처음 TanStack Query를 배울 때 가장 먼저 만나는 코드는 이렇습니다.

const { data, isLoading, error } = useQuery({
  queryKey: ['user'],
  queryFn: fetchUser,
})

혹은 조금더 익숙해지면 실제 저희 팀과 같은 구조로 발전할 거 같아요.

// query-key.ts
export const USER_QUERY_KEY = {
  ALL: ['user'],
  USER_INFO: () => [...USER_QUERY_KEY.ALL, 'userInfo'],
}

// queries.ts
export const USER_QUERY_OPTIONS = {
  GET_USER_INFO: () =>
    queryOptions({
      queryKey: USER_QUERY_KEY.USER_INFO(),
      queryFn: getUserInfo,
    }),
}

간결하고 직관적이죠! 그런데 실제 프로젝트에서 쓰다 보면 점점 이런 질문들이 생깁니다.

  • Query Key를 어떻게 설계해야 invalidateQueries를 효율적으로 쓸 수 있을까?
  • Optimistic Update를 구현했는데, 왜 가끔 UI가 원래대로 돌아와버릴까?
  • useSuspenseQueryuseQuery는 뭐가 다른가?

기본 사용법은 알지만, 왜 이렇게 동작하는지 아직 흐릿한 저를 위해 정리해 보려합니다. 제가 실제 프로젝트에서 적용한 코드와 함께 제대로 원리를 알아볼게요!


0. Tanstack Query의 특징

  • API요청(Data Fetching)을 위한 규격화된 방식 제공 ⇒ 코드 통일성
  • 클라이언트와 서버 상태를 분리하여 관리할 수 있다. (직관적이고 유지보수 용이)
  • 백그라운드에서 오래된 데이터 업데이트할 수 있다.
  • 오래된 데이터의 시기를 알 수 있다.
  • Devtool 지원 : @tanstack/react-query-devtools (데이터 흐름 파악 가능)
  • 캐싱기능, 낙관적 업데이트, 무한스크롤 등을 간단하게 구현가능 (단지 옵션 몇개만으로도)

1. Query Key와 캐시

Query Key는 캐시의 주소다

TanStack Query에서 캐시는 Query Key를 주소로 합니다. 같은 Key를 가진 쿼리는 같은 캐시를 바라보고, Key가 달라지면 완전히 별개의 데이터로 취급됩니다.

그런데 타 기술블로그를 읽으며 ‘왜 배열이어야 하는가’라는 질문을 봤었는데 저도 처음엔 그냥 규칙이라서..라고 생각했는지 배열 구조는 계층적 무효화를 가능하기 때문에 사용되는 설계된 패턴이라고 해요.

// 이 세 개의 쿼리가 있다면
['phase', 'phaseList']
['phase', 'phaseItemHome', 1]
['phase', 'phaseItemRoadmap', 1]

// prefix 하나로 세 쿼리를 한 번에 무효화할 수 있습니다
queryClient.invalidateQueries({ queryKey: ['phase'] })

한 가지 더 짚고 간다면, 쿼리키는 단일 문자열이 포함된 배열일 수도, 여러 문자열 및 중첩 객체의 배열처럼 복잡할 수도 있지만, 객체 Key는 순서에 무관하고 배열 Key는 순서에 유관하다는 점!

// 동일한 캐시를 참조 (객체는 순서 무관)
useQuery({ queryKey: ['todos', { status, page }], ... })
useQuery({ queryKey: ['todos', { page, status }], ... })

// 다른 캐시 (배열은 순서 유관)
useQuery({ queryKey: ['todos', status, page], ... })
useQuery({ queryKey: ['todos', page, status], ... })

배열로 동적 값을 담을 땐 순서를 팀 내에서 일관되게 정해야합니다.

query-key.ts 파일 한 곳에서 쿼리 키 관리하기

Key를 컴포넌트마다 직접 문자열로 적으면 오타, 불일치, 중복 선언이 생깁니다. 그래서 query-key.ts 로 한 곳에서 관리하는 방식을 권장합니다.

// query-key.ts
export const PHASE_QUERY_KEY = {
  ALL: ['phase'],

  PHASE_LIST: () => [...PHASE_QUERY_KEY.ALL, 'phaseList', getLocaleQueryKey()],

  PHASE_ITEM_HOME: (phaseId: number) => [
    ...PHASE_QUERY_KEY.ALL,
    'phaseItemHome',
    phaseId,
    getLocaleQueryKey(),
  ],

  // prefix 전용 — 범위 무효화에 사용
  PHASE_ITEM_ROADMAP_ALL: () => [
    ...PHASE_QUERY_KEY.ALL,
    'phaseItemRoadmap',
    getLocaleQueryKey(),
  ],
}

실제로 Todo 완료 시 Roadmap 쿼리를 일괄 갱신할 때 위와 같은 코드를 사용하고 있습니다!

// 모든 phaseItemRoadmap 관련 쿼리를 한 번에 무효화
queryClient.invalidateQueries({
  queryKey: PHASE_QUERY_KEY.PHASE_ITEM_ROADMAP_ALL(),
})

queryOptions로 쿼리 정의 몰아서 하기

query-key.ts에서 한 발 더 나아가면 queryOptions로 쿼리 정의 자체를 한 곳에 모을 수 있어요. Key와 queryFn, staleTime을 함께 선언하면 컴포넌트에서는 그냥 가져다 쓰기만 하면 됩니다.

// queries.ts
export const PHASE_QUERY_OPTIONS = {
  GET_PHASE_ITEM_HOME: (phaseId: number) => {
    return queryOptions({
      queryKey: PHASE_QUERY_KEY.PHASE_ITEM_HOME(phaseId),
      queryFn: () => getPhaseItemHome(phaseId),
      enabled: !!phaseId && phaseId > 0,
      staleTime: Infinity, // 변경이 거의 없는 데이터 → 무한 fresh
      placeholderData: (prev) => prev, // 이전 데이터 유지
    })
  },
}
// 컴포넌트에서는 이렇게 사용!
const { data } = useQuery({ ...PHASE_QUERY_OPTIONS.GET_PHASE_ITEM_HOME(phaseId) })

staleTime vs gcTime 정의

처음 접하면 비슷해 보이지만, 완전히 역할이 다릅니다.

  staleTime gcTime
역할 데이터를 언제까지 fresh로 볼 것인지 캐시를 메모리에 얼마나 유지할 것인지
시작 시점 데이터를 받아온 순간 쿼리를 사용하는 컴포넌트가 언마운트된 순간
만료되면 갱신 트리거 시 refetch 발생 메모리에서 캐시 삭제
기본값 0 (항상 stale) 5분

추가로 staleTime이 0이라도 로딩 깜빡임이 없는 이유는 데이터가 stale하더라도 캐시에 있으면 먼저 보여주면서 백그라운드에서 갱신하기 때문이예요. 덕분에 staleTime이 0이어도 페이지 전환 시 이전 데이터가 즉시 렌더됩니다. (TanStack Query의 SWR(Stale-While-Revalidate) 전략이라고 부르더라구요!)

// query-client.ts
export const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      retry: shouldRetry, // 커스텀 retry 로직 (저희는 query-config.ts에 400번대 에러가 아닌 경우에만 retry하도록 설정해 두었어요)
      staleTime: 0, // 항상 재검증하되 캐시는 먼저 보여줌
      gcTime: 1000 * 60 * 5,
      refetchOnWindowFocus: true,
    },
  },
})

(custom) retry 전략

재시도 횟수의 기본값은 3번입니다. 그런데 400, 401, 404 같은 400번대 에러는 재시도해도 의미가 없죠! 서버의 문제이지, 네트워크 같은 일시적 오류가 아니기 때문에 블랙 리스트에 넣어 바로 포기하게 만들도록 아래와 같이 커스텀 retry 로직을 적용했어요.

// query-config.ts
const RETRY_BLACKLIST = new Set<number>([
  HTTP_STATUS_CODE.BAD_REQUEST,    // 400
  HTTP_STATUS_CODE.UNAUTHORIZED,   // 401
  HTTP_STATUS_CODE.FORBIDDEN,      // 403
  HTTP_STATUS_CODE.NOT_FOUND,      // 404
  HTTP_STATUS_CODE.CONFLICT,       // 409
])

const MAX_RETRY_COUNT = 1

export const shouldRetry = (failureCount: number, error: unknown) => {
  if (failureCount > MAX_RETRY_COUNT) return false

  if (isHttpError(error) && error.response) {
    const { status } = error.response
    if (RETRY_BLACKLIST.has(status)) return false
  }

  return true
}

2. Optimistic Update: 서버 응답 전에 UI를 먼저 바꾸는 낙관적 업뎃

왜 필요한가

좋아요 버튼을 눌렀는데 서버 응답이 올 때까지 0.5초 동안 아무 반응이 없다면, 사용자는 버튼이 눌렸는지 안 눌렸는지 모를 거고 UX적으로 안 좋겠죠?! Optimistic Update는 어차피 성공할 가능성이 높으니, 서버 응답을 기다리지 않고 UI를 먼저 바꾸고! 실패하면 원래대로 돌린다!는 아이디어예요.

작동 플로우: onMutate → onError → onSettled

사용자 클릭
    ↓
onMutate: 스냅샷 저장 → cancelQueries → UI 즉시 변경
    ↓
API 요청 전송
    ㄴ> 성공 → onSuccess: invalidate로 서버와 동기화
    ㄴ> 실패 → onError: 스냅샷으로 롤백
    ↓
onSettled: 항상 실행 (성공/실패 무관)

Todo 체크박스 예시

// todo-panel.tsx
const { mutate } = useMutation({
  ...TODO_MUTATION_OPTIONS.PATCH_TODO(),

  onMutate: async (actionItemId) => {
    const queryKey = TODO_QUERY_KEY.TODO_LIST()

    // 1. 진행 중인 refetch를 취소
    //    -> 우리가 바꾼 캐시를 오래된 서버 응답이 덮어쓰는 걸 막기 위해서
    await queryClient.cancelQueries({ queryKey })

    // 2. 롤백을 위한 스냅샷을 저장
    const prev = queryClient.getQueryData<ActionItemListResponse>(queryKey)

    // 3. 캐시를 직접 수정해 UI를 즉시 변경
    queryClient.setQueryData<ActionItemListResponse>(queryKey, (current) => {
      if (!current) return current
      return {
        ...current,
        visaActionItems: toggleCompleted(current.visaActionItems, actionItemId),
        careerActionItems: toggleCompleted(current.careerActionItems, actionItemId),
      }
    })

    // context에 스냅샷을 담아 onError로 전달
    return { prev }
  },

  // 4. 실패 시 스냅샷으로 원상복구
  onError: (_error, _variables, context) => {
    if (context?.prev) {
      queryClient.setQueryData(TODO_QUERY_KEY.TODO_LIST(), context.prev)
    }
  },

  // 5. 성공이든 실패든 서버 데이터로 최종 동기화
  onSettled: (_data, _error, actionItemId) => {
    pendingActionItemIds.current.delete(actionItemId)
    queryClient.invalidateQueries({ queryKey: TODO_QUERY_KEY.TODO_LIST() })
  },
})

cancelQueries가 없으면 생기는 문제

낙관적 업데이트의 타이밍 시나리오를 보겠습니다.

t=0: 컴포넌트 마운트 → 서버에 GET 요청 시작
t=1: 사용자가 체크박스 클릭
t=2: onMutate에서 캐시를 { completed: true }로 변경
t=3: t=0에서 시작된 GET 요청이 완료
      → 서버는 아직 PATCH가 안 됐으니 { completed: false } 반환
      → 이 응답이 캐시를 덮어씀
t=4: UI가 체크 해제 상태로 돌아감 (버그)

cancelQueries는 t=3의 응답이 캐시를 덮어쓰는 것을 막습니다. onMutate에서 반드시 먼저 실행해야 합니다.

중복 클릭 방지

네트워크 요청이 진행 중인 항목에 다시 클릭이 들어오면 예기치 않은 동작이 생깁니다. useRef로 진행 중인 ID를 추적해서 막습니다.

const pendingActionItemIds = useRef(new Set<number>())

const handleTodoToggle = (actionItemId: number) => {
  const id = Number(actionItemId)
  if (pendingActionItemIds.current.has(id)) return  // 진행 중이면 무시
  mutate(id)
}

// onMutate에서 추가
pendingActionItemIds.current.add(actionItemId)

// onSettled에서 제거
pendingActionItemIds.current.delete(actionItemId)

3. Prefetching과 Infinite Query: 더 빠른 UX를 위해

Prefetching: 사용자가 필요하기 전에 미리 가져오기

Prefetching의 핵심은 사용자가 데이터를 요청하기 전에 캐시에 넣어두는 거예요. 대표적인 방법이 Route Loader에서 ensureQueryData를 쓰는 패턴입니다.

// shared/router/loader.ts
export const appRouteLoader = async () => {
  requireAuth()

  // 라우트 진입 시점에 미리 데이터를 캐시에 채워둡니다
  // 컴포넌트가 렌더될 때는 이미 캐시에 데이터가 있어 로딩 없이 표시됩니다
  const userStatus = await queryClient.ensureQueryData(
    USER_QUERY_OPTIONS.GET_USER_STATUS()
  )

  if (!userStatus.agreedTerm) throw redirect(ROUTE_PATH.TERMSAGREEMENT)
  if (userStatus.onboardingRequired) throw redirect(ROUTE_PATH.ONBOARDING)

  return null
}

prefetchQuery가져와두기만이고, ensureQueryData캐시에 없으면 가져오고, 있으면 그대로 반환합니다. 라우터 레벨에서는 ensureQueryData를 사용했네요!

placeholderData: 새 데이터 오기 전까지 이전 데이터 유지

페이지나 필터가 바뀔 때 잠깐 로딩 상태가 되면서 화면이 비는 경우가 있죠!
placeholderData를 쓰면 새 데이터가 올 때까지 이전 데이터를 그대로 보여줍니다.

// queries/queries.ts
GET_PHASE_ITEM_HOME: (phaseId: number) => {
  return queryOptions({
    queryKey: PHASE_QUERY_KEY.PHASE_ITEM_HOME(phaseId),
    queryFn: () => getPhaseItemHome(phaseId),
    enabled: !!phaseId && phaseId > 0,
    staleTime: Infinity,
    placeholderData: (prev) => prev, // 이전 페이지 데이터를 placeholder로 유지
  })
},

Polling: 주기적 자동 갱신

AI 로드맵 생성처럼 서버에서 비동기로 꽤 오래동안 처리되는 작업은 완료될 때까지 주기적으로 상태를 확인해야 하는데 이런 건 refetchInterval로 구현할 수 있습니다.

// queries/queries.ts
GET_PHASE_LIST: () => {
  return queryOptions({
    queryKey: PHASE_QUERY_KEY.PHASE_LIST(),
    queryFn: getPhaseList,
    refetchInterval: (query) => {
      const phases = query.state.data?.phases ?? []
      // 데이터가 없으면 2초마다 폴링, 있으면 중단
      return phases.length === 0 ? 2000 : false
    },
  })
},

useInfiniteQuery: 무한 스크롤의 작동 원리

일반 useQuery는 데이터를 한 번 가져오지만, useInfiniteQuery는 "다음 페이지"를 이어 붙일 수 있습니다. 내부 구조는 이렇습니다.

// useInfiniteQuery의 data 구조
{
  pages: [
    { list: [아이템1, 아이템2, ...], isLast: false }, // 페이지 0
    { list: [아이템11, 아이템12, ...], isLast: false }, // 페이지 1
    { list: [아이템21, ...], isLast: true }, // 페이지 2 (마지막)
  ],
  pageParams: [0, 1, 2]
}

getNextPageParam이 핵심입니다. 서버 응답 형태에 따라 세 가지 패턴으로 구현해요.

// 패턴 1: 데이터 개수로 마지막 페이지 판단
// 마지막 페이지가 정확히 PAGE_SIZE개면 빈 요청을 한 번 더 보내는 단점이 있어요
getNextPageParam: (lastPage, allPages) =>
  lastPage.list.length < PAGE_SIZE ? undefined : allPages.length

// 패턴 2: isLast 플래그 (good)
// 서버가 { data: [...], isLast: true } 형태로 내려줄 때
getNextPageParam: (lastPage, allPages) =>
  lastPage.data.isLast ? undefined : allPages.length

// 패턴 3: hasNext 플래그 (good)
// 서버가 { slice: { hasNext: false } } 형태로 내려줄 때
getNextPageParam: (lastPage, allPages) =>
  lastPage.slice?.hasNext ? allPages.length : undefined

서버가 직접 다음 있음/없음을 알려주기 때문에 패턴 2, 3이 더 정확합니다. 저도 hasNext 형태로 무한 스크롤을 구현했었어요!


4. Error / Loading 바운더리

isLoading vs isPending의 차이

const { isPending, isError, data, error } = useQuery({
  queryKey: ['todos'],
  queryFn: fetchTodoList,
})
  • isLoading: 첫 fetch가 지금 실행 중인지 (= isPending && isFetching)
  • isPending: 아직 한 번도 데이터를 받아온 적이 없는 상태인지

isLoading만 보면 ‘지금 요청 중’인지는 알 수 있지만, 데이터가 안전하게 존재하는지는 100% 확신하기 어려운 경우가 생기기 때문에, 공식 문서에서 권장하는 분기 순서는 아래와 같습니다.

if (isPending) return <Spinner />
if (isError) return <ErrorMessage error={error} />
// 여기서부터는 data가 반드시 존재함이 보장됩니다
return <TodoList data={data} />

useSuspenseQuery: 로딩 상태를 컴포넌트 밖으로 꺼내기

useQuery는 컴포넌트 안에서 isPending, isError를 직접 처리합니다. useSuspenseQuery는 로딩 중이면 Promise를 throw해서 가장 가까운 <Suspense> fallback이 작동하게 합니다.

// useQuery 방식 = 컴포넌트 안에서 직접 처리
function TodoList() {
  const { isPending, isError, data } = useQuery(...)

  if (isPending) return <Spinner />
  if (isError) return <ErrorMessage />
  return <ul>{data.map(...)}</ul>
}

// useSuspenseQuery 방식 = 컴포넌트는 성공 상태만 다룸
function TodoList() {
  const { data } = useSuspenseQuery(...)
  // isPending, isError 분기 없음 (data는 항상 존재)
  return <ul>{data.map(...)}</ul>
}
// 바깥에서 선언적으로 처리!
<Suspense fallback={<Spinner />}>
  <TodoList />
</Suspense>

즉 useSuspenseQuery는 관심사 분리를 위함입니다. 컴포넌트는 데이터로 인한 UI 책임만 담당하고, 로딩과 에러는 상위 컴포넌트에서 관리합니다.

QueryErrorResetBoundary: 에러 복구까지 TanStack Query로

에러가 나면 단순히 에러 화면을 보여주는 것만으로는 부족하고, 사용자가 "다시 시도" 버튼을 눌렀을 때 쿼리가 다시 실행되어야 합니다. QueryErrorResetBoundary는 이를 처리하는 방식이예요!

// router/app-route-layout.tsx
const AppRouteLayout = () => {
  return (
    <AppLayout>
      <QueryErrorResetBoundary>
        {({ reset }) => (
          <Sentry.ErrorBoundary
            onReset={reset} // 에러 바운더리가 reset되면 관련 쿼리도 함께 reset
            fallback={({ resetError }) => <ErrorPage onAction={resetError} />}
          >
            <Suspense fallback={<PageLoader />}>
              <Outlet />
            </Suspense>
          </Sentry.ErrorBoundary>
        )}
      </QueryErrorResetBoundary>
    </AppLayout>
  )
}

resetonReset에 연결하면, 사용자가 에러 화면에서 "다시 시도"를 누를 때 에러 바운더리와 쿼리 상태가 함께 초기화됩니다. 이게 없으면 에러 바운더리만 초기화되고 쿼리는 에러 상태로 남아 있어 같은 에러가 즉시 다시 발생하게 되는 버그가 생겨요.

enabled로 조건부 쿼리 실행

필요한 데이터가 준비되기 전에 쿼리가 실행되면 잘못된 요청이 나가겠죠?! enabled로 조건을 걸 수 있습니다.

// 조건 1개 — ID가 있을 때만 실행
enabled: !!phaseId && phaseId > 0

// 조건 여러개 — 로그인한 사용자의 특정 데이터 조회
enabled: !!userId && !!projectId

enabled: false일 때 쿼리는 아예 실행되지 않기 때문에 GET /phase/undefined 같은 엉뚱한 요청이 서버로 나가는 것을 아예 차단합니다.


5. 실제 프로젝트 패턴 공유!

실제 프로젝트를 진행하면서 사용했던 패턴들을 정리해보겠습니다.

mutationOptions로 mutation도 한 곳에서 관리

queryOptions처럼 mutation도 한 곳에서 정의할 수 있어요.

// queries/queries.ts
export const JOB_MUTATION_OPTIONS = {
  TOGGLE_BOOKMARK_JOB_POSTING: () => {
    return mutationOptions({
      mutationFn: toggleBookmarkJobPosting,
    })
  },
}

// 컴포넌트에서 사용할 땐 이렇게
const { mutate: toggleBookmark } = useMutation({
  ...JOB_MUTATION_OPTIONS.TOGGLE_BOOKMARK_JOB_POSTING(),
  onMutate: async ({ jobPostingId }) => { ... },
  onSuccess: () => { ... },
})

mutation 자체의 mutationFn만 중앙화하고, onSuccess / onError 같은 사이드 이펙트 콜백은 컴포넌트 레벨에서 붙이고 있습니다. (비즈니스 로직마다 사이드 이펙트가 다르기 때문에 당연한 설계!)

다국어(i18n)를 Query Key에 포함하기

다국어를 지원하는 앱에서는 언어가 바뀌면 데이터도 다시 가져와야 하겠죠! Query Key에 현재 언어를 포함하면 언어 전환 시 자동으로 refetch되도록 설정했어요.

// queries/query-key.ts
export const USER_QUERY_KEY = {
  ALL: ['user'],
  USER_INFO: () => [...USER_QUERY_KEY.ALL, 'userInfo', getLocaleQueryKey()],
}

// 마이페이지에서 언어가 바뀌면 invalidate
useEffect(() => {
  queryClient.invalidateQueries({ queryKey: ['user'] })
}, [i18n.language, queryClient])

DevTools를 production에서 조건부로 열기

DevTools는 개발 중에는 항상 열려 있으면 좋지만, production(배포 환경)에서는 불필요하겠죠! window.toggleDevtools()를 콘솔에서 직접 호출하는 방식으로 필요할 때만 켤 수 있게 설정했습니다.

// /apis/providers/devtools-provider.tsx
const DevtoolsProvider = () => {
  const [showDevtools, setShowDevtools] = useState(false)

  useEffect(() => {
    window.toggleDevtools = (status) =>
      setShowDevtools((prev) => status ?? !prev)
    return () => { delete window.toggleDevtools }
  }, [])

  return (
    <>
      <ReactQueryDevtools initialIsOpen={false} />
      {showDevtools && (
        <Suspense fallback={null}>
          <ReactQueryDevtoolsProduction />
        </Suspense>
      )}
    </>
  )
}

production 번들에서 DevTools 코드는 lazy load되므로 초기 번들 크기에 영향을 주지 않는다구 합니다.


마무리

이 글에서 다룬 내용을 한 줄씩 요약하자면!

  • Query Key는 캐시의 주소! 계층 구조로 설계하면 범위별 무효화가 쉬워진다
  • staleTime은 데이터가 더이상 fresh하지 않다고 판단하는 기준, gcTime은 메모리 정리 기준
  • Optimistic Update에서 cancelQueries를 빠뜨리면 race condition이 생긴다
  • useSuspenseQuery는 로딩 상태를 컴포넌트 밖으로 꺼내 관심사를 분리한다
  • QueryErrorResetBoundary는 에러 복구 시 쿼리 상태까지 함께 초기화한다

서버 상태는 동기화, 캐싱, 재시도, 오류처리, 로딩상태 처리 등 관리할게 많고 이걸 useEffect, useState, … 등으로 처리하고자하면 매우 개발이 복잡해지고, DX가 좋지 않게되는데 .. TanStack Query의 가치는 데이터를 가져오는 게 아니라 자동으로 서버와 동기화하고, 캐시를 공유하면서 서버 상태를 선언적으로 다루는 사고방식에 있습니다!

읽어주셔서 감사하고, 궁금한 점이 있다면 언제든 댓글 남겨주세요 💬

반응형