junyeokk
Blog
React-Ecosystem·2025. 09. 09

React Query Key Factory 패턴

React Query(TanStack Query)에서 queryKey는 캐시를 식별하는 유일한 수단이다. 간단한 앱에서는 ['todos'], ['todos', todoId] 같은 문자열 배열을 직접 넣어도 크게 문제가 없다. 하지만 프로젝트가 커지면 상황이 달라진다.

typescript
// 여기저기 흩어진 queryKey들
useQuery({ queryKey: ['projects'], ... })
useQuery({ queryKey: ['projects', 'detail', projectId], ... })
useQuery({ queryKey: ['projects', 'versions', projectId], ... })

// mutation 성공 후 캐시 무효화
queryClient.invalidateQueries({ queryKey: ['projects'] })
queryClient.invalidateQueries({ queryKey: ['projects', 'detail', projectId] })

문자열을 직접 쓰면 오타가 나기 쉽고, 'projects''project'로 잘못 쓰면 캐시 무효화가 안 돼서 UI에 stale 데이터가 남는다. 더 큰 문제는 키 구조를 바꿀 때 모든 파일을 돌아다니며 수정해야 한다는 것이다. 이런 문제를 해결하는 게 Query Key Factory 패턴이다.

핵심 아이디어

queryKey 생성을 하나의 객체에 집중시킨다. 각 도메인(auth, project, comment 등)마다 키 팩토리 객체를 만들고, 모든 queryKey를 이 객체의 메서드로 생성한다.

typescript
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,
  schedules: (projectId: string) => [...projectKeys.all, 'schedules', projectId] as const,
};

이렇게 하면 queryKey를 사용하는 쪽에서는 팩토리 메서드만 호출하면 된다.

typescript
// 사용하는 쪽
useQuery({ queryKey: projectKeys.detail(projectId), ... })
useQuery({ queryKey: projectKeys.versions(projectId), ... })

키 구조가 바뀌더라도 팩토리 객체 한 곳만 수정하면 된다. TypeScript의 as const 단언 덕분에 키 배열이 readonly 튜플 타입으로 추론되어 타입 안전성도 확보된다.

계층 구조 설계

Key Factory의 진짜 힘은 계층적 구조에서 나온다. React Query의 invalidateQueries는 queryKey의 prefix matching으로 동작한다. 즉 ['projects']를 무효화하면 ['projects', 'detail', '123'], ['projects', 'versions', '123']'projects'로 시작하는 모든 쿼리가 무효화된다.

이 특성을 활용하면 캐시 무효화 범위를 정밀하게 제어할 수 있다.

typescript
export const projectKeys = {
  // Level 0: 최상위 - 프로젝트 관련 모든 캐시
  all: ['projects'] as const,

  // Level 1: 특정 프로젝트의 상세
  detail: (projectId: string) =>
    [...projectKeys.all, 'detail', projectId] as const,

  // Level 1: 특정 프로젝트의 버전 목록
  versions: (projectId: string) =>
    [...projectKeys.all, 'versions', projectId] as const,

  // Level 1: 특정 프로젝트의 일정
  schedules: (projectId: string) =>
    [...projectKeys.all, 'schedules', projectId] as const,

  // Level 1: 검색 (워크스페이스 단위)
  search: (workspaceId: string) =>
    [...projectKeys.all, 'search', workspaceId] as const,
};

무효화할 때 원하는 레벨을 선택할 수 있다.

typescript
// 프로젝트 관련 모든 캐시를 날린다
queryClient.invalidateQueries({ queryKey: projectKeys.all })

// 특정 프로젝트의 상세 정보만 날린다
queryClient.invalidateQueries({ queryKey: projectKeys.detail(projectId) })

// 특정 프로젝트의 일정만 날린다
queryClient.invalidateQueries({ queryKey: projectKeys.schedules(projectId) })

파라미터가 포함된 쿼리

같은 리소스인데 필터나 정렬 조건에 따라 다른 캐시를 유지해야 하는 경우, 키에 파라미터 객체를 추가한다.

typescript
export const commentKeys = {
  all: ['comment'] as const,
  versionComments: (versionId: string) =>
    [...commentKeys.all, 'version', versionId] as const,
  versionCommentsWithParams: (versionId: string, params: GetCommentsParams) =>
    [...commentKeys.versionComments(versionId), params] as const,
};

이 구조에서 commentKeys.versionComments(versionId)로 무효화하면 해당 버전의 모든 파라미터 조합 쿼리가 함께 무효화된다. 파라미터 객체가 키의 마지막에 들어가기 때문에 prefix matching이 자연스럽게 동작한다.

typescript
// versionId가 '123'인 모든 댓글 쿼리 무효화 (파라미터 무관)
queryClient.invalidateQueries({
  queryKey: commentKeys.versionComments('123')
})

// 특정 파라미터 조합의 쿼리만 무효화
queryClient.invalidateQueries({
  queryKey: commentKeys.versionCommentsWithParams('123', { page: 1, sort: 'latest' })
})

도메인별 분리

규모가 있는 프로젝트에서는 도메인마다 별도의 Key Factory를 만든다.

typescript
// domain/auth/hooks/queries/useAuth.ts
export const authKeys = {
  all: ['auth'] as const,
  user: () => [...authKeys.all, 'user'] as const,
  sessions: () => [...authKeys.all, 'sessions'] as const,
  googleUrl: () => [...authKeys.all, 'google-url'] as const,
  appleUrl: () => [...authKeys.all, 'apple-url'] as const,
};

// domain/project/hooks/queries/useProject.ts
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,
  // ...
};

// domain/comment/hooks/queries/useComment.ts
export const commentKeys = {
  all: ['comment'] as const,
  versionComments: (versionId: string) => [...commentKeys.all, 'version', versionId] as const,
  // ...
};

각 도메인의 all 키가 서로 다른 문자열('auth', 'projects', 'comment')이기 때문에 도메인 간 캐시가 절대 충돌하지 않는다. 하나의 도메인 캐시를 전부 날려도 다른 도메인에는 영향이 없다.

보통 Key Factory는 해당 도메인의 쿼리 훅 파일 상단에 함께 선언한다. 키와 훅이 같은 파일에 있으면 어떤 키가 어떤 쿼리에 쓰이는지 바로 파악할 수 있다.

Mutation에서의 캐시 무효화

Key Factory 패턴이 가장 빛나는 순간은 mutation 성공 후 캐시를 갱신할 때다.

typescript
export const useUpdateProject = (projectId: string) => {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: (data: UpdateProjectRequest) =>
      projectApi.updateProject(projectId, data),

    onSuccess: () => {
      // 이 프로젝트의 상세 정보를 다시 가져온다
      queryClient.invalidateQueries({
        queryKey: projectKeys.detail(projectId)
      });

      // 프로젝트 목록도 갱신한다 (이름이 바뀌었을 수 있으니까)
      queryClient.invalidateQueries({
        queryKey: projectKeys.all
      });

      // 워크스페이스의 프로젝트 섹션도 갱신한다
      queryClient.invalidateQueries({
        queryKey: workspaceKeys.all
      });
    },
  });
};

이렇게 여러 도메인의 캐시를 동시에 무효화하는 패턴을 Invalidation Cascade라고 부른다. 프로젝트를 수정하면 프로젝트 상세, 프로젝트 목록, 워크스페이스 뷰까지 연쇄적으로 갱신되어 UI가 항상 최신 상태를 유지한다.

predicate를 활용한 세밀한 무효화

때로는 prefix matching만으로는 부족할 때가 있다. 예를 들어 "멤버" 관련 쿼리만 골라서 무효화하고 싶은데, 키 구조상 prefix로는 분리가 안 되는 경우다. 이럴 때는 predicate 함수를 사용한다.

typescript
queryClient.invalidateQueries({
  predicate: (query) => {
    const key = query.queryKey;
    return (
      Array.isArray(key) &&
      key[0] === 'projects' &&
      key[1] === 'members'
    );
  },
});

predicate는 현재 캐시에 있는 모든 쿼리를 순회하면서 true를 반환하는 쿼리만 무효화한다. Key Factory와 함께 쓰면 더 읽기 좋게 만들 수 있다.

typescript
queryClient.invalidateQueries({
  predicate: (query) => {
    const key = query.queryKey;
    return Array.isArray(key) && key[0] === projectKeys.all[0];
  },
});

as const 단언의 역할

Key Factory에서 as const는 단순한 취향이 아니라 타입 안전성을 위한 핵심이다.

typescript
// as const 없이
const keys = {
  all: ['projects'],
  detail: (id: string) => ['projects', 'detail', id],
};
// keys.all의 타입: string[]
// keys.detail('1')의 타입: string[]

// as const 사용
const keys = {
  all: ['projects'] as const,
  detail: (id: string) => [...keys.all, 'detail', id] as const,
};
// keys.all의 타입: readonly ["projects"]
// keys.detail('1')의 타입: readonly ["projects", "detail", string]

as const 없으면 모든 키가 string[] 타입으로 추론되어, 잘못된 키를 넣어도 TypeScript가 잡아주지 못한다. as const를 붙이면 배열의 길이와 각 위치의 리터럴 타입이 고정되어 실수를 컴파일 타임에 잡을 수 있다.

@lukemorales/query-key-factory

수동으로 Key Factory를 만드는 것도 좋지만, @lukemorales/query-key-factory 라이브러리를 사용하면 더 선언적으로 작성할 수 있다.

typescript
import { createQueryKeys, mergeQueryKeys } from '@lukemorales/query-key-factory';

export const projectKeys = createQueryKeys('projects', {
  detail: (projectId: string) => ({ queryKey: [projectId] }),
  versions: (projectId: string) => ({ queryKey: [projectId] }),
  members: (projectId: string) => ({ queryKey: [projectId] }),
});

export const authKeys = createQueryKeys('auth', {
  user: null,
  sessions: null,
});

// 전체 앱의 queryKey를 하나로 합치기
export const queries = mergeQueryKeys(projectKeys, authKeys);

이 라이브러리의 장점은 queryKeyqueryFn을 함께 정의할 수 있어서 useQuery에 전달할 옵션 객체를 통째로 반환할 수 있다는 것이다. 하지만 직접 만든 객체 리터럴 방식도 충분히 실용적이고, 외부 의존성 없이 동일한 효과를 얻을 수 있다.

설계 시 주의점

최상위 키를 도메인마다 고유하게

typescript
// ❌ 잘못된 예: 최상위 키가 같으면 캐시 충돌
const projectKeys = { all: ['data'] as const, ... };
const commentKeys = { all: ['data'] as const, ... };

// ✅ 올바른 예: 도메인명으로 구분
const projectKeys = { all: ['projects'] as const, ... };
const commentKeys = { all: ['comments'] as const, ... };

동적 파라미터는 키의 끝에

prefix matching을 활용하려면 변동이 잦은 값(ID, 필터 등)은 키 배열의 뒤쪽에 배치해야 한다.

typescript
// ✅ 좋은 구조: prefix matching이 잘 동작
['projects', 'detail', projectId]
['projects', 'versions', projectId]

// ❌ 나쁜 구조: projectId가 앞에 오면 prefix로 전체 projects를 무효화할 수 없음
[projectId, 'projects', 'detail']

accessKey 같은 조건부 파라미터

인증 없이 접근하는 게스트 링크처럼 동일한 리소스인데 접근 방식에 따라 캐시를 분리해야 하는 경우가 있다.

typescript
export const projectKeys = {
  detail: (projectId: string, accessKey?: string) =>
    accessKey
      ? ([...projectKeys.all, 'detail', projectId, accessKey] as const)
      : ([...projectKeys.all, 'detail', projectId] as const),
};

accessKey가 있으면 키 배열에 포함시켜 별도 캐시로 관리하고, 없으면 기본 키를 사용한다. 이렇게 하면 로그인 사용자와 게스트의 캐시가 섞이지 않는다.

정리

개념설명
Key FactoryqueryKey 생성을 객체 메서드에 집중시키는 패턴
all도메인 최상위 키, prefix matching의 루트
계층 구조alldetail(id)detailWithParams(id, params) 순서로 확장
as constreadonly 튜플 타입 추론으로 타입 안전성 확보
Invalidation Cascademutation 성공 시 관련 도메인 캐시를 연쇄 무효화
predicateprefix matching으로 부족할 때 함수로 세밀하게 필터링

React Query 프로젝트에서 queryKey를 문자열 하드코딩으로 관리하고 있다면, Key Factory로 전환하는 것만으로도 캐시 관련 버그가 크게 줄어든다. 특히 mutation 후 "데이터가 안 바뀌어요" 같은 문제의 대부분은 queryKey 불일치가 원인이고, Key Factory는 이 문제를 구조적으로 예방한다.

관련 문서