leo.dev
frontend

React Query 훅을 Query / Mutation으로 분리하는 패턴

src/core/hooks/useEmployee.ts가 400줄을 넘기면 무슨 일이 벌어지는가. useQuery key 상수, 데이터 조회 훅, useMutation 로직, invalidateQueries 대상이 한 파일에 뒤엉긴다. 어디에서 캐시가 날아가는지 추적하려면 파일 전체를 읽어야 한다.

7개 도메인(employee, issue, admin, company, govSupport, payment, subscription)에서 이 구조를 일괄 분리했다. 파일 크기 기준은 100줄 초과 시 분리다.

먼저, React Query

React Query(TanStack Query)는 서버 상태를 다루는 라이브러리다. API로 받아온 데이터를 가져오고(fetch) 캐시하고, 언제 다시 가져올지(refetch)를 관리한다. useState로 직접 들고 있던 로딩·에러·캐시 로직을 대신 맡아준다고 보면 된다.

이 글엔 네 조각만 나온다.

  • useQuery: 데이터 조회. 결과를 queryKey로 캐시한다.
  • useMutation: 데이터 변경(생성·수정·삭제).
  • queryKey: 캐시를 식별하는 배열. 같은 key면 같은 캐시를 공유한다.
  • invalidateQueries: 특정 key의 캐시를 “오래됨”으로 표시해 재조회를 일으킨다. mutation 성공 뒤 목록을 갱신할 때 쓴다.

훅 파일이 부푸는 건 이 네 조각이 도메인마다 한 파일에 뭉치기 때문이다.

분리 구조

src/core/hooks/
├── useXxxQuery.ts ← query key 상수 + useQuery 훅
├── useXxxMutation.ts ← useMutation 훅
└── useXxx.ts ← 배럴 re-export (하위 호환)

Query 파일

query key를 이 파일이 소유한다. Mutation 파일이 invalidateQueries를 호출할 때 여기서 import한다.

src/core/hooks/useEmployeeQuery.ts
export const employeeKeys = {
all: (companyId: string) => ['employee', companyId] as const,
list: (companyId: string, filters: EmployeeFilter) =>
[...employeeKeys.all(companyId), 'list', filters] as const,
detail: (id: string) => ['employee', id, 'detail'] as const,
};
export function useEmployeeList(companyId: string) {
return useQuery({
queryKey: employeeKeys.all(companyId),
queryFn: () => employeeApi.getList(companyId),
enabled: !!companyId,
staleTime: 1000 * 60 * 15,
});
}

staleTime은 반드시 명시한다. 기본값(0)으로 두면 포커스 전환마다 재요청이 나간다.

Mutation 파일

src/core/hooks/useEmployeeMutation.ts
import { employeeKeys } from './useEmployeeQuery'; // 배럴을 거치지 않는다
export function useCreateEmployee(companyId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (dto: CreateEmployeeDto) => employeeApi.create(companyId, dto),
onSuccess: () => {
qc.invalidateQueries({ queryKey: employeeKeys.all(companyId) });
toast.success('등록되었습니다');
},
onError: onMutationError,
});
}

배럴

기존 import를 전부 바꾸지 않아도 된다.

src/core/hooks/useEmployee.ts
export * from './useEmployeeQuery';
export * from './useEmployeeMutation';

이름 충돌 해결

usePayment.tsuseSubscription.ts 양쪽 모두 useSubscription을 export하고 있었다. 두 가지를 동시에 처리했다.

  1. 분리 파일에서 이름을 명확하게 바꾼다: usePaymentSubscription, useSubscriptionDetail.
  2. 배럴에서 구 이름을 별칭으로 재노출해 기존 import를 유지한다.
src/core/hooks/usePayment.ts
export * from './usePaymentQuery';
export { usePaymentSubscription as useSubscription } from './usePaymentQuery';
export * from './usePaymentMutation';

교차 도메인 import — 배럴을 거치면 안 된다

useCompanyMutation.ts에서 employeeKeys를 참조해야 하는 경우가 있다. 배럴(useEmployee.ts)을 통해 import하면 순환 참조가 생길 수 있다.

// ✅ Query 파일 직접 import
import { employeeKeys } from './useEmployeeQuery';
// ❌ 배럴 경유 — 순환 가능성
import { employeeKeys } from './useEmployee';

배럴은 소비자(컴포넌트)를 위한 인터페이스다. 훅 간 참조엔 쓰지 않는다.

쪼개고 나서야 보인 staleTime

staleTime가져온 데이터를 “신선하다(fresh)“고 볼 시간이다. 이 시간 안에는 컴포넌트가 다시 마운트되거나 창에 포커스가 돌아와도 재요청하지 않고 캐시를 그대로 쓴다. 시간이 지나면 데이터가 “오래됨(stale)“이 되고, 다음 트리거(마운트·포커스·invalidateQueries) 때 백그라운드로 다시 가져온다.

기본값은 0이라, 안 정하면 포커스만 돌아와도 재요청이 나간다. 그래서 조회 훅마다 명시한다.

훅을 쪼개 staleTime이 Query 파일에 모이니, 도메인 전체의 값이 한 화면에 들어왔다. 그러자 그동안 안 보이던 게 드러났다. 값이 제각각이고, 정해둔 기준이 없었다.

  • issue 한 도메인 안에서 1분·2분·5분이 공존한다. 목록은 2분인데 옆 쿼리는 왜 1분인지 설명이 안 된다.
  • govSupport는 메인만 2분, 나머지는 15분. 의도한 건지 방치된 건지 코드만 봐선 모른다.
  • 비슷한 성격의 마스터성 데이터인데 회사는 5·10분, 계약은 10분, 직원은 15분으로 갈린다.
  • leavestaleTime: 0이 세 곳. 매번 재요청이라, 정말 실시간이어야 하는 화면인지 확인이 필요하다.

정리해보니 지금 값들은 그때그때 정한 것이지, 기준이 있어서 정한 게 아니었다. 데이터 성격을 몇 단으로 나눠(실시간 0 / 자주 2분 / 보통 5분 / 안정 15분 / 불변 30분) 도메인을 거기에 맞추는 손질이 필요하다. refetchInterval이 붙은 훅은 staleTime을 폴링 주기와 같게 맞추는 것부터다. 쪼개기가 그 정리의 출발점을 만들어줬다.

분리 전후

도메인원본분리 후
employee~400줄Query 150 + Mutation 280
issue~550줄Query 120 + Mutation 310
admin신규Query 100 + Mutation 140
company~250줄Query 80 + Mutation 220
govSupport~300줄Query 90 + Mutation 190
payment~200줄Query 80 + Mutation 100
subscription~250줄Query 120 + Mutation 150

분리 과정에서 staleTime 누락 버그 2건(useGovSupportQuery, useSubscriptionQuery)을 함께 잡았다. 파일이 짧아지니 누락된 옵션이 눈에 띈다.

Query key의 소유권이 명확해지면 invalidateQueries를 추가하거나 수정할 때 어디를 봐야 하는지 망설임이 없어진다.

↑↓ 이동 열기esc 닫기