본문으로 건너뛰기
Tech Blog

구독을 알면 보이는 손잡이들

글 복사 완료!

queryKey 가 의존성 배열이고 staleTime 과 gcTime 이 다른 질문인 이유를 풀어요.

·6분·

지난 편 에서 useQuery 가 캐시를 구독한다는 걸 봤어요. 저는 한동안 staleTime 이랑 gcTime 을 그냥 외워서 썼는데, 구독 모델을 머리에 넣고 나니 그제서야 왜 두 개로 나뉘어 있는지 보이더라구요. queryKey, staleTime, gcTime, select 가 전부 "구독을 어떻게 다룰까" 라는 한 질문의 답이라는 게 이 편의 축이에요.

queryKey 는 의존성 배열처럼 움직여요

queryKeyuseEffect 의 의존성 배열과 똑같이 동작해요. 키가 바뀌면 다른 쿼리로 인식돼서 자동으로 다시 받아요. 그래서 요청을 식별하는 변수는 전부 키에 넣어야 해요.

useQuery({
  queryKey: ['todos', { status, page }],
  queryFn: () => fetchTodos({ status, page }),
})

useEffect 로 페칭을 직접 짜다 보면, 탭을 빨리 바꿨을 때 직전 응답이 늦게 도착해 화면을 덮어쓰는 경쟁 상태 를 손으로 막아야 했어요. 키 기반 캐시는 이 식별을 대신 해줘서, 같은 키면 캐시를 쓰고 키가 바뀌면 새로 받아요.

신선도와 보관 기간은 다른 손잡이예요

제일 많이 헷갈리는 한 쌍이 staleTimegcTime 이에요. 둘은 다른 질문에 답해요. staleTime 은 "언제 다시 받을까" 예요. 기본값이 0 이라, 마운트하거나 창에 다시 포커스할 때마다 새로 받아요. 자주 안 바뀌는 데이터는 이 값을 늘려주면 쓸데없는 요청이 줄어요. gcTime 은 "안 쓰는 캐시를 언제 버릴까" 고요. 화면에서 사라져 구독자가 0 이 된 데이터를 메모리에 얼마나 더 둘지 정하는데, 기본이 5 분이에요.

"a garbage collection timeout is set using gcTime to delete and garbage collect the query (defaults to 5 minutes)." - TanStack Query

staleTime 은 신선도, gcTime 은 보관 기간이에요. "자꾸 요청이 나가요" 는 보통 staleTime 쪽, "뒤로 갔다 오니 캐시가 날아갔어요" 는 gcTime 쪽 이야기예요.

select 도 구독을 안다면 더 잘 쓰게 돼요. 쿼리 결과를 변형해서 그 조각만 구독하거든요. 고른 값이 그대로면 리렌더를 건너뛰어서, 큰 응답에서 일부만 보는 화면의 불필요한 리렌더를 줄여줘요.

const { data: count } = useQuery({
  queryKey: ['todos'],
  queryFn,
  select: (todos) => todos.length,
})

todos 배열 전체가 바뀌어도 길이가 같으면 이 컴포넌트는 안 움직여요. 구독하는 조각을 좁힌 거예요.

읽기와 쓰기가 만나는 자리

1편에서 useQuery 는 구독, getQueryData 는 일회성 읽기로 갈라놨는데, 둘이 같이 일하는 대표적인 자리가 낙관적 업데이트예요. 서버 응답을 기다리지 않고 화면을 먼저 바꿔치기하는 패턴이죠.

여기서 getQueryData 는 롤백용 스냅샷을 뜨는 데 쓰고, setQueryData 는 캐시를 직접 갈아끼우는 데 써요. 구독은 useQuery 가 하고, 캐시를 손으로 만지는 건 이 명령형 짝이 맡아요. 흐름은 이렇게 흘러가요.

1

onMutate에서 스냅샷

cancelQueries 로 진행 중 페치를 멈추고, getQueryData 로 지금 캐시를 떠둬요.

const prev = queryClient.getQueryData(['todos'])
2

낙관적으로 먼저 반영

서버 응답을 기다리지 않고 setQueryData 로 캐시를 미리 바꿔요. 구독 중인 화면이 곧바로 갱신돼요.

queryClient.setQueryData(['todos'], add(newTodo))
3

실패하면 롤백

onError 에서 떠둔 스냅샷으로 되돌려요. 사용자는 잠깐 보였다 사라지는 걸 봐요.

queryClient.setQueryData(['todos'], ctx.prev)
4

끝나면 서버와 맞추기

onSettled 에서 invalidateQueries 로 진짜 서버 상태를 다시 받아 정합을 맞춰요.

queryClient.invalidateQueries({ queryKey: ['todos'] })

setQueryData 로 캐시를 바꾸는 순간, 그 키를 구독하던 화면이 알아서 갱신돼요. 우리가 화면을 직접 건드리지 않아도요. 결국 읽기 쪽 구독과 쓰기 쪽 조작이 같은 캐시 위에서 맞물리는 거예요.

시리즈를 닫으며

getQueryData 로 값을 꺼내 그리지 않고 useQuery 를 쓰는 이유는 하나로 모여요. 화면은 서버 상태의 변화를 따라가야 하니까, 값을 읽는 게 아니라 변화를 구독하는 거예요. useQuery 로 화면을 그리고, 이벤트 핸들러나 롤백처럼 렌더 바깥에서 값이 필요할 때만 getQueryDatasetQueryData 를 꺼내 쓰면 돼요. 에러 처리도 재시도도 캐시 수명도, 전부 이 구독 모델 위에 얹힌 손잡이고요.

같은 데이터 페칭이라도 SWR 은 이 그림을 조금 다르게 그려요. 두 라이브러리의 성격 차이가 궁금하면 SWR과 TanStack Query 가 다른 이유 에서 비교해뒀어요.

자주 묻는 질문

답을 펼치기 전에 스스로 답해보세요

화면을 그리는 데만 안 쓰면 돼요. 이벤트 핸들러나 낙관적 업데이트 롤백처럼 렌더 바깥에서 그 순간 값이 필요할 때는 getQueryData 가 정확한 도구예요.
자동 재요청만 안 일어나요. invalidateQueries 로 직접 무효화하거나 setQueryData 로 갈아끼우면 구독 중인 화면은 그대로 갱신돼요.
isPending 은 캐시에 데이터가 아직 없는 상태고, isFetching 은 지금 네트워크 요청 중인지예요. isLoading 은 둘이 겹친 처음 받는 중 상태라서, 최초 스피너는 isPending, 갱신 인디케이터는 isFetching 으로 나눠 쓰면 깔끔해요.

참고 자료

관련 글