Next.js Server Actions와 TanStack Query로 폼 제출 후 그리드를 자동 갱신하는 패턴
이 글은 Next.js App Router와 TanStack Query를 이미 사용 중이거나 도입을 고려하는 프론트엔드 개발자를 대상으로 합니다. 관리자 페이지, CRUD 그리드, 데이터 정합성이 중요한 화면을 만드는 분이라면 특히 도움이 될 거예요.
폼을 제출해서 아이템을 추가했는데, 그리드(테이블)는 여전히 이전 데이터를 보여주고 있는 상황, 한 번쯤 겪어보셨을 것 같습니다. 페이지를 새로고침하면 데이터가 뜨긴 하는데, 이걸 새로고침 없이 자동으로 동기화하려면 어떻게 해야 할까 고민하다가 캐시를 직접 손으로 조작하는 코드를 잔뜩 쌓아두신 경험, 혹시 있으신가요?
저도 처음엔 setQueryData로 낙관적 업데이트를 구현하려다 엣지 케이스 처리에 시간을 너무 많이 썼습니다. 솔직히 처음 이 패턴을 접했을 때 "이게 이렇게 간단해도 되나?" 싶었는데, 실제로 써보면 수동 캐시 조작 없이도 데이터 일관성을 유지할 수 있다는 걸 체감하게 됩니다.
이 글에서는 Server Actions를 useMutation의 mutationFn으로 연결하고, onSuccess에서 invalidateQueries를 호출하는 것만으로 캐시를 직접 조작하지 않고도 그리드 데이터를 서버 상태와 자동으로 동기화하는 구조를 단계적으로 살펴보겠습니다. 기본 패턴부터 시작해서, TkDodo가 2024년 5월에 제안한 이후 커뮤니티 채택 사례가 늘고 있는 글로벌 자동 무효화 패턴, 그리고 실무에서 자주 맞닥뜨리는 이중 캐시 문제까지 다뤄볼게요.
핵심 개념
Server Actions란 무엇인가
Next.js App Router에서 'use server' 지시어를 파일 상단이나 함수 내부에 붙이면, 그 함수는 서버에서만 실행되는 비동기 함수가 됩니다. 클라이언트 컴포넌트에서 일반 함수처럼 직접 호출할 수 있지만, 실제 실행은 서버에서 이루어지죠. DB 조작, 파일 처리, 민감한 로직을 클라이언트에 노출하지 않고 처리할 수 있어서 실무에서 매우 유용합니다.
// app/items/actions.ts
// @/lib/db는 프로젝트 루트의 lib/db.ts — Prisma 또는 Drizzle 클라이언트
'use server';
import { db } from '@/lib/db';
export async function createItem(formData: FormData) {
const name = String(formData.get('name') ?? '');
if (!name) throw new Error('이름을 입력해주세요');
await db.item.create({ data: { name } });
}
export async function getItems() {
return db.item.findMany({ orderBy: { createdAt: 'desc' } });
}한 가지 주의할 점이 있습니다. Server Action의 반환값은 직렬화 가능해야 합니다. Date 객체를 그대로 반환하면 클라이언트에서 직렬화 에러가 발생할 수 있어요. createdAt이 필요하다면 .toISOString() 형태로 변환하거나, Prisma의 serialize 유틸리티를 활용하는 것을 권장합니다.
invalidateQueries가 하는 일
TanStack Query는 클라이언트 캐시를 관리하는 라이브러리입니다. useQuery로 데이터를 패칭하면 쿼리 키(query key)를 기준으로 캐시에 저장되고, 이후 같은 키로 조회하면 네트워크 요청 없이 캐시를 반환합니다.
invalidateQueries는 이 캐시를 "오래된 것"으로 표시(stale)하는 함수입니다. 단순히 표시만 하는 게 아니라, 현재 마운트된 컴포넌트에서 해당 쿼리를 사용 중이면 즉시 백그라운드 리페치를 트리거합니다. 결과적으로 사용자는 새로운 서버 데이터를 자동으로 받아볼 수 있게 됩니다.
퍼지 매칭(Fuzzy Matching):
invalidateQueries({ queryKey: ['items'] })를 호출하면['items']뿐만 아니라['items', { page: 1 }],['items', 'detail', 123]등 해당 키로 시작하는 모든 하위 쿼리가 함께 무효화됩니다. 페이지네이션이나 필터링이 있는 그리드에서 특히 유용한 동작입니다.
두 가지가 만나는 지점
useMutation의 mutationFn으로 Server Action을 전달하고, 성공 콜백에서 invalidateQueries를 호출하는 것이 이 패턴의 핵심입니다. 폼 제출 → Server Action 실행(서버 DB 업데이트) → 클라이언트 캐시 무효화 → 그리드 자동 갱신이라는 흐름이 자연스럽게 연결됩니다.
실전 적용
예시 1 — 기본 패턴: useMutation + onSuccess (난이도: 기본)
가장 일반적인 형태입니다. useQuery로 데이터를 가져와 그리드에 렌더링하고, useMutation으로 폼 제출을 처리한 뒤 성공 시 캐시를 무효화합니다. 기존 코드베이스에서 mutation이 1~2개 정도라면 이 형태로 시작해보시면 좋습니다.
// app/items/ItemsGrid.tsx
'use client';
import { useRef } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { getItems, createItem } from './actions';
export function ItemsGrid() {
const formRef = useRef<HTMLFormElement>(null);
const queryClient = useQueryClient();
const { data: items, isLoading } = useQuery({
queryKey: ['items'],
queryFn: () => getItems(), // 에러는 throw로 전파 — TanStack Query가 에러 상태로 처리
});
const { mutate, isPending } = useMutation({
mutationFn: createItem,
onSuccess: () => {
// Server Action 성공 확인 후 캐시 무효화 → 그리드 자동 갱신
queryClient.invalidateQueries({ queryKey: ['items'] });
// 성공 이후에 폼을 리셋해야 실패 시 입력값이 보존됨
formRef.current?.reset();
},
onError: (error) => {
console.error('아이템 생성 실패:', error);
},
});
if (isLoading) return <p>로딩 중...</p>;
return (
<div>
<form
ref={formRef}
onSubmit={(e) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
mutate(formData);
}}
>
<input name="name" placeholder="아이템 이름" required />
<button type="submit" disabled={isPending}>
{isPending ? '추가 중...' : '추가'}
</button>
</form>
<table>
<thead>
<tr><th>이름</th><th>생성일</th></tr>
</thead>
<tbody>
{items?.map((item) => (
<tr key={item.id}>
<td>{item.name}</td>
<td>{new Date(item.createdAt).toLocaleDateString()}</td>
</tr>
))}
</tbody>
</table>
</div>
);
}각 라인이 하는 일을 짧게 정리하면:
| 코드 포인트 | 설명 |
|---|---|
queryFn: () => getItems() |
Server Action을 쿼리 함수로 사용. 에러 throw 시 TanStack Query가 에러 상태로 처리 |
mutationFn: createItem |
Server Action을 mutation 함수로 전달 |
onSuccess의 invalidateQueries |
mutation 성공 시 ['items'] 캐시 전체 무효화 |
onSuccess의 formRef.current?.reset() |
서버 응답 성공 후에만 폼 초기화 — 실패 시 입력값 보존 |
isPending |
mutation 진행 중 버튼 비활성화로 중복 제출 방지 |
예시 2 — 프로젝트 규모가 커지면: MutationCache 글로벌 패턴 (난이도: 심화)
mutation이 5개, 10개씩 늘어나기 시작하면 useMutation마다 onSuccess에 invalidateQueries를 작성하는 게 반복 작업이 됩니다. 우리 팀에서도 처음엔 각자 onSuccess를 붙이다가 무효화 대상이 빠진 mutation이 두 개 생기고 나서야 이 패턴으로 전환했습니다.
TkDodo가 2024년 5월에 제안한 이 패턴은 MutationCache(QueryClient 초기화 시 주입하는 글로벌 mutation 이벤트 버스)에 글로벌 콜백을 등록하고, 각 mutation의 meta 필드에 무효화 대상 쿼리 키를 선언하는 방식입니다.
// lib/query-client.ts
// 주의: queryClient를 모듈 스코프에서 선언하고 MutationCache에서 참조하는 구조.
// 동일 파일에서 선언해야 순환 참조를 피할 수 있음.
import { QueryClient, MutationCache, matchQuery } from '@tanstack/react-query';
export const queryClient = new QueryClient({
mutationCache: new MutationCache({
onSuccess: (_data, _variables, _context, mutation) => {
// meta.invalidates에 선언된 쿼리 키를 글로벌하게 자동 무효화
queryClient.invalidateQueries({
predicate: (query) =>
mutation.meta?.invalidates?.some((queryKey: unknown[]) =>
matchQuery({ queryKey }, query)
) ?? false,
});
},
}),
});TypeScript에서 meta.invalidates 타입이 추론되도록 선언 병합도 추가해두면 좋습니다.
// types/tanstack-query.d.ts
import '@tanstack/react-query';
declare module '@tanstack/react-query' {
interface Register {
mutationMeta: {
invalidates?: unknown[][];
};
}
}이후 각 useMutation에서는 meta만 선언하면 됩니다. onSuccess를 직접 붙이지 않아도 됩니다.
// mutation이 3개 이상인 프로젝트부터 이 패턴을 고려해볼 만합니다
const { mutate } = useMutation({
mutationFn: createItem,
meta: {
invalidates: [['items'], ['dashboard-stats']], // onSuccess 불필요
},
});
const { mutate: updateItem } = useMutation({
mutationFn: updateItemAction,
meta: {
invalidates: [['items'], ['items', itemId]], // 연관 쿼리 모두 선언
},
});
matchQuery: TanStack Query v5에서 제공하는 유틸리티 함수로, 쿼리 필터와 특정 쿼리가 매칭되는지 확인합니다. 글로벌 콜백에서 퍼지 매칭을 구현할 때 활용됩니다.
예시 3 — 타입 안전성까지 챙기려면: next-safe-action 어댑터 (난이도: 심화)
Server Action의 입력값 검증과 TanStack Query 통합을 동시에 처리하고 싶다면 next-safe-action과 그 TanStack Query 어댑터를 활용해볼 수 있습니다. Zod로 스키마를 정의하면 Server Action 입출력이 완전히 타입 추론됩니다.
여기서 next-safe-action 네이티브 useAction 훅 대신 useMutation(mutationOptions(...)) 형태를 선택한 이유는, 기존 예시 1, 2와 동일한 TanStack Query useMutation 인터페이스를 유지하면서 isPending, onSuccess, onError 등의 옵션을 일관성 있게 쓸 수 있기 때문입니다.
// lib/safe-action.ts — @/lib/safe-action 경로 별칭이 이 파일을 가리킴
import { createSafeActionClient } from 'next-safe-action';
export const actionClient = createSafeActionClient();// app/items/actions.ts
'use server';
import { z } from 'zod';
import { actionClient } from '@/lib/safe-action';
import { db } from '@/lib/db';
const createItemSchema = z.object({
name: z.string().min(1, '이름을 입력해주세요'),
});
export const createItemAction = actionClient
.schema(createItemSchema)
.action(async ({ parsedInput }) => {
const item = await db.item.create({ data: parsedInput });
return { item };
});// 클라이언트 컴포넌트
'use client';
import { useRef } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { mutationOptions } from '@next-safe-action/adapter-tanstack-query';
import { createItemAction } from './actions';
export function AddItemForm() {
const formRef = useRef<HTMLFormElement>(null);
const queryClient = useQueryClient();
const { mutate, isPending } = useMutation(
mutationOptions(createItemAction, {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['items'] });
formRef.current?.reset();
},
})
);
return (
<form
ref={formRef}
onSubmit={(e) => {
e.preventDefault();
const data = new FormData(e.currentTarget);
// next-safe-action이 Zod 스키마로 타입 안전하게 처리
mutate({ name: String(data.get('name') ?? '') });
}}
>
<input name="name" required />
<button disabled={isPending}>추가</button>
</form>
);
}장단점 분석
장점
| 항목 | 내용 |
|---|---|
| 수동 캐시 조작 불필요 | setQueryData로 낙관적 업데이트를 직접 구성하지 않아도 됩니다 |
| 서버 상태 일관성 | 항상 서버에서 최신 데이터를 받아오므로 UI와 DB가 항상 일치합니다 |
| 코드 단순화 | invalidateQueries 한 줄로 연관된 모든 쿼리를 갱신할 수 있습니다 |
| 퍼지 매칭 | ['items'] 무효화 시 하위 쿼리 키도 모두 자동 포함됩니다 |
| 타입 안전성 | next-safe-action 어댑터 사용 시 Server Action 입출력이 완전히 타입 추론됩니다 |
단점 및 주의사항
| 항목 | 내용 | 대응 방안 |
|---|---|---|
| 이중 캐시 처리 | Next.js 서버 캐시와 TanStack Query 클라이언트 캐시는 완전히 분리되어 있습니다 | Server Component가 있는 레이아웃이라면 Server Action 내부에서 revalidatePath도 함께 호출합니다. Client Component만 있는 화면이면 invalidateQueries만으로 충분합니다 |
| 추가 네트워크 요청 | 무효화 시마다 리페치가 발생하므로 UX 체감이 낙관적 업데이트보다 느릴 수 있습니다 | 데이터 정합성이 UX 응답속도보다 중요한 화면(관리자 그리드, 재고 관리 등)에 이 패턴을 적용하는 것을 권장합니다 |
| Server Component 미지원 | Server Component는 TanStack Query 옵저버가 없으므로 invalidateQueries로 갱신되지 않습니다 |
router.refresh() 또는 Server Action 내부의 revalidatePath를 병행합니다 |
| 글로벌 콜백 복잡도 | MutationCache 패턴은 팀 전체가 meta.invalidates 규약을 일관되게 지켜야 합니다 |
팀 규약을 문서화하고, TypeScript 선언 병합으로 타입을 강제하는 것을 권장합니다 |
낙관적 업데이트(Optimistic Update): 서버 응답을 기다리지 않고 클라이언트 캐시를 미리 업데이트해 UI를 즉시 반영하는 기법입니다. UX는 빠르지만 서버 응답이 실패했을 때 롤백 로직을 직접 구현해야 합니다.
invalidateQueries패턴은 이 복잡도를 포기하는 대신 정확성을 택하는 트레이드오프입니다.
실무에서 가장 흔한 실수
-
Server Component 데이터가 갱신되지 않아 혼란스러운 상황 — 우리 팀에서도 처음에 이걸 마주쳤을 때 "캐시 무효화가 제대로 안 되는 버그인가?" 싶었는데,
invalidateQueries는 Client Component에서useQuery로 구독 중인 쿼리만 갱신합니다. Server Component가 렌더링한 데이터는 Server Action 내부에서revalidatePath('/items')를 호출하거나router.refresh()를 함께 사용해야 갱신됩니다. -
queryKey불일치로 무효화가 조용히 무시되는 경우 — 처음 이 함정을 밟은 건useQuery의queryKey가['items', filters]인데invalidateQueries에서['item']으로 오타를 낸 적이 있어서였습니다. 무효화가 동작하지 않는데 에러도 없으니 원인 찾기가 꽤 걸렸어요. 쿼리 키를 상수로 관리하는 패턴(queryKeys.items.all()등)을 도입하면 이런 실수를 줄일 수 있습니다. -
MutationCache 글로벌 패턴에서 순환 참조가 발생하는 경우 —
queryClient를 별도 파일에서 선언하고,MutationCache의onSuccess콜백에서 다른 파일의queryClient를 import하려 하면 순환 참조가 생길 수 있습니다. 예시 2처럼queryClient와MutationCache정의를 같은 파일에 두는 것이 안전합니다.
마치며
Server Actions를 useMutation의 mutationFn으로 연결하고, onSuccess에서 invalidateQueries를 호출하는 것만으로 캐시를 직접 조작하지 않고도 그리드 데이터를 서버 상태와 자동으로 동기화할 수 있습니다. 데이터 정합성이 UX 응답속도보다 중요한 상황, 예를 들어 관리자 화면이나 재고·주문 같은 CRUD 그리드라면 이 패턴이 가장 현실적인 선택이 될 수 있습니다.
지금 바로 시작해볼 수 있는 3단계:
- 기존 코드베이스에서 가장 단순한 mutation 하나를 골라 이 패턴을 적용해볼 수 있습니다 —
'use server'가 붙은 함수 하나,useMutation에 연결하고onSuccess에서invalidateQueries를 호출하는 것으로 충분합니다. - TanStack Query DevTools(
@tanstack/react-query-devtools)를 추가하면 캐시가 무효화되고 리페치되는 과정을 시각적으로 확인할 수 있어서 동작 원리를 이해하는 데 큰 도움이 됩니다. - 프로젝트에 mutation이 많아질 경우,
MutationCache글로벌 콜백 패턴으로 마이그레이션하면서meta.invalidates에 쿼리 키를 선언하는 방식으로 점진적으로 전환해볼 수 있습니다.
낙관적 업데이트의 롤백 로직이나 setQueryData의 복잡한 업데이트 함수 없이도 데이터 일관성을 유지할 수 있다는 게 이 패턴의 가장 큰 매력입니다. "이게 이렇게 간단해도 되나?" 싶은 느낌이 드신다면, 아마 제가 처음 이 패턴을 접했을 때와 같은 지점에 계신 겁니다.
참고 자료
- Automatic Query Invalidation after Mutations | TkDodo's blog
- Invalidations from Mutations | TanStack Query v5 공식 문서
- Query Invalidation | TanStack Query v5 공식 문서
- MutationCache | TanStack Query v5 레퍼런스
- TanStack Query Integration | next-safe-action 공식 문서
- Getting Started: Mutating Data | Next.js 공식 문서
- Mastering Mutations in React Query | TkDodo's blog
- Server Action with TanStack Query in Next.JS Explained | Reetesh Kumar