왜 지금 React 19 Actions/useOptimistic인가
서버 액션과 낙관적 UI는 폼 제출 시 “요청 대기 → 리렌더 → 상태 동기화”로 생기는 지연과 깜빡임을 줄입니다. 기존 onSubmit + useState + 로더 패턴은 네트워크 왕복과 전역 상태 갱신이 겹치면 스피너 남용, 중복 전송, 충돌 해결 비용이 커집니다.
React 19의 Actions와 useOptimistic은 요청 전 가상 상태를 먼저 반영하고, 응답이 오면 확정 상태로 스왑합니다. 흐름은 입력 → optimistic 적용 → 서버 액션 호출 → 성공/실패 반영으로 단순화되며, 렌더 비용과 대기 체감 시간을 동시에 낮출 수 있습니다.
다만 낙관적 갱신은 실패 시 롤백, 중복 제출 방지, 비동기 레이스 처리 같은 운영 리스크를 동반합니다. 장점은 인터랙션 체감 개선이지만, 감수해야 할 것은 에러 복구 로직과 충돌 해소 규칙을 명시적으로 설계하는 비용입니다.
이 글은 기존 폼을 Actions/useOptimistic으로 옮길 때 놓치기 쉬운 체크포인트를 순서대로 제시합니다. “무엇을 없애고(불필요한 로컬 상태·스피너) 무엇을 추가할지(낙관적 패치 규칙·에러 경계)”를 기준으로 바로 적용할 수 있는 마이그레이션 기준을 제공합니다.
낙관적 업데이트 핵심 흐름
즉시 반응이 중요한 UI에서는 useOptimistic으로 클릭 직후 UI를 먼저 갱신하고, 서버가 나중에 검증·확정합니다. 장바구니 +1, 댓글 작성처럼 사용자 피드백이 우선인 작업에 적합합니다.
흐름은 `클릭 → optimistic(+1/임시항목) → 서버 액션 → 성공 시 확정/실패 시 롤백`입니다. 댓글은 목록 상단에 임시 아이템을 붙였다가 저장 결과로 교체합니다.
체감 속도는 좋아지지만, 롤백 규칙과 중복 제출 방지가 필수입니다. 서버는 유효성·레이스 조건을 처리하고, 클라이언트는 optimistic 큐 정리·중복 클릭 차단을 맡깁니다.
// client
function CommentForm({ addCommentAction }) {
const [list, setList] = useState([]);
const [optimistic, addOptimistic] =
useOptimistic(list, (prev, draft) => [draft, ...prev]);
async function onSubmit(text) {
const temp = { id: 'temp-'+Date.now(), text, pending: true };
addOptimistic(temp);
const res = await addCommentAction(text);
setList(cur => [res, ...cur.filter(i => i.id !== temp.id)]);
}
}
// server
export async function addCommentAction(text) { /* 검증/저장 후 { id, text } */ }
안티패턴: 임시 항목을 여러 컴포넌트에서 중복 수정하거나, 실패 시 제거·배지 노출 등 복구 경로를 두지 않는 구성입니다. 레이스 대비 키 충돌 방지, 재시도/취소 버튼 등 복원 전략을 준비하세요.
서버 액션과 낙관적 UI의 안전한 결합
폼 제출은 서버 액션으로 처리하고 클라이언트 상태는 최소화합니다. 제출 중에는 버튼을 비활성화해 중복 클릭을 차단합니다.
useOptimistic으로 목록·카운트를 임시 반영하되, 실패 시 되돌릴 규칙을 먼저 정의합니다. tempId나 idempotency key로 요청을 식별해 가장 최신 응답만 확정합니다.
에러는 입력 검증은 필드 단위, 시스템 오류는 토스트/배너로 분리합니다. 롤백 후 포커스를 원위치하고 재시도 버튼을 제공합니다.
아래 예시는 댓글 추가를 서버 액션으로 이관하고 tempId로 낙관적 항목을 매칭합니다. 레이스 컨디션과 중복 반영을 줄이는 구조입니다.
// server action
"use server";
import { revalidatePath } from "next/cache";
export async function addComment(postId: string, data: { tempId: string; text: string }) {
const saved = await db.comment.create({ data: { postId, text: data.text } });
revalidatePath(`/posts/${postId}`);
return { tempId: data.tempId, id: saved.id, text: saved.text };
}
// client
"use client";
import { useOptimistic, useState, useTransition } from "react";
import { addComment } from "@/app/actions";
export default function CommentForm({ postId, initial }) {
const [list] = useState(initial);
const [isPending, startTransition] = useTransition();
const [optimistic, addOptimistic] = useOptimistic(list, (s, a) => {
if (a.type === "add") return [{ id: a.tempId, text: a.text, pending: true }, ...s];
if (a.type === "confirm") return s.map(it => it.id === a.tempId ? { id: a.id, text: a.text } : it);
if (a.type === "rollback") return s.filter(it => it.id !== a.tempId);
return s;
});
async function onSubmit(formData: FormData) {
const text = String(formData.get("text") || "");
const tempId = `tmp_${crypto.randomUUID()}`;
addOptimistic({ type: "add", tempId, text });
startTransition(async () => {
try {
const res = await addComment(postId, { tempId, text });
addOptimistic({ type: "confirm", ...res });
} catch {
addOptimistic({ type: "rollback", tempId });
}
});
}
return (
<form action={onSubmit}>
<input name="text" disabled={isPending} />
<button disabled={isPending}>등록</button>
<ul>{optimistic.map(c => <li key={c.id}>{c.text}{c.pending ? " (전송 중)" : ""}</li>)}</ul>
</form>
);
}
정리: 즉시 반응성의 대가로 식별자 충돌, 재요청, 에러 회복을 엄격히 관리해야 합니다. tempId 추적과 confirm/rollback 수렴이 핵심입니다.
흔한 실수와 선택 기준: 낙관적 UI의 비용과 경계
낙관적 갱신은 “체감 지연”을 줄이지만 “정합성 관리 비용”을 만듭니다. 서버 확정 전 임시 상태가 떠 있기 때문에, 실패 롤백·중복 방지·레이스 해소 규칙이 없으면 데이터가 튀거나 덮어쓰기가 발생합니다.
비교 관점에서, 전통 onSubmit+로딩 스피너는 느리지만 정합성이 단순합니다. Actions/useOptimistic은 빠르지만 tempId, idempotency key, 최신성 비교(버전/업데이트 시간) 같은 보호 장치를 요구합니다.
즉 “속도 ↔ 규칙 설계”의 트레이드오프입니다.
안티패턴은 다음과 같습니다.
- 낙관적 변경을 여러 곳에 분산 저장해 이중 롤백을 유발
- 서버 오류와 사용자 입력 오류를 동일 토스트로 뭉뚱그림
- 재시도 시 새 tempId를 발급하지 않아 중복 항목 생성
- 서버 재검증 없이 클라이언트 상태만 믿고 캐시 고정
선택 기준은 작업 성격과 실패 확률입니다. 피드 수치, 좋아요, 장바구니처럼 “실패 영향이 낮고 잦은 인터랙션”이면 낙관적 적용이 유리하고, 결제·재고 이동처럼 “실패 비용이 큰 트랜잭션”은 서버 확정 후 반영이 안전합니다.
점검 체크리스트:
- 요청 식별: tempId/idempotency key 부여 → 응답 매칭 → 최신 응답만 승격
- 상태 스코프: optimistic 상태는 화면 국소화, 전역 스토어에는 확정치만
- 레이스 처리: A -> B -> 응답A 순서 대비 버전/타임스탬프 비교로 무효화
- 오류 UX: 필드 오류는 인라인, 시스템 오류는 배너/재시도 분리
- 접근성: 롤백 시 포커스 복원, 진행 중 aria-busy로 상태 전달
바로 적용 체크리스트와 다음 스텝
이 글의 요지는 “Actions로 제출을 단순화하고 useOptimistic으로 체감 지연을 숨기되, 실패·중복·레이스를 규칙으로 제어하라”입니다. 빠른 인터랙션을 얻는 대신 정합성 규칙 설계와 테스트 비용을 감수하는 선택입니다.
먼저 작은 폼 하나에만 적용해 흐름을 검증하세요. 추천 순서는 입력 검증 → 서버 액션 이관 → 버튼 비활성화 → tempId/idempotency → 낙관적 반영 → 실패 롤백/재시도입니다.
바로 실행할 체크리스트:
- 서버: 액션 하나로 단일 책임, 중복 제출을 idempotency key로 차단
- 클라이언트: useOptimistic으로 임시 상태, 버튼은 제출 중 disabled
- 식별: tempId로 임시 항목 매칭, 최신성은 updatedAt/버전으로 비교
- 오류: 필드 에러와 시스템 에러 UI 분리, 롤백 후 포커스 복구
- 레이스: 마지막 응답만 확정하는 guard, 낙관적 큐 정리
끝으로, 데이터 흐름을 기록해 테스트 케이스를 만드세요: 입력 → optimistic → 서버 검증 → 성공 스왑/실패 롤백 → 캐시 재검증. 이 경로가 깨지지 않는지 E2E로 확인하는 것이 안전합니다.
'프로그래밍' 카테고리의 다른 글
| OpenAI Realtime API(WebRTC)로 브라우저 음성 챗봇 구축: 에페메럴 키와 Agents SDK 실전 가이드 (0) | 2026.06.25 |
|---|---|
| ESLint v9 Flat Config 마이그레이션 가이드: typescript-eslint 연동 단계별 설명 (0) | 2026.06.25 |
| NextAuth.js v5(App Router)로 구글·카카오 OAuth 소셜 로그인 구현 가이드 (0) | 2026.06.25 |
| React Hook Form + Zod로 파일·이미지 유효성 검사 패턴과 App Router 연동 가이드 (0) | 2026.06.10 |
| Tailwind CSS v4 마이그레이션 체크리스트: v3→v4 변경점과 @import 전환 가이드 (0) | 2026.06.08 |