Fetcher에서 QueryOptions까지: 데이터 페칭 계층화 분투기
viewer 앱은 Supabase에서 포스트와 어노테이션 데이터를 가져와 화면에 렌더링하는 구조를 따르고 있습니다. 단순해 보이는 기능이었지만, **“어디서 데이터를 가져올 것인가”**보다 **“어떤 계층이 무엇을 알아야 하는가”**가 훨씬 어려운 질문이라는 걸 깨닫고 그 과정을 기록합니다.
1. 문제 상황
viewer 앱을 처음 만들 때 구조는 단순했다.
/share/:shareId라우트에서 포스트 정보와 어노테이션 목록을 가져온다- Supabase 클라이언트로 데이터를 조회한다
- 화면에 렌더링한다
React Query를 쓰기로 했고, 처음엔 이렇게 썼다.
// SharePage.tsx (초기 버전)
const SharePage = () => {
const { shareId } = useParams();
const { data: post } = useQuery({
queryKey: ["post", shareId],
queryFn: async () => {
const { data, error } = await supabase
.from("posts")
.select()
.eq("share_id", shareId)
.single();
if (error) throw error;
return data;
},
enabled: !!shareId,
});
const { data: annotations } = useQuery({
queryKey: ["annotations", shareId],
queryFn: async () => {
const { data, error } = await supabase
.from("annotations")
.select()
.eq("share_id", shareId);
if (error) throw error;
return data;
},
enabled: !!shareId,
});
// ... 렌더링 로직
};
동작은 했다. 그런데 불편한 느낌이 쌓이기 시작했다.
- 컴포넌트가 너무 많은 걸 알고 있다. Supabase 호출 방식, 테이블 이름, 에러 처리 방식, 쿼리 키 구조까지 전부 컴포넌트 안에 있었다.
- 같은 쿼리를 다른 곳에서 쓰고 싶을 때 queryKey를 문자열 그대로 다시 써야 한다. 오타가 나면 캐시가 공유되지 않는다.
- prefetch를 쓰고 싶을 때 queryClient.prefetchQuery에 똑같은 설정을 다시 복붙해야 한다.
- TypeScript 타입 추론이 queryFn 반환 타입에서 제대로 작동하지 않는 경우가 있었다.
규모가 커질수록 이 방식은 유지보수가 어려워질 게 뻔했다.
2. 내가 처음 한 생각
가장 먼저 든 생각은 이것이었다.
“커스텀 훅으로 감싸면 되지 않나?”
// hooks/usePost.ts
export const usePost = (shareId: string) => {
return useQuery({
queryKey: ["post", shareId],
queryFn: async () => {
// ... Supabase 호출
},
});
};
훅으로 분리하면 컴포넌트는 깔끔해진다. 실제로 많은 프로젝트에서 이 방식을 쓴다.
하지만 이 방식에도 문제가 있었다.
- useQuery 훅은 컴포넌트나 훅 내부에서만 호출 가능하다. queryClient.prefetchQuery나 queryClient.ensureQueryData 같은 훅 바깥에서의 활용이 여전히 불편하다.
- 훅이 늘어날수록 queryKey가 훅 안에 숨어버려서, 어디서 어떤 키를 쓰는지 파악하기 어렵다.
- 비슷한 설정(staleTime, enabled 등)이 필요한 경우, 훅마다 중복된다.
이 방식을 선택하지 않은 건 아니지만, 더 나은 방법이 있다고 생각했다.
3. 실제로 해 본 선택
React Query v5에서 공식적으로 제공하는 queryOptions 함수를 활용하기로 했다.
구조를 두 계층으로 나눴다.
src/
├── apis/ # Supabase 호출 함수
│ ├── share.api.ts
│ └── auth.api.ts
├── queries/ # queryOptions 정의
│ ├── post.ts
│ ├── annotation.ts
│ └── userInfo.ts
apis 계층: 순수한 비동기 함수
// apis/share.api.ts
export const fetchPost = async (shareId: string) => {
const { data: post, error: fetchError } = await supabase
.from("posts")
.select()
.eq("share_id", shareId)
.single();
if (fetchError) throw fetchError;
return post;
};
export const fetchAnnotations = async (shareId: string) => {
const { data: annotations, error: fetchError } = await supabase
.from("annotations")
.select()
.eq("share_id", shareId);
if (fetchError) throw fetchError;
return annotations;
};
이 함수들은 React Query를 모른다. Supabase를 어떻게 호출할지만 안다.
에러는 반환하지 않고 throw한다. React Query가 에러를 잡아서 처리해주기 때문이다.
queries 계층: queryOptions 팩토리 함수
// queries/post.ts
import { queryOptions } from "@tanstack/react-query";
import { fetchPost, fetchAllPost } from "../apis/share.api";
export const postQueryOptions = (shareId: string) =>
queryOptions({
queryKey: ["post", shareId],
queryFn: () => fetchPost(shareId),
enabled: !!shareId,
});
export const allPostQueryOptions = () =>
queryOptions({
queryKey: ["post"],
queryFn: () => fetchAllPost(),
});
// queries/annotation.ts
export const annotationQueryOptions = (shareId: string) =>
queryOptions({
queryKey: ["annotations", shareId],
queryFn: () => fetchAnnotations(shareId),
enabled: !!shareId,
});
queryOptions는 그냥 객체를 그대로 반환한다. 하지만 TypeScript 타입 추론이 제대로 작동하고, 훅 바깥에서도 동일한 설정을 재사용할 수 있다는 게 핵심이다.
컴포넌트: 소비만 한다
// pages/SharePage.tsx
const SharedPageContent = () => {
const currentShareId = useShareId();
const { data: post } = useSuspenseQuery(postQueryOptions(currentShareId));
const { data: highlights } = useSuspenseQuery(
annotationQueryOptions(currentShareId),
);
// ... 렌더링
};
컴포넌트는 Supabase를 모른다. queryKey 문자열을 직접 쓰지 않는다. postQueryOptions(shareId)를 호출하는 것만 안다.
useSuspenseQuery를 사용해 로딩과 에러 상태는 상위의 Suspense와 ErrorBoundary에 위임했다.
export const SharePage = () => (
<ErrorBoundary fallback={<NotFoundFallback />}>
<Suspense fallback={null}>
<SharedPageContent />
</Suspense>
</ErrorBoundary>
);
4. 결과
이 구조로 얻은 것들이다.
각 계층의 역할이 명확해졌다.
| 계층 | 역할 | 아는 것 (의존성/지식 범위) |
|---|---|---|
| apis/ | Supabase 호출 | DB 테이블 구조, 구체적인 쿼리 방식 |
| queries/ | 쿼리 설정 정의 | queryKey, queryFn, enabled 설정 |
| component | UI 렌더링 | queryOptions 함수 이름 및 데이터 사용법 |
prefetch가 쉬워졌다. queryOptions는 훅 바깥에서도 쓸 수 있기 때문에, 나중에 라우터 레벨에서 prefetch를 추가할 때 설정을 그대로 재사용할 수 있다.
// 예시: 라우터 레벨 prefetch
queryClient.prefetchQuery(postQueryOptions(shareId));
타입 추론이 정확해졌다. queryOptions를 통하면 data의 타입이 fetchPost의 반환 타입에서 자동으로 추론된다. Supabase의 Database 제네릭 타입이 끝까지 전파된다.
실패한 부분도 있었다. 초기 QueryClient 설정에 아무것도 지정하지 않았다.
const queryClient = new QueryClient();
staleTime, gcTime, retry 같은 글로벌 기본값을 설정하지 않아서, 쿼리가 생각보다 자주 재요청되는 경우가 있었다. 작은 앱이라 크게 문제가 되진 않았지만, 규모가 커지면 명시적으로 설정해야 할 부분이다.
5. 지금 다시 해본다면?
queryKey를 상수로 분리할 것이다.
지금은 queryKey가 queryOptions 안에 인라인으로 박혀 있다.
queryKey: [“post”, shareId],
이렇게 되면 queryClient.invalidateQueries를 쓸 때 같은 문자열을 다시 써야 한다. 다음엔 키를 먼저 정의하고 참조하는 방식으로 만들 것이다.
export const postKeys = {
all: ["post"] as const,
detail: (shareId: string) => ["post", shareId] as const,
};
export const postQueryOptions = (shareId: string) =>
queryOptions({
queryKey: postKeys.detail(shareId),
queryFn: () => fetchPost(shareId),
enabled: !!shareId,
});
QueryClient 기본 설정을 잡을 것이다.
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5, // 5분
retry: 1,
},
},
});
글로벌 기본값을 명시하면 각 queryOptions마다 중복 설정을 줄이고, 앱 전체 캐싱 전략을 한 곳에서 관리할 수 있다.
정리
처음엔 “동작하면 된다”는 생각으로 컴포넌트 안에 모든 걸 넣었다. 그러다 “이 컴포넌트는 왜 DB 테이블 이름을 알아야 하지?” 라는 질문이 생겼고, 그 질문이 이 구조를 만들었다.
apis → queries → component 3계층 구조는 과한 설계가 아니다. 각 계층이 자신의 역할만 알게 하는 것, 그게 핵심이다.