Next.js Server Actions와 useOptimistic으로 낙관적 UI 구현하기: 폼·뮤테이션·롤백 실전 패턴

네트워크 응답을 기다리는 동안, 사용자는 이미 다음 행동으로 넘어간다
우리 프론트엔드 개발자들은 폼 제출 버튼을 누른 후 스피너가 돌아가는 화면을 얼마나 오래 봐왔습니까. 사용자 입장에서는 이미 의도가 확실한 행동을 했는데 UI가 먼저 반응하지 않는 것이 오히려 어색합니다.
낙관적 UI(Optimistic UI)는 이 문제를 다른 방향으로 풉니다. 서버 응답이 오기 전에 요청이 성공했다고 가정하고 UI를 먼저 업데이트한 뒤, 실제로 실패하면 이전 상태로 롤백하는 패턴입니다.
Next.js 13 App Router가 등장하면서 이 패턴을 구현하는 방식이 크게 바뀌었습니다. 별도의 API 라우트를 만들고 fetch를 직접 호출하던 방식에서, Server Actions와 useOptimistic을 조합하는 방식으로 이동했습니다.
1. Server Actions 동작 원리
Server Actions는 Next.js가 React의 서버 컴포넌트 모델 위에 구축한 폼·뮤테이션 레이어입니다. 'use server' 디렉티브를 함수 또는 파일 상단에 선언하면, 해당 함수는 클라이언트에서 직접 호출할 수 있는 서버 측 엔드포인트로 변환됩니다.
공식 문서(Next.js Server Actions and Mutations)에 따르면, Server Action은 네트워크 경계를 자동으로 처리합니다. 빌드 타임에 Next.js가 해당 함수를 별도의 HTTP POST 엔드포인트로 분리하고, 클라이언트 번들에는 그 엔드포인트를 호출하는 스텁만 포함됩니다.
'use server' 경계가 만드는 분리는 물리적입니다. 해당 함수 안에서 process.env, fs, ORM 쿼리 등 서버 전용 API를 자유롭게 사용할 수 있습니다. 반대로 이 함수 안에서 window, document, React 훅은 접근할 수 없습니다.
2. form action과 Progressive Enhancement
HTML <form>의 action 속성에 Server Action을 직접 전달하면 JavaScript 없이도 폼 제출이 작동합니다.
// app/reviews/actions.ts
'use server';
import { db } from '@/lib/db';
import { revalidatePath } from 'next/cache';
export async function submitReview(formData: FormData) {
const productId = formData.get('productId') as string;
const rating = Number(formData.get('rating'));
const content = formData.get('content') as string;
if (!productId || isNaN(rating) || rating < 1 || rating > 5 || !content.trim()) {
return { error: '입력값이 올바르지 않습니다.' };
}
const review = await db.review.create({
data: { productId, rating, content },
});
revalidatePath(`/products/${productId}`);
return { success: true, reviewId: review.id };
}
'use client';
import { useActionState } from 'react';
import { submitReview } from './actions';
export function ReviewForm({ productId }: { productId: string }) {
const [state, formAction, isPending] = useActionState(submitReview, null);
return (
<form action={formAction}>
<input type="hidden" name="productId" value={productId} />
<fieldset disabled={isPending}>
<select name="rating" required>
{[5, 4, 3, 2, 1].map((v) => <option key={v} value={v}>{v}점</option>)}
</select>
<textarea name="content" minLength={10} maxLength={500} required />
<button type="submit">{isPending ? '제출 중...' : '리뷰 등록'}</button>
</fieldset>
{state?.error && <p role="alert">{state.error}</p>}
{state?.success && <p role="status">리뷰가 등록되었습니다.</p>}
</form>
);
}
Progressive Enhancement가 실질적으로 의미 있는 이유는 두 가지입니다. 첫째, 느린 네트워크에서 JavaScript 파싱이 완료되기 전에도 폼이 동작합니다. 둘째, 서버 측 렌더링 환경에서 Hydration이 완료되기 전 사용자가 폼을 제출해도 요청이 손실되지 않습니다.
3. useOptimistic Hook 기본 사용법
useOptimistic은 React 19에서 안정화된 훅입니다.
'use client';
import { useOptimistic, useTransition } from 'react';
import { submitReview } from '../actions';
interface Review {
id: string;
rating: number;
content: string;
author: string;
createdAt: string;
}
export function ReviewList({ productId, initialReviews }: {
productId: string;
initialReviews: Review[];
}) {
const [isPending, startTransition] = useTransition();
const [optimisticReviews, addOptimisticReview] = useOptimistic(
initialReviews,
(currentReviews: Review[], newReview: Review) => [newReview, ...currentReviews]
);
async function handleSubmit(formData: FormData) {
const tempReview: Review = {
id: `temp-${Date.now()}`,
rating: Number(formData.get('rating')),
content: formData.get('content') as string,
author: '나',
createdAt: new Date().toISOString(),
};
startTransition(async () => {
addOptimisticReview(tempReview);
const result = await submitReview(formData);
if (result.error) {
console.error(result.error);
}
});
}
return (
<ul>
{optimisticReviews.map((review) => (
<li
key={review.id}
style={{ opacity: review.id.startsWith('temp-') ? 0.6 : 1 }}
>
<strong>{review.rating}점</strong>
<p>{review.content}</p>
</li>
))}
</ul>
);
}
useOptimistic의 두 번째 인수는 리듀서 함수입니다. Transition이 완료되면 useOptimistic은 두 번째 인수인 initialReviews로 자동 복귀합니다. useTransition의 동작 원리에 대한 자세한 내용은 React useTransition과 Concurrent 렌더링 실전 패턴에서 다루고 있습니다.
4. 뮤테이션 성공·실패 롤백 패턴
낙관적 업데이트의 가장 큰 과제는 실패 처리입니다.
useOptimistic의 롤백은 자동입니다. 단, 롤백이 사용자에게 눈에 띄지 않으려면 다음 두 조건이 필요합니다.
첫째, initialState가 실제로 최신 서버 상태를 반영해야 합니다. 성공 시에는 revalidatePath나 revalidateTag가 캐시를 무효화해 최신 데이터가 prop으로 흘러내려오도록 해야 합니다.
둘째, 실패 원인을 사용자에게 반드시 알려야 합니다. 롤백만 일어나고 왜 원래대로 돌아왔는지 모르면 사용자는 자신의 행동이 씹혔다고 느낍니다.
에러 경계 설계 패턴은 React Error Boundary와 Suspense Fallback 패턴에서 더 자세히 다루고 있습니다.
5. 낙관적 업데이트와 캐시 무효화 조합
'use server';
import { db } from '@/lib/db';
import { revalidateTag } from 'next/cache';
import { getServerSession } from 'next-auth';
export async function likeReview(reviewId: string) {
const session = await getServerSession();
if (!session?.user?.id) {
return { error: '로그인이 필요합니다.' };
}
try {
const existing = await db.reviewLike.findUnique({
where: {
userId_reviewId: {
userId: session.user.id,
reviewId,
},
},
});
if (existing) {
await db.reviewLike.delete({
where: { userId_reviewId: { userId: session.user.id, reviewId } },
});
return { liked: false };
}
await db.reviewLike.create({
data: { userId: session.user.id, reviewId },
});
revalidateTag(`review-${reviewId}-likes`);
return { liked: true };
} catch (err) {
console.error('좋아요 처리 실패:', err);
return { error: '서버 오류가 발생했습니다.' };
}
}
낙관적 업데이트와 캐시 무효화의 실행 순서: 사용자 클릭 → addOptimistic* 호출 → Server Action 호출 → revalidateTag 실행 → 서버 컴포넌트 재렌더링 → 새 initialState 전달 → useOptimistic이 새 initialState로 복귀.
6. 중복 제출 방지(useFormStatus)
useFormStatus는 가장 가까운 부모 <form>의 제출 상태를 구독하는 훅입니다.
'use client';
import { useFormStatus } from 'react-dom';
export function SubmitButton({ label, pendingLabel }: {
label: string;
pendingLabel: string;
}) {
const { pending } = useFormStatus();
return (
<button
type="submit"
disabled={pending}
aria-disabled={pending}
aria-busy={pending}
>
{pending ? pendingLabel : label}
</button>
);
}
useFormStatus가 같은 컴포넌트 안에서 선언되면 동작하지 않습니다. 이 훅은 부모 트리에 있는 <form>의 상태를 읽습니다.
7. 에러 상태 UX 설계
낙관적 UI에서 에러 처리는 세 단계를 구분해야 합니다.
| 실패 유형 | 처리 위치 | UX 결과 |
|---|---|---|
| 클라이언트 검증 | startTransition 이전 | 낙관적 업데이트 없음, 인라인 에러 표시 |
| 서버 비즈니스 로직 | Server Action 반환값 확인 | 자동 롤백 + 토스트/인라인 에러 |
| 네트워크/인프라 | React 에러 경계 | 에러 경계 fallback 표시 + 재시도 버튼 |
8. 파일 업로드 패턴
Next.js의 기본 요청 본문 크기 제한은 next.config.js의 serverActions.bodySizeLimit으로 조정합니다. 기본값은 1MB입니다. 이미지·PDF 등을 서버로 직접 전송하기보다 클라이언트에서 Pre-signed URL을 받아 스토리지 서비스에 직접 업로드하고, Server Action에는 업로드 완료 후 생성된 URL만 전달하는 패턴이 효율적입니다.
낙관적 UI 관점에서 파일 업로드는 URL.createObjectURL(file)을 사용해 로컬 미리보기를 먼저 표시하고, 업로드가 완료된 후 서버 URL로 교체하는 방식을 씁니다.
9. revalidatePath vs revalidateTag
revalidatePath는 특정 URL 경로의 캐시를 무효화합니다.
revalidateTag는 fetch 호출에 태그를 붙이고 그 태그로 캐시를 선택적으로 무효화합니다.
const reviews = await fetch(`/api/reviews?productId=${id}`, {
next: { tags: [`product-${id}-reviews`] },
}).then((r) => r.json());
revalidateTag(`product-${id}-reviews`);
| 구분 | revalidatePath | revalidateTag |
|---|---|---|
| 무효화 단위 | URL 경로 전체 | 특정 태그가 붙은 fetch 캐시 항목 |
| 세밀도 | 낮음 | 높음 |
| 적합한 상황 | 경로 전체 데이터가 바뀔 때 | 특정 데이터만 갱신할 때 |
10. 보안 고려사항: CSRF, 권한 검증
Server Actions는 POST 요청을 자동으로 사용하고 Next.js가 CSRF 토큰을 자동으로 처리합니다. Next.js는 Same-Origin 요청만 허용하도록 Origin 헤더를 검증합니다.
Server Action 안에서 반드시 해야 할 보안 검증은 세 가지입니다.
인증 확인은 모든 뮤테이션 Server Action의 첫 번째 줄에서 수행합니다.
소유권 검증은 "인증된 사용자"와 "해당 데이터를 수정할 권한이 있는 사용자"를 구분합니다.
입력값 검증은 클라이언트와 서버 모두에서 수행합니다. 서버 검증이 실제 보안 방어선입니다.
레이트 리미팅도 고려해야 합니다.
결론
- Server Action 인증·권한 검증: 모든 뮤테이션 Action의 첫 번째 로직이
getServerSession()+ 소유권 DB 조회임을 확인한다. - useOptimistic + startTransition 쌍 사용:
addOptimistic*함수는 반드시startTransition블록 안에서 호출한다. - 클라이언트·서버 이중 검증: 폼의 입력값 스키마를 클라이언트 제출 전과 Server Action 안에서 각각 독립적으로 실행한다.
- revalidateTag 우선, revalidatePath는 최후 수단: 무효화 범위를 최소화해 불필요한 재렌더링을 막는다.
- 에러 유형별 UX 분기 명시: 클라이언트 검증 실패, 서버 비즈니스 에러, 네트워크 에러의 세 경로를 컴포넌트 설계 단계에서 구분한다.