TanStack Query를 공부한 후 남긴 기록을 담은 포스팅
직접 기록한 내용을 GPT를 활용하여 마크다운으로 변환했다!
1. TanStack Query
- 리액트 쿼리(React Query): 서버 상태 관리를 쉽게 하도록 도와주는 라이브러리
- 서버 상태(Server State): 원본이 서버에 있고, 클라이언트가 복사본을 잠깐 빌려와서 보고 있는 모든 비동기 처리 데이터
💡 주요 기능
- API 호출 시 로딩, 에러, 성공 상태 자동 트래킹 (isPending, error, data)
- API 응답 데이터 캐싱 및 재사용
- 데이터베이스(DB) 데이터와 UI 데이터의 동기화 및 업데이트
2. 데이터 조회: useQuery (Read)
📌 기본 규칙
- queryKey는 반드시 배열 형태
- queryFn은 반드시 Promise를 반환!
- Next.js App Router 환경에서는 Root Layout에서 QueryClientProvider로 감싸주어야 전역에서 사용할 수 있습니다.
const getTodos = async () => {
return [1, 2, 3];
};
const { data, isPending, error } = useQuery({
queryKey: ["todos"], // QueryCache(전역 저장소) 내에 데이터를 저장하는 고유 주소
queryFn: getTodos,
});
마운트 이후 queryFn을 실행하여 가져온 데이터를 queryKey에 저장한다.
🙋♂️ 궁금증과 그에대한 답 (Q&A)
Q1. queryKey는 왜 꼭 '배열'이어야 할까?
- 상세한 주소 지정과 계층 구조 관리: 단순히 문자열 하나만 쓰면 todos 전체를 다루기 어렵지만,
배열을 쓰면 ['todos', 'page', 1] 또는 ['todos', id]처럼 세부적인 조건을 순서대로 담아 QueryCache 안에서 고유한 주소로 삼을 수 있다. - v4부터의 강제 규칙: 내부적인 안정성과 예측 가능성을 위해 TanStack Query(v4 이후)부터는 무조건 배열 형태로만 전달하도록 규칙이 고정되었다.
Q2. queryFn이 반드시 Promise를 반환해야 하는 이유는?
- 서버 상태 관리는 비동기 요청에만 해당하기 때문
- 비동기 상태 추적의 기반: useQuery의 핵심 역할은 데이터를 가져오는 과정에서 발생하는
isPending(로딩), error(실패), data(성공) 상태를 자동으로 추적하는 것 - 약속된 신호: 자바스크립트에서 비동기 작업의 성공(resolve)과 실패(reject)를 처리하는 표준 객체가 바로 Promise useQuery는 이 Promise의 상태 변화를 감시하다가 UI를 그에 맞춰 실시간으로 업데이트
Q3. 여기서 말하는 '마운트(Mount)'란 무슨 뜻?
- 화면에 컴포넌트가 처음 등장하는 순간: 리액트에서 마운트(Mount)는 컴포넌트가 처음 화면에 그려지며
메모리에 얹어지는 시점 - TanStack Query에서의 의미: * 최초 실행: 컴포넌트가 화면에 마운트되면
useQuery는 queryFn을 실행하여 데이터를 가져오고 캐시에 저장- stale 상태와 refetch: 쿼리는 기본적으로 받아온 데이터를 즉시 stale(신선하지 않은 상태)로 취급한다. 유저가 다른 페이지에 갔다가 다시 이 컴포넌트로 돌아왔을 때(= 재마운트), "어? 데이터가 stale하네? 새로 받아와야지!" 하고 자동으로 queryFn을 다시 실행(refetchOnMount: true)하여 화면을 최신 데이터로 동기화
3. 데이터 조작: useMutation (Create, Update, Delete)
서버의 데이터를 수정, 추가, 삭제할 때 사용
CUD 작업 이후에는 UI와 DB의 싱크를 맞춰주는 동기화(Synchronization) 작업이 필수적
📌 특징 및 규칙
- mutationFn은 Promise를 반환
- mutationFn은 단 하나의 매개변수만 받을 수 있다. (여러 인자가 필요할 경우 객체 구조 분해 할당을 사용)
- 변경 작업이 성공하면 기존 캐시를 stale 상태로 변환시켜 새 데이터를 다시 패칭(fetching)해와야 한다.
이때 창고 관리자인 queryClient의 invalidateQueries() 메서드를 사용!
🛠️ 구현 흐름 및 사용 방법
- RootLayout에 QueryCache(창고) 생성
const client = new QueryClient(); - 사용 컴포넌트에서 관리자 호출 및 호출 처리
const queryClient = useQueryClient(); const postTodoMutation = useMutation({ mutationFn: postTodo, onSuccess: () => { // 'todos' 키를 가진 캐시를 stale하게 만들어 데이터를 새로고침(refetch)하도록 유도 queryClient.invalidateQueries({ queryKey: ["todos"] }); }, onError: (error) => { console.error(error); }, onSettled: () => { /* 성공/실패 여부와 상관없이 항상 실행 */ } }); - 이벤트 트리거 (버튼 클릭 등)
<button onClick={() => { // mutate()는 설정한 mutationFn을 당장 실행하라는 명령어 역할을 합니다. postTodoMutation.mutate({ id: Date.now(), title: 'TanStack Query 공부' }); }}> 투두 추가 </button>
4. TanStack Query의 캐싱 전략과 Lifecycle
🔄 Stale-While-Revalidate (SWR)
"새로운 데이터를 가져오는 동안, 우선은 기존에 캐싱된 데이터를 보여준다." 라는 전략
- 예시 (Cache-Control: max-age=1, stale-while-revalidate=59)
- 0~1초 사이 재요청: 서버 호출 없이 캐시 데이터를 바로 사용
- 1~60초 사이 재요청: 일단 가지고 있던 캐시 데이터를 화면에 즉시 보여주고, 백그라운드에서 서버로부터 최신 데이터를 받아와 자연스럽게 교체
💡 캐시 데이터는 전역 상태
QueryClientProvider는 내부적으로 리액트의 Context API를 사용하므로,
그 자식에 속한 모든 컴포넌트는 전역적으로 관리되는 캐시 데이터 창고에 접근할 수 있다.
⚙️ 기본 설정 (Default Config)과 라이프사이클 항목들
하나의 쿼리 인스턴스(Query Key)는 다음과 같은 생명주기를 가집니다.
- staleTime: 0
- 데이터를 패치해 오는 즉시 해당 데이터는 stale(신선하지 않은 상태)이 됩니다.
- refetchOnMount: true
- 컴포넌트가 마운트될 때 데이터가 stale 상태라면 자동으로 refetch를 실행합니다.
- refetchOnWindowFocus: true
- 브라우저 화면(창)을 다시 포커스할 때마다 자동으로 refetch를 실행합니다.
- refetchOnReconnect: true
- 네트워크가 끊겼다가 다시 연결되었을 때 자동으로 refetch를 실행합니다.
- gcTime: 5분 (v5 전에는 cacheTime)
- 컴포넌트가 언마운트되면 해당 쿼리는 inactive 상태가 됩니다. 이 상태로 5분이 지나면 가비지 컬렉터(GC)에 의해 캐시 메모리에서 완전히 지워집니다.
- retry: 3
- queryFn 요청이 실패하더라도 즉시 에러를 내지 않고 3번까지 재시도합니다.
🔥 staleTime vs gcTime 핵심 요약
- staleTime: 얼마의 시간이 흐른 뒤에 데이터를 '신선하지 않은 것'으로 취급할 것인가?
(staleTime > 0 이면 fresh, 0 이면 즉시 stale) - gcTime: 컴포넌트가 언마운트(inactive)된 후, 얼마의 시간 뒤에 메모리 창고에서 데이터를 완전히 삭제할 것인가?
5. 꼭 알아야 할 주요 옵션들 (Must-Know Options)
① enabled (조건부 실행)
useQuery({
queryKey: ["todos"],
queryFn: getTodos,
enabled: true // false로 지정하면 컴포넌트가 마운트되어도 자동 실행되지 않음
});
- 동작: true일 때만 queryFn이 실행됩니다.
- 활용: 이벤트 발생 시에만 쿼리를 트리거하고 싶을 때, 혹은 순차적 쿼리(Dependent Queries)를 실행하고 싶을 때 사용합니다.
// 순차적 쿼리 예제: 유저 정보가 먼저 조회되어 유저 ID가 존재할 때만 프로젝트 목록 조회
const { data: user } = useQuery({
queryKey: ['user', email],
queryFn: getUserByEmail,
});
const userId = user?.id;
const { data: projects } = useQuery({
queryKey: ['projects', userId],
queryFn: getProjectsByUser,
enabled: !!userId, // userId가 존재(true)할 때만 쿼리 가동
});
② select (데이터 가공)
서버에서 받아온 원본 데이터를 컴포넌트에 전달하기 전, 필요한 형태로 정제/변형하여 리턴
function User() {
const { data } = useQuery({
queryKey: ['user'],
queryFn: fetchUser,
select: (user) => user.username, // 데이터 중에서 username만 쏙 골라냄
});
return <div>Username: {data}</div>;
}
6. 로딩 및 페칭 상태 비교 (isPending vs isLoading vs isFetching)
| 속성명 | 핵심 정의 | 캐시 데이터 존재 여부 | 현재 API 요청 여부 (Fetching) | 주요 활용 UI 예시 | 비고 |
| isPending | 데이터가 없고 최초 실행 대기 중인 상태 | 없음 (undefined) |
상관없음 (대기 중일 수도 있음) |
진입 시의 전반적인 예외 처리 |
v5부터 status === 'loading'이 pending으로 명칭 변경 |
| isLoading | 데이터가 전혀 없는 상태에서 최초로 서버에 요청을 보낸 상태 | 없음 (undefined) |
현재 요청 중 (Yes) |
최초 화면 진입 시 전체 스켈레톤/로딩 스피너 |
isPending && isFetching 개념 (하드 로딩 감지) |
| isFetching | 기존 데이터 유무 불문, 지금 이 순간 서버에 요청을 보낸 상태 | 상관없음 | 현재 요청 중 (Yes) |
백그라운드 새로고침 시 화면 상단 조그만 인디케이터 |
초기 로딩과 원격 새로고침을 모두 포함하는 속성 |
7. useQuery 에러 핸들링 패턴
Pattern A. 컴포넌트 내부에서 처리 (표준 방식)
가장 보편적인 방법으로, 컴포넌트 내의 분기 처리를 통해 처리하거나 필요한 부가 작업(Side-effect)은 useEffect를 활용
function TodoList() {
const { data: todos, error, isPending } = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
});
// 렌더링과 상관없는 사이드 이펙트는 useEffect 내에서 처리 (예: 알림창)
useEffect(() => {
if (error) {
alert(error.message);
}
}, [error]);
if (isPending) return <p>Loading...</p>;
// Standard Error Handling UI
if (error) return <div style={{ fontSize: 24 }}>에러가 발생했습니다: {error.message}</div>;
return (
<div>
{todos.data.map((todo) => (
<Todo key={todo.id} {...todo} />
))}
</div>
);
}
Pattern B. QueryCache를 통한 전역 에러 핸들링 (Global Callback)
특정 도메인이나 공통적인 에러 처리가 반복될 때 중앙 저장소(QueryCache) 레벨에서 일괄 관리하는 방식
// 1. main.jsx (설정부)
const queryClient = new QueryClient({
queryCache: new QueryCache({
onError: (error, query) => {
// meta 설정을 확인하여 특정 출처의 에러만 골라 처리 가능
if (query.meta?.source === "todos") {
toast.error(`Something went wrong in TodoList: ${error.message}`);
}
}
}),
});
// 2. src/components/TodoList.jsx (사용부)
function TodoList() {
const { data: todos, isPending } = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
meta: { source: "todos" } // 전역 콜백에서 알아채도록 메타데이터 심어두기
});
if (isPending) return <p>Loading...</p>;
return (
<div>
{todos.data.map((todo) => (
<Todo key={todo.id} {...todo} />
))}
</div>
);
}
☝ 에러 처리 가이드라인
기본적으로 UI 제어가 수월한 컴포넌트 내 처리를 기본으로 하되, 아래와 같은 명확한 공통 로직이 필요할 때만 전역 콜백(QueryCache) 활용을 권장
- 전역 에러 로깅 처리 알고리즘 적용시
- Sentry 등 외부 모니터링 서비스로의 일괄 전송
- 401 Unauthorized 에러 발생 시 공통 로그아웃 및 /login 페이지 리다이렉트 처리 코드 등
'FE > Next' 카테고리의 다른 글
| 렌더링 전략: SSG, ISR, SSR, CSR (0) | 2026.06.12 |
|---|