`useOptimisticAction` + `react-hook-form`으로 낙관적 업데이트와 서버 검증 오류 한 번에 처리하기 (next-safe-action)
서버 응답을 기다리는 짧은 순간이 사용자 입장에서는 생각보다 길게 느껴집니다. 버튼을 눌렀는데 아무 반응이 없는 그 공백. 저도 처음엔 "어차피 200ms도 안 걸리는데 뭐가 문제야" 싶었는데, 낙관적 업데이트를 붙이고 나서 체감 품질이 눈에 띄게 달라졌습니다.
그런데 솔직히 저를 더 오래 고민하게 만든 건 낙관적 업데이트 자체가 아니었습니다. 서버에서 검증 오류가 돌아왔을 때, 그걸 폼 필드에 어떻게 "다시 붙이느냐"는 문제였거든요. 클라이언트 Zod 검증은 react-hook-form이 처리해주는데, 서버의 validationErrors는 별도로 꺼내서 수동으로 setError를 호출해야 했습니다. 그 반복 작업이 꽤 번거로웠죠.
이 글에서는 next-safe-action의 useOptimisticAction 훅으로 서버 응답 전에 UI를 먼저 갱신하고, @next-safe-action/adapter-react-hook-form 공식 어댑터로 서버 검증 오류를 폼 필드에 자동 바인딩하는 방법을 다룹니다. 핵심은 낙관적 업데이트, 서버 액션, 폼 검증이라는 세 가지 관심사를 Zod 스키마 하나에서 시작하는 타입 안전 흐름으로 통합하는 것입니다. 개념 설명 이후 바로 실전 코드로 이어지니 함께 살펴보시면 좋겠습니다.
핵심 개념
next-safe-action이란?
next-safe-action은 Next.js Server Actions의 타입 안전성과 미들웨어 처리를 도와주는 서드파티 라이브러리입니다. 공식 Next.js 패키지는 아니지만, App Router 생태계에서 서버 액션을 쓸 때 사실상 필수처럼 쓰이고 있습니다. Zod 스키마로 입출력을 선언하면 타입 추론부터 검증 오류 형식화까지 알아서 처리해줍니다.
// lib/safe-action.ts — 프로젝트 전체에서 공유하는 액션 클라이언트
import { createSafeActionClient } from "next-safe-action";
export const actionClient = createSafeActionClient();이 한 줄짜리 설정 파일이 이후 모든 서버 액션의 출발점이 됩니다.
낙관적 업데이트와 useOptimisticAction
낙관적 업데이트(Optimistic Update)는 서버 응답을 기다리지 않고 UI를 먼저 갱신하는 패턴입니다. 서버 작업이 성공할 거라고 "낙관적으로" 가정하고 화면을 먼저 바꾼 뒤, 실패하면 원래 상태로 되돌립니다.
| 방식 | 사용자 경험 | 구현 복잡도 |
|---|---|---|
| 일반 (서버 응답 후 갱신) | 버튼 누른 후 대기 | 낮음 |
직접 useOptimistic 사용 |
즉시 반영 + 직접 롤백 구현 | 높음 |
useOptimisticAction 사용 |
즉시 반영 + 자동 롤백 | 낮음 |
useOptimisticAction은 React 19의 useOptimistic을 기반으로 하면서, Safe Action과의 연결·롤백 처리·타입 추론을 모두 담당합니다. v7부터 두 번째 인수가 옵션 객체로 바뀌었으니 주의가 필요합니다.
// v6 (구버전)
useOptimisticAction(action, currentState, updateFn, callbacks);
// v7 (현재) — 두 번째 인수가 단일 옵션 객체로 통합
useOptimisticAction(action, {
currentState,
updateFn,
onSuccess,
onError,
});
updateFn의 순수 함수 제약:updateFn은 React 렌더 사이클 안에서 실행되기 때문에 반드시 순수 함수여야 합니다. 비동기 로직이나 외부 상태 변경을 넣으면 예측 불가능한 버그로 이어집니다. 서버 액션이 성공하면revalidatePath나revalidateTag로 서버 컴포넌트가 리프레시되면서 실제 데이터로 자연스럽게 교체됩니다.
서버 검증 오류와 react-hook-form 연결
@next-safe-action/adapter-react-hook-form 어댑터의 useHookFormActionErrorMapper 훅이 이 문제를 해결합니다. 서버의 validationErrors 형식을 react-hook-form이 이해하는 FieldErrors 형식으로 변환한 뒤, useForm의 errors 옵션으로 주입하면 각 필드에 자동으로 오류가 연결됩니다.
const { hookFormValidationErrors } = useHookFormActionErrorMapper(
action.result.validationErrors,
{ joinBy: "\n" }
);
const form = useForm({
resolver: zodResolver(schema),
errors: hookFormValidationErrors, // 서버 오류가 폼 필드에 자동 연결
});
joinBy옵션: 하나의 필드에 여러 검증 오류가 있을 때 메시지를 합치는 구분자입니다."\n"," | ",", "등을 상황에 맞게 선택할 수 있습니다. 복잡한 오류 UI가 필요하다면validationErrors를 직접 파싱하는 방식을 선택할 수 있습니다.
실전 적용
예시 1: useOptimisticAction으로 할 일 즉시 추가
가장 전형적인 사례입니다. "추가" 버튼을 누르면 서버 응답이 오기 전에 목록에 항목이 먼저 나타납니다.
서버 액션을 먼저 정의합니다.
// app/todos/actions.ts
"use server";
import { actionClient } from "@/lib/safe-action";
import { z } from "zod";
import { db } from "@/lib/db";
import { revalidatePath } from "next/cache";
const addTodoSchema = z.object({
newTodo: z.string().min(1, "할 일을 입력해 주세요"),
});
export const addTodoAction = actionClient
.schema(addTodoSchema)
.action(async ({ parsedInput }) => {
await db.todo.create({ data: { text: parsedInput.newTodo } });
revalidatePath("/todos"); // 서버 성공 후 실제 데이터로 교체
return { success: true };
});클라이언트 컴포넌트에서 useOptimisticAction을 사용합니다.
// app/todos/AddTodoForm.tsx
"use client";
import { useState } from "react";
import { useOptimisticAction } from "next-safe-action/hooks";
import { addTodoAction } from "./actions";
type Props = { todos: string[] };
export function AddTodoForm({ todos }: Props) {
const [input, setInput] = useState("");
const { execute, optimisticState, isExecuting } = useOptimisticAction(
addTodoAction,
{
currentState: { todos },
// input의 타입은 addTodoSchema에서 자동 추론 — { newTodo: string }
updateFn: (state, input) => ({
todos: [...state.todos, input.newTodo],
}),
onSuccess: () => setInput(""),
onError: ({ error }) => {
console.error("서버 오류:", error);
// updateFn의 낙관적 상태는 자동으로 currentState로 롤백
},
}
);
return (
<div>
<ul>
{optimisticState.todos.map((todo, i) => (
// 실제 코드에서는 DB의 고유 id를 key로 사용하는 것을 권장합니다
<li key={`todo-${i}`}>{todo}</li>
))}
</ul>
<div className="flex gap-2 mt-4">
<input
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="새 할 일을 입력하세요"
className="border rounded px-3 py-2 flex-1"
/>
<button
onClick={() => {
if (!input.trim()) return;
execute({ newTodo: input });
}}
disabled={isExecuting}
className="bg-blue-600 text-white px-4 py-2 rounded disabled:opacity-50"
>
{isExecuting ? "저장 중..." : "추가"}
</button>
</div>
</div>
);
}서버 컴포넌트에서 초기 데이터를 내려줍니다.
// app/todos/page.tsx
import { AddTodoForm } from "./AddTodoForm";
import { db } from "@/lib/db";
export default async function TodoPage() {
const todos = await db.todo.findMany({ select: { text: true } });
// App Router의 기본 패턴: 서버에서 데이터를 가져온 뒤 클라이언트 컴포넌트에 초기 상태로 전달
return <AddTodoForm todos={todos.map((t) => t.text)} />;
}| 코드 포인트 | 설명 |
|---|---|
currentState: { todos } |
서버에서 받은 초기 상태. 액션 실패 시 이 값으로 자동 롤백됨 |
updateFn의 input 타입 |
addTodoSchema에서 자동 추론 — 별도 타입 선언 불필요 |
optimisticState.todos |
execute() 직후 즉시 렌더링에 사용되는 낙관적 목록 |
revalidatePath |
서버 성공 후 서버 컴포넌트가 리프레시되어 실제 DB 데이터로 교체됨 |
예시 2: 서버 validationErrors를 폼 필드에 자동 바인딩
클라이언트 Zod 검증은 통과했지만 서버에서 추가 검증 오류가 돌아오는 경우를 처리합니다. 이 시점에서 자연스럽게 생기는 의문이 있습니다. "서버 오류를 받았을 때 setError를 일일이 호출해야 하나?" — 어댑터를 쓰면 그럴 필요가 없습니다.
// app/login/LoginForm.tsx
"use client";
import { useHookFormActionErrorMapper } from "@next-safe-action/adapter-react-hook-form/hooks";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { useAction } from "next-safe-action/hooks";
import { z } from "zod";
import { loginAction } from "./actions";
const loginSchema = z.object({
email: z.string().email("올바른 이메일 형식이 아닙니다"),
password: z.string().min(8, "비밀번호는 8자 이상이어야 합니다"),
});
type LoginInput = z.infer<typeof loginSchema>;
export function LoginForm() {
const action = useAction(loginAction);
const { hookFormValidationErrors } = useHookFormActionErrorMapper(
action.result.validationErrors,
{ joinBy: "\n" }
);
const form = useForm<LoginInput>({
resolver: zodResolver(loginSchema),
errors: hookFormValidationErrors, // 서버 오류 → 폼 필드 자동 바인딩
});
// executeAsync는 Promise를 반환하므로
// form.handleSubmit의 인자 시그니처와 타입 에러 없이 호환됩니다
return (
<form onSubmit={form.handleSubmit(action.executeAsync)}>
<div>
<input
{...form.register("email")}
placeholder="이메일"
type="email"
className="border rounded px-3 py-2 w-full"
/>
{form.formState.errors.email && (
<p className="text-red-500 text-sm mt-1">
{form.formState.errors.email.message}
</p>
)}
</div>
<div className="mt-3">
<input
{...form.register("password")}
placeholder="비밀번호"
type="password"
className="border rounded px-3 py-2 w-full"
/>
{form.formState.errors.password && (
<p className="text-red-500 text-sm mt-1">
{form.formState.errors.password.message}
</p>
)}
</div>
<button
type="submit"
disabled={action.isExecuting}
className="mt-4 bg-blue-600 text-white px-4 py-2 rounded disabled:opacity-50"
>
{action.isExecuting ? "로그인 중..." : "로그인"}
</button>
</form>
);
}클라이언트 Zod 검증(즉각 피드백)과 서버 검증(보안 최종 확인)이 동일한 폼 상태로 자연스럽게 통합됩니다. form.formState.errors를 한 번만 읽으면 두 출처의 오류가 모두 담겨 있습니다.
양측 검증 패턴: 클라이언트에서는 Zod +
react-hook-form으로 즉각적인 피드백을, 서버에서는next-safe-action의 스키마 검증으로 최종 보안 검증을 수행합니다. 클라이언트 검증은 브라우저 개발자 도구로 얼마든지 우회할 수 있기 때문에, 서버 검증을 생략하면 안 됩니다.
예시 3: useHookFormOptimisticAction으로 세 가지를 하나로
이 API 이름을 처음 봤을 때 솔직히 너무 길어서 흠칫했는데, 실제로 써보니 낙관적 업데이트 + 폼 관리 + 서버 검증 오류 바인딩이 한 번에 처리됩니다. 앞의 두 예시에서 따로 설정했던 것들을 actionProps, formProps, errorMapProps 세 옵션으로 모아서 선언하는 방식입니다.
// app/todos/AddTodoForm.tsx
"use client";
import { useHookFormOptimisticAction } from "@next-safe-action/adapter-react-hook-form/hooks";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { addTodoAction } from "./actions";
const addTodoSchema = z.object({
newTodo: z.string().min(1, "할 일을 입력해 주세요"),
});
type Props = { todos: string[] };
export function AddTodoForm({ todos }: Props) {
const { form, action, handleSubmitWithAction } = useHookFormOptimisticAction(
addTodoAction,
zodResolver(addTodoSchema),
{
actionProps: {
currentState: { todos },
updateFn: (state, input) => ({
todos: [...state.todos, input.newTodo],
}),
},
formProps: {
defaultValues: { newTodo: "" },
},
errorMapProps: { joinBy: " | " },
}
);
return (
<div>
{/* action.optimisticState로 낙관적 목록 렌더링 — execute 직후 즉시 반영 */}
<ul className="mb-4 space-y-1">
{action.optimisticState.todos.map((todo, i) => (
<li key={`todo-${i}`} className="text-sm">
{todo}
</li>
))}
</ul>
<form onSubmit={handleSubmitWithAction} className="flex gap-2">
<input
{...form.register("newTodo")}
placeholder="새 할 일을 입력하세요"
className="border rounded px-3 py-2 flex-1"
/>
{form.formState.errors.newTodo && (
<p className="text-red-500 text-sm mt-1">
{form.formState.errors.newTodo.message}
</p>
)}
<button
type="submit"
disabled={action.isExecuting}
className="bg-blue-600 text-white px-4 py-2 rounded disabled:opacity-50"
>
{action.isExecuting ? "저장 중..." : "추가"}
</button>
</form>
</div>
);
}| 반환값 | 역할 |
|---|---|
form |
react-hook-form의 UseFormReturn 객체 — register, formState 등 전부 포함 |
action |
useOptimisticAction의 반환값 — isExecuting, optimisticState 등 포함 |
handleSubmitWithAction |
form.handleSubmit과 action.execute를 연결한 submit 핸들러 |
action.optimisticState를 실제 목록 렌더링에 쓰는 것이 이 훅을 선택하는 핵심 이유입니다. 이 값이 UI에 실제로 쓰이는 유일한 상태이고, execute() 호출 직후 바로 반영되기 때문에 별도의 로컬 상태 관리가 필요 없어집니다.
장단점 분석
장점
| 항목 | 내용 |
|---|---|
| 즉각적인 UX | 서버 응답 대기 없이 UI가 즉시 갱신되어 체감 속도가 크게 향상됩니다 |
| 자동 롤백 | 서버 액션 실패 시 별도 로직 없이 자동으로 원래 상태로 복원됩니다 |
| 전 구간 타입 안전성 | Zod 스키마에서 서버 액션, 훅, 폼까지 TypeScript 타입이 일관되게 추론됩니다 |
| 양측 검증 통합 | 클라이언트(즉각 피드백)와 서버(보안 검증)를 동일 스키마로 관리할 수 있습니다 |
| 오류 자동 바인딩 | 서버 validationErrors가 setError 없이 폼 필드에 자동으로 연결됩니다 |
| 보일러플레이트 감소 | useHookFormOptimisticAction 하나로 세 가지 관심사를 통합할 수 있습니다 |
단점 및 주의사항
| 항목 | 내용 | 대응 방안 |
|---|---|---|
updateFn 순수성 필수 |
렌더 사이클 중 실행되므로 사이드 이펙트, 비동기 로직 사용 불가 | 상태 계산 로직만 담은 순수 함수로 작성 |
| 낙관적/실제 상태 불일치 | 서버에서 다른 값이 반환될 경우 짧은 깜빡임(flicker) 발생 가능 | currentState와 updateFn 결과가 최대한 일치하도록 설계 |
| Server Component 전달 구조 | currentState를 서버 컴포넌트에서 클라이언트로 props로 전달해야 함 |
서버 컴포넌트에서 fetch 후 클라이언트 컴포넌트로 주입하는 구조 유지 |
| 복잡한 낙관적 로직 한계 | 관계형 데이터나 복잡한 상태 전이에는 적합하지 않을 수 있음 | 단순 목록 추가/삭제 수준의 상태 변경에 적용 권장 |
| Next.js 의존성 | Next.js App Router와 Server Actions에 강하게 결합되어 있음 | Next.js 외 환경에서는 다른 접근 방식 검토 |
| 에러 메시지 결합 방식 | 여러 검증 오류를 joinBy로 합치므로 복잡한 오류 UI에는 별도 처리 필요 |
validationErrors를 직접 파싱하여 커스텀 UI 구성 |
실무에서 가장 흔한 실수
-
updateFn안에 비동기 로직이나 외부 상태 변경을 넣는 경우 — 렌더 중에 실행되기 때문에 예측 불가능한 버그로 이어집니다.updateFn은(state, input) => newState형태의 순수 계산 함수여야 합니다. -
v6 API 시그니처를 v7에서 그대로 사용하는 경우 — v7에서 두 번째 인수가 평탄한 파라미터에서 옵션 객체로 변경되었습니다. 기존 코드를 마이그레이션할 때 공식 문서의 v7 릴리스 노트를 확인하는 것을 권장합니다.
-
currentState를 클라이언트에서 직접 fetch하는 경우 —useOptimisticAction의 롤백 기준이 되는currentState가 실제 서버 데이터와 동기화되지 않으면 롤백 후 상태가 어긋납니다.currentState는 반드시 서버 컴포넌트에서 props로 내려오는 데이터를 기반으로 해야 합니다.
마치며
지금 바로 시작해볼 수 있는 3단계가 있습니다.
-
의존성을 추가하는 것부터 시작할 수 있습니다.
pnpm add next-safe-action react-hook-form @hookform/resolvers zod @next-safe-action/adapter-react-hook-form를 실행하고,lib/safe-action.ts에createSafeActionClient()를 호출해actionClient를 export해 두면 이후 작업이 한결 편해집니다. -
기존에 사용 중인 Server Action 하나를 골라
useOptimisticAction으로 전환해보는 것을 권장합니다. 할 일 목록, 좋아요 버튼, 댓글 추가처럼 상태 변화가 단순한 곳부터 적용해보시면 개념이 빠르게 잡힙니다. -
서버 검증 오류가 있는 폼에
useHookFormActionErrorMapper를 붙여보시면 좋습니다. 기존에 수동으로 처리하던 서버 오류 바인딩 로직이 사라지고, 클라이언트·서버 검증 오류가 동일한 폼 상태로 관리되는 경험을 해볼 수 있습니다.
useOptimisticAction, react-hook-form, next-safe-action의 조합은 Next.js App Router 환경에서 서버 액션 기반 폼의 사용자 경험과 타입 안전성을 동시에 끌어올리는 실용적인 선택입니다.
참고 자료
공식 문서
- next-safe-action 공식 문서
- useOptimisticAction() API | next-safe-action
- Optimistic Updates 가이드 | next-safe-action
- React Hook Form 통합 | next-safe-action
- useOptimistic – React 공식 문서
패키지
- @next-safe-action/adapter-react-hook-form | NPM
- adapter-react-hook-form | GitHub
- next-safe-action | GitHub
참고 아티클