FSD + TanStack Query + Next.js App Router: queryKey와 revalidateTag를 entities에서 단일 진실 공급원으로 관리하는 법
사용자가 프로필을 수정했는데, 목록 화면에선 여전히 이전 이름이 그대로 보이는 상황을 겪어본 적 있으신가요? 저도 한 번은 이 버그를 추적하느라 꽤 긴 시간을 보냈습니다. 원인은 단순했습니다. revalidateTag('users')로 서버 캐시는 날렸는데, TanStack Query의 클라이언트 캐시는 그대로 살아있었던 거였죠. 거기다 누군가는 entities/user/api에 queryKey를 정의해두고, 다른 팀원은 features/user-edit/ui 안에 같은 key를 문자열로 다시 적어둔 상황까지 겹치면서 무효화가 엉뚱한 범위에만 적용되고 있었습니다.
이 글은 FSD를 이미 도입했고, TanStack Query v5와 Next.js App Router를 함께 쓰고 있는 분들을 위한 글입니다. FSD 자체가 처음이라면 FSD 공식 문서를 먼저 살펴보시면 좋습니다. 여기서는 "어떻게 설정하나요"보다 "팀 전체가 일관되게 유지하려면 어떤 구조가 필요한가"에 초점을 맞춥니다.
queryKey는 entities에서 정의하고, revalidateTag 태그는 shared에서 상수로 관리하며, features는 이 둘을 조합해 서버와 클라이언트 캐시를 동시에 무효화하는 구조. 이 패턴을 팀 컨벤션으로 적용하면, PR 리뷰에서 "이 key가 여기 또 있네요"라는 코멘트가 사라지는 걸 경험할 수 있습니다.
핵심 개념
FSD 레이어 — 명사와 동사로 나누는 세계
FSD의 핵심은 레이어 간 단방향 의존성입니다. 상위 레이어는 하위 레이어를 import할 수 있지만, 역방향은 허용되지 않습니다.
app/ → pages/ → widgets/ → features/ → entities/ → shared/처음 FSD를 접하면 entities와 features를 구분하는 게 가장 까다롭습니다. 저도 한동안 "이게 entity야, feature야?"를 팀원들과 계속 논쟁했는데, 결국 가장 명확한 기준은 명사냐 동사냐였습니다.
| 구분 | entities | features |
|---|---|---|
| 성격 | 비즈니스 개념 (명사) | 사용자 액션 (동사) |
| 예시 | User, Post, Comment | login, add-to-cart, edit-profile |
| 보유 데이터 | 타입 정의, 기본 CRUD, queryOptions | 복잡한 UI 인터랙션, mutation |
| 재사용 범위 | 전체 레이어에서 참조됨 | 여러 page에서 재사용 |
UserCard는 entity입니다. 사용자를 표시하는 개념이니까요. UserEditForm은 feature입니다. 사용자가 프로필을 수정하는 액션을 담기 때문입니다.
단방향 의존성 규칙: features는 entities를 import할 수 있지만, entities는 절대 features를 import하면 안 됩니다. 이 규칙 하나가 FSD 전체 아키텍처의 예측 가능성을 만들어냅니다.
queryKey — 캐시의 주소이자 무효화의 기준
TanStack Query에서 queryKey는 단순한 문자열 배열이 아닙니다. 캐시를 찾는 주소이자, 어느 범위를 무효화할지 결정하는 계층적 식별자입니다.
['users'] // 모든 user 관련 캐시
['users', 'list'] // user 목록 전체
['users', 'list', { status: 'active' }] // 필터된 목록
['users', 'detail', userId] // 특정 user 상세TanStack Query v5에서 도입된 queryOptions() helper가 이 패턴을 한 단계 끌어올렸습니다. key와 fn을 분리해 관리하던 이전 방식과 달리, 두 가지를 하나의 타입 안전한 객체로 묶어 여러 곳에서 공유할 수 있게 됩니다. 이 구조를 query factory 패턴이라고 부르며, 이 파일이 해당 entity의 모든 쿼리에 대한 단일 진실 공급원(Single Source of Truth) 역할을 합니다.
// v5 이전 — key와 fn이 분리되어 중복 발생
const userDetailKey = (id: string) => ['users', 'detail', id]
const { data } = useQuery({
queryKey: userDetailKey(id),
queryFn: () => fetchUser(id),
})
// v5 이후 — queryOptions로 묶어서 재사용
const userDetailOptions = (id: string) =>
queryOptions({
queryKey: ['users', 'detail', id],
queryFn: () => fetchUser(id),
staleTime: 10 * 60 * 1000, // 10분간 fresh로 간주
})
// 동일한 객체를 여러 곳에서 재사용 가능
const { data } = useQuery(userDetailOptions(id))
await queryClient.prefetchQuery(userDetailOptions(id))
queryClient.getQueryData(userDetailOptions(id).queryKey) // 타입 안전staleTime은 TanStack Query가 캐시된 데이터를 "최신"으로 간주하는 시간입니다. 이 시간이 지나면 다음 렌더링 시 백그라운드에서 refetch가 발생합니다. revalidateTag와 달리 서버를 즉각 호출하지 않고 클라이언트 메모리에서만 관리됩니다.
revalidateTag — 서버 캐시와 클라이언트 캐시, 두 층을 함께 관리하기
Next.js App Router를 쓰면 캐시가 두 층으로 나뉩니다. 서버의 fetch 캐시(RSC가 읽는 데이터)와 브라우저의 TanStack Query 캐시(클라이언트 컴포넌트가 읽는 데이터). 이 둘을 동시에 무효화하지 않으면 한쪽은 최신, 다른 쪽은 낡은 데이터를 보여주는 상황이 생깁니다.
// Server Component — 태그를 붙여서 fetch
const users = await fetch('/api/users', {
next: { tags: ['users'] }
})
// Server Action — 데이터가 바뀌면 태그로 무효화
'use server'
async function updateUser(id: string, data: UpdateUserDto) {
await api.updateUser(id, data)
revalidateTag('users') // Next.js 서버 캐시 무효화
}| 구분 | revalidateTag | invalidateQueries |
|---|---|---|
| 동작 환경 | 서버 (Server Actions, Route Handlers) | 클라이언트 |
| 무효화 대상 | Next.js fetch 캐시 (RSC) | TanStack Query 메모리 캐시 |
| 사용 시점 | Server Component가 읽는 데이터 | useQuery로 로드된 데이터 |
| 병행 사용 | Server Action 내에서 둘 다 호출 가능 | — |
문제는 revalidateTag('users')와 queryKey: ['users']가 서로 다른 문자열 상수로 흩어지기 쉽다는 점입니다. 태그 이름을 바꿔야 할 때 한 곳을 빠뜨리면, 클라이언트는 최신 데이터를 보여주는데 서버 컴포넌트는 캐시된 낡은 데이터를 내려주거나, 그 반대 상황이 생깁니다.
실전 적용
앞으로 나올 세 예시는 순서대로 쌓이는 구조입니다. 예시 1에서 entities에 기반을 만들고, 예시 2에서 shared에 서버 캐시 상수를 추가하며, 예시 3에서 features가 이 둘을 연결합니다. 예시 4는 팀 규모가 커졌을 때 선택적으로 적용할 수 있는 고급 옵션입니다.
예시 1: entities 레이어에 단일 진실 공급원 만들기
entities는 데이터의 형태와 기본 조회 로직을 책임집니다. queryOptions도 여기에 정의하는 것이 자연스럽습니다. features가 user 데이터를 조회하고 싶을 때, 이 파일에서 export한 객체를 그대로 가져다 쓰면 됩니다.
// entities/user/api/queries.ts
import { queryOptions } from '@tanstack/react-query'
import { fetchUser, fetchUsers } from './fetch'
import type { UserFilters } from '../model/types'
// userKeys는 내부 구현 — 외부에 직접 노출하지 않습니다
const userKeys = {
all: ['users'] as const,
lists: () => [...userKeys.all, 'list'] as const,
list: (filters?: UserFilters) => [...userKeys.lists(), filters] as const,
details: () => [...userKeys.all, 'detail'] as const,
detail: (id: string) => [...userKeys.details(), id] as const,
}
export const userQueries = {
list: (filters?: UserFilters) =>
queryOptions({
queryKey: userKeys.list(filters),
queryFn: () => fetchUsers(filters),
staleTime: 5 * 60 * 1000,
}),
detail: (id: string) =>
queryOptions({
queryKey: userKeys.detail(id),
queryFn: () => fetchUser(id),
staleTime: 10 * 60 * 1000,
}),
}
// invalidateQueries에서 사용할 key prefix를 명시적으로 노출
export const userQueryKeys = userKeys// entities/user/index.ts — 공개 API만 외부로 노출
export { userQueries, userQueryKeys } from './api/queries'
export type { User, UserFilters } from './model/types'
export { UserCard } from './ui/UserCard'
export { UserAvatar } from './ui/UserAvatar'
// fetch.ts의 내부 구현은 export하지 않습니다userQueryKeys를 별도로 노출하는 건, invalidateQueries에 queryOptions 객체를 직접 넘기면 타입 문제가 생길 수 있기 때문입니다. v5의 invalidateQueries는 InvalidateQueryFilters 타입을 받으므로, 아래처럼 .queryKey를 꺼내서 사용하는 것이 권장 방식입니다.
// ✅ 권장: queryKey를 명시적으로 꺼내서 사용
queryClient.invalidateQueries({ queryKey: userQueryKeys.lists() })
queryClient.invalidateQueries({ queryKey: userQueryKeys.detail(userId) })
// ❌ 주의: queryOptions 객체를 InvalidateQueryFilters 자리에 직접 넘기면 타입 에러 가능
queryClient.invalidateQueries(userQueries.list())예시 2: shared/config에서 revalidateTag 상수 공유하기
예시 1에서 queryKey를 entities에 모았다면, 이번엔 서버 캐시 태그 문자열을 shared 레이어에 모아둡니다. revalidateTag에 쓰는 문자열과 fetch에 붙이는 태그가 동일한 상수를 참조하게 되면, 나중에 태그 이름이 바뀌어도 한 파일만 수정하면 됩니다.
// shared/config/cache-tags.ts
export const CACHE_TAGS = {
USERS: 'users',
USER_DETAIL: (id: string) => `user-${id}`,
POSTS: 'posts',
POST_DETAIL: (id: string) => `post-${id}`,
} as const// entities/user/api/fetch.ts — Server Component용 fetch 함수
// 참고: 실제 프로젝트에선 shared/api에 API 클라이언트 추상화가 있다면 그쪽을 사용하는 것이
// FSD 원칙에 더 부합합니다. 여기서는 개념 전달을 위해 fetch를 직접 사용합니다.
import { CACHE_TAGS } from '@/shared/config/cache-tags'
export async function fetchUsersWithCache() {
const res = await fetch('/api/users', {
next: { tags: [CACHE_TAGS.USERS] }, // 상수 참조
})
return res.json()
}
export async function fetchUserWithCache(id: string) {
const res = await fetch(`/api/users/${id}`, {
next: { tags: [CACHE_TAGS.USERS, CACHE_TAGS.USER_DETAIL(id)] },
})
return res.json()
}이렇게 하면 CACHE_TAGS.USERS라는 상수 하나가 fetch에 붙이는 태그와 revalidateTag 호출 양쪽에서 동일하게 쓰입니다.
예시 3: features 레이어에서 서버·클라이언트 캐시 동시 무효화
저희 팀이 이 패턴을 처음 도입했을 때, 가장 자주 했던 실수가 revalidateTag만 호출하거나 invalidateQueries만 호출하는 거였습니다. 한쪽만 하면 반드시 한 쪽 화면에서 낡은 데이터가 남습니다.
// features/user-edit/api/mutations.ts
'use server'
import { revalidateTag } from 'next/cache'
import { CACHE_TAGS } from '@/shared/config/cache-tags'
import { apiClient } from '@/shared/api/client'
import type { UpdateUserDto } from '@/entities/user'
export async function updateUserAction(id: string, data: UpdateUserDto) {
const updated = await apiClient.patch(`/users/${id}`, data)
// 서버 캐시 무효화 — 예시 2에서 만든 CACHE_TAGS 상수 사용
revalidateTag(CACHE_TAGS.USERS)
revalidateTag(CACHE_TAGS.USER_DETAIL(id))
return updated
}// features/user-edit/ui/UserEditForm.tsx
'use client'
import { useState } from 'react'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { userQueryKeys } from '@/entities/user' // 예시 1에서 노출한 key prefix
import { updateUserAction } from '../api/mutations'
import type { UpdateUserDto } from '@/entities/user'
interface Props {
userId: string
defaultValues?: { name: string; email: string }
}
export function UserEditForm({ userId, defaultValues }: Props) {
const queryClient = useQueryClient()
const [name, setName] = useState(defaultValues?.name ?? '')
const [email, setEmail] = useState(defaultValues?.email ?? '')
const mutation = useMutation({
mutationFn: (data: UpdateUserDto) => updateUserAction(userId, data),
onSuccess: () => {
// 클라이언트 캐시 무효화 — 예시 1에서 노출한 userQueryKeys 사용
queryClient.invalidateQueries({ queryKey: userQueryKeys.lists() })
queryClient.invalidateQueries({ queryKey: userQueryKeys.detail(userId) })
},
onError: () => {
// 에러 시에는 캐시를 건드리지 않습니다 — 기존 데이터가 그대로 유지됩니다
},
})
return (
<form
onSubmit={(e) => {
e.preventDefault()
mutation.mutate({ name, email })
}}
>
<input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="이름"
/>
<input
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="이메일"
/>
<button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? '저장 중...' : '저장'}
</button>
</form>
)
}features/user-edit/
api/
mutations.ts # Server Action — revalidateTag 호출
ui/
UserEditForm.tsx # 클라이언트 컴포넌트 — invalidateQueries 호출
model/
schema.ts # Zod 검증 스키마
index.ts실무적인 주의사항이 하나 있습니다. Server Action이 완료되면 Next.js RSC가 리렌더링되고, 동시에 onSuccess의 invalidateQueries도 호출됩니다. 이 두 작업이 경합(race condition)하는 경우, 클라이언트 컴포넌트가 오래된 데이터로 한 번 깜빡이는 현상이 발생할 수 있습니다. 이런 경우 RSC 리렌더링을 기다린 뒤 클라이언트 무효화를 연이어 처리하는 방식을 검토해볼 수 있습니다.
예시 4 (고급 옵션): @lukemorales/query-key-factory로 타입 안전성 강화하기
팀 규모가 커지거나 entity 수가 많아지면, createQueryKeys를 활용해 더 선언적으로 관리할 수 있습니다. 예시 1–3이 충분히 동작한다면 굳이 라이브러리를 추가할 필요는 없지만, 필터 조건에 상관없이 특정 그룹 전체를 한 번에 무효화하는 _def 패턴이 필요할 때 유용합니다.
// entities/post/api/queries.ts
import { createQueryKeys } from '@lukemorales/query-key-factory'
import { fetchPosts, fetchPost } from './fetch'
import type { PostFilters } from '../model/types'
export const postKeys = createQueryKeys('posts', {
all: null,
list: (filters: PostFilters) => ({
queryKey: [filters],
queryFn: () => fetchPosts(filters),
}),
detail: (id: string) => ({
queryKey: [id],
queryFn: () => fetchPost(id),
}),
})// features/post-publish/ui/PostPublishButton.tsx
'use client'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { postKeys } from '@/entities/post'
import { publishPostAction } from '../api/mutations'
export function PostPublishButton({ postId }: { postId: string }) {
const queryClient = useQueryClient()
const mutation = useMutation({
mutationFn: () => publishPostAction(postId),
onSuccess: () => {
// _def를 사용하면 필터 조건에 상관없이 모든 list 쿼리가 무효화됩니다
queryClient.invalidateQueries({ queryKey: postKeys.list._def })
queryClient.invalidateQueries({ queryKey: postKeys.detail(postId).queryKey })
},
onError: () => {
// 에러 시에는 캐시를 건드리지 않습니다
},
})
return (
<button onClick={() => mutation.mutate()} disabled={mutation.isPending}>
{mutation.isPending ? '발행 중...' : '발행'}
</button>
)
}
_def속성:@lukemorales/query-key-factory가 생성하는_def는 해당 쿼리 그룹의 base key입니다.postKeys.list._def는['posts', 'list']를 의미하며, 이를invalidateQueries에 넘기면 필터 조건에 상관없이 모든 list 쿼리가 무효화됩니다.
장단점 분析
장점
| 항목 | 내용 |
|---|---|
| 단일 진실 공급원 | queryOptions가 entities에만 정의되므로 key 중복·불일치가 원천 차단됩니다 |
| 정밀한 무효화 | 계층적 key 구조 덕분에 list 전체 또는 특정 detail 하나만 선택적으로 무효화할 수 있습니다 |
| 서버-클라이언트 동기화 | CACHE_TAGS 상수 하나로 revalidateTag와 invalidateQueries를 연동해 캐시 불일치를 방지합니다 |
| 타입 안전성 | queryOptions 반환값의 .queryKey를 그대로 넘기면 TypeScript가 key 구조를 추론해줍니다 |
| 리팩터링 안전성 | key 구조를 바꿀 때 entities 내부만 수정하면 나머지는 자동으로 따라옵니다 |
단점 및 주의사항
| 항목 | 내용 | 대응 방안 |
|---|---|---|
| 초기 진입 장벽 | entities/features 경계 판단이 모호한 케이스가 많습니다 | "명사면 entity, 동사면 feature" 기준을 팀 컨벤션으로 문서화해두면 좋습니다 |
| 복합 쿼리 위치 | user + post를 함께 조회하는 쿼리는 어느 레이어에 둘지 애매합니다 | widgets 또는 pages의 api 세그먼트에 두는 것을 권장합니다 |
| 소규모 프로젝트 오버헤드 | 파일이 늘어나는 만큼 초기 설정 비용이 있습니다 | 팀 5인 미만, 페이지 10개 미만이라면 단순 구조로 시작해도 충분합니다 |
| 이중 캐시 무효화 누락 | revalidateTag만 하거나 invalidateQueries만 하면 한쪽 캐시가 남습니다 |
Server Action 체크리스트를 PR 템플릿에 추가해두면 좋습니다 |
| RSC-클라이언트 경합 | Server Action 완료 후 RSC 리렌더링과 invalidateQueries 호출이 동시에 발생할 수 있습니다 |
깜빡임이 생기면 RSC 리렌더링 완료 후 클라이언트 무효화를 이어서 처리하는 방식을 검토해볼 수 있습니다 |
실무에서 가장 흔한 실수
-
userKeys를index.ts에서 직접 export해버리는 것 — features에서 key를 직접 조작하기 시작하면, entities 내부 구조를 바꿀 때 features도 함께 수정해야 하는 강결합이 생깁니다.userQueryKeys처럼 의도적으로 노출할 부분만 export하는 것을 권장합니다. -
revalidateTag에 리터럴 문자열을 직접 쓰는 것 —revalidateTag('users')처럼 문자열을 하드코딩하면, fetch 태그와 무효화 태그가 싱크를 잃는 순간을 타입 시스템이 잡아주지 못합니다.CACHE_TAGS상수를 경유하면 이 문제가 사라집니다. -
Server Action에서
invalidateQueries를 호출하려는 것 — Server Action은 서버에서 실행되므로queryClient에 접근할 수 없습니다.invalidateQueries는 반드시 클라이언트 컴포넌트의onSuccess나onSettled에서 호출해야 합니다.
마치며
이 패턴의 핵심 원칙을 세 줄로 정리하면 이렇습니다:
- queryKey의 형태는 entities가 책임집니다.
userQueryKeys,userQueries가 그 역할을 합니다. - 서버 캐시 태그 이름은 shared가 책임집니다.
CACHE_TAGS상수 하나가 fetch와revalidateTag양쪽을 묶어줍니다. - 무효화 실행은 features가 책임집니다. Server Action에서 서버 캐시를,
onSuccess에서 클라이언트 캐시를 함께 날립니다.
이 세 가지가 자리잡히면, "캐시 무효화가 왜 안 됐지?"를 디버깅하는 시간보다 "이번 기능은 어느 entity를 건드리나"를 고민하는 데 더 많은 시간을 쓸 수 있습니다.
제가 팀에 처음 이 구조를 도입했을 때 가장 먼저 했던 것들입니다:
-
shared/config/cache-tags.ts파일을 하나 만들어볼 수 있습니다. 현재 코드에서revalidateTag에 사용하는 문자열들을 모아CACHE_TAGS상수 객체로 정리하고, fetch에 붙인 태그 문자열과 일치하는지 확인해보시면 좋습니다. -
가장 자주 쓰는 entity 하나를 골라
queries.ts에queryOptionsfactory를 도입해볼 수 있습니다.userQueries.list(),userQueries.detail(id)형태로 옮기고,useQuery와invalidateQueries에서 같은 key를 참조하도록 연결해보시면 됩니다. -
Server Action 하나를 선택해
revalidateTag+onSuccess: invalidateQueries패턴을 적용해볼 수 있습니다. 데이터를 수정한 뒤 서버 컴포넌트와 클라이언트 컴포넌트가 동시에 갱신되는지 DevTools로 확인해보시면, 이중 캐시 무효화 전략이 얼마나 깔끔하게 동작하는지 바로 체감할 수 있습니다.
참고 자료
- Usage with TanStack Query | Feature-Sliced Design
- Usage with React Query | Feature-Sliced Design (공식)
- Layers Reference | Feature-Sliced Design
- queryOptions API Reference | TanStack Query v5
- Query Invalidation | TanStack Query v5 Docs
- @lukemorales/query-key-factory - GitHub
- Functions: revalidateTag | Next.js 공식 문서
- TanStack Query integration | next-safe-action