React 19 낙관적 UI: useOptimistic + Server Actions로 폼 제출·에러·자동 롤백을 선언적으로 처리하기 (Next.js 15 App Router)
지금도 낙관적 UI를 직접 관리하고 있다면 이 글이 시간을 아껴줄 것입니다. 저도 한동안 useState로 로컬 상태를 따로 만들고 try/catch 안에서 수동 롤백 로직을 짜던 시절이 있었습니다. 코드가 쌓일수록 "이 loading 상태는 어디서 온 거지?", "에러 났을 때 이 임시 항목은 누가 지우는 거지?" 같은 질문이 꼬리를 물었고, 결국 상태 관리 코드가 비즈니스 로직보다 더 많아지는 기묘한 경험을 했습니다.
React 19가 stable로 출시되면서 이 문제를 꽤 우아하게 풀었습니다. useOptimistic, useActionState, useFormStatus, 그리고 Server Actions의 조합이 폼 제출 → 낙관적 업데이트 → 에러 복구 → 자동 롤백을 선언적으로 처리하는 표준 패턴으로 자리잡았고, Next.js 15 App Router 환경에서는 이 패턴이 별도 API Route조차 필요 없게 만들어 줍니다. 이 글을 읽고 나면 Server Action 하나만으로 낙관적 업데이트, 에러 표시, 자동 롤백을 동시에 처리하는 코드를 작성할 수 있게 될 것입니다.
이 글은 React 훅 기초와 Next.js App Router를 어느 정도 써본 중급 프론트엔드 개발자를 대상으로 합니다. 'use server'와 'use client'가 낯설다면 한 문장만 짚고 넘어가겠습니다 — 이 두 디렉티브는 Next.js App Router에서 코드가 서버에서 실행될지 클라이언트 브라우저에서 실행될지를 명시하는 경계선입니다. 이 경계를 이해하면 아래 패턴이 훨씬 자연스럽게 읽힙니다.
핵심 개념
낙관적 UI가 왜 필요한가
네트워크 왕복이 발생하는 모든 뮤테이션에서 "서버 응답이 올 때까지 UI가 멈춰있는" 경험은 사용자에게 앱이 느리다는 인상을 줍니다. 댓글 달기, 좋아요, 할 일 추가처럼 실패할 가능성이 낮은 작업이라면 더욱 그렇습니다.
낙관적 UI는 이 가정을 뒤집습니다. "일단 성공했다고 치고 UI를 먼저 바꿔놓고, 정말로 실패하면 그때 되돌리자"는 전략입니다. 체감 성능이 크게 올라가는 대신, 실패 시 롤백 처리라는 숙제가 따라옵니다. React 19 이전에는 이 숙제를 개발자가 직접 풀어야 했지만, 이제는 상당 부분을 프레임워크가 가져갔습니다.
네 가지 API 역할 분담
이 API들을 처음 보면 역할이 겹쳐 보입니다. 실무에서도 처음 이 패턴을 접했을 때 어떤 훅이 어떤 역할을 하는지 한참 헷갈렸는데, 명확하게 정리해두면 좋습니다.
| API | 출처 | 역할 |
|---|---|---|
useOptimistic |
react |
비동기 작업 중 낙관적 상태 생성, 완료/실패 시 자동 복원 |
useActionState |
react |
Server Action 반환값과 pending 상태 구독 (구 useFormState 대체) |
useFormStatus |
react-dom |
가장 가까운 부모 <form>의 제출 상태 추적 |
| Server Actions | Next.js / React | 'use server' 함수, 내부적으로 HTTP POST로 호출됨 |
useFormState→useActionState마이그레이션: React 19에서useFormState는useActionState로 통합되었습니다. 기존 코드베이스를 운영 중이라면react패키지의useActionState로 교체하는 것을 권장합니다. API 시그니처는 거의 동일합니다.
useOptimistic의 동작 원리
const [optimisticState, addOptimistic] = useOptimistic(
state, // 실제 상태 (서버/부모에서 내려오는 ground truth)
(currentState, action) => nextOptimisticState // 순수 함수
);두 번째 인자의 함수가 핵심입니다. addOptimistic(action)을 호출하는 순간 React는 이 함수를 즉시 실행해서 낙관적 상태를 만들고 렌더링에 반영합니다.
비동기 작업이 끝나면 — 성공이든 실패든 — React는 첫 번째 인자 state(실제 상태)로 자동 복원합니다. 성공 케이스에서는 revalidatePath가 서버 캐시를 퍼지하고, Next.js가 부모 서버 컴포넌트를 다시 렌더링해서 갱신된 데이터를 props로 내려줍니다. 그 결과 initialComments가 새 값으로 교체되는 식입니다. 실패 케이스에서는 아무 작업 없이 원래 state로 돌아옵니다. 개발자가 직접 롤백 코드를 작성할 필요가 없다는 뜻입니다.
자동 롤백의 의미:
useOptimistic의 낙관적 상태는 비동기 작업 범위 안에서만 유지되는 임시 뷰입니다. 작업이 끝나면 React가 항상 실제 상태로 덮어씁니다.
한 가지 더 짚어두면 — React 19에서는 <form action={asyncFn}> 형태로 비동기 함수를 폼 액션에 직접 연결하면, React가 암묵적으로 트랜지션 컨텍스트 안에서 실행해줍니다. 덕분에 useTransition과 startTransition을 별도로 쓰지 않아도 addOptimistic이 올바르게 동작합니다. 단, 폼이 아닌 버튼 클릭 이벤트에서 직접 호출할 때는 startTransition으로 명시적으로 감싸는 것이 안전합니다.
실전 적용
예시 1: 댓글 추가 폼 — 기본 패턴
가장 전형적인 시나리오입니다. 댓글을 작성하면 서버 응답 전에 목록에 즉시 나타나고, 실패하면 사라지는 흐름입니다.
먼저 Server Action을 정의합니다.
// app/actions.ts
'use server'
import { revalidatePath } from 'next/cache';
type CommentActionState =
| { error: string }
| { success: true; comment: { id: number; content: string; createdAt: string } }
| null;
export async function addComment(
prevState: CommentActionState,
formData: FormData
): Promise<CommentActionState> {
const content = formData.get('content') as string;
if (!content.trim()) {
return { error: '내용을 입력해주세요.' };
}
try {
// Prisma ORM 사용 가정
const comment = await db.comment.create({
data: { content, createdAt: new Date() },
});
revalidatePath('/post/[id]', 'page');
return {
success: true,
comment: { ...comment, createdAt: comment.createdAt.toISOString() },
};
} catch {
return { error: '댓글 저장에 실패했습니다. 잠시 후 다시 시도해주세요.' };
}
}prevState 타입을 any 대신 CommentActionState로 명시했습니다. TypeScript strict mode에서 Server Action 입출력 타입이 end-to-end로 추론되려면 이 부분이 정확해야 합니다. Date 객체를 직접 반환하지 않고 .toISOString()으로 변환한 것도 같은 이유입니다 — React의 직렬화 제약 때문입니다.
클라이언트 컴포넌트에서 조합합니다.
// app/components/CommentForm.tsx
'use client'
import { useOptimistic, useActionState } from 'react';
import { useFormStatus } from 'react-dom';
import { addComment } from '../actions';
type Comment = { id: number | string; content: string; pending?: boolean };
export function CommentForm({ initialComments }: { initialComments: Comment[] }) {
const [optimisticComments, addOptimisticComment] = useOptimistic(
initialComments,
(state: Comment[], newComment: Comment) => [
...state,
{ ...newComment, pending: true },
]
);
const [state, formAction, isPending] = useActionState(addComment, null);
async function handleSubmit(formData: FormData) {
const content = formData.get('content') as string;
// 빠른 연속 제출이 있는 프로덕션 환경에서는 crypto.randomUUID() 권장
addOptimisticComment({ id: Date.now(), content });
await formAction(formData);
}
return (
<>
<ul>
{optimisticComments.map((c) => (
<li
key={c.id}
style={{ opacity: c.pending ? 0.5 : 1, transition: 'opacity 0.2s' }}
>
{c.content}
{c.pending && <span aria-label="저장 중"> ⏳</span>}
</li>
))}
</ul>
<form action={handleSubmit}>
<input name="content" placeholder="댓글을 입력하세요" />
<SubmitButton />
{state?.error && (
<p role="alert" style={{ color: 'red' }}>
{state.error}
</p>
)}
</form>
</>
);
}
function SubmitButton() {
const { pending } = useFormStatus();
return (
<button type="submit" disabled={pending}>
{pending ? '저장 중...' : '댓글 달기'}
</button>
);
}SubmitButton이 별도 컴포넌트로 분리된 이유가 있습니다. useFormStatus는 반드시 <form>의 자식 컴포넌트에서 호출해야 합니다. 같은 컴포넌트 안의 <form> 위에서 호출하면 항상 pending: false를 반환합니다. 저도 처음엔 이 점에서 한참 헤맸습니다.
성공 후 폼 입력 필드 초기화가 필요한 경우, <form key={isPending ? 'submitting' : 'idle'}> 패턴으로 폼을 강제 리마운트하거나 ref.current.reset()을 useEffect 안에서 호출하는 방식이 있습니다. 에러 시에는 입력값이 유지되어야 사용자 경험상 더 좋으므로, 성공 케이스에서만 리셋하는 로직을 추가하는 것을 권장합니다.
실무에서 가장 흔한 실수
코드를 방금 본 시점에서 바로 대조해볼 수 있도록 짚고 넘어가겠습니다.
1. useFormStatus를 폼과 같은 컴포넌트에서 호출하는 경우
useFormStatus는 반드시 <form> 내부의 자식 컴포넌트에서만 동작합니다. 같은 컴포넌트 안에서 <form>을 렌더링하면서 그 위에서 useFormStatus를 호출하면 항상 pending: false를 반환합니다. 위 예시처럼 SubmitButton을 별도 컴포넌트로 분리해야 올바르게 동작합니다.
2. addOptimistic이 트랜지션 컨텍스트 밖에서 호출되는 경우
form action={asyncFn} 패턴은 React 19가 암묵적으로 트랜지션을 설정해주기 때문에 별도 startTransition 없이도 동작합니다. 하지만 폼이 아닌 버튼 클릭 이벤트에서 직접 addOptimisticComment를 호출한다면 startTransition으로 감싸는 것이 안전합니다.
3. Server Action 반환값에 직렬화 불가 타입을 담는 경우
Date 객체를 그대로 반환하면 클라이언트에서 직렬화 오류가 발생합니다. .toISOString()으로 변환하거나, 반환 타입을 명시적으로 정의해두면 TypeScript가 컴파일 타임에 잡아줍니다. prevState: any로 넘어가면 이 보호막이 사라지기 때문에 타입 정의를 꼼꼼히 해두는 것을 권장합니다.
예시 2: 프로덕션 환경 — next-safe-action 조합
솔직히 예시 1의 패턴만으로도 동작은 하지만, 실제 서비스에서는 인증 체크, Zod 스키마 검증, 에러 로깅이 빠지면 안 됩니다. 이걸 매 Server Action마다 반복해서 작성하다 보면 금세 보일러플레이트가 쌓입니다. next-safe-action이 이 문제를 깔끔하게 해결해줍니다 — 인증 미들웨어와 Zod 검증을 한번만 선언해두면 모든 Server Action에 자동으로 적용됩니다. 이 라이브러리를 도입하고 나서야 Server Actions 패턴이 진짜 편하다는 걸 체감했습니다.
// lib/safe-action.ts
import { createSafeActionClient } from 'next-safe-action';
import { getServerSession } from 'next-auth';
export const authActionClient = createSafeActionClient().use(async ({ next }) => {
const session = await getServerSession();
if (!session?.user) throw new Error('Unauthorized');
return next({ ctx: { userId: session.user.id } });
});// app/todos/actions.ts
'use server'
import { z } from 'zod';
import { revalidatePath } from 'next/cache';
import { authActionClient } from '@/lib/safe-action';
interface Todo {
id: number;
title: string;
completed: boolean;
userId: string;
}
export const createTodoAction = authActionClient
.schema(z.object({ title: z.string().min(1, '제목을 입력해주세요.') }))
.action(async ({ parsedInput, ctx }) => {
// Prisma ORM 사용 가정
const todo: Todo = await db.todo.create({
data: { title: parsedInput.title, userId: ctx.userId },
});
revalidatePath('/todos');
return todo;
});// app/todos/TodoList.tsx
'use client'
import { useOptimisticAction } from 'next-safe-action/hooks';
import { createTodoAction } from './actions';
interface Todo {
id: number;
title: string;
completed: boolean;
pending?: boolean;
}
export function TodoList({ todos }: { todos: Todo[] }) {
const { execute, optimisticState, isPending } = useOptimisticAction(
createTodoAction,
{
currentState: { todos },
updateFn: (state, input) => ({
todos: [
...state.todos,
{ id: -1, title: input.title, completed: false, pending: true },
],
}),
}
);
function handleAdd(formData: FormData) {
const title = formData.get('title') as string;
execute({ title });
}
return (
<>
<ul>
{optimisticState.todos.map((todo) => (
<li key={todo.id} style={{ opacity: todo.pending ? 0.5 : 1 }}>
{todo.title}
</li>
))}
</ul>
<form action={handleAdd}>
<input name="title" placeholder="할 일 추가" />
<button type="submit" disabled={isPending}>추가</button>
</form>
</>
);
}useOptimisticAction은 useOptimistic + useActionState + 에러 처리를 하나의 훅으로 래핑한 것입니다. 직접 조합하는 방식보다 코드가 간결해지고, Zod 검증 에러도 result.validationErrors로 자동 분류됩니다.
장단점 분석
장점
| 항목 | 내용 |
|---|---|
| 즉각적인 피드백 | 네트워크 왕복 없이 UI가 즉시 반응, 체감 성능 대폭 향상 |
| 자동 롤백 | Server Action 실패 시 React가 이전 상태로 자동 복원, 수동 롤백 코드 불필요 |
| 선언적 에러 처리 | useActionState 반환값으로 에러 메시지를 UI에 직접 바인딩 |
| Progressive Enhancement | JS 비활성 환경에서도 네이티브 HTML 폼으로 동작 |
| 타입 안전성 | TypeScript strict mode에서 Server Action 입출력 타입이 end-to-end 추론 |
| 코드 간소화 | useActionState 하나로 기존 useState 3~4개를 대체, 별도 API Route와 fetch 호출 불필요 |
단점 및 주의사항
| 항목 | 내용 | 대응 방안 |
|---|---|---|
| 직렬화 제약 | Server Action 인수/반환값은 React 직렬화 가능 타입이어야 함 | Date는 ISO 문자열로, Map/Set은 배열로 변환 후 전달 |
| 페이로드 크기 제한 | 5MB 이상 페이로드는 타임아웃 가능 | 파일 업로드는 Presigned URL 방식으로 분리 처리 |
| HTTP POST 전용 | Server Actions는 항상 POST로 호출됨 | GET 기반 검색/조회는 searchParams나 Route Handlers 사용 |
| 복잡한 롤백 시나리오 | 부분 성공, 낙관적 충돌 해결은 자동 롤백으로 처리 불가 | 서버 응답 기반 수동 상태 조정, 또는 TanStack Query의 onMutate 패턴 활용 |
| 알려진 버그 | React 19 초기 버전에서 이유 없이 롤백되는 이슈 보고됨 (Issue #31967) | 최신 패치 버전 사용 권장 |
| 클라이언트 컴포넌트 필수 | useOptimistic은 Client Component에서만 사용 가능 |
'use client' 경계를 명확히 설계, 서버 컴포넌트를 부모로 유지 |
| revalidation 비용 | revalidatePath 호출은 서버 캐시를 퍼지함 |
경로 범위를 최소화, 가능하면 revalidateTag로 세밀하게 제어 |
실제로 가장 자주 맞닥뜨리는 제약은 직렬화 문제였습니다. Prisma에서 Date 타입이 그대로 반환되는 경우를 미처 생각하지 못하고 런타임 에러를 만난 적이 한두 번이 아니었는데, 반환 타입을 명시적으로 정의하는 습관이 생기고 나서야 이 문제가 눈에 띄게 줄었습니다.
revalidateTag:revalidatePath가 특정 경로 전체의 캐시를 무효화한다면,revalidateTag는 사전에 태그를 붙인 fetch 요청만 선택적으로 무효화합니다. 세밀한 캐시 제어가 필요할 때 유용합니다.
마치며
useOptimistic과 Server Actions의 조합은 낙관적 UI 구현에 필요했던 보일러플레이트 대부분을 제거하고, useActionState 하나로 기존 useState 3~4개를 대체할 수 있게 해줍니다. 이 패턴이 익숙해지면 "서버 응답 기다리는 동안 뭘 보여줄까"가 아니라 "어떤 상호작용을 만들까"를 먼저 생각하게 됩니다.
지금 바로 시작해볼 수 있는 3단계:
- 기존 프로젝트 폼 하나를 골라 Server Action으로 전환해볼 수 있습니다 —
app/actions.ts를 만들고'use server'async 함수로 옮기면,useActionState연결만으로loading/error관리용useState3~4개가 사라지는 것을 바로 체감할 수 있습니다. useOptimistic을 붙여서 낙관적 업데이트를 추가해볼 수 있습니다 — 실패 케이스를 확인하고 싶다면 Server Action 안에if (Math.random() > 0.5) throw new Error('테스트 오류')를 추가해서 롤백이 자동으로 동작하는지 눈으로 확인해보시면 좋습니다.- 프로덕션 서비스라면
next-safe-action도입을 검토해볼 수 있습니다 —pnpm add next-safe-action zod로 설치하고 인증 미들웨어와 Zod 스키마를 연결하면 타입 안전성과 보안이 한번에 확보됩니다.
참고 자료
- useOptimistic – React 공식 문서
- useActionState – React 공식 문서
- React v19 공식 릴리즈 노트
- Next.js 공식: Forms and Server Actions 가이드
- next-safe-action 공식 문서 – useOptimisticAction()
- React 19 useOptimistic Deep Dive — DEV Community
- React 19 useOptimistic: The Complete Guide for Production SaaS — DEV Community
- How to Use the Optimistic UI Pattern with the useOptimistic() Hook — freeCodeCamp
- Building an Optimistic UI Task Management App with React 19 and Next.js — Syncfusion
- React 19 useOptimistic 알려진 버그 Issue #31967 — GitHub
- Next.js Server Actions: The Complete Guide (2026) — makerkit.dev