낙관적 업데이트 — Optimistic UI를 쓰는 이유와 롤백 패턴
서버 응답을 기다리지 않고 UI를 먼저 업데이트하는 Optimistic UI 패턴이 왜 좋고, 실패 시 어떻게 롤백하는지 React와 Next.js Server Actions 기준으로 정리했다.
낙관적 업데이트 — Optimistic UI
좋아요 버튼을 눌렀을 때 즉시 숫자가 올라가는 인스타그램, 체크박스를 클릭하면 바로 완료 표시가 되는 투두 앱. 서버 응답을 기다리지 않고 UI가 먼저 반응한다. 이것이 Optimistic UI다.
왜 쓰는가
서버 요청은 보통 100ms ~ 수백ms가 걸린다. 이 시간 동안 버튼을 비활성화하고 스피너를 보여주는 것이 "정직"하지만, 사용자 입장에서는 느리게 느껴진다.
대부분의 요청은 성공한다. 실패는 예외적인 경우다. 그러니 성공을 가정하고 UI를 먼저 반영하고, 실패하면 되돌리는 것이 더 자연스러운 경험을 만든다.
기본 패턴
function LikeButton({ postId, initialLikes }: { postId: string; initialLikes: number }) {
const [likes, setLikes] = useState(initialLikes);
const [liked, setLiked] = useState(false);
async function handleLike() {
// 1. UI를 먼저 업데이트
const prevLikes = likes;
const prevLiked = liked;
setLikes(liked ? likes - 1 : likes + 1);
setLiked(!liked);
try {
// 2. 서버 요청
await toggleLike(postId);
} catch {
// 3. 실패 시 롤백
setLikes(prevLikes);
setLiked(prevLiked);
toast.error('요청에 실패했습니다. 다시 시도해주세요.');
}
}
return (
<button onClick={handleLike}>
{liked ? '❤️' : '🤍'} {likes}
</button>
);
}핵심은 이전 상태를 저장해두고, 실패 시 복원하는 것이다.
React 19 — useOptimistic
React 19에서 낙관적 업데이트를 위한 useOptimistic 훅이 추가됐다.
import { useOptimistic, useTransition } from 'react';
function TodoList({ todos }: { todos: Todo[] }) {
const [isPending, startTransition] = useTransition();
const [optimisticTodos, addOptimisticTodo] = useOptimistic(
todos,
(state, newTodo: Todo) => [...state, newTodo], // 낙관적 업데이트 로직
);
async function handleAdd(text: string) {
const newTodo = { id: crypto.randomUUID(), text, done: false };
startTransition(async () => {
addOptimisticTodo(newTodo); // UI 즉시 반영
await createTodo(text); // 서버 요청 (실패하면 자동 롤백)
});
}
return (
<ul>
{optimisticTodos.map((todo) => (
<li key={todo.id}>{todo.text}</li>
))}
</ul>
);
}useOptimistic의 특징:
startTransition안에서 사용해야 한다.- 서버 요청이 완료되면 실제 서버 응답 값(
todos)으로 자동 대체된다. - 에러가 나면 자동으로 원래 상태로 돌아간다.
Next.js Server Actions와 함께
// app/actions.ts
'use server';
export async function toggleTodo(id: string, done: boolean) {
await db.todo.update({ where: { id }, data: { done } });
revalidatePath('/todos');
}// app/todos/TodoItem.tsx
'use client';
import { useOptimistic, useTransition } from 'react';
import { toggleTodo } from '@/app/actions';
export function TodoItem({ todo }: { todo: Todo }) {
const [isPending, startTransition] = useTransition();
const [optimisticDone, setOptimisticDone] = useOptimistic(todo.done);
function handleToggle() {
startTransition(async () => {
setOptimisticDone(!optimisticDone);
await toggleTodo(todo.id, !todo.done);
});
}
return (
<label style={{ opacity: isPending ? 0.7 : 1 }}>
<input type="checkbox" checked={optimisticDone} onChange={handleToggle} />
{todo.text}
</label>
);
}isPending으로 요청 중인 상태를 시각적으로 표현할 수 있다. 완전히 숨기기보다 살짝 흐리게 처리하면 "뭔가 진행 중"이라는 피드백을 준다.
목록에서 항목 삭제
삭제는 낙관적 업데이트를 적용하기 좋은 대표 케이스다.
function TodoList({ initialTodos }: { initialTodos: Todo[] }) {
const [todos, setTodos] = useState(initialTodos);
async function handleDelete(id: string) {
// 즉시 목록에서 제거
const removed = todos.find((t) => t.id === id)!;
setTodos(todos.filter((t) => t.id !== id));
try {
await deleteTodo(id);
} catch {
// 실패 시 원래 위치에 복원
setTodos((prev) => {
const index = prev.findIndex((t) => t.id > id); // 정렬 기준에 맞게 위치 복원
if (index === -1) return [...prev, removed];
return [...prev.slice(0, index), removed, ...prev.slice(index)];
});
toast.error('삭제에 실패했습니다.');
}
}
}주의할 점
낙관적 업데이트가 맞지 않는 경우
- 결제, 예약 — 실패 시 사용자에게 혼란을 주는 중요한 트랜잭션
- 서버에서 생성된 값이 UI에 필요한 경우 — ID, 타임스탬프 등 서버 응답 전에 알 수 없는 값
- 실패율이 높은 작업 — 낙관적으로 표시했다가 자주 롤백되면 오히려 신뢰를 잃음
롤백 시 UX
롤백은 부드럽게 처리한다. 갑자기 사라지거나 변경되는 것보다 토스트 메시지와 함께 자연스럽게 이전 상태로 돌아오는 것이 낫다.
catch (error) {
// 롤백
setLiked(prevLiked);
setLikes(prevLikes);
// 사용자에게 알림
toast.error('네트워크 오류가 발생했습니다. 잠시 후 다시 시도해주세요.');
}정리
Optimistic UI의 원칙은 단순하다: 성공을 가정하고 먼저 보여주되, 실패하면 조용히 원래대로 돌려놓는다.
React 19의 useOptimistic은 이 패턴을 선언적으로 표현하게 해준다. Server Actions와 함께 쓰면 서버 상태와 UI 상태의 동기화를 거의 자동으로 처리할 수 있다.
Related Posts
같이 읽으면 좋은 글
URL을 상태로 쓰는 패턴 — searchParams로 필터·페이지네이션 관리하기
필터, 정렬, 페이지네이션 같은 UI 상태를 useState 대신 URL searchParams에 저장하면 뭐가 좋은지, Next.js App Router 기준으로 구체적인 패턴을 정리했다.
문서형 블로그 IA를 먼저 설계한 이유
개인 기술 블로그를 오래 운영하려면 첫 화면보다 정보 구조를 먼저 설계해야 하는 이유를 정리했다.
contain과 content-visibility — 브라우저가 렌더링을 건너뛰는 방법
CSS contain과 content-visibility 속성이 브라우저에게 렌더링 범위를 제한하는 힌트를 주어 성능을 높이는 원리와 실무 적용 방법을 정리했다.