글 목록으로개발 경험

TanStack Query v4의 query key를 Query Key Factory로 정리한 이유

하나의 키 구조에서 관련 조회 키를 만드는 모습을 그린 일러스트

글의 주제를 설명하려고 만든 일러스트예요. 실제 제품 화면이나 정확한 구성도는 아니에요.

후보자 메모를 수정하는 코드를 보다가, 저장한 뒤 어떤 쿼리를 다시 조회해야 하는지 찾기 어려웠어요. query key의 첫 값은 enum에 있었지만 그 뒤의 detail, ID, memos는 화면마다 직접 붙이고 있었어요. 조회하는 코드와 갱신하는 코드를 맞춰 보려면 같은 문자열을 저장소에서 검색해야 했어요.

text
enum CandidateQueryKey {
  Detail = 'CandidateDetail',
}
 
useQuery([CandidateQueryKey.Detail, 'detail', candidateId, 'memos'], fetchMemos);

API 인자가 바뀌면 key를 만드는 배열과 queryFn을 함께 찾아야 했어요. mutation 쪽에는 API 함수를 감싼 lambda가 인자 타입까지 다시 적고 있었고요. 조회를 정의하는 코드에서 key와 요청 함수를 같이 꺼내고, 그 정의에서 무효화 범위도 찾을 수 있게 바꿨어요.

예제는 TanStack Query v4와 @lukemorales/query-key-factory 1.3.x를 사용해요. 실제 코드에서 이름을 바꾸고 필요한 부분만 남겼어요. v5는 useQuery, useMutation, invalidateQueries 등의 위치 인자 overload를 없애고 객체 형식만 받아요. 본문의 invalidateQueries(queryKey)는 v4 문법이며 v5에서는 { queryKey }로 감싸야 해요. mutation 예제에는 두 버전을 함께 적었어요.

enum 뒤의 배열을 만드는 곳을 모았어요

enum이 문제라기보다, enum 뒤에 어떤 계층과 인자를 붙일지 화면마다 정하는 게 문제였어요. 직접 만든 함수가 key 배열과 queryFn을 반환해도 이 조합을 한곳에 둘 수 있어요. 라이브러리를 쓰는 경우와 비교하면 차이는 계층과 타입을 누가 만들어 주느냐였어요.

방법함께 정할 수 있는 것직접 맡아야 하는 것
enum과 호출부의 배열key의 첫 값하위 문자열, 인자 순서와 무효화 범위를 호출부에서 맞추기
직접 만든 key와 조회 옵션 함수key 배열과 queryFn공통 prefix, 하위 계층과 타입 유지하기
Query Key Factorykey, queryFn, _def와 _ctx로 꺼내는 계층정의 문법을 익히고 라이브러리의 반환 구조 사용하기

이 작업에서는 여러 앱이 같은 계층을 쓰도록 Query Key Factory 정의를 공용 패키지에 뒀어요. 쿼리가 적고 무효화 범위가 단순하다면 직접 만든 옵션 함수로도 충분할 수 있어요.

목록 조회의 인자를 key와 queryFn에 함께 써요

typescript
import { createQueryKeys } from '@lukemorales/query-key-factory';
 
export const candidateCollectionKeys = createQueryKeys('CandidateCollection', {
  list: (params?: CandidateListParams) => ({
    queryKey: [params],
    queryFn: () => candidateApi.getList(params),
  }),
  config: {
    queryKey: null,
    queryFn: candidateApi.getConfig,
  },
});

list에 params를 넘기면 같은 params로 key 배열과 조회 함수가 만들어져요. 조회 인자를 바꿀 때 key 쪽을 따로 찾아 고치지 않아도 돼요. 반면 config처럼 인자가 없는 쿼리는 객체로 바로 정의했어요.

candidateApi.getConfig를 그대로 넘길 때는 함수의 모양도 봐야 해요. this 없이 호출할 수 있어야 하고, 첫 인자를 사용하지 않아야 해요. queryFn은 첫 인자로 QueryFunctionContext를 받으므로, 선택 인자를 받는 API 함수를 그대로 넣으면 예상한 인자 대신 context가 들어갈 수 있어요.

factory가 만든 배열은 다음처럼 나와요. _def는 아직 구체적인 조회 인자를 붙이지 않은 prefix예요.

text
candidateCollectionKeys._def;
// ['CandidateCollection']
 
candidateCollectionKeys.list._def;
// ['CandidateCollection', 'list']
 
candidateCollectionKeys.list({ page: 1 }).queryKey;
// ['CandidateCollection', 'list', { page: 1 }]
 
candidateCollectionKeys.config.queryKey;
// ['CandidateCollection', 'config']

페이지별 목록 key가 달라도 list._def까지는 같아요. 어떤 목록 하나를 구독할 때와 목록 계층 전체를 무효화할 때 같은 정의를 쓸 수 있어요.

같은 후보자 아래의 조회를 이어 붙여요

메모와 이력, 메시지는 후보자 상세와 같은 candidateId를 써요. contextQueries로 상세 아래에 두면 ID를 넘기는 자리에서 하위 쿼리도 함께 만들어져요.

typescript
export const candidateDetailKeys = createQueryKeys('CandidateDetail', {
  detail: (candidateId: number) => ({
    queryKey: [candidateId],
    queryFn: () => candidateApi.getDetail(candidateId),
    contextQueries: {
      memos: {
        queryKey: null,
        queryFn: () => candidateApi.getMemos(candidateId),
      },
      histories: {
        queryKey: null,
        queryFn: () => candidateApi.getHistories(candidateId),
      },
    },
  }),
});

조회할 때는 그 결과를 useQuery에 넘기고, 수정한 뒤에는 거기서 key를 꺼내요.

typescript
const detail = candidateDetailKeys.detail(candidateId);
 
useQuery(detail);
useQuery(detail._ctx.memos);
 
queryClient.invalidateQueries(detail.queryKey); // 상세·메모·이력 전부
queryClient.invalidateQueries(detail._ctx.memos.queryKey); // 메모만

detail.queryKey는 ['CandidateDetail', 'detail', candidateId]예요. 메모 key는 여기에 'memos'를 붙이므로 상세 key를 prefix로 포함해요. 기본 invalidateQueries는 앞부분이 맞는 쿼리를 함께 찾기 때문에, 상세를 넘기면 메모와 이력도 대상이 되고 메모 key를 넘기면 메모만 대상이 돼요.

목록도 같은 방식이에요. 개별 params가 붙기 전의 _def를 넘기면 list 아래의 목록을 함께 무효화해요.

text
queryClient.invalidateQueries(candidateCollectionKeys.list._def);

함께 찾기 쉬워진 만큼 넓게 갱신할 수도 있어요

이 계층은 메모 수정 뒤 상세 전체를 갱신하기 쉽게 해주지만, 바뀌지 않은 이력도 무효화할 수 있어요. 사용 중인 쿼리는 기본적으로 백그라운드에서 다시 조회하고, 사용하지 않는 쿼리는 stale로 표시해요. 무효화한 쿼리가 모두 즉시 요청을 보내는 것은 아니에요. TanStack Query의 무효화 동작

상세 요약까지 바뀌는 수정이면 상세 범위를 쓸 수 있고, 메모만 달라졌다면 _ctx.memos.queryKey로 좁혀요. 상세 자체만 갱신하고 하위 쿼리를 남기려면 exact를 지정해요.

text
// TanStack Query v4에서도 객체 형식을 쓸 수 있어요.
queryClient.invalidateQueries({
  queryKey: detail.queryKey,
  exact: true,
});

메모가 바뀌었다고 목록의 요약까지 달라지는지는 factory가 알 수 없어요. 넓게 잡으면 불필요한 재조회가 붙고, 좁게 잡으면 갱신할 데이터를 빠뜨릴 수 있어요. 그래서 key를 만드는 코드는 공유하되, 어느 범위를 넘길지는 mutation 옆에 뒀어요.

mutation의 입력 타입은 API 함수에서 가져와요

기존에는 (body: CreateReviewBody) => candidateReviewApi.create(body)처럼 인자를 받아 그대로 넘기는 lambda에도 타입을 적었어요. mutation이 API와 같은 입력을 받는 경우에는 API 함수를 직접 넘겼어요.

typescript
// TanStack Query v4
const createReview = useMutation(candidateReviewApi.create, {
  onSuccess: (_review, { candidateId }) => {
    queryClient.invalidateQueries(
      candidateDetailKeys.detail(candidateId).queryKey,
    );
  },
});

v5에서는 mutationFn과 invalidateQueries의 queryKey를 객체 속성으로 넘겨요.

typescript
// TanStack Query v5
const createReview = useMutation({
  mutationFn: candidateReviewApi.create,
  onSuccess: (_review, { candidateId }) => {
    queryClient.invalidateQueries({
      queryKey: candidateDetailKeys.detail(candidateId).queryKey,
    });
  },
});

이렇게 하면 mutation의 변수 타입과 onSuccess의 candidateId가 API 함수의 인자에서 추론돼요. API 인자가 바뀌었을 때 lambda에 남아 있는 옛 타입을 따라가는 대신, 맞지 않는 mutate() 사용처에서 오류를 볼 수 있어요. candidateReviewApi.create 역시 this 바인딩 없이 호출할 수 있어야 해요.

mutation까지 factory에 넣지는 않았어요. 조회 정의는 공유하지만 수정 뒤 갱신할 데이터는 각각의 mutation이 정하는 구조로 남겼어요.

타입이 있어도 key에 빠진 조건은 찾아야 해요

key만 필요한 자리에서도 배열을 다시 쓰지 않고 factory의 queryKey나 _def를 꺼내게 했어요. 조회 인자는 직렬화할 수 있는 값으로 key에 넣어요. 그래야 같은 리소스의 key 모양을 정의한 곳에서 계속 관리할 수 있어요.

그래도 queryFn이 쓰는 조건을 key에서 빼먹는 실수까지 타입이 막지는 않아요. 새 정렬 조건으로 요청하면서 key에는 그 조건을 넣지 않으면, 서로 다른 결과가 같은 캐시를 가리켜요. TanStack Query의 query key 기준

옮기는 동안에는 기존 배열의 값과 순서를 그대로 뒀어요. 문자열 순서가 달라지면 다른 캐시를 보게 되고, prefix가 바뀌면 무효화할 범위도 달라지기 때문이에요. 메모를 고친 뒤 상세를 갱신할지 메모만 갱신할지는 이제 문자열 검색 대신 같은 정의의 계층을 보며 고를 수 있어요.

v5의 queryOptions로도 정의를 함께 쓸 수 있어요

v5에서 새로 만든다면 queryOptions도 비교할 만해요. key와 queryFn, 타입을 함께 정의해 재사용할 수 있어서 별도 factory 의존성 없이 조회 옵션을 모을 수 있어요. 다만 _def나 _ctx처럼 prefix와 하위 계층을 생성해 주지는 않으므로, 배열과 무효화용 prefix는 직접 정해야 해요. TanStack Query의 Query Options

이 작업에서 필요했던 것은 여러 앱이 같은 계층을 쓰고 그 계층으로 갱신 범위를 찾는 것이었어요. 조회 옵션을 함께 가져다 쓰는 것만 필요하다면 기본 헬퍼로도 그 부분을 해결할 수 있어요.

참고