yoowonyoungabout

TanStack Query Key Factory 패턴으로 FSD 프로젝트의 캐시 무효화 관리하기

queryKey를 Factory로 체계화하고 cross-domain 무효화까지

들어가며

React + TanStack Query로 백오피스를 개발하다 보면, 데이터를 변경한 뒤 다른 페이지에서 이전 데이터가 그대로 보이는 상황을 마주하게 된다. 예를 들어 상품을 새로 등록했는데, 주문 생성 페이지의 상품 드롭다운에는 여전히 이전 상품 목록만 표시되는 것이다.

원인은 단순했다. 쿼리 키가 여기저기 흩어져 있어서, mutation 이후 어떤 캐시를 무효화해야 하는지 놓치고 있었다. 이 글에서는 Query Key Factory 패턴을 도입하여 캐시 무효화를 체계적으로 관리한 과정과, FSD(Feature-Sliced Design) 아키텍처에서 이 패턴이 어떻게 자연스럽게 들어맞는지를 정리한다.


1. 문제: 쿼리 키를 문자열로 관리하면 생기는 일

TanStack Query에서 queryKey는 캐시를 식별하는 유일한 수단이다. 문자열 배열을 직접 작성하면 처음에는 간단하지만, 프로젝트가 커지면서 여러 문제가 생긴다.

문자열 직접 사용의 문제

// [X] Bad: 쿼리 키를 여기저기서 직접 작성
// 파일 A — 상품 목록 조회
const { data } = useQuery({
  queryKey: ['products', 'list'],
  queryFn: () => fetchProducts(),
})

// 파일 B — 상품 이름 드롭다운
const { data } = useQuery({
  queryKey: ['products', 'names'],
  queryFn: () => fetchProductNames(),
})

// 파일 C — 상품 생성 mutation
onSuccess: () => {
  queryClient.invalidateQueries({ queryKey: ['products', 'list'] })
  // 'names'도 무효화해야 하는데... 잊었다!
}

이 방식의 핵심 문제는 세 가지다:

  1. 오타 위험: 'proudcts'처럼 오타가 나도 타입 에러가 발생하지 않는다
  2. 중복 정의: 같은 키를 여러 파일에서 반복 작성하므로, 키 구조를 바꾸려면 모든 파일을 수정해야 한다
  3. 무효화 누락: mutation이 영향을 주는 쿼리를 수동으로 추적해야 하므로, 프로젝트가 커지면 어떤 mutation이 어떤 쿼리에 영향을 주는지 파악이 불가능해진다

2. Query Key Factory 패턴이란

패턴의 기원: TkDodo의 “Effective React Query Keys”

Query Key Factory 패턴은 TanStack Query의 핵심 메인테이너인 TkDodo(Dominik Dorfmeister)가 블로그 글 Effective React Query Keys에서 체계적으로 정리한 패턴이다.

그가 정의한 Query Key Factory는 사실 거창한 것이 아니다. 본인의 표현을 빌리면:

“I like to keep them in simple objects with functions — it’s just objects and functions.”

GoF 디자인 패턴의 Factory Method처럼 클래스 상속이나 다형성이 필요한 게 아니다. 그저 객체와 함수로 이루어진 쿼리 키 생성기일 뿐이다. 다만 "Factory"라는 이름이 붙은 이유는 GoF Factory의 핵심 아이디어 — 생성 로직을 한 곳에 모아 캡슐화하여 호출부가 내부 구조를 몰라도 되게 한다 — 를 그대로 따르기 때문이다.

// 호출하는 쪽은 키의 내부 구조를 몰라도 됨
const key = productKeys.list({ page: 1 })
// → 내부적으로 ['products', 'list', { page: 1 }]이 만들어지지만,
//   사용하는 쪽은 이 구조를 알 필요가 없다

TkDodo가 이 패턴에서 강조한 세 가지 원칙이 있다.

원칙 1: 일반적인 것에서 구체적인 것으로 (Generic to Specific)

키 배열을 가장 범용적인 것부터 가장 구체적인 것 순서로 구성한다. 이 계층 구조 덕분에 prefix 매칭으로 원하는 범위만 정확히 무효화할 수 있다.

['products']                                    // 가장 범용적
['products', 'list']                            // 모든 목록
['products', 'list', { category: 'shoes' }]     // 특정 필터의 목록
['products', 'detail', '123']                   // 가장 구체적

원칙 2: 쿼리는 선언적이다 (Queries are Declarative)

TkDodo는 데이터 변경 시 refetch()를 맹목적으로 호출하는 명령형 접근을 경계한다. 대신 Query Key가 달라지면 자동으로 refetch가 발생하는 선언적 모델을 권장한다. 필터나 파라미터를 키에 포함시키면, 상태가 바뀔 때 쿼리가 알아서 새 데이터를 가져온다.

// [X] 명령형: 필터 변경 시 refetch 수동 호출
const { data, refetch } = useQuery({ queryKey: ['products'], ... })
onFilterChange = () => refetch() // 필터를 어떻게 전달?

// [O] 선언적: 필터를 키에 포함시켜 자동 refetch
const { data } = useQuery({
  queryKey: productKeys.list(filters),
  queryFn: () => fetchProducts(filters),
})
// filters가 바뀌면 키가 달라지고, 자동으로 새 데이터를 가져온다

원칙 3: 키를 기능 디렉토리에 함께 배치하라 (Colocate)

TkDodo는 모든 키를 전역 queryKeys.ts에 모으는 것을 권장하지 않는다. 대신 키를 해당 기능의 디렉토리에 함께 두라고 말한다. 이 원칙은 뒤에서 다룰 FSD 아키텍처와 자연스럽게 연결된다 — entities 레이어 안에 도메인별로 키를 함께 두는 것이 정확히 colocate 원칙에 부합한다.

기본 구조

// src/entities/product/api/useProductQueries.ts

export const productKeys = {
  all: ['products'] as const,
  lists: () => [...productKeys.all, 'list'] as const,
  list: (params?: ProductListParams) =>
    [...productKeys.lists(), params] as const,
  names: () => [...productKeys.all, 'names'] as const,
  details: () => [...productKeys.all, 'detail'] as const,
  detail: (id: string) => [...productKeys.details(), id] as const,
  reviews: (id: string) => [...productKeys.all, 'reviews', id] as const,
  inventory: (id: number) => [...productKeys.all, 'inventory', id] as const,
}

계층 트리

productKeys.all
└── ['products']
    ├── productKeys.lists()
    │   └── ['products', 'list']
    │       └── productKeys.list(params)
    │           └── ['products', 'list', { page: 1, category: 'shoes' }]
    ├── productKeys.names()
    │   └── ['products', 'names']
    ├── productKeys.details()
    │   └── ['products', 'detail']
    │       └── productKeys.detail('123')
    │           └── ['products', 'detail', '123']
    ├── productKeys.reviews('123')
    │   └── ['products', 'reviews', '123']
    └── productKeys.inventory(1)
        └── ['products', 'inventory', 1]

이렇게 하면 쿼리를 사용하는 쪽에서는 팩토리 함수만 호출하면 된다:

// 쿼리 정의
export const useProductNames = () => {
  return useQuery({
    queryKey: productKeys.names(),
    queryFn: () => productApi.getProductNames(),
    staleTime: 30 * 60 * 1000, // 30분
  })
}

// 무효화
queryClient.invalidateQueries({ queryKey: productKeys.names() })

여기서 names()드롭다운/셀렉트 UI용으로 상품의 { id, name } 목록만 반환하는 경량 API를 뜻한다. 전체 상품 목록(list)은 가격, 재고, 카테고리 등 모든 필드를 포함하지만, names는 오직 식별용 정보만 담고 있어 응답이 가볍고 변경 빈도가 낮다. 그래서 staleTime을 30분으로 길게 설정하여 불필요한 네트워크 요청을 줄인다. 하지만 바로 이 긴 staleTime이 문제의 원인이 되는데, 이는 섹션 5에서 자세히 다룬다.


3. 핵심: TanStack Query의 Prefix 매칭

Query Key Factory 패턴이 강력한 이유는 TanStack Query의 invalidateQueries배열의 앞부분(prefix)을 비교하기 때문이다. 키 배열의 앞부분이 일치하면 해당 쿼리를 모두 무효화한다.

3가지 무효화 범위

// 범위 1: names만 무효화
queryClient.invalidateQueries({ queryKey: productKeys.names() })
// → ['products', 'names']와 정확히 일치하는 쿼리만

// 범위 2: 모든 목록 무효화 (파라미터 무관)
queryClient.invalidateQueries({ queryKey: productKeys.lists() })
// → ['products', 'list']로 시작하는 모든 쿼리
// → ['products', 'list', { page: 1 }] [O]
// → ['products', 'list', { page: 2, category: 'shoes' }] [O]

// 범위 3: 상품 관련 전부 무효화
queryClient.invalidateQueries({ queryKey: productKeys.all })
// → ['products']로 시작하는 모든 쿼리

매칭 비교표

무효화 키 ['products', 'list', {page:1}] ['products', 'list', {page:2}] ['products', 'names'] ['products', 'detail', '1']
productKeys.list({page:1}) [O] [X] [X] [X]
productKeys.lists() [O] [O] [X] [X]
productKeys.names() [X] [X] [O] [X]
productKeys.all [O] [O] [O] [O]

lists()로 무효화하면 파라미터가 다른 모든 목록 쿼리가 한 번에 무효화된다. all로 무효화하면 해당 도메인의 모든 캐시가 날아간다. 필요한 범위만 정확히 무효화하는 것이 핵심이다.


4. FSD 아키텍처에서의 배치

FSD에서는 레이어 의존 방향이 features → entities → shared로 고정된다. 이 규칙이 Query Key Factory의 소유권을 자연스럽게 결정한다.

entities: 키 정의 + 읽기 훅

// entities/product/api/useProductQueries.ts

export const productKeys = { /* 섹션 2와 동일 */ }

export const useProducts = (params?: ProductListParams) => {
  return useQuery({
    queryKey: productKeys.list(params),
    queryFn: () => productApi.getProducts(params),
  })
}

export const useProductNames = () => {
  return useQuery({
    queryKey: productKeys.names(),
    queryFn: () => productApi.getProductNames(),
    staleTime: 30 * 60 * 1000,
  })
}

features: 키를 import하여 mutation에서 사용

// src/features/product-management/api/useProductMutations.ts

import { useMutation, useQueryClient } from '@tanstack/react-query'
import { productKeys } from '@/entities/product'

export const useCreateProduct = () => {
  const queryClient = useQueryClient()

  return useMutation({
    mutationFn: (data: CreateProductRequest) =>
      productApi.createProduct(data),
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: productKeys.lists() })
      queryClient.invalidateQueries({ queryKey: productKeys.names() })
    },
  })
}

export const useUpdateProduct = () => {
  const queryClient = useQueryClient()

  return useMutation({
    mutationFn: ({ id, data }: { id: string; data: UpdateProductRequest }) =>
      productApi.updateProduct(id, data),
    onSuccess: (_, { id }) => {
      queryClient.invalidateQueries({ queryKey: productKeys.lists() })
      queryClient.invalidateQueries({ queryKey: productKeys.names() })
      queryClient.invalidateQueries({ queryKey: productKeys.detail(id) })
    },
  })
}

Note: entities에서 키를 정의하고 features에서 가져다 쓰므로, "누가 이 캐시를 무효화할 수 있는가"가 import 관계로 명확해진다. FSD의 방향 규칙(상→하)이 키 소유권을 자연스럽게 강제하는 셈이다.

import 흐름도

graph TD subgraph features A["features/product-management
useCreateProduct"] end subgraph entities B["entities/product
productKeys + useQuery 훅"] end subgraph shared C["shared/lib
공통 유틸"] end A -->|"import { productKeys }"| B style A fill:#fee2e2,stroke:#ef4444 style B fill:#dbeafe,stroke:#3b82f6 style C fill:#d1fae5,stroke:#10b981

위 다이어그램에서 화살표 방향에 주목하자. features는 entities를 import할 수 있지만, 역방향은 불가능하다. 이 규칙이 "키는 entities에서 정의하고, 무효화는 features에서 수행한다"는 소유권을 강제한다.


5. 실전: Cross-Domain Invalidation

같은 도메인 안에서의 무효화는 직관적이다. 상품을 수정하면 상품 목록을 무효화하면 된다. 하지만 실제 백오피스에서는 하나의 mutation이 여러 도메인의 데이터에 영향을 주는 경우가 빈번하다.

사례: 상품 등록 → 주문 생성 페이지 드롭다운에 반영 안 됨

상품관리 페이지(features/product-management)에서 새 상품을 등록했다. 그런데 주문 생성 페이지에서 useProductNames() 훅으로 상품 드롭다운을 그리고 있었고, 이 훅의 staleTime이 30분이라 캐시가 살아있어 새 상품이 드롭다운에 나타나지 않았다.

// [X] Before: 상품 생성 시 list만 무효화
export const useCreateProduct = () => {
  const queryClient = useQueryClient()

  return useMutation({
    mutationFn: (data) => productApi.createProduct(data),
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: productKeys.lists() })
      // names()를 무효화하지 않아 드롭다운이 갱신되지 않음!
    },
  })
}
// [O] After: names()도 함께 무효화
export const useCreateProduct = () => {
  const queryClient = useQueryClient()

  return useMutation({
    mutationFn: (data) => productApi.createProduct(data),
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: productKeys.lists() })
      queryClient.invalidateQueries({ queryKey: productKeys.names() })
    },
  })
}

더 복잡한 Cross-Domain 사례들

실제 커머스 백오피스에서 만날 수 있는 cross-domain 무효화 패턴들이다.

주문 상태 변경 → 주문 + 재고 + 정산 무효화

// src/features/order-management/api/useOrderMutations.ts

import { useMutation, useQueryClient } from '@tanstack/react-query'
import { orderKeys } from '@/entities/order'
import { inventoryKeys } from '@/entities/inventory'
import { settlementKeys } from '@/entities/settlement'

export const useUpdateOrderStatus = (orderId: number) => {
  const queryClient = useQueryClient()

  return useMutation({
    mutationFn: (status: OrderStatus) =>
      orderApi.updateStatus(orderId, status),
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: orderKeys.all })
      queryClient.invalidateQueries({ queryKey: inventoryKeys.all })
      queryClient.invalidateQueries({ queryKey: settlementKeys.all })
    },
  })
}

import문을 보면 이 mutation이 3개 도메인의 entities에 의존한다는 것이 명확하다. FSD의 레이어 규칙상 features는 entities를 자유롭게 import할 수 있으므로, cross-domain 무효화가 자연스럽게 성립한다.

재고 수정 → 상품 가용수량 무효화

// src/features/inventory-management/api/useInventoryMutations.ts

import { useMutation, useQueryClient } from '@tanstack/react-query'
import { productKeys } from '@/entities/product'

export const useUpdateInventory = (productId: number) => {
  const queryClient = useQueryClient()

  return useMutation({
    mutationFn: (items: UpdateInventoryItem[]) =>
      inventoryApi.update(productId, items),
    onSuccess: () => {
      // 다른 도메인(entities/product)의 캐시를 무효화
      queryClient.invalidateQueries({
        queryKey: productKeys.inventory(productId),
      })
    },
  })
}

이 패턴은 환불 처리, 일괄 배송 등 다양한 도메인 간 무효화에도 동일하게 적용된다.

Query Key Factory가 없었다면, 이 모든 키를 문자열로 직접 작성해야 했을 것이다. 팩토리 패턴 덕분에 onSuccess 안의 무효화 코드만 보면 이 mutation이 어떤 도메인의 데이터에 영향을 주는지 한눈에 파악할 수 있다.

Point: mutation을 만들 때 반드시 검토할 질문: “이 액션이 영향을 주는 다른 도메인의 데이터가 있는가?” 이 질문에 대한 답이 onSuccessinvalidateQueries 목록이 된다.


6. 적용 범위와 성과

지표 수치
Entity 당 Keys 팩토리 20개 (Entity 슬라이스 수와 동일)
Cross-Domain 무효화 케이스 다수 (mutation이 다른 도메인의 캐시를 무효화하는 경우)
쿼리 키 오타 버그 팩토리 도입 후 0건 (TypeScript가 자동 검증)
무효화 누락 버그 import 관계로 영향 범위가 명시적이라 누락 방지
FSD 레이어 규칙 준수 Keys 정의는 entities, 무효화 사용은 features — 의존 방향 일관

7. 정리: staleTime과 Invalidation은 역할이 다르다

캐시 관리에서 자주 혼동되는 두 개념을 정리한다.

비교표

구분 staleTime invalidateQueries
트리거 시간 기반 (자동) 이벤트 기반 (수동)
역할 “얼마나 자주 서버에 물어볼 것인가” “언제 강제로 새로고침할 것인가”
적합한 상황 데이터 변경 빈도가 낮은 경우 사용자 액션으로 데이터가 변경된 경우
한계 다른 페이지에서의 변경을 놓칠 수 있음 모든 mutation에 일일이 지정해야 함

staleTime만 의존하면 다른 페이지에서 데이터를 변경해도 캐시가 살아있는 동안 반영되지 않는다. invalidation만 의존하면 staleTime이 0이 되어 컴포넌트가 마운트될 때마다 불필요한 refetch가 발생한다.

둘을 조합하는 것이 정답이다:

  • 변경이 드문 정적 데이터(useProductNames 등): staleTime을 길게 + mutation에서 명시적으로 invalidation
  • 변경이 잦은 동적 데이터(목록, 상세): staleTime을 짧게 또는 기본값 + mutation에서 invalidation

도입 체크리스트

항목 설명
Entity당 Keys 팩토리 1개 productKeys, orderKeys처럼 도메인별로 하나의 팩토리 객체를 정의한다
Mutation마다 영향 쿼리키 검토 mutation을 작성할 때 onSuccess에 영향받는 쿼리 키를 빠짐없이 나열한다
Cross-domain 영향 확인 다른 도메인의 데이터에도 영향을 주면 해당 entity 키도 무효화한다
all 남용 주의 productKeys.all은 편리하지만, 불필요한 쿼리까지 무효화될 수 있다. 필요한 범위만 무효화한다
FSD 레이어 준수 Keys 팩토리는 entities에 정의하고, mutation에서의 사용은 features에서 import한다

Query Key Factory 패턴은 거창한 것이 아니다. 쿼리 키를 한 곳에 모으고, 계층 구조를 활용하여 무효화 범위를 제어하는 것이 전부다. 하지만 이 단순한 패턴이 “상품을 추가했는데 왜 드롭다운에 안 나와요?” 같은 문제를 근본적으로 방지해 준다.