Tanstack Query에 대한 고찰

KayoungKayoung
8 min read

개발을하면서 Tanstack Query의 위엄을 느끼곤 한다. 코드는 직관적이고 사용법도 어렵지 않은데 방대한 어플리케이션을 효율적으로 관리할 수 있게 해준다. 그리고 GraphQl을 써보면서 비동기 데이터를 잘 처리하는 것이 얼마나 중요한 것인지 다시 한번 깨달았다.

상태(State)

브라우저는 단방향으로 페이지를 보여주고 웹을 제어하는 정도의 웹 페이지의 역할에서, 프로덕트 규모가 확장되면서, 다양한 인터렉션 및 사용자 경험(UI/UX)이 중요한 웹/앱 어플리케션으로 확대되었다. 이에 따라, 프론트엔드(client) 단에서 수행해야 하는 역할이 늘어났고, 이는 곧 관리해야하는 상태가 많아졌다는 것을 의미한다.


상태(state)란,

문자열, 배열, 객체 등의 형태로 응용 프로그램에 저장된 데이터주어진 시간에 대한 시스템을 나타내는 것으로 언제든지 변경될 수 있다.

그러므로 ‘상태관리’를 한다는 것은 이렇게 시간에 따라 변경되는 데이터들을 관리하는 방법에 대한 것이다. 프론트엔드에서는 이를 위해 UI 라이브러리/프레임워크를 사용하고 추가로 ‘상태관리 라이브러리(Redux, Recoil..)’를 사용해 상태를 관리한다.

  • React는 단방향 바인딩이므로 Props Drilling 이슈 존재 (props를 오직 하위 컴포넌트로 전달하는 용도로만 쓰이는 상태로 컴포넌트가 혼재되어 유지보수가 힘들다)

그러나, 상태관리 라이브러리를 통해 해본 사람은 알 것이다. 정확한 상태를 쓴다기보다는 써야 한다니까, 좋다니까 쓰게 된다. 흔히 하는 실수는 상태관리에 API fetching 서버값까지 저장해 버리는 상황. API fetching을 하는 액션 자체는 굳이 상태관리를 하지 않아도 된다. 사용 목적에 어긋난다.

Server state vs Client state


💡 데이터의 ownership이 어디에 있느냐에 따라,

Server stateClient state
클라이언트에서 제어하거나 소유되지 않은 원격의 공간에서 관리되고 유지됨 (서버 데이터)Client에서 소유하며 온전이 제어가능
fetching이나 updating에 비동기 API가 필요함 (data 받아오기)초기값 설정이나 조약에 제약사항 없음
다른 사람들과 공유되는 것으로 사용자가 모르는 사이에 변경될 수 있음 (ex: 주문상태 대기→완료)다른 사람들과 공유되지 않으며, Client 내에서 UI/UX 흐름이나 사용자 인터렉션에 따라 변할 수 있음
신경쓰지 않는다면 잠재적으로 out of date가 될 가능성을 지남 (ex: 접수완료 시 이후 상태 신경안씀)항상 클라이언트 내에서 최신 상태로 관리됨

⇒ 사실상 FE에서 이 값들이 저장되어 있는 state들은 일종의 캐시

💡
캐싱은 서버 요청 횟수를 줄이기 위해 비동기 데이터를 저장, 전역상태 관리는 App 내부 컴포넌트 간 데이터 공유가 목적. 일반적으로 API 응답 데이터는 캐싱되고, 클라이언트 측 비즈니스 로직, 사용자 입력 값 등은 클라이언트에서 관리한다.

Tanstack Query


React Query에서 Tanstack Query로 명칭을 바꿨는데, 이는 React에만 국한하지 않고 독립적인 라이브러리로 TS/JS, React, Solid, Vue, Svelte, Angular 등 호환이 가능하다는 것을 내세웠다고 보고 있다.

Tanstack Query는 강력한 비동기적 상태 관리 라이브러리로, 서버 상태의 fetching, caching, synchronizing, updating 처리를 도와주는 라이브러리다.

서버 상태의

  • 데이터 가져오기

  • 캐시

  • 동기화

  • 데이터 업데이트

✔️ zero-config(무설정 서버)로 즉시 사용이 가능하며, config 커스텀도 가능하다.

React Setting

  • QueryClientProvider를 감싸 사용한다.
import {
  QueryClient,
  QueryClientProvider,
  useQuery,
} from '@tanstack/react-query'

const queryClient = new QueryClient()

export default function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <Example />
    </QueryClientProvider>
  )
}

주요 개념

Queries(데이터 가져오기), Mutations(데이터 업데이트), Query Invalidation


Queries

  • 클라이언트에서 서버에 데이터를 요청하는 작업을 의미 (GET으로 받는 대부분의 API에 사용)

    • 서버로부터 데이터를 fetching할 때 Promise(GET, POST) 기반의 메소드에서 사용
  • A query is a declarative dependency on an asynchronous source of data that is tied to a unique key.

    • 유니크한 키는 각 쿼리를 고유하게 식별하기 위해 사용되는 식별자. TQ에서 캐시를 관리하고 동일한 데이터를 재사용하거나 갱신할 때 사용됨

    • 선언적 디펜던시란 특정 데이터가 필요한 컴포넌트에서 해당 데이터가 필요하다고 선언하면 TQ가 이를 알아서 관리하고 가져오는 방식. 개발자가 어떤 데이터를 필요로 하는지 명시만 하면 라이브러리가 필요한 데이터를 알아서 가져옴

import {useQuery} from 'tanstack-query'

function App() {
    const info = useQuery('todos', fetchTodoList, options)
}

//'todos': Query Key
// fetchTodoList : Query Function API fetching 함수
  • Query key에 따라 query caching을 관리한다. (key, value 맵핑)

  • Query function은 Promise 반환 함수 ⇒ 데이터를 resolve 하거나 error throw

useQuery

  • useQuerydata, error, isFetching, refetch, remove 등 반환

  • options

    • onSucess, onError, onSettled: query fetching 성공/실패/완료시 실행할 side effect 정의

    • enabled: 자동으로 쿼리를 실행시킬지 말지 여부

    • select: 성공 시 가져온 데이터를 가공해서 전달

    • keepPreviousData: 새롭게 fetching시 이전 데이터 유지 여부

    • refethInterval: 주기적으로 refetch 결정

Mutations

  • 데이터 업데이트에 사용. (Create/Update/Delete)
import {useMutation} from 'tanstack-query'

function App() {
    const mutation = useMutation(newTodo => {
        return axios.post('/todos', newTodo)
    })
}

// Promise 반환 함수만 있어도 가능
// Query key 넣어주면 devtools에서 확인 가능

useMutation

  • useMutationmutate(mutation 실행, 자동으로 실행되지 않음), mutateAsync (Promise 반환), reset (mutation 내부 상태 clean)등 반환

  • options

    • onMutate: 본격적인 mutation 동작 전에 먼저 동작하는 함수, Optimistic update 적용시 유용. ex) ‘좋아요’ 기능. api fetch 성공/실패 상관없이 일단 UI 변화는 일어나게 →성공시 update, 에러 시 roll back

    • enabled: 자동으로 쿼리를 실행시킬지 말지 여부

    • select: 성공 시 가져온 데이터를 가공해서 전달

    • keepPreviousData: 새롭게 fetching시 이전 데이터 유지 여부

    • refethInterval: 주기적으로 refetch 결정

Query Invalidation

  • 쿼리를 무효화하는 메서드

  • queryClient를 통해 invalidate메소드 호출

queryClient.invalidateQueries() 
queryClient.invalidateQueries('todos')
  • 해당 key를 가진 쿼리 캐시는 stale되고, 현재 렌더링되는 쿼리는 백그라운드에서 refetch

Caching & Synchronization

캐시 및 동기화 처리는 개인적으로 TQ의 가장 큰 장점이라고 생각한다. 개발에서 제일 어려운 두 가지가 변수명 짓기와 ‘캐시 처리’라고 하는 우스갯소리가 있는데, 그만큼 캐시 처리는 애플리케이션의 성능을 결정 짓는 중요한 요소라고 할 수 있다. TQ에서는 Query의 keyoptions을 통해 캐싱과 동기화를 간편하게 수행할 수 있다.


RFC 5861

HTTP Cache-Control Extenstions for Stale Content

  • HTTP 캐싱에 대한 사양 중 하나로, stale-while-revalidatestale-if-error라는 두 가지 새로운 HTTP 캐시 제어 확장자를 정의하고 있다.

  • RFC는 웹 서버와 클라이언트 간의 캐싱 동작을 더욱 유연하게 만들어, 네트워크 연결이나 서버 응답 시간 문제로 인해 발생할 수 있는 사용자 경험 저하를 줄이는 것을 목적으로 한다.

  1. stale-while-revalidate

    • 백그라운드에서 stale response를 revalidate하는 동안 캐시가 가진 stale response를 반환

    • 캐시된 리소스의 유효기간(expiry)이 지난 경우, 클라이언트가 새 데이터를 가져오는 동안 기존의 캐시된 데이터를 계속 사용할 수 있게함

    • 클라이언트가 만료된 데이터를 제공하면서 동시에 백그라운드에서 새 데이터를 가져올 수 있게 함으로써 클라이언트는 오래된 데이터를 즉시 제공받아 더 빠른 응답 시간을 경험할 수 있음

        Cache-Control: max-age=600, stale-while-revalidate=30
      

      max-age=600은 캐시가 10분 동안 유효, stale-while-revalidate=30은 만료된 캐시를 최대 30초 동안 사용하면서 새 데이터를 가져올 수 있음

  2. stale-if-error

    • 서버 오류나 네트워크 문제로 인해 새 데이터를 가져오지 못하는 경우, 유효기간이 지난 데이터를 대신 제공

    • 서버가 응답하지 않을 때, 만료된 캐시 데이터를 임시로 반환하여 서비스 중단을 방지할 수 있음. (장애 발생시 사용자에게 최소한의 서비스 제공)

    • 헤더 예시:

        Cache-Control: max-age=600, stale-if-error=86400
      

      stale-if-error=86400은 서버 오류 시 만료된 캐시를 최대 24시간 동안 사용할 수 있음

위의 컨셉을 메모리 캐시에 적용 → Tanstack-Query, SWR..

  • cacheTime: 메모리에 저장되는 시간 (해당시간 이후 GC에 의해 처리, default 5분)

  • staleTime: 데이터를 stale 상태로 만들 시간 (default 0)

  • refetchOnMount / refetchOnWindowFocus / refetchOnReconnect: 컴포넌트 마운트될 때마다 / 브라우저 창에 다시 포커스가 맞춰졌을 때 / 네트워크 연결이 다시 활성화되었을 때 데이터 갱신 (default true)

      useQuery('user', fetchUserData, { refetchOnMount: true, 
      refetchOnWindowFocus: false, 
      refetchOnReconnect: true, 
      });
      // 컴포넌트 마운트 시와 네트워크 연결이 재개될 때 refetch, 창 포커스시 X
    

→ 이를 이용해 프로덕트에 맞는 캐시 전략을 세울 수 있다. 예를 들어, 실시간으로 변하는 차트, 주문 데이터는 staleTime:0으로 최신 데이터를, 광고는 1시간 등 config

  • Query 상태 흐름

    • Fetching: 서버에서 데이터를 가져오는 동안의 상태

    • Fresh: staleTime > 0인 경우, 서버 / 클라이언트의 정보가 동일 보장

    • Stale: staleTime = 0 인 경우, 서버 / 클라이언트의 정보가 동일함을 보장할 수 없는 상태. 서버로부터 새로운 값을 업데이트 받지 않았거나, 클라이언트에서 입력 받은 값을 서버에 전송하지 않은 경우 stale. 이 경우 TQ는 값을 업데이트 하기 위해 새 요청을 보냄.

    • Inactive: 해당 쿼리가 스크린에서 사용되지 않음

    • Deleted: 가비지컬렉터(GC)에 의해 삭제

ㅇㅇㅇㅇㅇ

  • cacheTime이 만료되기 전, TQ refetch 옵션에 따라 트리거가 발생하면, 다시 active 상태가 됨

📌 zero-config 기본값 설정에 주의!

  • staleTime → default 0 : 쿼리에서 캐시된 데이터는 언제나 stale 상태

  • refetchOnMount / refetchOnWindowFocus / refetchOnReconnect → default “true” : 각 시점에서 데이터가 stale이면 항상 refetch 발생

  • cacheTime → default 60 × 5 × 1000 : Inactive 쿼리들은 5분 뒤 GC에 의해 삭제

  • retry → default 3, retryDelay → default exponential backoff function : 쿼리 실패 3번까지 retry 발생

데이터 관리

TQ는 내부적으로 React의 Context API를 사용하여 서버 상태를 전역상태처럼 관리한다.


Context API

  • Context : 전역 상태 저장. Context 객체 생성

  • Provider: 전역 상태 제공. Context를 Provider 하위에 컴포넌트에 제공

  • Consumer: 전역 상태 받아 사용

import type { QueryClient } from '@tanstack/query-core'

export const QueryClientContext = React.createContext<QueryClient | undefined>(
  undefined,
)

export const useQueryClient = (queryClient?: QueryClient) => {
  const client = React.useContext(QueryClientContext)

  if (queryClient) {
    return queryClient
  }

  if (!client) {
    throw new Error('No QueryClient set, use QueryClientProvider to set one')
  }

  return client
}

export type QueryClientProviderProps = {
  client: QueryClient
  children?: React.ReactNode
}

export const QueryClientProvider = ({
  client,
  children,
}: QueryClientProviderProps): React.JSX.Element => {
  React.useEffect(() => {
    client.mount()
    return () => {
      client.unmount()
    }
  }, [client])

  return (
    <QueryClientContext.Provider value={client}>
      {children}
    </QueryClientContext.Provider>
  )
}
  • QueryClientProvider

    • Context API를 사용해 QueryClient 인스턴스 생성, 하위 컴포넌트에서 해당 인스턴스에 접근할 수 있도록 React의 Context API로 제공

    • React Query 내부에서 React.createContext로 Context 객체를 생성하고, 이를 통해 QueryClient를 전역 상태처럼 다룬다.

useQuery, useMutation 등 hook 또한 Context API를 통해 QueryClient 인스턴스 참조

사용해보니..


  • 클라이언트 상태와 서버 상태를 분리해 UI에만 집중하여 개발할 수 있음

    • 서버 상태와 혼재되어 있던 클라이언트 상태가 분리되어 전역 상태 관리 건이 감소

    • 서버, 클라이언트 로직이 명확하고 관심사 분리에 용이

  • API 처리 관련 각종 인터페이스 및 옵션 제공

    • fetch, axios API, 전역상태관리만을 사용할 경우 refetch, error, sucess 등의 상태를 개별 컴포넌트 안에서 별도로 처리해야 했으나, TQ 옵션으로 일관성 있게 처리 가능

    • 상태 변화에 따른 동기화가 자동화되어 따로 로직을 구현하지 않아도 됨

export const useDeliDelete = () => {
    const queryClient = useQueryClient();
    const { showToastMessage } = useModal();
    return useMutation(deleteDeli, {
        onSuccess: (data) => {
            showToastMessage('success', '삭제에 성공하였습니다.');
            queryClient.invalidateQueries(['deliList']);
            return data;
        },
        onError: (_error, _dutyList) => {
            showToastMessage('error', '삭제에 실패하였습니다.');
        },
    });
};
// onSucess, onError 옵션으로 모달 및 메시지 액션 한번에 처리
  • 캐시 전략 용이

    • 캐시 옵션을 활용해 도메인 별로 쉽게 캐쉬 설정이 가능하며, 가독성이 좋아 관리가 용이함
  • devtool 제공으로 가시성이 좋아 디버깅이 용이함

📚 참고

0
Subscribe to my newsletter

Read articles from Kayoung directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Kayoung
Kayoung

프론트엔드 개발자 김가영입니다. 개발과 일에 대한 생각을 주로 씁니다. https://velog.io/@kykim_dev/posts (2023 이전 블로깅)