최근 페이지 기반의 구조에 fsd를 도입하면서, tanstack query관련 파일들을 어떻게 분리해서 관리하는게 좋을지 생각해보고, 나름 정리를 해보았습니다.
src/
app/
api/
client.ts // axios/fetch 인스턴스
types.ts // 공통 DTO/에러
queryClient.ts // queryClient 생성부
서버 통신에 사용될 기초적인 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 => {
//...
}
팀 내에서 서버로부터 받은 공통된 응답이나 에러 구조를 타입으로 정의합니다. 이런 구조는 주로 백엔드에서 결정하거나 미리 논의 후 정해지기 때문에 상황에 맞게 타입을 설정하는 것이 좋습니다.
//app/api/types.ts
export interface ApiResponse<T> {
data: T;
status: number;
message?: string;
}
export interface ApiError {
status: number;
message: string;
code?: string;
}
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(...)
}
})
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/ 내에서는 서버 요청과 데이터 후 처리 정도만 담당하는 것이 좋습니다.
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에서 던져진 에러만 감지할 수 있기 때문입니다.
말 그대로 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
}
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>
);
}
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() });
},
})
}
배럴(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'
index.ts(배럴)에서 Button.tsx를 재수출하고, Button.tsx 내부에서 다시 index.ts의 무언가를 가져오는 식으로 연결되면 모듈 로딩 순서가 꼬일 수 있습니다.export * 같은 광범위한 재수출 패턴은 편하지만, 실제로는 단 하나의 컴포넌트를 사용하더라도 배럴이 가리키는 내부 모듈 전체가 가져오기 후보가 됩니다.cold start 비용이 더 커지고, 개발 환경에서도 HMR 속도에 영향을 줍니다.external을 풀고 전부 tree-shake하면 해결되는 것 아닌가? 라는 생각이 들 수 있지만, 실제로는 더 비싼 경우도 있습니다.
tree-shaking이 제대로 작동하려면
sideEffects 여부까지 검사해야 합니다.즉, 최적화를 수행하는 데 드는 비용 자체가 매우 큽니다.
그래서 결국
실제 개선 사례
export *가 한 단계가 아니라, 여러 계층으로 재귀적으로 이어지는 구조라면 번들러는 모듈 해석 과정에 더 많은 비용을 사용하게 됩니다.optimizePackageImports 기능을 도입하고, 배럴을 실제 모듈 경로로 자동 변환하는 방식으로 성능을 크게 개선하고 있습니다.결국, barrel export는 편리하지만 규모가 커질수록 비용이 누적되는 구조입니다.
위 문제들이 언급된 것이 Next.js 가 13.5 버전일 때 소개되었고,
많은 최적화가 이루어 졌지만, 여전히 무시할 수 없는 부분입니다.
프로젝트의 크기, 팀 컨벤션, 서버/클라이언트 경계를 얼마나 명확히 가져가야 하는지, 그리고 사용하는 라이브러리의 성격에 따라 채택할지 여부가 달라집니다.
Reference