프론트엔드 데이터 패칭 다시 보기 (03) - TanStack Query는 서버 상태를 어떻게 추상화하는가
query key, cache, staleTime, mutation, invalidation이 왜 한 세트로 묶이는지, TanStack Query가 서버 상태를 어떤 모델로 다루는지 정리합니다.
용어 보기접기/펼치기
TanStack Query는 fetch를 대신 써 주는 도구라기보다, 서버 데이터를 어떤 규칙으로 저장하고 다시 맞출지를 모델링하는 도구에 가깝습니다.
들어가며
앞선 글에서는 두 가지를 정리했습니다. React에서 비동기 데이터 처리가 왜 금방 복잡해지는지, 그리고 그 복잡함이 결국 서버 상태라는 별도 문제 영역에서 나온다는 점입니다.
여기까지 오면 자연스럽게 다음 질문이 나옵니다. 그렇다면 TanStack Query는 이 문제를 정확히 어떻게 풀고 있을까요.
많은 경우 TanStack Query는 "useQuery로 데이터를 쉽게 가져오는 라이브러리" 정도로 소개됩니다. 물론 틀린 설명은 아닙니다. 하지만 이 설명만으로는 왜 queryKey, cache, staleTime, mutation, invalidation 같은 개념이 항상 함께 따라오는지까지는 설명되지 않습니다.
이번 글에서는 TanStack Query를 사용법보다 모델 기준으로 보려고 합니다. 이 도구가 서버 상태를 어떤 단위로 식별하고, 어떤 기준으로 재사용하고, 어떤 시점에 다시 맞추는지를 차근차근 살펴보겠습니다.
이 글에서는 다섯 가지를 중심으로 보겠습니다.
- queryKey가 왜 단순 배열이 아닌지
- cache가 어떤 기준점 역할을 하는지
- staleTime이 왜 최신성 규칙인지
- mutation이 왜 query와 다른 문제를 만드는지
- invalidation이 왜 이 모델의 핵심 연결 고리인지
TanStack Query의 핵심은 데이터를 "가져오는 법"보다, 같은 서버 상태를 같은 이름으로 다루고 같은 규칙으로 다시 맞추는 법에 있습니다.
한 줄로 먼저 요약하면 이렇습니다.
- queryKey는 서버 상태의 경계를 정합니다.
- cache는 마지막으로 확인한 값을 붙잡습니다.
- staleTime은 얼마나 믿을지 정합니다.
- mutation과 invalidation은 바뀐 값을 다시 맞춥니다.
queryKey는 이름표가 아니라 식별자입니다
TanStack Query를 처음 볼 때 가장 먼저 접하는 개념 중 하나가 queryKey입니다.
const postQuery = useQuery({
queryKey: ["post", slug],
queryFn: () => fetchPost(slug),
});겉으로 보면 queryKey는 그냥 배열처럼 보입니다. 하지만 실제 역할은 훨씬 중요합니다.
queryKey는 단순한 이름표가 아니라, 어떤 서버 상태를 어떤 캐시 항목으로 다룰지 정하는 식별자입니다.
즉 ["post", slug]라고 쓰는 순간 우리는 사실 아래를 한 번에 정하고 있습니다.
- 이 데이터는 글 상세 데이터다
- slug가 다르면 서로 다른 서버 상태로 본다
- 같은 key를 쓰는 화면은 같은 캐시를 바라본다
- 이후 refetch나 invalidation도 이 key를 기준으로 움직인다
그래서 queryKey는 예쁘게 이름 짓는 문제가 아니라, 서버 상태의 경계를 어디서 나눌지 정하는 문제에 가깝습니다.
이걸 너무 느슨하게 잡으면 서로 다른 데이터가 같은 캐시를 공유하게 되고, 너무 잘게 나누면 재사용성이 떨어집니다. 즉 queryKey 설계는 서버 상태 모델의 첫 단추입니다.
한 줄로 정리하면 이렇습니다.
- queryKey는 데이터를 부르는 이름이 아닙니다.
- queryKey는 같은 서버 상태를 어디까지 같은 것으로 볼지 정하는 기준입니다.
cache는 마지막 값을 보관하는 장소이자 판단의 기준점입니다
queryKey가 식별자를 만든다면, cache는 그 식별자를 기준으로 실제 값을 보관하는 장소입니다.
여기서 중요한 점은 cache를 단순한 속도 개선 장치로만 보면 절반만 이해하게 된다는 것입니다. TanStack Query에서 cache는 "마지막으로 확인한 값"을 남겨 두는 저장소이면서, 동시에 다음 행동을 정하는 판단의 기준점이기도 합니다.
예를 들어 같은 queryKey로 여러 화면이 데이터를 읽는다고 가정해 보겠습니다.
- 이미 값이 있으면 이전 데이터를 먼저 보여 줄 수 있습니다.
- 아직 fresh하면 새 요청 없이 그대로 쓸 수 있습니다.
- stale하면 background refetch를 시작할 수 있습니다.
- 값이 없으면 처음부터 로딩 상태로 진입합니다.
즉 cache는 데이터 본문만 보관하지 않습니다. 지금 이 값을 어떻게 다뤄야 하는지 판단할 출발점도 함께 제공합니다.
TanStack Query의 cache는 "저장소"이면서 동시에 "다음 행동을 결정하는 기준점"입니다.
즉 cache가 없으면 모든 요청은 매번 첫 요청처럼 취급되고, cache가 있으면 같은 서버 상태를 연속된 흐름으로 다룰 수 있습니다.
staleTime은 숫자 옵션이 아니라 최신성 규칙입니다
처음 TanStack Query를 배울 때 staleTime은 옵션 하나처럼 보입니다.
const postQuery = useQuery({
queryKey: ["post", slug],
queryFn: () => fetchPost(slug),
staleTime: 30_000,
});하지만 staleTime은 단순한 튜닝 값이 아닙니다. 이 값은 얼마 동안 이 데이터를 최신으로 간주할지를 정하는 규칙입니다.
이 규칙이 중요한 이유는, TanStack Query가 "값이 있나 없나"만 보는 도구가 아니기 때문입니다.
- 값이 있어도 stale할 수 있습니다.
- stale해도 바로 화면을 비울 필요는 없습니다.
- stale하다고 판단하는 순간 refetch 전략도 함께 달라집니다.
즉 staleTime은 성능 최적화용 숫자가 아니라, 재사용성과 최신성 사이에서 어디에 선을 그을지 정하는 모델의 일부입니다.
그래서 같은 글 상세라도 상황에 따라 다른 값을 가질 수 있습니다.
- 거의 바뀌지 않는 문서는 staleTime을 길게 줄 수 있습니다.
- 자주 바뀌는 대시보드 데이터는 더 짧게 줄 수 있습니다.
- 사용자 프로필처럼 "조금 오래된 값이어도 괜찮은가" 를 따져야 하는 데이터도 있습니다.
중요한 것은 staleTime이 "이 옵션을 얼마나 줄까"의 문제가 아니라, 이 데이터를 어느 정도까지 신뢰할 것인가의 문제라는 점입니다.
즉 staleTime은 캐시를 오래 붙잡는 숫자가 아니라, 최신성을 어떤 기준으로 해석할지 정하는 규칙입니다.
여기서부터 TanStack Query가 단순 캐시 라이브러리가 아니라는 점이 드러납니다. cache가 값만 저장한다면 staleTime은 필요 없고, 최신성 판단까지 맡기기 때문에 이 규칙이 같이 붙습니다.
useQuery는 fetch 호출이 아니라 상태 머신에 가깝습니다
TanStack Query를 단순 fetch 도구로만 보면 useQuery는 API를 한 번 감싼 훅처럼 보입니다.
하지만 실제로 useQuery는 아래 상황을 함께 다룹니다.
- 처음 요청하는가
- 이미 cache가 있는가
- 그 값이 fresh한가 stale한가
- background refetch 중인가
- 마지막 요청이 실패했는가
즉 useQuery는 "한 번 호출하고 끝나는 함수"보다, 서버 상태의 현재 단계에 따라 다른 동작을 고르는 상태 머신에 가깝습니다.
그래서 TanStack Query를 쓰면 로딩과 에러, 이전 데이터 유지, 재시도 같은 문제가 한 API 안에서 함께 다뤄지기 시작합니다. 이건 편의 기능이 많아서가 아니라, 원래 서버 상태 자체가 그만큼 여러 층의 전환 상태를 갖기 때문입니다.
즉 useQuery는 fetch를 감싼 훅이라기보다, 현재 이 서버 상태가 어느 단계에 있는지 해석하는 읽기 인터페이스에 가깝습니다.
mutation은 query와 전혀 다른 질문을 만듭니다
query가 "무엇을 읽을 것인가"의 문제라면, mutation은 "무엇을 바꿀 것인가"의 문제입니다.
const savePost = useMutation({
mutationFn: updatePost,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["post", slug] });
},
});여기서 중요한 점은 mutation이 단순히 POST 요청을 보내는 API가 아니라는 것입니다. 서버 데이터를 바꾸는 순간, 우리는 곧바로 다른 질문을 만나게 됩니다.
- 어떤 query가 영향을 받는가
- 현재 cache는 여전히 믿을 수 있는가
- 성공 직후 화면을 바로 바꿀 것인가
- background에서 다시 맞출 것인가
즉 mutation은 write 작업 자체보다, write 이후 read를 어떻게 다시 맞출지까지 포함한 문제를 만듭니다.
이 지점부터 TanStack Query는 단순 fetch 도구처럼 보이지 않습니다. 읽기와 쓰기가 서로 분리된 기능이 아니라, 같은 서버 상태를 다루는 두 방향으로 보이기 시작합니다.
한 줄로 줄이면 이렇습니다.
- query는 읽기 문제를 다룹니다.
- mutation은 바뀐 뒤에 무엇을 다시 믿을지까지 함께 다룹니다.
invalidation은 이 모델의 연결 고리입니다
TanStack Query에서 invalidation은 mutation과 query를 이어 주는 핵심 장치입니다.
invalidateQueries는 "이 캐시를 당장 지워라"는 뜻이라기보다, 이 값은 더 이상 그대로 최신이라고 믿지 않겠다고 표시하는 쪽에 가깝습니다.
이게 중요한 이유는 서버 상태 도구의 목표가 캐시를 오래 붙잡는 일 자체가 아니기 때문입니다. 목표는 필요할 때 다시 맞추는 것입니다.
그래서 invalidation은 아래를 한 번에 담당합니다.
- 어떤 key가 영향을 받았는지 표시하고
- 그 값을 stale하게 만들고
- 이후 적절한 시점에 다시 맞출 수 있게 연결합니다
즉 mutation이 서버에 변화를 만들었다면, invalidation은 그 변화가 cache 모델 안에서 어떻게 반영되어야 하는지 알려 주는 연결 고리입니다.
mutation이 "서버를 바꾸는 일"이라면, invalidation은 그 변화가 cache 세계에 어떻게 번역될지 정하는 일에 가깝습니다.
그래서 이 개념들은 항상 한 세트로 움직입니다
여기까지를 하나로 묶어 보면 TanStack Query의 모델은 생각보다 분명합니다.
- queryKey로 서버 상태를 식별합니다.
- cache에 마지막으로 확인한 값을 저장합니다.
- staleTime으로 얼마나 믿을지 정합니다.
- useQuery는 현재 상태에 따라 읽기 전략을 고릅니다.
- mutation이 일어나면 invalidation으로 다시 맞출 대상을 정합니다.
즉 queryKey, cache, staleTime, mutation, invalidation은 각각 따로 배우는 기능이 아니라, 같은 서버 상태를 끝까지 일관되게 다루기 위한 하나의 모델입니다.
이렇게 보면 TanStack Query를 "서버 상태 라이브러리"라고 부르는 이유도 더 분명해집니다. 서버 데이터를 저장하고, 읽고, 다시 맞추는 규칙을 한 구조 안에서 다루기 때문입니다.
다시 말해 이 모델의 핵심은 "요청을 보내는 법"이 아니라, 같은 데이터를 같은 방식으로 식별하고 같은 규칙으로 다시 맞추는 일관성에 있습니다.
마치며
TanStack Query는 fetch를 대신 쓰는 편의 도구가 아닙니다. 서버 상태를 어떤 이름으로 식별하고, 어디까지 믿고, 언제 다시 맞출지를 구조화하는 도구에 더 가깝습니다.
그래서 queryKey, cache, staleTime, mutation, invalidation은 각각 따로 배우는 기능이 아니라 같은 모델의 다른 면입니다. 이걸 한 세트로 보기 시작하면 TanStack Query API도 훨씬 덜 낯설어집니다.
다음 글에서는 여기서 한 걸음 더 나가 보겠습니다. 지금까지는 React 기준으로 서버 상태 문제와 TanStack Query 모델을 정리했다면, 이제는 이 모델이 Next.js App Router 환경에서 어떻게 다시 배치되는지 살펴볼 차례입니다. prefetch와 hydration이 어디서 등장하고, 왜 화면 하위가 아니라 페이지 상단에서 orchestration이 필요해지는지를 이어서 정리하겠습니다.