TanStack Query vs SWR — 어떤 기준으로 선택할까

_sw_·2026년 3월 16일
post-thumbnail

서버 상태라는 개념이 주목받기 시작하면서 TanStack Query에 대한 관심도 자연스럽게 높아졌다. 나 역시 여러 프로젝트에서 TanStack Query를 활용해왔는데, 돌아보면 "왜 TanStack Query인가?"라는 질문을 스스로에게 제대로 던진 적이 없었다. 그냥 레퍼런스가 많고, 팀원들이 익숙하고, 기능이 많으니까 — 라는 이유로 선택해왔던 것 같다.

비슷한 문제를 푸는 SWR이라는 라이브러리가 있다는 건 알고 있었다. 하지만 "TanStack Query가 더 좋다"는 막연한 인식만 있었을 뿐, 실제로 어떻게 다른지, 혹은 SWR이 더 나은 선택이었을 상황이 있었는지는 따져본 적이 없었다.

그래서 두 라이브러리를 여러가지에서 알아보고 앞으로 의식적으로 기술 스택을 선택하기 위한 기준을 마련하기 위해서 정리해보았다.

철학

TanStack Query

  • 기존에 서버 데이터를 다루는 방식은 stateuseEffect의 조합이었다. 일반적인 클라이언트 상태를 표현하고 관리하는 데는 효과적이지만, 비동기 상태 즉 서버 상태를 관리하기에는 어색한 부분이 있었다.
  • TanStack Query는 이 문제를 해결하기 위해 '서버 상태 라이브러리' 로 정의한다. 서버와 클라이언트 사이의 비동기 작업을 관리하는 역할을 담당하며, Redux나 Zustand 같은 클라이언트 상태 라이브러리와는 명확히 역할을 구분한다.
  • 그리고 캐싱, 백그라운드 업데이트, Mutation, DevTools까지 — 비동기 데이터와 관련한 복잡한 상황을 처음부터 고려하여 설계된 라이브러리다.

SWR

  • 반면 SWR은 데이터 캐싱에 초점을 맞춘 라이브러리다.
  • SWR은 캐시에서 먼저 데이터를 반환하고(stale) 이후 요청을 보내 최신 데이터로 업데이트하는(revalidate) 플로우를 단 하나의 훅으로 단순화한다.
  • Vercel에서 만든 라이브러리인 만큼, Next.js와의 궁합이 좋다는 제품적인 특징도 존재한다.

핵심 기능 비교

2.1 캐시 키 설계와 무효화 전략

캐싱은 두 라이브러리 모두 지원하지만, 어떻게 캐시를 식별하고 무효화하는지에서 차이가 난다.

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는 데이터의 신선도를 staleTimegcTime 두 개의 개념으로 분리해서 관리한다.

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'] })

2.2 번들 사이즈

SWR은 gzip 기준 약 4.2KB의 매우 가벼운 라이브러리다.

TanStack Query는 풍부한 기능만큼 11.4KB로 조금 더 무겁다.

SWRTanStack Query
번들 사이즈 (gzip)~4.2KB~13KB
DevTools비공식 커뮤니티공식 내장
프레임워크 지원React 전용React, Vue, Svelte, Solid, Angular

하지만 13KB가 엄청난 병목을 일으킬 만큼의 용량이 아니기 때문에 크게 유의미한 비교는 아닌 것 같다.


2.3 Mutation과 Optimistic Update

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

SWR은 언제 써보면 좋을까?

솔직히 비교하면서도 TanStack Query가 더 좋아 보였던 건 사실이다. 비동기 작업과 관련된 다양한 케이스를 처음부터 고려해 설계된 라이브러리라는 것이 많이 드러났던 것 같다.

그럼에도 SWR을 쓰면 좋은 상황들은 있는 것 같다.

단순한 앱에서의 키 관리가 편리하다. TanStack Query를 쓰다 보면 쿼리키를 어떻게 설계할지 고민하게 되는 순간이 온다. 키가 다른 쿼리의 무효화에도 영향을 줄 수 있기 때문에 쿼리키 팩토리 패턴 같은 것까지 고려해야 하는 상황이 생긴다. 이 복잡도는 정교한 캐시 제어를 위한 필연적인 과정인 것 같다. 반면 SWR은 URL이 곧 캐시 키이기 때문에, 정교한 무효화 전략이 필요 없는 단순한 앱이라면 오히려 이 단순함이 장점이 되는 것 같다.

러닝커브가 상대적으로 낮다. TanStack Query는 useQueryuseMutation 각각의 사용법과 내부 옵션을 파악해야 한다. SWR은 mutate 하나로 처리하기 때문에 배우는 데 드는 비용이 적다. 다만 이 단순함의 이면에는 mutation 상태(isPending, isError)를 라이브러리가 제공하지 않아 직접 관리해야 한다는 점이 있긴 하다.

결국 두 라이브러리의 선택 기준은 앱의 복잡도에 있는 것 같다.

단순한 read-heavy 앱이라면 SWR의 단순함이 오히려 강점이 되고, mutation이 복잡하거나 정교한 캐시 무효화가 필요하다면 TanStack Query의 구조화된 설계가 필요한 상황이라고 생각한다.

마치며

상당히 닮은 두 라이브러리를 비교해보면서 써보지 않았던 SWR에 대해서 알게되어 새로웠다. 그러면서 tanstack query가 비동기와 관련한 작업을 코드레벨에서 부터 잘 설계한 라이브러리라고 느껴져 새삼 인기가 많은 이유를 알게 된 것 같다. 앞으로 두 라이브러리를 가지고 어떤 것을 사용할지 판단할 때 적합한 판단을 내릴 수 있을 것 같다.

Reference

0개의 댓글