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한다.
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 파일
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를 전부 바꾸지 않아도 된다.
export * from './useEmployeeQuery';export * from './useEmployeeMutation';이름 충돌 해결
usePayment.ts와 useSubscription.ts 양쪽 모두 useSubscription을 export하고 있었다. 두 가지를 동시에 처리했다.
- 분리 파일에서 이름을 명확하게 바꾼다:
usePaymentSubscription,useSubscriptionDetail. - 배럴에서 구 이름을 별칭으로 재노출해 기존 import를 유지한다.
export * from './usePaymentQuery';export { usePaymentSubscription as useSubscription } from './usePaymentQuery';export * from './usePaymentMutation';교차 도메인 import — 배럴을 거치면 안 된다
useCompanyMutation.ts에서 employeeKeys를 참조해야 하는 경우가 있다. 배럴(useEmployee.ts)을 통해 import하면 순환 참조가 생길 수 있다.
// ✅ Query 파일 직접 importimport { 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분으로 갈린다.
leave엔staleTime: 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를 추가하거나 수정할 때 어디를 봐야 하는지 망설임이 없어진다.