메인 콘텐츠로 건너뛰기
Sonamu의 자동 생성된 TanStack Query Hook을 사용하여 React에서 타입 안전하고 효율적으로 데이터를 관리하는 방법을 알아봅니다.

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>
  );
}
특징:
  • dataUserSubsetMapping["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>;
}
enabled 옵션:
  • 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 사용 시 주의사항:
  1. Hook은 컴포넌트 내부에서만 호출 (React 규칙)
  2. Subset 파라미터 필수: useUser("A", userId)
  3. enabled: false조건부 페칭 구현
  4. Mutation 성공 시 캐시 무효화 필수
  5. Query Key는 자동 생성되므로 수동 작성 금지
  6. 생성된 Hook은 수정 금지 (재생성 시 덮어씀)
  7. QueryClientProvider로 앱 전체 감싸기

다음 단계