Next.js Server Actions와 useOptimistic으로 낙관적 UI 구현하기: 폼·뮤테이션·롤백 패턴 정리
핵심 요약
이 글에서 확인할 내용
네트워크 응답을 기다리는 동안, 사용자는 이미 다음 행동으로 넘어간다 클라이언트 개발자들은 폼 제출 버튼을 누른 후 스피너가 돌아가는 화면을 얼마나 오래 봐왔습니까. 사용자 입장에서는 이미 의도가 확실한 행동을 했는데 UI가 먼저 반응하지 않는 것이 오히려 어색합니다. 낙관적 UI(Optimistic UI)는 이 문제를 다른 방향으로 풉니다.
- 1. Server Actions 동작 원리
- 2. form action과 Progressive Enhancement
- 3. useOptimistic Hook 기본 사용법
네트워크 응답을 기다리는 동안, 사용자는 이미 다음 행동으로 넘어간다
클라이언트 개발자들은 폼 제출 버튼을 누른 후 스피너가 돌아가는 화면을 얼마나 오래 봐왔습니까. 사용자 입장에서는 이미 의도가 확실한 행동을 했는데 UI가 먼저 반응하지 않는 것이 오히려 어색합니다.
낙관적 UI(Optimistic UI)는 이 문제를 다른 방향으로 풉니다. 서버 응답이 오기 전에 요청이 성공했다고 가정하고 UI를 먼저 업데이트한 뒤, 실제로 실패하면 이전 상태로 롤백하는 패턴입니다.
핵심은 체감 지연(perceived latency) 을 줄이는 것입니다. 실제 네트워크 왕복 시간(RTT)은 그대로지만, 사용자가 인지하는 지연은 0에 가까워집니다. 사람은 100ms 이내에 반응하는 인터페이스를 "즉각적"이라고 느끼고, 1초를 넘어가면 흐름이 끊겼다고 느낍니다. 좋아요 버튼, 별점, 댓글, 장바구니 담기처럼 결과가 뻔하고 실패 확률이 낮은 조작에서 낙관적 UI가 특히 효과적인 이유가 여기 있습니다. 대부분의 경우 요청은 성공하고, 드물게 실패할 때만 롤백을 감수하면 되기 때문입니다. 우리는 "성공을 기본값으로 두고 실패를 예외로 처리한다"는 관점의 전환을 하는 셈입니다.
Next.js 13 App Router가 등장하면서 이 패턴을 구현하는 방식이 크게 바뀌었습니다. 별도의 API 라우트를 만들고 fetch를 직접 호출하던 방식에서, Server Actions와 useOptimistic을 조합하는 방식으로 이동했습니다. App Router로의 전환 자체가 처음이라면 Next.js 14 App Router 도입기: 변화와 적응에서 서버·클라이언트 컴포넌트 경계의 기초를 먼저 정리하고 오는 편이 이 글을 읽기에 수월합니다.
과거 방식에서 낙관적 UI를 구현하려면 세 가지를 손으로 관리해야 했습니다. 서버로 보낼 요청, 낙관적으로 갱신한 로컬 상태, 그리고 실패 시 되돌릴 이전 상태 스냅샷입니다. React Query나 SWR 같은 라이브러리가 이 세 가지를 onMutate/onError/onSettled 콜백으로 묶어 관리했지만, 서버 상태와 클라이언트 상태를 이중으로 유지한다는 본질은 그대로였습니다. Server Actions와 useOptimistic은 이 이중화를 프레임워크 레벨에서 흡수합니다. 서버 뮤테이션 이후 캐시를 무효화하면 최신 서버 상태가 prop으로 다시 흘러내려오고, 낙관적 상태는 그 위에 잠깐 얹혔다 사라지는 임시 레이어가 됩니다.
1. Server Actions 동작 원리
Server Actions는 Next.js가 React의 서버 컴포넌트 모델 위에 구축한 폼·뮤테이션 레이어입니다. 'use server' 디렉티브를 함수 또는 파일 상단에 선언하면, 해당 함수는 클라이언트에서 직접 호출할 수 있는 서버 측 엔드포인트로 변환됩니다.
공식 문서(Next.js Server Actions and Mutations)에 따르면, Server Action은 네트워크 경계를 자동으로 처리합니다. 빌드 타임에 Next.js가 해당 함수를 별도의 HTTP POST 엔드포인트로 분리하고, 클라이언트 번들에는 그 엔드포인트를 호출하는 스텁만 포함됩니다.
이 구조를 정확히 이해하려면 Server Action을 타입 안전한 RPC(원격 프로시저 호출) 로 바라보는 것이 좋습니다. 우리가 클라이언트에서 submitReview(formData)를 호출하면, 실제로 그 자리에서 함수 본문이 실행되는 게 아닙니다. 빌드 타임에 Next.js가 각 액션에 고유한 액션 ID(대개 함수 시그니처와 모듈 경로를 해싱한 값)를 부여하고, 클라이언트 번들에는 이 ID를 가리키는 참조만 남습니다. 런타임에 그 참조를 호출하면 브라우저는 현재 페이지 URL로 POST 요청을 보내며, Next-Action 헤더에 액션 ID를, 본문에 직렬화된 인자를 실어 보냅니다. 서버는 ID로 실제 함수를 찾아 실행하고, 결과와 갱신된 RSC 페이로드를 함께 돌려줍니다. 우리 눈에는 함수 호출 한 줄이지만, 그 아래에서는 직렬화·전송·역직렬화·실행·재렌더링이라는 다섯 단계가 조용히 돌아가고 있는 것입니다.
이 직렬화 경계는 인자와 반환값 모두에 제약을 겁니다. Server Action에 넘길 수 있는 인자는 직렬화 가능한 값이어야 합니다. 원시 타입, 일반 객체, 배열, Date, Map, Set, FormData, 그리고 File까지는 통과하지만, 함수나 클래스 인스턴스, DOM 노드는 넘길 수 없습니다. 반환값도 마찬가지로 클라이언트로 다시 직렬화되어 내려오므로 같은 규칙을 따릅니다. 이 제약은 불편이 아니라 안전장치입니다. 어떤 데이터가 네트워크 경계를 넘나드는지를 타입 시스템과 런타임이 함께 강제하기 때문에, "실수로 서버 전용 객체를 클라이언트로 흘리는" 실수가 구조적으로 차단됩니다. 이 경계 감각은 서버 컴포넌트의 직렬화 규칙과 정확히 같은 뿌리에서 나옵니다. 배경이 궁금하다면 React 서버 컴포넌트(RSC) 시대의 상태 관리와 성능 최적화 대전망에서 페이로드 직렬화를 더 깊게 다룹니다.
'use server' 경계가 만드는 분리는 물리적입니다. 해당 함수 안에서 process.env, fs, ORM 쿼리 등 서버 전용 API를 자유롭게 사용할 수 있습니다. 반대로 이 함수 안에서 window, document, React 훅은 접근할 수 없습니다.
여기서 반드시 각인해야 할 사실이 하나 있습니다. Server Action은 프레임워크가 자동 생성한 공개 HTTP 엔드포인트입니다. "클라이언트 코드에서만 호출하니까 외부에서 접근할 수 없다"는 가정은 성립하지 않습니다. 액션 ID는 추측하기 어려운 해시지만, 브라우저 개발자 도구의 Network 탭을 열면 실제 요청에 실려 나가는 액션 ID와 페이로드 형식이 그대로 보입니다. 즉 누구든 그 ID와 형식을 알면 curl로 동일한 POST 요청을 재현할 수 있습니다. 컴포넌트가 화면에 렌더링되지 않는 상태여도, 그 액션이 번들에 포함되어 있다면 엔드포인트는 살아 있습니다. 이 사실이 뒤에서 다룰 보안 검증(10절)의 전제가 됩니다. 모든 뮤테이션 액션의 첫 줄은 인증·인가 검증이어야 한다는 원칙은 여기서 나옵니다.
인자 직렬화에는 한 가지 더 미묘한 지점이 있습니다. 인라인 Server Action이 상위 스코프의 변수를 클로저로 캡처하면, Next.js는 그 캡처된 값을 클라이언트 번들에 실어 보냈다가 호출 시점에 서버로 되돌립니다. 이 값이 그대로 노출되면 민감 정보가 새어 나갈 수 있으므로, Next.js는 클로저로 바인딩된 인자를 빌드별 키로 암호화합니다. 여러 인스턴스에 배포하거나 무중단 롤링 배포를 한다면 인스턴스마다 키가 달라 복호화가 깨질 수 있으므로, 암호화 키를 환경 변수로 고정해 모든 인스턴스가 같은 키를 쓰도록 맞춰야 합니다. 이 세부는 로컬에서는 절대 드러나지 않다가 프로덕션 다중 인스턴스 환경에서만 터지는 전형적인 함정입니다.
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이 완료되기 전 사용자가 폼을 제출해도 요청이 손실되지 않습니다.
조금 더 풀어 설명하면, <form action={serverAction}> 형태는 하이드레이션 전과 후에 서로 다른 방식으로 동작하되 결과는 동일하게 맞춰집니다. JavaScript가 아직 로드되지 않았다면 브라우저의 네이티브 폼 제출 메커니즘이 그대로 작동해 해당 액션 엔드포인트로 표준 POST 요청을 보내고, 서버는 응답으로 갱신된 페이지를 돌려줍니다. JavaScript가 로드된 뒤에는 같은 제출이 클라이언트 사이드 내비게이션과 부분 렌더링으로 업그레이드되어 전체 페이지 리로드 없이 처리됩니다. 사용자는 두 경우 모두 "버튼을 눌렀더니 리뷰가 등록됐다"는 동일한 결과를 얻습니다. 이것이 접근성과 견고함을 동시에 잡는 지점입니다. 자바스크립트 실행이 실패하는 저사양 기기, 확장 프로그램이 스크립트를 차단한 브라우저, 스크린 리더 사용자 모두에게 최소한의 기능이 보장됩니다.
여기서 낙관적 UI와 Progressive Enhancement 사이에는 미묘한 긴장이 있다는 점을 짚어야 합니다. 낙관적 업데이트는 클라이언트 사이드 JavaScript가 있어야만 가능한 향상입니다. 따라서 우리는 낙관적 UI를 "있으면 더 좋은 층"으로 설계하고, 그것이 없어도 폼이 정상 동작하도록 기반을 먼저 다져야 합니다. 낙관적 갱신을 전제로 UI 상태를 짜면, JavaScript가 없는 환경에서 화면이 깨지는 역설이 생깁니다. 기본 폼 동작을 바닥에 깔고 그 위에 낙관적 층을 얹는 순서를 지키는 것이 원칙입니다.
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 렌더링 패턴에서 다루고 있습니다.
이 훅의 내부 동작을 조금 더 정확히 이해하면 실무에서 겪는 거의 모든 혼란이 풀립니다. useOptimistic이 반환하는 첫 번째 값은 "지금 화면에 보여줄 상태"인데, 이 값은 두 요소의 합성입니다. 하나는 첫 번째 인수로 넘긴 기준 상태(base state), 즉 서버에서 내려온 실제 데이터이고, 다른 하나는 아직 완료되지 않은 트랜지션 안에서 addOptimisticReview로 쌓아 올린 낙관적 변경분입니다. React는 진행 중인 트랜지션이 있는 동안 기준 상태 위에 리듀서를 순서대로 적용해 낙관적 값을 만들어 보여주고, 트랜지션이 끝나 컴포넌트가 새 기준 상태로 다시 렌더링되면 그 낙관적 변경분을 통째로 버립니다. 즉 롤백은 "이전 값을 복원하는 별도 로직"이 아니라 "임시로 얹었던 낙관적 층을 제거하는 것"입니다. 그래서 성공이든 실패든 낙관적 값은 반드시 사라지고, 화면에 남는 것은 언제나 기준 상태입니다.
이 설계에서 두 가지 실무 규칙이 곧바로 따라 나옵니다. 첫째, addOptimistic* 함수는 반드시 트랜지션(또는 Server Action) 안에서 호출해야 합니다. 트랜지션 바깥에서 호출하면 React가 "이 낙관적 값을 언제 걷어내야 하는지"를 알 수 없기 때문에 경고를 내고 정상 동작하지 않습니다. 낙관적 값의 수명은 곧 트랜지션의 수명입니다. 둘째, 성공 후 화면이 올바르게 유지되려면 기준 상태가 실제로 갱신되어야 합니다. 리뷰를 추가했는데 서버 데이터가 그대로라면, 낙관적 값이 사라지는 순간 방금 추가한 리뷰가 화면에서 함께 사라져 "깜빡" 하는 현상이 생깁니다. 그래서 낙관적 UI는 언제나 캐시 무효화와 짝을 이뤄야 합니다. 이 부분은 5절에서 이어집니다.
임시 항목을 구분하기 위한 temp- 접두사와 opacity 처리도 단순한 장식이 아닙니다. 낙관적 항목은 아직 서버가 확정하지 않은 상태이므로, 사용자에게 "전송 중"임을 시각적으로 알리는 것이 정직합니다. 반투명 처리, 흐릿한 애니메이션, "전송 중" 배지 같은 신호는 만약 롤백이 일어났을 때 사용자가 덜 당황하게 만드는 완충 장치입니다. 낙관적 UI의 목표는 거짓말을 하는 것이 아니라, "거의 확실한 결과를 미리 보여주되 확정 전임을 숨기지 않는" 것입니다.
4. 뮤테이션 성공·실패 롤백 패턴
낙관적 업데이트의 가장 큰 과제는 실패 처리입니다.
useOptimistic의 롤백은 자동입니다. 단, 롤백이 사용자에게 눈에 띄지 않으려면 다음 두 조건이 필요합니다.
첫째, initialState가 실제로 최신 서버 상태를 반영해야 합니다. 성공 시에는 revalidatePath나 revalidateTag가 캐시를 무효화해 최신 데이터가 prop으로 흘러내려오도록 해야 합니다.
둘째, 실패 원인을 사용자에게 반드시 알려야 합니다. 롤백만 일어나고 왜 원래대로 돌아왔는지 모르면 사용자는 자신의 행동이 씹혔다고 느낍니다.
실패 처리에서 자주 놓치는 지점은 "롤백은 자동이지만 알림은 자동이 아니다"라는 사실입니다. useOptimistic은 트랜지션이 끝나면 조건 없이 낙관적 값을 버립니다. 성공이든 실패든 똑같이 버립니다. 따라서 성공과 실패를 구분해 사용자에게 다른 메시지를 보여주는 책임은 전적으로 우리에게 있습니다. Server Action이 { error }를 반환했는지, 예외를 던졌는지, 혹은 정상 반환했는지를 트랜지션 안에서 검사해 토스트나 인라인 메시지로 분기해야 합니다. 이 검사를 빠뜨리면 사용자는 리뷰가 잠깐 나타났다가 아무 설명 없이 사라지는 유령 같은 경험을 하게 됩니다. 이것이 낙관적 UI를 어설프게 도입한 서비스에서 가장 흔히 나오는 불만입니다.
또 하나 주의할 점은 실패한 낙관적 항목을 어떻게 다룰지에 대한 정책입니다. 단순히 롤백해 사라지게 할 수도 있지만, 사용자가 공들여 작성한 긴 리뷰가 통째로 날아가면 그 자체로 나쁜 경험입니다. 이럴 때는 낙관적 항목을 완전히 제거하는 대신 "전송 실패, 다시 시도" 상태로 남겨 두고 재시도 버튼을 붙이는 편이 낫습니다. 이 패턴을 쓰려면 낙관적 상태를 useOptimistic에만 맡기지 않고, 실패한 입력값을 별도 로컬 상태에 보존했다가 폼에 되채워 넣는 보완 장치가 필요합니다. 낙관적 UI는 "성공 확률이 높을 때" 빛나지만, 실패했을 때 사용자의 노동을 지켜 주는 설계까지 갖춰야 비로소 완성됩니다.
에러 경계 설계 패턴은 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로 복귀.
이 흐름에서 가장 중요한 감각은 "낙관적 상태와 서버 상태가 매끄럽게 교대해야 한다"는 것입니다. 이상적인 시나리오에서는 낙관적 항목이 화면에 떠 있는 동안 서버 뮤테이션이 완료되고 캐시가 무효화되며, 새 기준 상태가 낙관적 항목과 동일한 내용을 담아 내려옵니다. 그 순간 낙관적 층이 걷히더라도 화면상 아무 변화가 없어 사용자는 전환을 인지하지 못합니다. 이 "깜빡임 없는 교대"가 낙관적 UI 구현의 완성도를 가르는 지점입니다. 반대로 캐시 무효화를 빠뜨리면 낙관적 항목이 사라지면서 화면이 뒤로 감기고, 잠시 후 다음 요청이나 페이지 재방문 때 갑자기 나타나는 시간차 버그가 생깁니다.
revalidatePath와 revalidateTag가 동기적으로 데이터를 다시 가져오는 것이 아니라 캐시를 무효화 표시만 한다는 점도 기억해야 합니다. 실제 재조회는 다음 렌더링 사이클에서 일어납니다. Server Action이 반환하는 순간 함께 내려오는 갱신된 RSC 페이로드가 이 재렌더링 결과를 담아 오는 구조입니다. 그래서 뮤테이션과 무효화가 같은 액션 안에서 원자적으로 묶여 있는 것이 중요합니다. 뮤테이션은 성공했는데 무효화를 잊으면 서버는 여전히 옛 데이터를 캐시에서 꺼내 주고, 우리 화면은 낙관적 값이 사라진 옛 상태로 되돌아갑니다.
한편 이 액션은 매 요청마다 데이터베이스에 최소 한 번, 조건에 따라 두 번의 쿼리를 던집니다. 좋아요·별점처럼 트래픽이 몰리는 뮤테이션이 서버리스 환경에서 실행되면 요청마다 새 커넥션을 맺으려다 데이터베이스 커넥션이 순식간에 고갈되는 문제가 생기기 쉽습니다. 이 지점의 대응은 PostgreSQL 커넥션 풀링 이해하기에서 서버리스 커넥션 폭증 대응과 함께 자세히 다루므로, 낙관적 UI의 뒷단을 실제 트래픽에서 견디게 하려면 함께 읽어 두길 권합니다. 낙관적 UI는 프론트엔드의 체감 속도를 끌어올리지만, 그만큼 사용자가 버튼을 더 자주, 더 빨리 누르게 만들기 때문에 백엔드 부하는 오히려 늘어날 수 있다는 점을 잊으면 안 됩니다.
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>의 상태를 읽습니다.
중복 제출 방지가 단순한 UX 문제가 아니라는 점을 짚고 넘어가야 합니다. 사용자가 제출 버튼을 빠르게 두 번 누르거나, 응답이 느릴 때 초조해서 여러 번 클릭하면 동일한 뮤테이션이 중복 실행될 수 있습니다. 결제 승인이나 주문 생성처럼 부수 효과가 있는 액션에서는 이것이 곧 이중 결제, 중복 주문으로 이어집니다. pending 상태로 버튼을 비활성화하는 것은 클라이언트 측 1차 방어선일 뿐입니다. 앞서 강조했듯 Server Action은 공개 엔드포인트이므로, 버튼 비활성화만으로는 동시 요청을 막을 수 없습니다. 진짜 방어선은 서버에 있습니다. 데이터베이스의 유니크 제약, 멱등성 키(idempotency key), 또는 낙관적 잠금으로 "같은 의도의 요청은 한 번만 반영되도록" 보장해야 합니다. 이 서버 측 멱등성 설계는 재시도와 중복 실행을 다루는 API 회복탄력성 설계: Timeout·Retry·Circuit Breaker로 장애 전파 끊기 관점과 맞닿아 있습니다. 클라이언트의 pending 비활성화와 서버의 멱등성 보장은 서로를 대체하지 않고 이중으로 겹쳐 두는 것이 원칙입니다.
aria-disabled와 aria-busy 속성을 함께 준 것도 접근성을 위한 의도적 선택입니다. 시각적으로 비활성화된 버튼이 스크린 리더에도 "지금 처리 중"으로 전달되어야, 보조 기술을 쓰는 사용자도 제출이 진행 중임을 인지하고 중복 조작을 피할 수 있습니다.
7. 에러 상태 UX 설계
낙관적 UI에서 에러 처리는 세 단계를 구분해야 합니다.
| 실패 유형 | 처리 위치 | UX 결과 |
|---|---|---|
| 클라이언트 검증 | startTransition 이전 | 낙관적 업데이트 없음, 인라인 에러 표시 |
| 서버 비즈니스 로직 | Server Action 반환값 확인 | 자동 롤백 + 토스트/인라인 에러 |
| 네트워크/인프라 | React 에러 경계 | 에러 경계 fallback 표시 + 재시도 버튼 |
이 세 단계를 구분하는 것이 왜 중요한지 실무 감각으로 풀어 보겠습니다. 세 유형은 발생 시점, 낙관적 상태에 미치는 영향, 사용자에게 보여줄 메시지가 모두 다릅니다. 클라이언트 검증 실패는 아예 서버로 요청을 보내지 않는 단계이므로 낙관적 업데이트를 시작해서는 안 됩니다. 빈 내용이나 별점 미선택 같은 명백한 오류는 트랜지션에 진입하기 전에 걸러 내고 필드 옆에 즉시 안내를 띄우는 것이 옳습니다. 여기서 낙관적 항목을 먼저 얹었다가 롤백하면 사용자는 불필요한 깜빡임만 겪습니다.
서버 비즈니스 로직 실패는 요청은 정상적으로 서버에 도달했지만 비즈니스 규칙에 걸린 경우입니다. 이미 좋아요를 누른 리뷰에 다시 좋아요를 시도하거나, 마감된 이벤트에 참여를 시도하는 상황입니다. 이때는 낙관적 항목이 이미 화면에 떠 있으므로 자동 롤백이 일어나고, 우리는 그 롤백에 맞춰 "이미 처리된 요청입니다" 같은 구체적 메시지를 붙여야 합니다. 이 유형은 예외가 아니라 정상적인 사용자 흐름의 일부이므로, 예외를 던지기보다 { error } 객체를 반환해 조용히 처리하는 편이 UX 면에서 자연스럽습니다.
네트워크·인프라 실패는 서버가 죽었거나 타임아웃이 났거나 연결 자체가 끊긴 경우입니다. 이건 특정 폼의 문제가 아니라 시스템 수준의 문제이므로, 개별 필드 에러가 아니라 에러 경계에서 잡아 재시도 UI를 제공하는 것이 맞습니다. 이 세 경로를 컴포넌트 설계 단계에서 미리 그려 두지 않으면, 나중에 모든 실패를 하나의 catch로 뭉뚱그려 처리하게 되고 사용자는 상황에 맞지 않는 엉뚱한 메시지를 보게 됩니다.

8. 파일 업로드 패턴
Next.js의 기본 요청 본문 크기 제한은 next.config.js의 serverActions.bodySizeLimit으로 조정합니다. 기본값은 1MB입니다. 이미지·PDF 등을 서버로 직접 전송하기보다 클라이언트에서 Pre-signed URL을 받아 스토리지 서비스에 직접 업로드하고, Server Action에는 업로드 완료 후 생성된 URL만 전달하는 패턴이 효율적입니다.
낙관적 UI 관점에서 파일 업로드는 URL.createObjectURL(file)을 사용해 로컬 미리보기를 먼저 표시하고, 업로드가 완료된 후 서버 URL로 교체하는 방식을 씁니다.
이 패턴이 중요한 이유를 조금 더 설명하면, 큰 파일을 Server Action의 FormData에 담아 그대로 보내는 방식은 두 가지 부담을 만듭니다. 첫째, 본문 크기 제한에 걸립니다. bodySizeLimit을 무작정 키우면 서버가 대용량 페이로드를 메모리에 올리며 메모리 압박과 처리 지연이 커집니다. 둘째, 서버리스 함수의 실행 시간과 페이로드 한도가 병목이 됩니다. Pre-signed URL 방식은 파일 바이트가 우리 애플리케이션 서버를 거치지 않고 스토리지로 직행하게 만들어 이 두 부담을 모두 우회합니다. Server Action은 그저 "이 URL에 업로드된 파일을 이 리소스에 연결하라"는 가벼운 메타데이터만 처리하면 됩니다.
낙관적 미리보기에서 반드시 챙겨야 할 세부는 메모리 해제입니다. URL.createObjectURL이 만든 오브젝트 URL은 명시적으로 URL.revokeObjectURL로 해제하지 않으면 그 문서가 살아 있는 동안 메모리에 남습니다. 업로드가 끝나 서버 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 캐시 항목 |
| 세밀도 | 낮음 | 높음 |
| 적합한 상황 | 경로 전체 데이터가 바뀔 때 | 특정 데이터만 갱신할 때 |
둘 사이의 선택 기준을 실무 원칙으로 정리하면 "무효화 범위를 필요 최소한으로 좁혀라"입니다. revalidatePath는 편리하지만 해당 경로가 의존하는 모든 데이터 캐시를 함께 날리기 때문에, 리뷰 하나를 추가했을 뿐인데 상품 정보, 추천 목록, 배너까지 전부 다시 조회되는 과잉 무효화가 일어날 수 있습니다. 트래픽이 많은 페이지에서 이 과잉 무효화는 데이터베이스와 외부 API에 불필요한 부하를 얹고, 캐시 히트율을 떨어뜨립니다. revalidateTag는 이 문제를 세밀한 태그 단위로 풀어, 실제로 바뀐 데이터에 연결된 캐시 항목만 정확히 겨냥합니다. 그래서 우리는 뮤테이션마다 "무엇이 바뀌었는가"를 태그로 명시하고, 그 태그만 무효화하는 습관을 들이는 편이 좋습니다.
여기에 하나 덧붙이면, 무효화가 늘 클라이언트 상호작용의 결과일 필요는 없습니다. 배경 작업이나 다른 사용자의 뮤테이션으로 데이터가 바뀌었을 때는 시간 기반 재검증(revalidate 옵션)이나 온디맨드 무효화를 함께 설계해야 합니다. 정적 셸과 동적 데이터를 한 라우트에서 조합하는 최신 렌더링 전략에 관심이 있다면 Next.js PPR(Partial Prerendering) 아키텍처에서 정적·동적 경계 설계를 함께 참고할 수 있습니다. 캐시 무효화는 낙관적 UI의 마지막 퍼즐 조각이자, 잘못 설계하면 성능 회귀를 부르는 양날의 검이라는 점을 기억해야 합니다.
10. 보안 고려사항: CSRF, 권한 검증
Server Actions는 POST 요청을 자동으로 사용하고 Next.js가 CSRF 토큰을 자동으로 처리합니다. Next.js는 Same-Origin 요청만 허용하도록 Origin 헤더를 검증합니다.
조금 더 정확히 말하면, Next.js는 요청의 Origin 헤더와 Host 헤더를 비교해 서로 다르면 액션 실행을 거부합니다. 리버스 프록시나 로드 밸런서 뒤에 배포해 호스트 정보가 변형되는 환경, 혹은 여러 도메인에서 액션을 호출해야 하는 구성이라면 next.config.js의 serverActions.allowedOrigins에 신뢰할 출처를 명시해야 정상 동작합니다. 이 헤더 검증이 크로스 사이트 요청 위조(CSRF)에 대한 기본 방어선이지만, 그것만으로 모든 보안이 끝나는 것은 결코 아닙니다.
Server Action 안에서 반드시 해야 할 보안 검증은 세 가지입니다.
인증 확인은 모든 뮤테이션 Server Action의 첫 번째 줄에서 수행합니다. 앞서 여러 번 강조했듯 액션은 공개 엔드포인트이고, 프레임워크가 자동 생성한 이 엔드포인트로는 우리 UI를 거치지 않은 요청도 얼마든지 들어올 수 있습니다. 따라서 "이 화면은 로그인한 사용자만 보니까 액션도 안전하다"는 추론은 성립하지 않습니다. 액션 본문에서 세션을 직접 검증해야 합니다. 인증 자체를 더 견고하게 만들고 싶다면 비밀번호 없는 로그인으로의 전환을 다룬 패스키(Passkey)와 WebAuthn 도입 가이드가 참고가 됩니다.
소유권 검증은 "인증된 사용자"와 "해당 데이터를 수정할 권한이 있는 사용자"를 구분합니다. 로그인한 사용자라는 사실만으로 아무 리소스나 수정하게 두면, 남의 리뷰를 지우거나 남의 주문을 취소하는 수평적 권한 상승(IDOR)이 열립니다. 액션에 넘어온 리소스 ID가 정말 현재 사용자의 것인지를 데이터베이스에서 조회해 확인해야 합니다. 이 검증은 클라이언트가 어떤 ID를 보내든 서버가 최종 판단하는 유일한 방어선입니다.
입력값 검증은 클라이언트와 서버 모두에서 수행합니다. 서버 검증이 실제 보안 방어선입니다. 클라이언트 검증은 사용자에게 빠른 피드백을 주는 편의일 뿐, 공격자는 이를 손쉽게 우회합니다. FormData는 신뢰할 수 없는 외부 입력이라는 전제 아래, 스키마 검증 라이브러리(예: Zod)로 타입·길이·형식·범위를 서버에서 다시 강제해야 합니다. 특히 타입 캐스팅으로 통과시킨 값(formData.get('rating') as string)은 런타임에 실제로 그 타입임을 보장하지 않으므로, 캐스팅과 검증을 혼동하지 말아야 합니다.
레이트 리미팅도 고려해야 합니다. 좋아요·댓글·검색처럼 값싸 보이는 액션도 자동화된 요청으로 폭주하면 데이터베이스와 서버를 무너뜨립니다. 사용자·IP 단위로 호출 빈도를 제한하고, 남용이 의심되면 일시 차단하는 장치를 액션 앞단에 두어야 합니다. 인증·인가·입력 검증이 지나는 공통 관문을 요청 경계에서 한 번 더 걸러 내고 싶다면, 실행 타이밍과 Edge Runtime 제약을 다룬 Next.js Middleware 완전 해부에서 미들웨어 계층의 역할과 한계를 함께 확인해 두는 것이 좋습니다. 다만 미들웨어는 보조 방어선일 뿐, 최종 검증 책임은 언제나 액션 본문에 있다는 원칙은 변하지 않습니다.
11. useActionState로 폼 상태를 스레딩하기
useActionState는 React 19에서 이전의 useFormState를 대체한 훅으로, Server Action의 실행 결과를 컴포넌트 상태로 자연스럽게 이어 붙이는 다리 역할을 합니다. 이 훅은 세 가지를 한 번에 돌려줍니다. 액션이 마지막으로 반환한 상태, <form action>에 바로 꽂을 수 있는 래핑된 액션, 그리고 요청이 진행 중인지를 나타내는 isPending 플래그입니다. 우리가 2절에서 본 const [state, formAction, isPending] = useActionState(submitReview, null) 한 줄이 바로 이 세 값을 꺼내는 코드입니다.
여기서 "상태 스레딩"이라는 표현을 쓰는 이유가 있습니다. useActionState로 감싼 액션은 첫 번째 인자로 항상 "이전 상태(previous state)"를 받습니다. 즉 액션의 시그니처가 (prevState, formData) => newState 형태로 바뀝니다. 이 덕분에 폼을 제출할 때마다 직전 결과를 참고해 다음 상태를 계산할 수 있습니다. 예를 들어 연속 실패 횟수를 누적해 일정 횟수 이상이면 다른 안내를 보여주거나, 이전에 입력한 값을 되채워 넣는 식의 상태 연결이 가능해집니다. 반환한 상태는 다음 제출 때 다시 prevState로 흘러 들어오므로, 별도의 useState 없이도 폼의 세션 상태를 액션과 컴포넌트 사이에서 실처럼 이어 갈 수 있습니다.
useActionState와 useOptimistic은 역할이 겹치지 않고 보완합니다. useActionState는 "액션이 최종적으로 확정한 결과"를 다루고, useOptimistic은 "확정 전에 미리 보여줄 임시 결과"를 다룹니다. 리뷰 목록에는 useOptimistic으로 낙관적 항목을 얹고, 폼의 성공·실패 메시지와 검증 오류는 useActionState로 받아 표시하는 식으로 둘을 함께 쓰는 구성이 실무에서 가장 흔합니다. 두 훅을 역할대로 나눠 쓰면, "화면에 즉시 반영되는 것"과 "서버가 확정해 돌려준 것"의 경계가 코드에서도 선명하게 드러납니다.
12. 경합 조건과 순서 보장: 낙관적 상태가 깨지는 실제 시나리오
낙관적 UI가 로컬 개발 환경에서는 완벽해 보이다가 실제 트래픽에서 이상하게 동작하는 대부분의 원인은 경합 조건(race condition)입니다. 사용자가 좋아요를 빠르게 여러 번 토글하는 상황을 생각해 봅시다. 첫 번째 클릭의 요청이 서버로 가고, 응답이 오기 전에 두 번째 클릭이 또 다른 요청을 보냅니다. 네트워크 상황에 따라 두 응답이 도착하는 순서는 보낸 순서와 뒤바뀔 수 있습니다. 만약 나중에 보낸 요청의 응답이 먼저 도착하고, 먼저 보낸 요청의 응답이 뒤늦게 도착하면, 화면의 최종 상태가 사용자의 마지막 의도와 어긋날 수 있습니다.
useOptimistic은 진행 중인 트랜지션이 여러 개일 때 기준 상태 위에 낙관적 리듀서를 순서대로 겹쳐 적용하고, 각 트랜지션이 끝나면 해당 낙관적 층만 걷어냅니다. 이 모델은 낙관적 표시 자체는 비교적 잘 관리하지만, 문제는 서버가 확정해 돌려주는 기준 상태의 순서입니다. 캐시 무효화 이후 내려오는 최신 기준 상태는 "가장 마지막에 완료된 뮤테이션의 결과"를 반영하는데, 이 완료 순서가 사용자 의도 순서와 다를 수 있는 것입니다. 따라서 토글처럼 순서가 결과를 뒤집는 뮤테이션에서는, 서버가 "요청 시각"이나 "버전 번호"를 기준으로 낡은 요청을 무시하도록 설계하거나, 클라이언트에서 이전 진행 중 요청을 취소(AbortController)하는 보완이 필요합니다.
또 다른 흔한 함정은 stale closure입니다. 낙관적 항목을 만들 때 Date.now()로 임시 ID를 붙이는 것은, 서버가 확정해 내려준 진짜 ID와 임시 ID를 구분하기 위한 장치입니다. 만약 임시 항목과 확정 항목을 구분하지 못하면, 캐시 무효화 후 같은 항목이 두 번 렌더링되어 리스트에 중복으로 보이는 순간이 생길 수 있습니다. 리스트의 key를 안정적으로 잡고, 확정 데이터가 내려왔을 때 임시 항목이 자연스럽게 대체되도록 리듀서와 데이터 형태를 맞추는 것이 이런 깜빡임을 없애는 핵심입니다. 이 문제들은 단위 테스트로는 거의 잡히지 않고, 느린 네트워크를 흉내 낸 통합 테스트나 실제 기기에서 빠르게 연타해 보는 수동 검증에서만 드러난다는 점도 기억해 둘 만합니다.
13. 언제 낙관적 UI를 쓰지 말아야 하나
낙관적 UI는 강력하지만 만능이 아닙니다. 오히려 잘못 적용하면 사용자 신뢰를 갉아먹습니다. 첫째, 실패 확률이 높은 조작에는 쓰지 않는 것이 좋습니다. 낙관적 UI의 전제는 "대부분 성공한다"입니다. 재고가 자주 동나는 한정 상품의 구매나, 승인율이 낮은 결제처럼 실패가 흔한 조작에서 성공을 미리 보여주면 롤백이 잦아지고, 사용자는 "됐다가 안 됐다"를 반복 경험하며 오히려 더 큰 불신을 갖게 됩니다.
둘째, 결과가 되돌릴 수 없거나 금전적 영향이 큰 조작에는 신중해야 합니다. 송금, 결제 확정, 예약 확정처럼 실패했을 때 롤백이 사용자에게 실질적 혼란을 주는 경우에는, 차라리 정직하게 로딩 상태를 보여주고 서버 확정 후에 결과를 반영하는 편이 안전합니다. "성공했다고 보여줬다가 실은 실패였다"는 경험은 좋아요 버튼에서는 사소하지만 결제에서는 치명적입니다.
셋째, 여러 사용자의 상태가 실시간으로 얽히는 화면에서는 낙관적 표시가 실제 서버 상태와 크게 어긋날 수 있습니다. 실시간 경매의 최고가, 좌석 선점, 협업 문서의 동시 편집처럼 다른 사용자의 행동이 내 낙관적 가정을 즉시 무효화하는 상황에서는, 낙관적 UI보다 서버가 밀어 주는 실시간 갱신(웹소켓, SSE)을 신뢰의 단일 출처로 삼는 설계가 더 적합합니다. 요컨대 낙관적 UI는 "결과가 거의 확실하고, 되돌려도 안전하며, 내 조작이 결과를 단독으로 결정하는" 조작에 어울립니다. 이 세 조건 중 하나라도 흔들린다면 낙관적 갱신을 도입하기 전에 다시 한번 재고해야 합니다.
14. 자주 묻는 질문(FAQ)
Server Action 하나에서 여러 캐시 태그를 무효화해도 되나요? 됩니다. revalidateTag를 필요한 만큼 여러 번 호출할 수 있습니다. 다만 무효화 범위가 넓어질수록 재조회 비용이 커지므로, 정말 바뀐 데이터에 연결된 태그만 고르는 원칙은 그대로 지켜야 합니다.
낙관적 업데이트를 쓰면 useState로 관리하던 로컬 상태는 전부 없앨 수 있나요? 아닙니다. useOptimistic은 "서버로 향하는 뮤테이션의 임시 표시"에 특화된 도구입니다. 순수한 UI 상태(모달 열림 여부, 탭 선택, 입력 포커스)는 여전히 useState의 영역입니다. 두 도구의 경계를 흐리면 상태 관리가 오히려 복잡해집니다.
Server Action을 클라이언트 컴포넌트가 아닌 서버 컴포넌트에서도 호출할 수 있나요? 서버 컴포넌트에서는 <form action={serverAction}> 형태로 폼에 직접 연결하는 방식이 자연스럽습니다. useOptimistic이나 useActionState 같은 훅은 클라이언트 컴포넌트에서만 동작하므로, 낙관적 표시가 필요한 인터랙티브한 부분만 'use client' 경계로 잘라 내어 조합하는 것이 정석입니다.
isPending이 있는데 왜 useFormStatus가 따로 필요한가요? useActionState가 돌려주는 isPending은 그 훅을 호출한 컴포넌트에서 액션 상태를 알 때 쓰고, useFormStatus는 제출 버튼처럼 폼 하위의 별도 컴포넌트에서 부모 폼의 상태를 구독할 때 씁니다. 버튼을 재사용 가능한 컴포넌트로 분리하고 싶을 때 useFormStatus가 특히 유용합니다.
타입 안전성은 어떻게 확보하나요? Server Action의 인자와 반환값 타입은 클라이언트에서 그대로 추론되므로, 액션 시그니처를 명시적으로 타이핑하면 호출부까지 타입이 이어집니다. 다만 FormData.get()이 돌려주는 값은 FormDataEntryValue | null이라 그대로 신뢰할 수 없으므로, 런타임 스키마 검증으로 타입을 좁히는 과정을 반드시 거쳐야 실제 타입 안전이 완성됩니다.
정리
- Server Action 인증·권한 검증: 모든 뮤테이션 Action의 첫 번째 로직이
getServerSession()+ 소유권 DB 조회임을 확인한다. 액션은 공개 엔드포인트라는 사실을 잊지 않는다. - useOptimistic + startTransition 쌍 사용:
addOptimistic*함수는 반드시startTransition블록 안에서 호출한다. 낙관적 값의 수명은 트랜지션의 수명과 같다. - 낙관적 상태와 서버 상태의 매끄러운 교대: 성공 시 캐시를 무효화해 새 기준 상태가 내려오게 하고, 낙관적 층이 걷힐 때 화면이 깜빡이지 않도록 맞춘다.
- 클라이언트·서버 이중 검증: 폼의 입력값 스키마를 클라이언트 제출 전과 Server Action 안에서 각각 독립적으로 실행한다. 서버 검증만이 실제 방어선이다.
- revalidateTag 우선, revalidatePath는 최후 수단: 무효화 범위를 최소화해 불필요한 재렌더링과 과잉 재조회를 막는다.
- 에러 유형별 UX 분기 명시: 클라이언트 검증 실패, 서버 비즈니스 에러, 네트워크 에러의 세 경로를 컴포넌트 설계 단계에서 구분한다.
- 경합과 중복에 대비: 순서가 결과를 뒤집는 뮤테이션은 서버의 버전·시각 기준 판단과 클라이언트의 요청 취소로 보완하고, 부수 효과가 있는 액션은 서버 멱등성으로 중복 실행을 막는다.
- 낙관적 UI가 어울리지 않는 곳을 안다: 실패가 잦거나, 되돌릴 수 없거나, 여러 사용자 상태가 실시간으로 얽히는 조작에는 정직한 로딩 상태가 더 낫다.