React Query 구조 개선: 기능별 분류에서 도메인 응집으로

내가 react-query를 도입한 초기 프로젝트에서 자주 마주친 분포가 있다. fetch 함수는 lib/api/에, 훅은 hooks/에, queryKey는 사용처마다 배열 리터럴로 박혀 있다. 한 도메인이 세 곳에 흩어져 있는 상태다.

처음엔 멀쩡히 굴러간다. 문제는 변경할 때 드러난다. 예를 들어 공지사항 목록(NoticeList)가 있다고 가정하자. notice 도메인의 응답 타입 하나 바뀌었을 뿐인데, fetch 파일 → 훅 파일 → queryKey가 박힌 컴포넌트 N개 → 무효화 호출 M개를 모두 손대야 한다. 어떤 곳은 옛 형식 그대로 남고, 어떤 곳은 새 형식으로 바뀐다. 그 시점부터 캐시는 조금씩 어긋나기 시작한다.

한 도메인 = 한 파일

규칙은 단순하다.

한 도메인의 fetch 함수, queryKey, 사용 훅을 같은 파일에 둔다.

┌─ View ────────────────────────────────┐
│ NoticeList.tsx                        │
│   calls useNoticeList()               │
└──────────────────┬────────────────────┘
                   │
                   ▼
┌─ Cohesion unit (single file) ─────────┐
│ useNoticeList.ts                      │
│   ├ noticeListQueryKey   (export)     │
│   ├ fetchNoticeList      (private)    │
│   └ useNoticeList        (export)     │
└──────────────────┬────────────────────┘
                   │
                   ▼
┌─ HTTP client ─────────────────────────┐
│ lib/api/client.ts                     │
│   axios (baseURL, interceptor)        │
└───────────────────────────────────────┘

컴포넌트가 부르는 건 훅 하나, 그 훅은 같은 파일의 fetch 함수와 queryKey를 안고 있고, 가장 아래에는 공용 axios 인스턴스가 있다. 응집 단위가 곧 도메인 단위다.

// hooks/useNoticeList.ts
import { useQuery } from "@tanstack/react-query";

import { apiClient } from "@/lib/api/client";

interface NoticeListParams {
  filter?: { category?: string; title?: string };
  paging?: { offset: number; limit: number };
}
interface NoticeListResponse {
  list: Notice[];
  total: number;
}

export const noticeListQueryKey = (params?: NoticeListParams) =>
  params
    ? (["notice", "list", params] as const)
    : (["notice", "list"] as const);

const fetchNoticeList = (params: NoticeListParams) =>
  apiClient.get<NoticeListResponse>("/notice", { params });

export function useNoticeList(params: NoticeListParams) {
  return useQuery({
    queryKey: noticeListQueryKey(params),
    queryFn: () => fetchNoticeList(params),
  });
}

이 파일은 외부에 정확히 두 개를 노출한다.

  • useNoticeList — 데이터를 가져오는 인터페이스
  • noticeListQueryKey — 캐시를 식별·무효화하기 위한 키

fetchNoticeList는 비공개다. 컴포넌트가 axios를 직접 부르거나, fetch 함수를 따로 import할 일은 없다.

queryKey를 함수로 export하는 이유

응집된 파일의 진가는 변경 훅에서 드러난다. 다른 파일에서 같은 도메인의 캐시를 무효화할 때, 키를 새로 만들지 않고 import해서 호출한다.

// hooks/useDeleteNotice.ts
import { useMutation, useQueryClient } from "@tanstack/react-query";

import { apiClient } from "@/lib/api/client";

import { noticeListQueryKey } from "./useNoticeList";

const deleteNotice = (id: number) => apiClient.delete(`/notice/${id}`);

export function useDeleteNotice() {
  const queryClient = useQueryClient();
  return useMutation({
    mutationFn: deleteNotice,
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: noticeListQueryKey() });
    },
  });
}

여기서 두 가지가 자연스럽게 따라온다.

  • 키 형태가 한 곳에만 정의돼 있다. 무효화 코드는 noticeListQueryKey() 한 줄이면 끝.
  • 키가 바뀌어야 할 때, 호출처는 손대지 않아도 된다. 함수 안만 고치면 끝.

queryKey를 사용처마다 배열 리터럴로 적었다면, 키 형태를 바꿀 때 grep으로 사용처를 추적해 모두 수정해야 한다. 함수로 export하면 IDE의 "Find usages" 한 번이면 답이 나온다.

응집했을 때 따라오는 것들

키 재사용 외에도 부산물이 더 있다.

도메인 단위 무효화가 한 줄로 끝난다. react-query는 배열 queryKey의 prefix가 일치하면 같은 캐시로 본다. noticeListQueryKey()를 인자 없이 부르면 ["notice", "list"]가 반환돼 그 아래로 묶인 모든 캐시가 한 번에 무효화된다.

// 특정 캐시만 무효화
queryClient.invalidateQueries({
  queryKey: noticeListQueryKey({ filter: { category: "event" } }),
});

// notice/list 전체 무효화
queryClient.invalidateQueries({ queryKey: noticeListQueryKey() });

// notice 도메인 전체 무효화
queryClient.invalidateQueries({ queryKey: ["notice"] });

시그니처가 곧 문서다. queryKey 함수의 파라미터 타입만 보면 "이 캐시가 어떤 인자로 식별되는가"가 한눈에 드러난다. 새 파라미터를 추가할 때도 변경 지점이 한 곳뿐이라 누락이 없다.

변경의 단일 지점. 응답 타입이 바뀌든 엔드포인트가 옮겨가든, 손대는 파일은 항상 그 하나다.

이름 규칙

키 함수의 이름은 두 가지 형태로 통일한다.

  • 단건 조회: {도메인}QueryKey (예: noticeQueryKey)
  • 목록 조회: {도메인}ListQueryKey (예: noticeListQueryKey)

prefix 한 단계 차이라 사용처에서 헷갈리지 않고, 무효화 시 어느 캐시를 가리키는지 이름만으로 파악된다.

어디에 둘 것인가

응집된 파일을 어디에 둘 것인가는 별도 결정이다. 나는 사용하는 화면 옆에 두는 쪽을 선호한다.

containers/Notice/List/hooks/useNoticeList.ts

전역 hooks/ 디렉토리에 모으지 않는 이유는 단순하다. 한 곳에서만 쓰는 코드는 그 옆에 둘 때 가장 찾기 쉽고, 옮길 때도 가장 작은 단위로 함께 옮겨진다. 두 군데 이상에서 쓰이기 시작하는 시점이 와야 비로소 위로 끌어올린다. 이건 "지역성 우선" 원칙인데, 다음 글에서 따로 다뤄볼 생각이다.

응집은 "어디에 둘 것인가"의 답이다

흩어진 코드는 처음엔 오히려 정돈돼 보인다. fetch는 fetch끼리, hooks는 hooks끼리 모이니까. 하지만 그 분류는 기능별이지 도메인별이 아니다. 변경은 항상 도메인 단위로 일어나는데, 코드가 기능 단위로 쪼개져 있으면 변경 비용이 분산된다. 정돈처럼 보이는 구조가 사실은 변경을 비싸게 만드는 셈이다.

한 파일에 한 도메인을 가두는 규칙은 작아 보여도, 도메인이 늘어날수록 효과가 커진다.

  • 응집도 — 한 도메인의 캐시 정책이 한 파일에서 끝난다
  • 재사용 — 무효화 코드는 queryKey 함수 한 번의 import로 일관된다
  • 변경 안전 — 손대는 파일이 항상 하나, 누락은 타입 에러로 잡힌다
  • 삭제 용이 — 기능을 걷어낼 때 파일 하나 삭제로 끝난다

새 프로젝트에서 react-query 컨벤션을 잡을 때 가장 먼저 합의해두면 좋다.