tanstack query 폴더 구조 잡기

Rosevillage·2025년 11월 4일

최근 페이지 기반의 구조에 fsd를 도입하면서, tanstack query관련 파일들을 어떻게 분리해서 관리하는게 좋을지 생각해보고, 나름 정리를 해보았습니다.

시작은 app/api

src/
  app/
    api/
      client.ts        // axios/fetch 인스턴스
      types.ts         // 공통 DTO/에러
      queryClient.ts   // queryClient 생성부

api client

서버 통신에 사용될 기초적인 api client를 선언하는 것 부터 시작합니다.(client나 instance 등 네이밍은 크게 상관없습니다.)

export class ApiClient {
  constructor(private baseURL: string) {}
  
  //...
}

axios와 같은 라이브러리를 사용한다면, 인증 여부에 따른 public / private 정도로 구분된 api client를 생성하는 단계입니다.

//app/api/client.ts

import axios from 'axios';

const publicApiClient = axios.create({ 
  //... 
});

const privateApiClient = axios.create({ 
  //... 
});

export { publicApiClient, privateApiClient }

만약 feature 별로 instance를 따로 생성해 관리한다면, 공통된 설정과 로직만 추출해 api client 생성 함수나 클래스를 구현해 볼 수 있습니다.

export class BaseApiClient implements AxiosInstance {
  //...
}

export const genApiClient = (options: AxiosRequestConfig): AxiosInstance => {
  //...
}

api types

팀 내에서 서버로부터 받은 공통된 응답이나 에러 구조를 타입으로 정의합니다. 이런 구조는 주로 백엔드에서 결정하거나 미리 논의 후 정해지기 때문에 상황에 맞게 타입을 설정하는 것이 좋습니다.

//app/api/types.ts

export interface ApiResponse<T> {
  data: T;
  status: number;
  message?: string;
}

export interface ApiError {
  status: number;
  message: string;
  code?: string;
}

queryClient

queryClient를 생성합니다.
useQuery에서 발생한 에러를 감지하고자 한다면, queryClient의 getQueryCache().subscribe() 메서드를 활용할수 있습니다. 다만 로깅이나 토스트 같은 처리만 하는 것이 권장됩니다.

//app/api/queryClient.ts

import { QueryClient, QueryCache } from '@tanstack/react-query';

const queryClient = new QueryClient({
  defaultOptions: {
    //...
  },
});

queryClient.getQueryCache().subscribe((event) => {
  if(event.type === 'error') {
    //...
    addToast(...)
  }
})

본격적으로 features/../api

src/
  //...
  features/   //무조건 features, entities 둘 중 하나일 필요는 없다.
    user/     //여러 fsd관련 아티클이나 튜토리얼에서 설명하듯 프로젝트의 상황과 입맛에 가져가면 될 듯 하다.
      api/
	    fetchers.ts    // 서버 통신 순수 함수
	    keys.ts        // key factory
	    queries.ts     // queryOptions factory
	    mutations.ts   // custom mutation hook
	    index.ts       // 배럴(export) 파일
	  types.ts

fsd 구조를 바탕으로 진행하지만, 무조건적으로 fsd구조가 선행되어야 하는 것은 아닙니다.
여러 아키텍처의 설명들 처럼 각 파일의 책임을 구분하는 것이 핵심입니다.

여기서는 서버 요청과 비즈니스 로직을 분리하는 것이 해당합니다. api/ 내에서는 서버 요청과 데이터 후 처리 정도만 담당하는 것이 좋습니다.

fetchers.ts : 순수 통신 함수

UI나 캐싱 등 다른 것에 의존하지 않고, 입∙출력을 담당하는 함수들만 생성합니다.
fetchers는 서버(api 서버)와의 인터페이스를 정의하는 역할을 담당합니다.

//features/user/api/fetchers.ts

import { api } from '@/app/api/client';
import type { User } from '../types';

export const getUser = async (id: string) => {
  const { data } = await api.get<any, User>(`/users/${id}`);
  return data;
}

전역으로 감지 시킬 에러가 있다면, 여기서 throw해야 합니다. queryCache.subscribe는 queryFn에서 던져진 에러만 감지할 수 있기 때문입니다.

keys.ts : 중앙화된 queryKey 생성 객체

말 그대로 queryKey를 관리하는 파일입니다. 튜플로 관리하고, as const를 붙여 타입 안정성 또한 확보하는 것이 좋습니다.

//features/user/api/keys.ts

export const userQueryKeys = {
  all: () => ['user'] as const,
  lists: () => [...userQueryKeys.all(), 'list'] as const.
  list: (params: { userId?: string }) => [...userQueryKeys.lists(), params] as const,
  detail: (id: string) => [userQueryKeys.all(), 'detail', id] as const
}

queries.ts

queryOptions를 리턴하는 팩토리를 만들면 prefetch, useQuery 등 여러곳에서 동일한 정책으로 재사용이 가능해집니다.

export const getUserDetailQueryOptions = (id: string) => {
  return queryOptions({
    queryKey: userQueryKeys.detail(id),
    queryFn: () => getUser(id)
  })
}

팩토리 함수를 사용하면 Next.js 처럼 데이터 서버 -> 클라이언트로 흐르는 구조에서 데이터 캐싱의 일관성을 확보할 수 있습니다.

// app/users/[id]/page.tsx

export default async function Page({ params: { id } }: { params: { id: string } }) {
  const qc = new QueryClient();
  awati qc.prefetchQuery(getUserDetailQueryOptions(id)); // 서버에서 미리 캐시
  const dehydrated = dehydrated(qc);
  
  return (
    <HydrationBoundary state={dehydrated}>
      <UserDetail id={id}/>
    </HydrationBoundary>
  )
}
'use client';

export default function UserDetail({ id }: { id: string }) {
  const { data } = useQuery(getUserDetailQueryOptions(id)); // 동일한 queryKey, queryFn

  return <div>{data?.name}</div>;
}

추가적으로 같은 queryOption의 useQuery를 여러 곳에서 자주사용한다면 커스텀 훅으로 만들어 사용해 볼 수도 있습니다.

export const useUserDetailQuery = (id: string) => {
  return useQuery(getUserDetailQueryOptions(id));
} 

물론 queryOptions 팩토리와 query 커스텀 훅 둘 중 하나만 선택할 수도 있습니다.

queryOptions 팩토리는 Next.js 뿐만 아니라 React Router의 loader에서도 활용할 수 있습니다.

// routes/user.$id.loader.ts
export async function userDetailLoader({ params }: LoaderFunctionArgs) {
  const id = params.id;
  if (!id) {
    throw new Response('Missing user id', { status: 400 });
  }

  // 캐시에 없으면 fetch해서 채우고, 있으면 그대로
  await queryClient.ensureQueryData(getUserDetailQueryOptions(id));

  return null;
}

// routes/user.$id.route.tsx
export function UserDetailRoute() {
  const { id } = useParams<{ id: string }>();
  const { data, isLoading, error } = useQuery(getUserDetailQueryOptions(id!));

  if (isLoading) return <div>Loading…</div>;
  if (error) return <div>Failed to load</div>;

  return (
    <section>
      <h1>{data!.name}</h1>
      <p>{data!.email}</p>
    </section>
  );
}

mutations.ts 커스텀 Mutation 훅

mutation은 대개 부수효과가 많습니다. 그래서 mutationOptions 팩토리보다는 커스텀 훅을 만들어야 할 경우가 더 많습니다.

type Vars = { id: string; name: string };

export const useUpdateUserMutation = () => {

  return useMutation({
    mutationFn: updateUser,
    onMutate: async (vars) => {
      await queryClient.cancelQueries({ queryKey: userQueryKeys.detail(vars.id) });
      const prev = queryClient.getQueryData(userQueryKeys.detail(vars.id));
      queryClient.setQueryData(userQueryKeys.detail(vars.id), (cur: any) => ({
        ...cur,
        name: vars.name,
      }));
      return { prev };
    },
    onError: (_err, vars, ctx) => {
      if (ctx?.prev)
        queryClient.setQueryData(userQueryKeys.detail(vars.id), ctx.prev);
    },
    onSuccess: (_data, vars) => {
      queryClient.invalidateQueries({ queryKey: userQueryKeys.detail(vars.id) });
      queryClient.invalidateQueries({ queryKey: userQueryKeys.lists() });
    },
  })
}

마지막으로 index.ts - Barrel Export (option)

배럴(Barrel) 파일은 user/api 폴더의 공개 인터페이스를 담당합니다. 폴더 안의 여러 모듈을 한 곳에서 모아 내보내므로, 외부에서는 경로가 간결해지고 유지보수성과 일관성이 높아집니다.

// features/user/api/index.ts
export { userKeys } from './keys';
export { getUserDetailQueryOptions } from './queries';
export { useUpdateUserMutation } from './mutations';

이렇게 하면 외부 모듈에서 api/ 내의 여러 함수나 변수를 한곳에서 가져오듯이 간결하게 불러올 수 있습니다.

import { userKeys, getUserDetailQueryOptions, useUpdateUserMutation } from '@/features/user/api'

Barrel export가 장점만 가지는 것은 아닙니다.

1) 순환 참조로 인한 HMR 오류 가능성

  • index.ts(배럴)에서 Button.tsx를 재수출하고, Button.tsx 내부에서 다시 index.ts의 무언가를 가져오는 식으로 연결되면 모듈 로딩 순서가 꼬일 수 있습니다.
  • 이 경우, 코드 변경이 제대로 감지되지 않거나, HMR이 전체 페이지 리프레시로 떨어지거나, 개발 서버가 에러를 내는 상황도 발생할 수 있습니다.
  • 배럴은 여러 import 경로를 한 곳에 모으는 역할을 하기 때문에, 이런 순환 참조가 눈에 잘 띄지 않게 생성되기 쉬운 구조입니다.

2) 재수출이 많아질수록 불필요한 코드가 번들에 포함될 수 있음

  • export * 같은 광범위한 재수출 패턴은 편하지만, 실제로는 단 하나의 컴포넌트를 사용하더라도 배럴이 가리키는 내부 모듈 전체가 가져오기 후보가 됩니다.
  • 최신 번들러가 트리 셰이킹을 지원하더라도, 모듈의 내부 구조나 sideEffects 여부에 따라 최적화되지 않는 경우가 존재하고, 결국 쓰지 않는 코드까지 번들 크기에 포함될 수 있습니다.
  • 특히 외부 패키지가 배럴 구조일 경우, 번들러는 그 패키지를 external(블랙박스) 로 판단할 수 있고, 그로 인해 tree-shaking이 불가능합니다.
    → 사용하지 않는 컴포넌트가 함께 로드되고, 서버리스 환경에서는 cold start 비용이 더 커지고, 개발 환경에서도 HMR 속도에 영향을 줍니다.

3) external을 포기하고 앱과 함께 번들링하더라도 비용이 사라지지 않음

  • external을 풀고 전부 tree-shake하면 해결되는 것 아닌가? 라는 생각이 들 수 있지만, 실제로는 더 비싼 경우도 있습니다.

  • tree-shaking이 제대로 작동하려면

    • 수천 개의 모듈을 모두 파싱하고,
    • 전체 dependency graph를 분석하고,
    • sideEffects 여부까지 검사해야 합니다.
  • 즉, 최적화를 수행하는 데 드는 비용 자체가 매우 큽니다.

  • 그래서 결국

    • external 유지 시 → tree-shaking 불가
    • internal 번들링 시 → tree-shaking을 위한 분석 비용 증가
      어느 쪽이든 성능 비용이 발생하는 구조입니다.

실제 개선 사례

  • 일부 대형 패키지는 엔트리 배럴에서 수천~수만 개의 모듈을 재수출하고 있었고,
    Next.js 팀의 최적화 후 측정 결과 컴파일 시간이 약 10~30초에서 3~7초로 단축,
    서버리스 cold start는 최대 40%까지 감소,
    HMR 속도 또한 체감될 정도로 개선되었다고 블로그를 통해 소개했습니다.

4) 배럴이 중첩될수록 문제는 더 커짐

  • export *가 한 단계가 아니라, 여러 계층으로 재귀적으로 이어지는 구조라면 번들러는 모듈 해석 과정에 더 많은 비용을 사용하게 됩니다.
  • 실제로 10,000개 이상의 재수출이 포함된 배럴 구조는 컴파일에 30초 이상이 걸린 사례도 있었습니다.
  • 이런 문제를 해결하기 위해, Next.js는 optimizePackageImports 기능을 도입하고, 배럴을 실제 모듈 경로로 자동 변환하는 방식으로 성능을 크게 개선하고 있습니다.

결론

결국, barrel export는 편리하지만 규모가 커질수록 비용이 누적되는 구조입니다.

위 문제들이 언급된 것이 Next.js 가 13.5 버전일 때 소개되었고,
많은 최적화가 이루어 졌지만, 여전히 무시할 수 없는 부분입니다.

프로젝트의 크기, 팀 컨벤션, 서버/클라이언트 경계를 얼마나 명확히 가져가야 하는지, 그리고 사용하는 라이브러리의 성격에 따라 채택할지 여부가 달라집니다.


Reference

vercel - How we optimized package imports in Next.js

Tanstack Query - hydration

0개의 댓글