Fetcher에서 QueryOptions까지: 데이터 페칭 계층화 분투기

Fetcher에서 QueryOptions까지: 데이터 페칭 계층화 분투기

2026년 2월 13일

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 설정
componentUI 렌더링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계층 구조는 과한 설계가 아니다. 각 계층이 자신의 역할만 알게 하는 것, 그게 핵심이다.