Custom Hook으로 Query 캡슐화
React 앱에서 서버 데이터를 다루다 보면 컴포넌트 안에 API 호출 로직이 직접 들어가는 경우가 많다. TanStack Query(React Query)를 쓰더라도 useQuery나 useMutation 호출이 컴포넌트에 그대로 노출되면 문제가 생긴다.
function ProjectPage({ projectId }: { projectId: string }) {
const { data, isLoading } = useQuery({
queryKey: ['projects', 'detail', projectId],
queryFn: () => projectApi.getProject(projectId),
enabled: !!projectId,
staleTime: 5 * 60 * 1000,
retry: (failureCount, error) => {
if (error.status === 404 || error.status === 403) return false;
return failureCount < 3;
},
});
// ... 렌더링
}
이 코드가 왜 문제인지 하나씩 보면:
-
queryKey가 분산된다. 같은 데이터를 여러 컴포넌트에서 조회하면 queryKey 문자열이 곳곳에 흩어진다.
['projects', 'detail', projectId]를 A 컴포넌트에서는 맞게 쓰고, B 컴포넌트에서는['project', projectId]로 잘못 쓰면 캐시가 안 맞는다. -
옵션이 중복된다.
staleTime,retry,enabled같은 설정을 매번 복사-붙여넣기하게 된다. 나중에 staleTime을 바꾸려면 모든 곳을 찾아다녀야 한다. -
컴포넌트가 API 구조를 알게 된다. 컴포넌트가
projectApi.getProject라는 함수 이름, 파라미터 구조, 에러 형태까지 전부 알아야 한다. UI 레이어가 데이터 레이어에 강하게 결합된다.
커스텀 훅으로 Query를 캡슐화하면 이 세 가지 문제가 한 번에 해결된다.
기본 구조: useQuery 래핑
가장 단순한 형태는 useQuery 호출을 함수로 감싸는 것이다.
// domain/project/hooks/queries/useProject.ts
export const useProject = (projectId: string) => {
return useQuery({
queryKey: ['projects', 'detail', projectId],
queryFn: () => projectApi.getProject(projectId),
enabled: !!projectId,
staleTime: 5 * 60 * 1000,
});
};
컴포넌트에서는 이렇게 쓴다:
function ProjectPage({ projectId }: { projectId: string }) {
const { data: project, isLoading } = useProject(projectId);
// queryKey? queryFn? 몰라도 됨
}
컴포넌트 입장에서는 "프로젝트 ID를 넘기면 프로젝트 데이터가 나온다"는 것만 알면 된다. 내부적으로 어떤 API를 호출하는지, 캐시를 얼마나 유지하는지, 재시도는 어떻게 하는지 전부 훅 안에 숨어 있다.
Query Key Factory 패턴
커스텀 훅만으로는 queryKey 일관성 문제가 완전히 해결되지 않는다. invalidateQueries나 setQueryData를 쓸 때도 같은 키가 필요하기 때문이다. 이걸 해결하는 게 Query Key Factory 패턴이다.
export const projectKeys = {
all: ['projects'] as const,
detail: (projectId: string) => [...projectKeys.all, 'detail', projectId] as const,
versions: (projectId: string) => [...projectKeys.all, 'versions', projectId] as const,
members: (projectId: string) => [...projectKeys.all, 'members', projectId] as const,
search: (workspaceId: string) => [...projectKeys.all, 'search', workspaceId] as const,
};
as const를 붙이는 이유는 TypeScript가 배열 타입을 readonly ['projects', 'detail', string]처럼 튜플로 추론하게 하기 위해서다. 그냥 string[]이 되면 타입 안전성이 없어진다.
이 팩토리의 핵심은 계층 구조다. projectKeys.all은 ['projects']이고, projectKeys.detail(id)는 ['projects', 'detail', id]다. React Query의 invalidateQueries는 prefix 매칭을 하기 때문에, projectKeys.all로 무효화하면 하위의 detail, versions, members 캐시가 전부 날아간다.
// 특정 프로젝트만 무효화
queryClient.invalidateQueries({ queryKey: projectKeys.detail(projectId) });
// 프로젝트 관련 캐시 전부 무효화
queryClient.invalidateQueries({ queryKey: projectKeys.all });
이 패턴을 도메인별로 만들면 앱 전체의 캐시 키가 체계적으로 관리된다:
export const authKeys = {
all: ['auth'] as const,
user: () => [...authKeys.all, 'user'] as const,
sessions: () => [...authKeys.all, 'sessions'] as const,
};
export const workspaceKeys = {
all: ['workspace'] as const,
lists: () => [...workspaceKeys.all, 'list'] as const,
detail: (id: string) => [...workspaceKeys.all, 'detail', id] as const,
};
useMutation 캡슐화
useQuery만 감싸면 반쪽짜리다. 데이터를 변경하는 useMutation도 캡슐화해야 한다. mutation은 query보다 더 복잡한데, 성공 시 관련 캐시를 무효화하거나, 낙관적 업데이트를 하거나, 토스트를 띄우는 등의 부수 효과가 붙기 때문이다.
export const useDeleteProject = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (projectId: string) => projectApi.deleteProject(projectId),
onSuccess: (_, projectId) => {
queryClient.invalidateQueries({ queryKey: projectKeys.all });
queryClient.invalidateQueries({ queryKey: workspaceKeys.all });
toast.success('프로젝트가 삭제되었습니다');
},
onError: () => {
toast.error('프로젝트 삭제에 실패했습니다');
},
});
};
컴포넌트에서는:
function DeleteButton({ projectId }: { projectId: string }) {
const { mutate: deleteProject, isPending } = useDeleteProject();
return (
<button onClick={() => deleteProject(projectId)} disabled={isPending}>
삭제
</button>
);
}
컴포넌트는 "삭제 요청을 보내고, 로딩 상태를 보여준다"만 담당한다. 삭제 후 어떤 캐시를 날리는지, 어떤 메시지를 보여주는지는 훅이 알아서 처리한다.
Mutation 후 캐시 무효화 캐스케이드
복잡한 앱에서는 하나의 mutation이 여러 캐시에 영향을 줄 수 있다. 예를 들어 프로젝트 소유권을 이전하면:
export const useTransferProjectOwnership = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ projectId, data }) =>
projectApi.transferProjectOwnership(projectId, data),
onSuccess: (_, { projectId }) => {
// 프로젝트 상세 정보 갱신
queryClient.invalidateQueries({ queryKey: projectKeys.detail(projectId) });
// 멤버 목록 갱신
queryClient.invalidateQueries({ queryKey: projectKeys.members(projectId) });
// 내 멤버십 정보 갱신
queryClient.invalidateQueries({
queryKey: projectKeys.myProjectMembership(projectId),
});
// 워크스페이스 멤버 목록도 갱신
queryClient.invalidateQueries({
predicate: (query) => {
const key = query.queryKey;
return key[0] === 'workspace' && key[1] === 'members';
},
});
},
});
};
이런 무효화 로직이 컴포넌트에 있으면 재앙이다. 소유권 이전 버튼이 두 군데에 있으면 두 군데 다 똑같이 무효화 코드를 넣어야 하고, 하나를 빠뜨리면 캐시가 꼬인다. 훅에 한 번만 작성하면 어디서 호출하든 동일하게 동작한다.
predicate를 사용하면 동적인 키 매칭도 가능하다. queryKey를 정확히 모르더라도 패턴으로 매칭해서 무효화할 수 있다.
조건부 쿼리 캡슐화
인증 상태에 따라 쿼리를 실행하거나 안 하는 패턴은 매우 흔하다. 이것도 훅 안에서 처리하면 깔끔하다.
export const useCurrentUser = () => {
const { token, isAuthenticated } = useAuthStore();
return useQuery({
queryKey: authKeys.user(),
queryFn: authApi.getCurrentUser,
enabled: !!token && isAuthenticated,
staleTime: 5 * 60 * 1000,
retry: (failureCount, error) => {
if (error instanceof FetchError && error.status === 401) return false;
return failureCount < 3;
},
});
};
enabled 옵션이 핵심이다. token이 없거나 인증되지 않은 상태면 쿼리 자체가 실행되지 않는다. 401 에러에는 재시도하지 않도록 retry 함수도 커스텀했다. 컴포넌트에서 매번 이 조건 분기를 하는 건 비합리적이다.
비슷하게, URL 파라미터에 따라 동작이 달라지는 쿼리도 있다:
export const useProject = (projectId: string) => {
const accessKey = getAccessKeyFromUrl(); // ?accessKey=xxx
return useQuery({
queryKey: accessKey
? [...projectKeys.detail(projectId), accessKey]
: projectKeys.detail(projectId),
queryFn: () => projectApi.getProject(projectId),
enabled: !!projectId,
});
};
게스트 접근 시 accessKey가 URL에 붙는데, 이 경우 queryKey에 accessKey를 포함시켜서 인증 사용자와 게스트 사용자의 캐시를 분리한다. 이런 세부 사항을 컴포넌트가 알 필요가 없다.
복합 훅: 여러 쿼리 조합
실제 앱에서는 하나의 화면에 여러 데이터가 필요한 경우가 많다. 이때 여러 쿼리 훅을 조합하는 상위 훅을 만들 수 있다.
export const useProjectData = (projectId: string) => {
const projectQuery = useProject(projectId);
const versionsQuery = useProjectVersions(projectId);
return {
project: projectQuery.data,
versions: versionsQuery.allVersions,
loading: projectQuery.isLoading || versionsQuery.isLoading,
error: projectQuery.error || versionsQuery.error,
refetch: () => {
projectQuery.refetch();
versionsQuery.refetch();
},
};
};
useProjectData는 프로젝트 정보와 버전 목록을 한 번에 가져온다. 로딩 상태는 둘 다 완료되어야 false가 되고, 에러는 어느 쪽이든 발생하면 표시된다. refetch도 한 번 호출로 두 쿼리 모두 갱신된다.
이 패턴의 장점은 의존 쿼리(dependent query) 처리가 자연스럽다는 것이다:
export const useProjectVersions = (projectId: string) => {
const categoriesQuery = useProjectVersionCategories(projectId);
const uncategorizedQuery = useProjectVersionsWithoutCategory(projectId);
// 카테고리와 미분류 버전이 모두 로드된 후에만 실행
const allVersionsQuery = useQuery({
queryKey: [...projectKeys.versions(projectId), 'all'],
queryFn: async () => {
const categories = categoriesQuery.data || [];
const categoryVersionsPromises = categories.map(
(cat) => versionApi.getVersionsInCategory(cat.id)
);
const results = await Promise.all(categoryVersionsPromises);
return { categories, uncategorized: uncategorizedQuery.data, results };
},
enabled: categoriesQuery.isSuccess && uncategorizedQuery.isSuccess,
});
// ... 가공된 데이터 반환
};
카테고리 목록이 먼저 로드되고, 그 결과를 기반으로 각 카테고리의 버전을 가져오는 연쇄 쿼리다. enabled에 이전 쿼리의 성공 상태를 넣어서 순서를 보장한다. 이 복잡한 데이터 흐름이 useProjectVersions(projectId) 한 줄로 추상화된다.
옵션 확장 패턴
훅을 만들 때 유연성을 위해 외부에서 옵션을 주입할 수 있게 하면 좋다. React Query의 타입을 활용하면 타입 안전하게 만들 수 있다.
export const useProject = (
projectId: string,
options?: Omit<
UseQueryOptions<GetProjectResponse, Error>,
'queryKey' | 'queryFn'
>
) => {
return useQuery({
queryKey: projectKeys.detail(projectId),
queryFn: () => projectApi.getProject(projectId),
enabled: !!projectId,
staleTime: 5 * 60 * 1000,
...options, // 외부 옵션으로 기본값 오버라이드 가능
});
};
Omit<..., 'queryKey' | 'queryFn'>으로 queryKey와 queryFn은 오버라이드 못 하게 막고, 나머지 옵션(staleTime, enabled, onSuccess 등)은 커스텀할 수 있게 열어둔다.
mutation도 마찬가지다:
export const useCreateProject = (
options?: UseMutationOptions<
Project, Error,
{ workspaceId: string; data: CreateProjectRequest }
>
) => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ workspaceId, data }) =>
projectApi.createProject(workspaceId, data),
onSuccess: (newProject, variables, context) => {
queryClient.setQueryData(projectKeys.detail(newProject.id), newProject);
toast.success('프로젝트가 생성되었습니다');
// 외부에서 추가 동작 가능
options?.onSuccess?.(newProject, variables, context);
},
...options,
});
};
onSuccess 안에서 기본 동작(캐시 업데이트, 토스트)을 수행한 뒤, 외부에서 전달된 onSuccess도 호출한다. 이렇게 하면 특정 페이지에서만 필요한 추가 동작(예: 모달 닫기, 페이지 이동)을 주입할 수 있다.
에러 상태 관리 통합
mutation 훅에 에러 상태 관리까지 포함시키면 폼 컴포넌트가 훨씬 단순해진다.
export const useLogin = () => {
const [emailError, setEmailError] = useState('');
const [passwordError, setPasswordError] = useState('');
const loginMutation = useMutation({
mutationFn: (data: LocalSignInRequest) => authApi.localSignIn(data),
onError: (error) => {
if (error instanceof FetchError) {
const { code } = error.data;
if (code === 'USER_NOT_FOUND_EXCEPTION') {
setEmailError('존재하지 않는 계정입니다');
return;
}
if (code === 'INVALID_CREDENTIAL_EXCEPTION') {
setPasswordError('비밀번호가 틀렸습니다');
return;
}
}
toast.error('로그인에 실패했습니다');
},
});
const login = (email: string, password: string) => {
setEmailError('');
setPasswordError('');
const emailValidation = validateEmail(email);
if (emailValidation) {
setEmailError(emailValidation);
return;
}
if (!password) {
setPasswordError('비밀번호를 입력해주세요');
return;
}
loginMutation.mutate({ email, password });
};
return { login, isPending: loginMutation.isPending, emailError, passwordError };
};
이 훅은 세 가지를 통합한다:
- 클라이언트 유효성 검사 — 이메일 형식, 빈 값 체크
- 서버 에러 매핑 — 서버 에러 코드를 사용자 친화적 메시지로 변환
- 상태 관리 — 각 필드별 에러 상태를 관리하고 제출 시 초기화
컴포넌트에서는:
function LoginForm() {
const { login, isPending, emailError, passwordError } = useLogin();
return (
<form onSubmit={() => login(email, password)}>
<Input error={emailError} />
<Input error={passwordError} />
<Button loading={isPending}>로그인</Button>
</form>
);
}
폼 로직이 전부 훅에 있으니 컴포넌트는 순수하게 UI만 담당한다.
파일 구조
커스텀 훅을 어디에 둘지도 중요하다. 도메인 단위로 분리하는 게 가장 관리하기 좋다.
src/
├── domain/
│ ├── auth/
│ │ └── hooks/
│ │ └── queries/
│ │ └── useAuth.ts ← authKeys + useCurrentUser, useLogin, ...
│ ├── project/
│ │ └── hooks/
│ │ └── queries/
│ │ ├── useProject.ts ← projectKeys + useProject, useCreateProject, ...
│ │ └── useProjects.ts ← projectsKeys + useGetProjectInSection, ...
│ └── version/
│ └── hooks/
│ └── queries/
│ └── useVersion.ts ← versionKeys + useVersion, ...
└── hooks/
└── queries/
└── useWorkspace.ts ← 도메인 횡단 또는 공통 쿼리
Key Factory와 관련 훅을 같은 파일에 두면 "이 데이터의 캐시 키가 뭐지?"라는 질문에 한 파일만 보면 답이 나온다. 도메인 간 참조가 필요한 경우(예: 프로젝트 삭제 시 워크스페이스 캐시도 무효화)에는 다른 도메인의 키 팩토리를 import해서 사용한다.
정리
커스텀 훅으로 Query를 캡슐화하는 이유는 결국 관심사 분리다.
| 관심사 | 담당 |
|---|---|
| 어떤 API를 호출하는지 | Query 훅 내부 |
| 캐시 키가 뭔지 | Key Factory |
| 캐시를 얼마나 유지하는지 | Query 훅의 옵션 |
| 에러를 어떻게 처리하는지 | Mutation 훅 내부 |
| mutation 후 어떤 캐시를 날리는지 | Mutation 훅 내부 |
| 데이터를 화면에 어떻게 보여줄지 | 컴포넌트 |
컴포넌트는 마지막 한 줄만 신경 쓰면 된다. 나머지는 전부 훅이 책임진다. API 엔드포인트가 바뀌어도, 캐시 전략이 바뀌어도, 에러 메시지가 바뀌어도 컴포넌트는 수정할 필요가 없다. 수정은 훅 파일 하나에서만 일어난다.