WhereYouAd 프로젝트를 진행하면서, 최근까지도 가장 많이 헷갈렸던 부분이 Tanstack Query였다. 이부분을 계속 헷갈려해서 한번 정리하고 넘어가려고한다.
요즘 조금 이해가 된것같다. 뭔가 깨달은것같아서 지금 정리하고, 필요할때마다 한번씩 꺼내서 보려고 정리 해본다!
라이브러리다. Vite나 ESLint 같은 개발 툴이 아니라 라이브러리다. React 안에서 import해서 쓰는 코드 패키지인거다.
쉽게 얘기해서, 서버에서 받아온 데이터를 담아두는 저장창고다.
저장창고에 칸마다 이름표(queryKey)가 붙어있고, 안에 물건(data)마다 보관제한시간(staleTime)이 있고, 오랫동안 주인이 찾아가지 않는다면 버리는 규칙(gcTime)이 있다.
그리고, 그 저장창고를 관리하는 관리인은 QueryClient다.

WhereYouAd 프로젝트에서 gcTime을 포함한 이 저장창고의 규칙들은 queryClient.ts에 정해놓았다.
new QueryClient({
defaultOptions: {
queries: {
retry: retryPolicy, //4xx면 재시도 안함. 5xx면 1번만
staleTime: 1000 * 60 * 5, //5분동안은 데이터 신선하다고 믿음.
gcTime: 1000 * 60 * 10, //아무도 찾지 않는 데이터 10분뒤 버림
refetchOnWindowFoucs: false, //탭 다시 돌아와도 자동 재요청 안함
refetchOnReconnect: true, // 인터넷 끊겼다 붙으면 재요청
},
}
});
(참고로 라이브러리의 기본값중에는 창에 다시 포커스 되면 다시 가져온다 라는 것이 있지만, 우리 프로젝트에서는 꺼놓았다)
왜? true가 라이브러리 기본값인가? 생각해보면,
=> 사용자가 항상 새로운 최신 데이터 값을 보게 하자는 것!
but, 우리 서비스에서는 대시보드에 쿼리가 많아서, 탭 왔다갔다 할때마다 상태 업데이트하면, API가 왕창 나가고 서버 부담이 되겠지?
그렇기 때문에, 주식같이 일초단위로 실시간으로 변화가 꼭 필요한 데이터가 아니라면, staleTime이 있기 때문에, 그 기준으로 생각하면된다.
우리 WhereYouAd 서비스는 staleTime이 5분이니까, 네이버 탭에 3분있다가 돌아오면 true여도 요청이 안나간다. 만약 6분있다가 돌아왔다? 그러면 이제 보관제한 시간(staleTime: 5분)을 지났으니까 그때는 요청하지.
즉, refetchOnWindowFous와 refetchOnReconnet 둘다 "어떤 순서일때" 재요청할지를 정하는것.
무엇을 재요청 하냐? => 이건 항상 지금화면이 구독중인 stale 쿼리를 의미하는거지.
음. 다시 돌아와서 Tanstack Query는 서버에 있는 데이터를 가져오고, 캐시하고, 최신으로 맞추는 일을 React가 대신 관리하게 하는것.
기존에는
const [data, setData] = useState(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() =. {
fetch("/api/workspaces");
.then((res) => res.json());
.then(setData)
.finally(() => setIsLoading(false));
}, []);
이런식으로 useState에 상태값이랑 데이터 담고 진행했는데, 이방식 대로 하면 로딩/에러/중복 요청/화면 나갔다 돌아온 후 다시 불러오기/ 생성이나 삭제 후 목록 갱신을 전부 직접 구현해야하는 번거로움이 있다.
이걸, Tanstack Query는 훅으로 맡는거지.
근데, 여기서 하나 짚고 넘어가야하는 것.
Tanstack Query는 워크스페이스 목록/알림 설정/멤버 목록 등 서버의 상태를 관리하는 훅이고,
모달이 열리거나, 선택한 탭이 무엇인지 같은 클라이언트 상태는 Zustand가 관리한다.
- 서버 상태 (워크스페이스 목록, 알림 설정, 멤버 목록) → Tanstack Query
- 클라이언트 상태 (모달 열림, 선택한 탭) → Zustand
WhereYouAd 프로젝트에서,
UI 상태(=서버 상태는) → src/store/ 에 넣고,
서버 데이터는 → src/hooks/ 에 Query 훅을 넣는다.
서버 데이터를 다루는 훅은 여러가지 있지만, 서버 데이터를 다루는 핵심 데이터 패턴은 3가지다.
useQuery: 읽기useMutation: 변경useInfiniteQuery: 페이지 쌓아가면서 읽기
WhereYouAd 프로젝트에서는 바로 직접 구현하여 쓰지않고, src/hooks/customQuery.ts로 깜싸고 프로젝트 전용으로 사용한다.
useQuery → useCoreQueryuseMutation → useCoreMutationuseInfiniteQuery → useCoreInfiniteQuery(참고로 에러타입을 IApiErrorResponse로 고정하고, mutation이면 invalidateKeys를 같은 팀 공통 처리로 넣었다)
일단 먼저 이 훅들을 구분해보면,
| 훅 | 한 줄 정리 | HTTP 감 | 언제 실행? |
|---|---|---|---|
| useQuery | 서버에서 한 덩어리 읽어온다 | GET | 컴포넌트가 마운트되면 자동 |
| useMutation | 서버에게 뭔가 하라고 시킨다 | POST/PUT/PATCH/DELETE | mutate()를 직접 호출할때 |
| useInfiniteQuery | 서버에서 페이지를 이어서 읽어온다 | GET | 첫 페이지는 자동, 다음 페이지는 fetchNextPage() |
중요한건, HTTP 메서드보다 실행 시점이다.
Query 계열은 → 여기 화면 열렸으니까, 데이터 가져와라
Mutation은 → 사용자가 버튼을 눌렀으니까 이제 보내라
그래서, 알리 설정 조회는 useQuery 이고, 타임라인 생성은 useMutation이고, 멤버 무한스크롤은 useInfiniteQuery다.
이제 본격적으로 각 훅에 대해서 알아봦.
일단, 캐시가 있는 GET 훅이다.
queryKey라는 이름표 붙이고, 그 키로 서버 응답을 메모리에 저장한다.
그리고 같은 이름표(키)를 쓰는 다른 컴포넌트들은 API를 또 치지않고, 캐시를 먼저 본다.
QUERY_KEY 파일에 캐시 이름표를 한곳에 모아두었다.
export const QUERY_KEYS = {
notification: {
settings: (orgId: number | null) =>
["notification", "settings", orgId] as const,
members: (orgId: number) => ["notification", "members", orgId] as const,
history: (orgId: number | null) =>
["notification", "history", orgId] as const,
vapid: () => ["notification", "vapid"] as const,
},
} as const;
요런식으로 "notification" "settings" 라는 이름표랑 orgId(조직아이디)라는 이름표로 캐시를 구분한다.
그리고, WhereYouAd 프로젝트에서는 useQuery 직접 호출하지 않고, 커스텀 훅 쓴다고 했지? 그래서 원래는
useQuery({
queryKey: ["notification", "settings", orgId],
queryFn: () => getMyNotificationSettings(orgId),
enabled: orgId != null,
});
이런식으로 쓰는걸, 커스텀훅을 이용해서,
useCoreQuery(
QUERY_KEYS.notification.settings(orgId),
() => getMyNotificationSettings(orgId!),
{ enabled: orgId != null },
);
이렇게 훨씬 코드를 간결하게 써서, 더 파악이 빠르게.
지금은 짧아서 비슷해보이지만, 이런코드가 길어지고 복잡해지면 사소한 부분들을 통합해놓아서 보기좋게 만드는것이 큰 프로젝트를 할 때는 꼭 필요하다.
그래서 동작을 어떻게 하는지 보면,
- 훅 실행되면
queryKey로 캐시를 찾는다- 없거나 오래되었으면,
queryFn으로 API를 쏜다- 결과를
data,isLoading,isError로 준다- 창에 다시 focus되거나 일정 시간 지나서 필요할 때 다시 가져온다.
컴포넌트가 mutate()를 부를 필요가 없고, 구독하면 알아서 가져온다.
그래서, 데이터가 지금 서버에 있고, 화면에 보여주면 될때! 사용한다.
가끔 드물게 버튼을 눌러야지 불러오는 GET도 query로 두고, enabled: false 후에 refetch()를 쓰지만, WhereYouAd 서비스의 기본은 화면이 열리면 자동 GET이다.
이건 서버한테 명령하는 훅이다. Query처럼 마운트 된다고 실행되는게 아니다.
const { mutate, isPending } = useMutation({
mutationFn: createTimeline,
});
// 사용자가 저장을 눌렀을 때만
mutate(formValues);
서버한테 "이거 만들어라/ 이거 변경해라 / 이거 지워라" 하고 보내는 훅이다.
WhereYouAd로 보면, useCreateTimeline은 성공하면, 목록 캐시를 무효화한다.
useCoreMutation(createTimeline, {
invalidateKeys: [QUERY_KEYS.timeline.list(orgId)],
userOnSuccess: () => toast.success("타임라인이 생성되었습니다"),
});
이 코드로 흐름을 보면
- 타임라인을 POST한다.
- 성공하면,
timeline.list캐시를 무효화한다. (= invalidate)- 그 키를 구독 중인
useQuery가 다시 GET한다.- 화면에 새 목록이 반영된다.
Mutation은 자기 캐시가 거의 없다. Query 캐시를 꺤후에 다시 읽게 만든다.
사용자의 행동이 트리거가 되어서, 그 행동을 하면 이제 그 원래 그 이름표의 값을 무효화하고, useQuery를 통해서 최신 값을 가져오는거지.
트리거가 되는 행동들은 알림 설정 변경/저장, 타임라인 생성/변경/삭제 같은 행동들이 있지. (POST/PUT/PATCH/DELETE)
그래서, GET이어도 보내기 버튼이면 mutation에 가깝다.
반대로는 POST여도 화면에 진입할때 자동으로 받아야할 캐시 데이터이면 또 query에 가깝다.
그럼 어떻게 고르냐? → 메서드가 자동인가? 눌러야하나? 로 정리.
useQuery | useMutation | |
|---|---|---|
| 실행 | 자동 | mutate() / mutateAsync() |
| 캐시 | queryKey로 저장 | 기본적으로 안 쌓임 |
| 결과 필드 | data, isLoading | data, isPending |
| 성공 후 | 캐시가 곧 화면 | 보통 validateQueries로 query를 다시 돌림 |
회원 탈퇴 같이 이후의 데이터가 의미없으면, queryClient.clear()로 캐시 전체를 비운다.
(아! 로그아웃할때, 이전 사용자의 데이터를 다음 사용자가 볼수없도록 queryClient.clear()를 이용했다!)
+) 추가로, Mutation 성공 ≠ 화면 자동 갱신
목록을 다시 보려면 invalidateKeys로 해당 데이터의 useQuery와 useInfiniteQuery를 다시 돌리기 때문에. 값 바뀐게 성공했다고 바로 화면이 자동 갱신은 아니다!
invalidateKeys = 성공하면 이 이름표들을 무효화해라 라는 배열useQuery와 비슷하지만, useQuery는 데이터가 한 덩어리였고,useInfiniteQuery는 페이지 배열이다.
수천 수만개의 데이터가 한번에 안오잖아. 보통 우리 메일함 같은거 보면, 한번에 20개씩 주고, 그다음에 스크롤하면 20개 더 주고, 또 스크롤 하면 20개 더주고 해서 천개 만개를 계속 스크롤하면서 보는거지.
이게 한번에 다 나오려면 많은 시간이 걸리고. 그리고 사용자가 그걸 다 보는것이 아닐 가능성이 높기 때문에, UX 하락하지 않게 사용자가 루즈해지지 않게 스크롤 단위로 데이터를 끊어서 제공한다.
useInfiniteQuery({
queryKey: ["notification", "history", orgId],
queryFn: ({ pageParam }) =>
getNotificationHistory(orgId, { cursor: pageParam }),
initialPageParam: null,
getNextPageParam: (lastPage) =>
lastPage.hasNext ? lastPage.nextCursor : undefined,
});
코드 보니까, useQuery랑 비슷하지?
queryKey로 이름표로 캐시 찾고, 오래되거나 없으면 queryFn으로 API를 써서 데이터 가져오는것 까지는 동하다.
useQuery에 없는건
initialPageParam : 첫 요청에 넣을 커서, 보통은 null이다.getNextPageParam : 다음 페이지가 있으면 커서, 없으면 undefinedfetchNextPage() : 다음 페이지 요청hasNextPage / isFetchingNextPagedata.pages : [{20개}, {다음 20개}, ...]그래서 화면에서 평탄화가 필요하다.
const notifications =
query.data?.pages.flatMap((page) => page.notifications) ?? [];
이게 멤버 관리, 알림 히스토리 패턴이다.
목록이 길고, 데이터가 길어서 스크롤이나 더보기로 이어서 받을때 사용된다.
WhereYouAd 서비스에서는 워크스페이스 멤버 목록 / 알림 히스토리 / 멤버 알림 설정 목록 같은것들.
그래서, 멤버 수는 useQuery, 멤버 목록은 useInfiniteQuery로 한 이유는 숫자는 하나의 값으로, 목록은 커서로 이어서 받는거지.
페이지 버튼 눌러서 1페이지 에서 2페이지로 넘어가는 교체 UI는 useQuery에 page를 넣는 경우가 많고,
페이지가 계속해서 연결해 나가는 방식으로, 이전 페이지를 유지하고 뒤로 붙이는건 useInfiniteQuery가 맞다.
다음 페이지는 자동이 아니라, 스크롤 observer나 버튼에서 fetchNextPage()를 호출해서 사용한다.
캐시 모양은 { pages: [page1, page2, ...], pageParams: [...]}
그리고 중요한점. Tanstack Query v5부터는 initialPageParams, getNextPageParams는 필수다.

useQueryuseInfiniteQueryuseMutation