name: tanstack-query description: TanStack Query 서버 상태 관리 가이드. useQuery, useMutation, queryKey 팩토리, Optimistic Update, Prefetching 등 서버 상태 관리 시 참조한다.
TanStack Query Skill - 서버 상태 관리 규칙
TanStack Query (React Query)를 사용한 서버 상태 관리 규칙을 정의한다.
클라이언트 상태 관리는 ../Zustand/SKILL.md를 참고한다.
1. 기본 개념
서버 상태 vs 클라이언트 상태
- 서버 상태: API에서 가져오는 데이터 (사용자 목록, 게시글 등)
- 클라이언트 상태: UI에서만 존재하는 데이터 (모달 열림/닫힘, 폼 입력값 등)
- TanStack Query는 서버 상태만 관리한다
Stale-While-Revalidate
- 캐시된 데이터(stale)를 먼저 보여주고, 백그라운드에서 최신 데이터를 가져온다
- 사용자 경험과 데이터 최신성을 동시에 확보한다
2. useQuery 패턴
기본 사용법
// Good
const { data, isLoading, error } = useQuery({
queryKey: ['users', { status: 'active' }],
queryFn: () => userApi.getUsers({ status: 'active' }),
});
// Good - enabled로 조건부 실행
const { data: user } = useQuery({
queryKey: ['users', userId],
queryFn: () => userApi.getUserById(userId),
enabled: !!userId,
});
// Good - select로 데이터 변환
const { data: userNames } = useQuery({
queryKey: ['users'],
queryFn: () => userApi.getUsers(),
select: (users) => users.map((user) => user.name),
});
queryKey 컨벤션
- 배열 형태로 작성한다
- 첫 번째 요소는 엔티티명 (복수형)
- 이후 요소는 필터/파라미터
// 목록
['users']
['users', { status: 'active', page: 1 }]
// 단건
['users', userId]
// 관계 데이터
['users', userId, 'posts']
3. queryKey 팩토리 패턴
queryKey를 객체로 중앙 관리하여 일관성과 재사용성을 확보한다.
export const userKeys = {
all: ['users'] as const,
lists: () => [...userKeys.all, 'list'] as const,
list: (filters: UserFilters) => [...userKeys.lists(), filters] as const,
details: () => [...userKeys.all, 'detail'] as const,
detail: (id: string) => [...userKeys.details(), id] as const,
};
// 사용
useQuery({
queryKey: userKeys.detail(userId),
queryFn: () => userApi.getUserById(userId),
});
// Invalidation - 모든 user 목록 캐시 무효화
queryClient.invalidateQueries({ queryKey: userKeys.lists() });
4. useMutation 패턴
기본 사용법
const createUser = useMutation({
mutationFn: (data: CreateUserDto) => userApi.createUser(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: userKeys.lists() });
},
onError: (error) => {
toast.error(`사용자 생성 실패: ${error.message}`);
},
});
// 호출
createUser.mutate({ name: '홍길동', email: 'hong@example.com' });
onSuccess에서 반드시 캐시를 무효화한다
| 작업 | invalidateQueries 대상 |
|---|---|
| 생성 | 목록 쿼리 (lists()) |
| 수정 | 목록 + 해당 상세 (all) 또는 정밀 지정 |
| 삭제 | 목록 쿼리 (lists()) |
5. Optimistic Update 패턴
사용자 경험을 위해 서버 응답 전에 UI를 먼저 업데이트한다.
const updateUser = useMutation({
mutationFn: (data: UpdateUserDto) => userApi.updateUser(data),
onMutate: async (newData) => {
// 1. 진행 중인 쿼리 취소 (덮어쓰기 방지)
await queryClient.cancelQueries({ queryKey: userKeys.detail(newData.id) });
// 2. 이전 데이터 스냅샷 저장
const previousUser = queryClient.getQueryData(userKeys.detail(newData.id));
// 3. 캐시를 낙관적으로 업데이트
queryClient.setQueryData(userKeys.detail(newData.id), (old: User) => ({
...old,
...newData,
}));
// 4. 롤백용 컨텍스트 반환
return { previousUser };
},
onError: (_error, newData, context) => {
// 에러 시 이전 데이터로 롤백
queryClient.setQueryData(userKeys.detail(newData.id), context?.previousUser);
},
onSettled: (_data, _error, variables) => {
// 성공/실패 무관하게 캐시 재검증
queryClient.invalidateQueries({ queryKey: userKeys.detail(variables.id) });
},
});
6. Prefetching
라우트 전환 시 Prefetching
// 마우스 호버 시 미리 데이터를 가져온다
function UserListItem({ userId }: { userId: string }) {
const queryClient = useQueryClient();
const handleMouseEnter = () => {
queryClient.prefetchQuery({
queryKey: userKeys.detail(userId),
queryFn: () => userApi.getUserById(userId),
staleTime: 1000 * 60 * 5, // 5분 동안 재요청 방지
});
};
return (
<Link to={`/users/${userId}`} onMouseEnter={handleMouseEnter}>
사용자 상세
</Link>
);
}
7. Suspense 모드
React Suspense와 함께 사용하여 로딩 처리를 선언적으로 한다.
// useSuspenseQuery는 data가 항상 존재함을 보장한다
function UserProfile({ userId }: { userId: string }) {
const { data: user } = useSuspenseQuery({
queryKey: userKeys.detail(userId),
queryFn: () => userApi.getUserById(userId),
});
// user는 undefined가 아님 - 타입 안전
return <div>{user.name}</div>;
}
// 부모에서 Suspense로 감싼다
function UserPage({ userId }: { userId: string }) {
return (
<Suspense fallback={<UserProfileSkeleton />}>
<UserProfile userId={userId} />
</Suspense>
);
}
8. QueryClient 설정
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5, // 5분 - 데이터가 stale로 간주되기까지의 시간
gcTime: 1000 * 60 * 30, // 30분 - 미사용 캐시 제거까지의 시간
retry: 1, // 실패 시 1회 재시도
refetchOnWindowFocus: false, // 윈도우 포커스 시 자동 refetch 비활성화
},
},
});
| 옵션 | 권장값 | 설명 |
|---|---|---|
staleTime |
5분 |
짧으면 요청 과다, 길면 데이터 지연 |
gcTime |
30분 |
staleTime보다 길어야 한다 |
retry |
1 |
네트워크 오류 대비 최소 재시도 |
refetchOnWindowFocus |
false |
프로젝트 요구사항에 따라 조정 |
9. 캐시 전략 가이드
데이터 특성에 따라 staleTime을 다르게 설정한다.
| 데이터 유형 | staleTime | 예시 |
|---|---|---|
| 거의 안 바뀜 | Infinity |
코드 테이블, 카테고리 목록, 약관 |
| 가끔 바뀜 | 10~30분 |
사용자 프로필, 설정 |
| 자주 바뀜 | 1~5분 |
게시글 목록, 댓글 |
| 실시간 필요 | 0 |
채팅, 알림, 재고 수량 |
도메인별 staleTime 설정
// queryKey 팩토리에서 기본 옵션을 함께 관리
export const categoryKeys = {
all: ['categories'] as const,
list: () => [...categoryKeys.all, 'list'] as const,
};
// 거의 안 바뀌는 데이터
useQuery({
queryKey: categoryKeys.list(),
queryFn: fetchCategories,
staleTime: Infinity,
});
// 자주 바뀌는 데이터
useQuery({
queryKey: postKeys.list(filters),
queryFn: () => fetchPosts(filters),
staleTime: 1000 * 60, // 1분
});
실시간 데이터: refetchInterval 사용
// 폴링 방식 (WebSocket이 없을 때)
useQuery({
queryKey: ['notifications'],
queryFn: fetchNotifications,
staleTime: 0,
refetchInterval: 1000 * 30, // 30초마다 재요청
});
10. 금지 사항
useEffect로 데이터 페칭 금지 - TanStack Query를 사용한다- queryKey 하드코딩 금지 - queryKey 팩토리 패턴을 사용한다
- 불필요한
refetchOnWindowFocus: true설정 금지 - 기본값 대신 명시적으로 관리한다 onSuccess콜백 안에서 상태 동기화 금지 (useState에 서버 데이터 복사 등)queryFn안에서 에러를 삼키는 try-catch 금지 - TanStack Query가 에러를 관리하게 한다cacheTime사용 금지 - v5부터gcTime으로 변경되었다