Sonamu + TanStack Query 개요
자동 생성
useUser, usePost Hook코드 작성 불필요
타입 안전
Service 타입 유지완전한 타입 체인
자동 캐싱
메모리 캐시중복 요청 제거
자동 재검증
포커스 시 갱신주기적 폴링
Sonamu에서 TanStack Query Hook 사용하기
Hook 자동 생성
Sonamu는 각 Service 함수마다 TanStack Query Hook을 자동 생성합니다. 생성되는 Hook (services.generated.ts):복사
import { useQuery, queryOptions } from "@tanstack/react-query";
import qs from "qs";
export namespace UserService {
// 1. 일반 함수
export async function getUser<T extends UserSubsetKey>(
subset: T,
id: number
): Promise<UserSubsetMapping[T]> {
return fetch({
method: "GET",
url: `/api/user/findById?${qs.stringify({ subset, id })}`,
});
}
// 2. Query Options (재사용 가능)
export const getUserQueryOptions = <T extends UserSubsetKey>(
subset: T,
id: number
) =>
queryOptions({
queryKey: ["User", "getUser", subset, id],
queryFn: () => getUser(subset, id),
});
// 3. React Hook (자동 생성)
export const useUser = <T extends UserSubsetKey>(
subset: T,
id: number,
options?: { enabled?: boolean }
) =>
useQuery({
...getUserQueryOptions(subset, id),
...options,
});
}
- 함수명:
get{Entity}→ Hook명:use{Entity} - Query Key:
["{Entity}", "{methodName}", ...params] - 타입 완전 보존 (Subset 지원)
기본 사용법
useUser Hook
가장 기본적인 데이터 조회 Hook입니다.복사
import { UserService } from "@/services/services.generated";
function UserProfile({ userId }: { userId: number }) {
// 자동 생성된 Hook 사용
const { data, error, isLoading } = UserService.useUser("A", userId);
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
if (!data) return <div>User not found</div>;
return (
<div>
<h1>{data.username}</h1>
<p>{data.email}</p>
</div>
);
}
data는UserSubsetMapping["A"] | undefined타입- 자동 캐싱: 같은 userId로 다시 호출하면 캐시 사용
- 자동 재검증: 포커스 시, 재연결 시 자동 갱신
- 중복 제거: 여러 컴포넌트에서 동시에 호출해도 API는 1번만
Subset과 타입 안전성
Subset에 따라 정확한 타입이 반환됩니다.복사
function UserProfile({ userId }: { userId: number }) {
// Subset "A": 기본 정보
const { data: basicUser } = UserService.useUser("A", userId);
console.log(basicUser?.id); // ✅ OK
console.log(basicUser?.username); // ✅ OK
console.log(basicUser?.bio); // ❌ 컴파일 에러 (Subset A에 없음)
// Subset "B": bio 포함
const { data: userWithBio } = UserService.useUser("B", userId);
console.log(userWithBio?.bio); // ✅ OK
// Subset "C": 전체 필드
const { data: fullUser } = UserService.useUser("C", userId);
console.log(fullUser?.createdAt); // ✅ OK
}
복사
Backend Entity
→ Subset Mapping
→ Service Function
→ TanStack Query Hook
→ React Component
고급 사용법
조건부 페칭
특정 조건에서만 데이터를 가져옵니다.복사
function UserProfile({ userId }: { userId: number | null }) {
const { data, isLoading } = UserService.useUser(
"A",
userId!,
{ enabled: userId !== null } // userId가 null이면 호출 안함
);
if (!userId) return <div>Please select a user</div>;
if (isLoading) return <div>Loading...</div>;
if (!data) return <div>User not found</div>;
return <div>{data.username}</div>;
}
true: 즉시 데이터 로드 (기본값)false: 데이터 로드하지 않음- 사용 사례: 로그인 여부, 파라미터 존재 여부 등
의존적 페칭
이전 데이터를 다음 요청에 사용합니다.복사
function UserDashboard({ userId }: { userId: number }) {
// 1단계: 사용자 정보
const { data: user } = UserService.useUser("A", userId);
// 2단계: 사용자의 게시글 (user가 있을 때만)
const { data: posts } = PostService.usePosts(
"A",
user ? user.id : 0,
{ enabled: !!user } // user가 있을 때만 호출
);
return (
<div>
<h1>{user?.username}</h1>
<div>Posts: {posts?.length || 0}</div>
</div>
);
}
Query Options 재사용
동일한 Query Options를 여러 곳에서 재사용합니다.복사
import { UserService } from "@/services/services.generated";
import { useQueryClient } from "@tanstack/react-query";
function UserProfile({ userId }: { userId: number }) {
const queryClient = useQueryClient();
// Options 재사용
const queryOptions = UserService.getUserQueryOptions("A", userId);
// 1. Hook에서 사용
const { data } = useQuery(queryOptions);
// 2. 수동 refetch
function handleRefresh() {
queryClient.invalidateQueries(queryOptions);
}
// 3. Prefetch
function handleMouseEnter() {
queryClient.prefetchQuery(queryOptions);
}
return <div onMouseEnter={handleMouseEnter}>{data?.username}</div>;
}
Mutation (데이터 변경)
useMutation으로 생성/수정/삭제
TanStack Query의useMutation으로 데이터를 변경합니다.
복사
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { UserService } from "@/services/services.generated";
function EditProfile({ userId }: { userId: number }) {
const queryClient = useQueryClient();
// Mutation 정의
const updateMutation = useMutation({
mutationFn: (data: { username?: string; bio?: string }) =>
UserService.updateProfile(data),
onSuccess: () => {
// 성공 시 User 쿼리 무효화
queryClient.invalidateQueries({
queryKey: ["User"],
});
},
});
async function handleSubmit(data: { username: string; bio: string }) {
await updateMutation.mutateAsync(data);
alert("Updated!");
}
return (
<form onSubmit={(e) => {
e.preventDefault();
handleSubmit({/* data */});
}}>
<input name="username" />
<textarea name="bio" />
<button type="submit" disabled={updateMutation.isPending}>
{updateMutation.isPending ? "Saving..." : "Save"}
</button>
</form>
);
}
낙관적 업데이트
API 응답을 기다리지 않고 즉시 UI를 업데이트합니다.복사
function EditProfile({ userId }: { userId: number }) {
const queryClient = useQueryClient();
const updateMutation = useMutation({
mutationFn: (data: { username: string }) =>
UserService.updateProfile(data),
// 1. Mutation 시작 전 (낙관적 업데이트)
onMutate: async (newData) => {
// 진행 중인 쿼리 취소
await queryClient.cancelQueries(
UserService.getUserQueryOptions("A", userId)
);
// 이전 데이터 백업
const previousUser = queryClient.getQueryData(
UserService.getUserQueryOptions("A", userId).queryKey
);
// 낙관적으로 캐시 업데이트
queryClient.setQueryData(
UserService.getUserQueryOptions("A", userId).queryKey,
(old: any) => ({ ...old, username: newData.username })
);
return { previousUser };
},
// 2. 성공 시
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ["User"],
});
},
// 3. 실패 시 (롤백)
onError: (err, newData, context) => {
queryClient.setQueryData(
UserService.getUserQueryOptions("A", userId).queryKey,
context?.previousUser
);
},
});
return (
<button onClick={() => updateMutation.mutate({ username: "newname" })}>
Update Username
</button>
);
}
- 버튼 클릭 → 즉시 UI 업데이트 (대기 없음)
- 백그라운드에서 API 호출
- 성공 시 서버 데이터로 확정
- 실패 시 자동 롤백
캐시 관리
캐시 무효화
특정 쿼리의 캐시를 무효화하여 재로드합니다.복사
import { useQueryClient } from "@tanstack/react-query";
import { UserService } from "@/services/services.generated";
function SomeComponent({ userId }: { userId: number }) {
const queryClient = useQueryClient();
function handleUpdate() {
// 1. 특정 User 쿼리만 무효화
queryClient.invalidateQueries(
UserService.getUserQueryOptions("A", userId)
);
// 2. 모든 User 쿼리 무효화
queryClient.invalidateQueries({
queryKey: ["User"],
});
// 3. 특정 메서드의 모든 쿼리 무효화
queryClient.invalidateQueries({
queryKey: ["User", "getUser"],
});
}
}
캐시 직접 수정
API 호출 없이 캐시를 직접 수정합니다.복사
function LikeButton({ postId }: { postId: number }) {
const queryClient = useQueryClient();
function handleLike() {
// 캐시 직접 수정
queryClient.setQueryData(
PostService.getPostQueryOptions("A", postId).queryKey,
(old: any) => ({
...old,
likes: old.likes + 1,
isLiked: true,
})
);
// 백그라운드에서 API 호출
PostService.likePost(postId);
}
return <button onClick={handleLike}>Like</button>;
}
Prefetching
데이터를 미리 로드하여 사용자 경험을 향상시킵니다.복사
function UserList({ userIds }: { userIds: number[] }) {
const queryClient = useQueryClient();
// 호버 시 미리 로드
function handleMouseEnter(userId: number) {
queryClient.prefetchQuery(
UserService.getUserQueryOptions("A", userId)
);
}
return (
<ul>
{userIds.map((id) => (
<li
key={id}
onMouseEnter={() => handleMouseEnter(id)}
>
<Link to={`/users/${id}`}>User {id}</Link>
</li>
))}
</ul>
);
}
실전 예제
무한 스크롤
페이지네이션 대신 무한 스크롤을 구현합니다.복사
import { useInfiniteQuery } from "@tanstack/react-query";
import { PostService } from "@/services/services.generated";
function PostList() {
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isLoading,
} = useInfiniteQuery({
queryKey: ["Post", "list"],
queryFn: ({ pageParam = 1 }) =>
PostService.getPosts("A", { page: pageParam, pageSize: 20 }),
getNextPageParam: (lastPage, pages) => {
return lastPage.length === 20 ? pages.length + 1 : undefined;
},
initialPageParam: 1,
});
// 모든 페이지의 게시글을 평탄화
const posts = data?.pages.flat() || [];
return (
<div>
{posts.map((post) => (
<div key={post.id}>{post.title}</div>
))}
{hasNextPage && (
<button onClick={() => fetchNextPage()} disabled={isFetchingNextPage}>
{isFetchingNextPage ? "Loading..." : "Load More"}
</button>
)}
</div>
);
}
실시간 폴링
주기적으로 데이터를 갱신합니다.복사
function RealtimeFeed() {
const { data } = FeedService.useLatest(
"A",
{},
{
refetchInterval: 3000, // 3초마다 갱신
refetchIntervalInBackground: false, // 백그라운드에서는 안함
}
);
return (
<div>
{data?.items.map((item) => (
<div key={item.id}>{item.content}</div>
))}
</div>
);
}
여러 Hook 조합
복사
function UserDashboard({ userId }: { userId: number }) {
// 병렬로 여러 데이터 로드
const userQuery = UserService.useUser("A", userId);
const postsQuery = PostService.usePostsByUser("A", userId);
const statsQuery = UserService.useStats(userId);
// 모든 쿼리가 로드될 때까지 대기
if (userQuery.isLoading || postsQuery.isLoading || statsQuery.isLoading) {
return <div>Loading...</div>;
}
// 하나라도 에러면 에러 표시
if (userQuery.error || postsQuery.error || statsQuery.error) {
return <div>Error loading data</div>;
}
return (
<div>
<h1>{userQuery.data?.username}</h1>
<div>Posts: {postsQuery.data?.length}</div>
<div>Views: {statsQuery.data?.totalViews}</div>
</div>
);
}
TanStack Query 설정
전역 설정
복사
// app/providers.tsx
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
const queryClient = new QueryClient({
defaultOptions: {
queries: {
// 데이터가 stale 상태로 간주되는 시간 (5초)
staleTime: 5000,
// 포커스 시 재검증
refetchOnWindowFocus: true,
// 재연결 시 재검증
refetchOnReconnect: true,
// 에러 재시도
retry: 1,
// 사용하지 않는 데이터 캐시 유지 시간 (5분)
gcTime: 5 * 60 * 1000,
},
},
});
export function Providers({ children }: { children: React.ReactNode }) {
return (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
);
}
DevTools
개발 중 캐시 상태를 시각적으로 확인합니다.복사
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
export function Providers({ children }: { children: React.ReactNode }) {
return (
<QueryClientProvider client={queryClient}>
{children}
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);
}
성능 최적화
Subset으로 최적화
상황에 맞는 Subset을 선택하여 네트워크 비용 절감:복사
// 목록 화면: 최소 정보만
const { data: users } = UserService.useUsers("A");
// 상세 화면: 전체 정보
const { data: user } = UserService.useUser("C", userId);
선택적 재검증
불필요한 재검증을 막습니다:복사
// 자주 변하지 않는 데이터
const { data } = UserService.useUser("A", userId, {
staleTime: Infinity, // 영원히 fresh
refetchOnWindowFocus: false,
refetchOnReconnect: false,
});
병렬 요청
여러 데이터를 동시에 로드:복사
function Dashboard({ userId }: { userId: number }) {
// 모든 Hook이 병렬로 실행됨
const { data: user } = UserService.useUser("A", userId);
const { data: posts } = PostService.usePosts("A", { userId });
const { data: stats } = StatsService.useUserStats(userId);
// 세 요청이 동시에 실행되어 빠름!
}
주의사항
TanStack Query Hook 사용 시 주의사항:
- Hook은 컴포넌트 내부에서만 호출 (React 규칙)
- Subset 파라미터 필수:
useUser("A", userId) enabled: false로 조건부 페칭 구현- Mutation 성공 시 캐시 무효화 필수
- Query Key는 자동 생성되므로 수동 작성 금지
- 생성된 Hook은 수정 금지 (재생성 시 덮어씀)
QueryClientProvider로 앱 전체 감싸기
