서버 상태라는 개념이 주목받기 시작하면서 TanStack Query에 대한 관심도 자연스럽게 높아졌다. 나 역시 여러 프로젝트에서 TanStack Query를 활용해왔는데, 돌아보면 "왜 TanStack Query인가?"라는 질문을 스스로에게 제대로 던진 적이 없었다. 그냥 레퍼런스가 많고, 팀원들이 익숙하고, 기능이 많으니까 — 라는 이유로 선택해왔던 것 같다.
비슷한 문제를 푸는 SWR이라는 라이브러리가 있다는 건 알고 있었다. 하지만 "TanStack Query가 더 좋다"는 막연한 인식만 있었을 뿐, 실제로 어떻게 다른지, 혹은 SWR이 더 나은 선택이었을 상황이 있었는지는 따져본 적이 없었다.
그래서 두 라이브러리를 여러가지에서 알아보고 앞으로 의식적으로 기술 스택을 선택하기 위한 기준을 마련하기 위해서 정리해보았다.
state와 useEffect의 조합이었다. 일반적인 클라이언트 상태를 표현하고 관리하는 데는 효과적이지만, 비동기 상태 즉 서버 상태를 관리하기에는 어색한 부분이 있었다.캐싱은 두 라이브러리 모두 지원하지만, 어떻게 캐시를 식별하고 무효화하는지에서 차이가 난다.
SWR은 캐시 키로 문자열을 사용한다.
보통 fetch URL이 그대로 키가 되기 때문에 직관적이고 간단하다.
// SWR — 문자열 기반 캐시 키
useSWR('/api/todos?status=done', fetcher)
useSWR('/api/todos?status=pending', fetcher)
// todos 관련 캐시 전체 무효화 → 패턴 매칭을 직접 구현해야 함
// 문자열 관련 매칭 로직을 많이 사용
mutate(key => typeof key === 'string' && key.startsWith('/api/todos'), undefined, { revalidate: true })
반면 TanStack Query는 캐시 키로 배열을 사용한다. 배열을 사용하면 계층적인 키를 만들 수 있어 캐시 무효화가 훨씬 강력해진다.
실제 앱에서는 "유저 정보를 수정했으니 유저 관련 캐시를 전부 날려야 하는" 상황이 자주 생긴다. TanStack Query의 계층적 키 설계는 이런 케이스를 훨씬 명확하게 처리할 수 있다.
// TanStack Query — 배열 기반 캐시 키
useQuery({ queryKey: ['todos', { status: 'done' }], queryFn: fetchTodos })
useQuery({ queryKey: ['todos', { status: 'pending' }], queryFn: fetchTodos })
// todos 관련 캐시 전체 무효화 → prefix 하나로 끝
queryClient.invalidateQueries({ queryKey: ['todos'] })
또한 TanStack Query는 데이터의 신선도를 staleTime과 gcTime 두 개의 개념으로 분리해서 관리한다.
useQuery({
queryKey: ['user'],
queryFn: fetchUser,
staleTime: 1000 * 60 * 5, // 5분간 fresh → 이 안에선 refetch 안 함
gcTime: 1000 * 60 * 10, // 10분 후 메모리에서 제거
})
staleTime- 가져온 데이터가 얼마나 오래 "신선한" 상태로 유지되는지를 결정한다. 이 기간 동안에는 컴포넌트가 다시 마운트되거나 refetch가 트리거되더라도 TanStack Query는 네트워크 요청을 보내지 않는다.
gcTime- 비활성 캐시가 메모리에서 제거되기까지의 시간을 제어한다. 반면 SWR은 staleTime이나 조건부 자동 재검증 개념이 없다.
캐싱된 데이터의 refetching 시점을 구체적으로 제어하고 싶다면 TanStack Query가 더 적합하다.
SWR
예시 코드에 드러나있듯이 문자열 기반 키 비교를 수행하기 때문에 JS의 일반적인 문자열 비교 연산을 많이 수행한다. 라이브러리 차원에서 키 비교를 하지 않기 때문에 개발자가 직접 키 매칭 로직을 작성해야한다.
Tanstack Query
hashKey 함수를 통해 전달받은 쿼리키를 문자열로 직렬화해 캐시 키로 활용한다. 이때 쿼리키 배열 안에 객체가 포함된 경우, 해당 객체의 프로퍼티를 정렬한 뒤 직렬화하기 때문에 객체 내부 키의 순서가 달라도 동일한 캐시로 취급된다.
// packages/query-core/src/utils.ts
export function hashKey(queryKey: QueryKey | MutationKey): string {
return JSON.stringify(queryKey, (_, val) =>
isPlainObject(val)
? Object.keys(val)
.sort() // ← 객체 키 정렬
.reduce((result, key) => {
result[key] = val[key]
return result
}, {} as any)
: val,
)
}
캐시 키를 조회할때는 hashkey와 일치 여부를 판단하는 것이 아니라 부분적으로 일치하는지 prefix 매칭을 수행한다. 그래서 부분적인 캐시 키를 통해서 여러 개의 쿼리들을 invalidate 할 수 있게 된다.
// ['todos']를 prefix로 가지는 모든 캐시 키가 무효화된다.
queryClient.invalidateQueries({ queryKey: ['todos'] })
SWR은 gzip 기준 약 4.2KB의 매우 가벼운 라이브러리다.
TanStack Query는 풍부한 기능만큼 11.4KB로 조금 더 무겁다.
| SWR | TanStack Query | |
|---|---|---|
| 번들 사이즈 (gzip) | ~4.2KB | ~13KB |
| DevTools | 비공식 커뮤니티 | 공식 내장 |
| 프레임워크 지원 | React 전용 | React, Vue, Svelte, Solid, Angular |
하지만 13KB가 엄청난 병목을 일으킬 만큼의 용량이 아니기 때문에 크게 유의미한 비교는 아닌 것 같다.
SWR은 별도의 useMutation 훅이 없다. mutate 함수 하나가 캐시 업데이트와 재검증을 동시에 담당한다. v2부터 useSWRMutation이 추가됐다고 하지만, 뮤테이션 이후 캐시 무효화는 여전히 수동으로 처리해야 한다.
const toggleTodo = async () => {
mutate(`/api/todos/${id}`, { ...todo, done: !todo.done }, false) // 낙관적 업데이트
try {
await updateTodo(id, { done: !todo.done })
mutate(`/api/todos/${id}`) // 성공 후 재검증
} catch {
mutate(`/api/todos/${id}`, todo, false) // 실패 시 수동 롤백
}
// mutation 로딩 상태는 useState로 따로 관리해야 함
}
TanStack Query는 useMutation 훅이 명시적으로 분리되어 있고, onMutate / onSuccess / onError / onSettled 라이프사이클 콜백을 제공한다.
const mutation = useMutation({
mutationFn: (newData) => updateTodo(id, newData),
onMutate: async (newData) => {
await queryClient.cancelQueries({ queryKey: ['todos', id] })
const snapshot = queryClient.getQueryData(['todos', id])
queryClient.setQueryData(['todos', id], (old) => ({ ...old, ...newData }))
return { snapshot }
},
onError: (err, newData, context) => {
queryClient.setQueryData(['todos', id], context.snapshot) // 자동 롤백
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['todos', id] })
},
})
// 로딩·에러 상태가 훅에서 바로 제공됨
const { isPending, isError } = mutation
솔직히 비교하면서도 TanStack Query가 더 좋아 보였던 건 사실이다. 비동기 작업과 관련된 다양한 케이스를 처음부터 고려해 설계된 라이브러리라는 것이 많이 드러났던 것 같다.
그럼에도 SWR을 쓰면 좋은 상황들은 있는 것 같다.
단순한 앱에서의 키 관리가 편리하다. TanStack Query를 쓰다 보면 쿼리키를 어떻게 설계할지 고민하게 되는 순간이 온다. 키가 다른 쿼리의 무효화에도 영향을 줄 수 있기 때문에 쿼리키 팩토리 패턴 같은 것까지 고려해야 하는 상황이 생긴다. 이 복잡도는 정교한 캐시 제어를 위한 필연적인 과정인 것 같다. 반면 SWR은 URL이 곧 캐시 키이기 때문에, 정교한 무효화 전략이 필요 없는 단순한 앱이라면 오히려 이 단순함이 장점이 되는 것 같다.
러닝커브가 상대적으로 낮다. TanStack Query는 useQuery와 useMutation 각각의 사용법과 내부 옵션을 파악해야 한다. SWR은 mutate 하나로 처리하기 때문에 배우는 데 드는 비용이 적다. 다만 이 단순함의 이면에는 mutation 상태(isPending, isError)를 라이브러리가 제공하지 않아 직접 관리해야 한다는 점이 있긴 하다.
결국 두 라이브러리의 선택 기준은 앱의 복잡도에 있는 것 같다.
단순한 read-heavy 앱이라면 SWR의 단순함이 오히려 강점이 되고, mutation이 복잡하거나 정교한 캐시 무효화가 필요하다면 TanStack Query의 구조화된 설계가 필요한 상황이라고 생각한다.
상당히 닮은 두 라이브러리를 비교해보면서 써보지 않았던 SWR에 대해서 알게되어 새로웠다. 그러면서 tanstack query가 비동기와 관련한 작업을 코드레벨에서 부터 잘 설계한 라이브러리라고 느껴져 새삼 인기가 많은 이유를 알게 된 것 같다. 앞으로 두 라이브러리를 가지고 어떤 것을 사용할지 판단할 때 적합한 판단을 내릴 수 있을 것 같다.