- Published on
Next.js useOptimistic로 즉시반응 UI 만들기
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 액션이나 API 호출이 끝나기 전까지 버튼이 멈춰 보이거나, 리스트가 늦게 갱신되어 사용자가 “눌렸나?”를 의심하는 순간이 UX를 크게 떨어뜨립니다. Next.js App Router 환경에서는 useOptimistic를 활용해 서버 확정 전 상태를 먼저 UI에 반영하는 낙관적 업데이트(Optimistic UI)를 비교적 간단하게 구현할 수 있습니다.
이 글에서는 useOptimistic의 동작 원리, 서버 액션과의 결합, 실패 시 롤백, 중복 요청 방지, 그리고 운영에서 자주 겪는 함정까지 한 번에 정리합니다.
참고로 성능 관점에서 UI가 빨라져도 이미지가 느리면 체감이 떨어질 수 있으니, LCP 최적화는 별도로 챙기는 것을 권합니다: Next.js Image 최적화로 LCP 1초 줄이기
useOptimistic는 무엇을 해결하나
useOptimistic는 기본 상태(base state) 와 낙관적 업데이트 함수(reducer) 를 받아, 아직 서버에서 확정되지 않은 변경을 UI에 합성해서 보여주는 훅입니다.
- 사용자 액션 직후 UI를 먼저 바꿈
- 서버 요청은 백그라운드로 진행
- 실패하면 원래 상태로 되돌리거나(롤백) 에러 표시
- 성공하면 서버에서 내려온 최신 상태로 base state를 갱신
핵심은 “서버가 확정하기 전 임시 상태”를 자연스럽게 UI에 끼워 넣는 것입니다.
언제 쓰고 언제 쓰지 말아야 하나
잘 맞는 경우
- 좋아요, 북마크, 팔로우처럼 즉시 반응이 중요한 토글
- 댓글/아이템 추가처럼 리스트에 항목을 즉시 삽입
- 체크박스, 라벨 변경처럼 로컬에서 먼저 바꿔도 큰 문제가 없는 변경
조심해야 하는 경우
- 재고/결제/잔액처럼 서버 검증이 절대적인 도메인
- 동시성 충돌이 잦고 롤백 비용이 큰 작업
- 서버가 반환하는 값이 클라이언트 예측과 자주 어긋나는 작업
이런 경우는 낙관적 UI 자체는 가능하지만, “서버 확정 전 표시”가 법적/금전적 리스크가 될 수 있으니 정책적으로 제한하는 편이 안전합니다.
기본 패턴: 댓글 추가를 낙관적으로 처리하기
아래 예시는 App Router에서 서버 액션으로 댓글을 생성하고, 클라이언트에서는 useOptimistic로 임시 댓글을 즉시 리스트에 추가합니다.
1) 서버 액션
app/actions/comments.ts
'use server'
import { revalidatePath } from 'next/cache'
export type Comment = {
id: string
postId: string
body: string
createdAt: string
authorName: string
}
export async function createComment(input: {
postId: string
body: string
}): Promise<Comment> {
// 예시: DB insert
// 실패 시 throw로 에러 전파
const now = new Date().toISOString()
const saved: Comment = {
id: crypto.randomUUID(),
postId: input.postId,
body: input.body,
createdAt: now,
authorName: 'me',
}
// 실제 구현에서는 DB 결과를 반환
// 그리고 필요한 경로만 재검증
revalidatePath(`/posts/${input.postId}`)
return saved
}
2) 클라이언트 컴포넌트에서 useOptimistic 적용
app/posts/[id]/CommentComposer.tsx
'use client'
import { useOptimistic, useRef, useState, useTransition } from 'react'
import type { Comment } from '@/app/actions/comments'
import { createComment } from '@/app/actions/comments'
type Props = {
postId: string
initialComments: Comment[]
}
type OptimisticComment = Comment & {
optimistic?: boolean
}
export function CommentComposer({ postId, initialComments }: Props) {
const [comments, setComments] = useState<Comment[]>(initialComments)
const [isPending, startTransition] = useTransition()
const [optimisticComments, addOptimistic] = useOptimistic<
OptimisticComment[],
OptimisticComment
>(comments, (state, newItem) => {
return [newItem, ...state]
})
const formRef = useRef<HTMLFormElement>(null)
async function onSubmit(formData: FormData) {
const body = String(formData.get('body') ?? '').trim()
if (!body) return
const temp: OptimisticComment = {
id: `temp-${crypto.randomUUID()}`,
postId,
body,
createdAt: new Date().toISOString(),
authorName: 'me',
optimistic: true,
}
// 1) UI 즉시 반영
addOptimistic(temp)
// 2) 입력창 즉시 비우기
formRef.current?.reset()
// 3) 서버 액션은 transition으로 실행
startTransition(async () => {
try {
const saved = await createComment({ postId, body })
// base state를 서버 확정값으로 갱신
// temp는 서버에서 다시 내려온 목록으로 자연스럽게 사라지게 하거나
// 아래처럼 직접 치환할 수도 있습니다.
setComments(prev => {
// temp 항목 제거 후 saved를 맨 앞에
const withoutTemp = prev.filter(c => c.id !== temp.id)
return [saved, ...withoutTemp]
})
} catch (e) {
// 실패 시: base state에 temp가 없으므로
// optimistic에만 있던 항목은 다음 렌더에서 사라집니다.
// 사용자에게는 토스트/에러 UI를 보여주는 것이 좋습니다.
setComments(prev => prev)
}
})
}
return (
<div>
<form ref={formRef} action={onSubmit} className="flex gap-2">
<input
name="body"
placeholder="댓글을 입력"
className="border px-2 py-1"
disabled={isPending}
/>
<button className="border px-3" disabled={isPending}>
등록
</button>
</form>
<ul className="mt-4 space-y-2">
{optimisticComments.map(c => (
<li key={c.id} className="border p-2">
<div className="text-sm text-gray-600">
{c.authorName} · {new Date(c.createdAt).toLocaleString()}
{c.optimistic ? ' (전송 중)' : ''}
</div>
<div>{c.body}</div>
</li>
))}
</ul>
</div>
)
}
이 코드의 포인트
useOptimistic의 base state는comments입니다.addOptimistic로 임시 댓글을 합성해optimisticComments로 렌더링합니다.- 서버 성공 시
setComments로 base state를 확정값으로 갱신합니다. - 서버 실패 시 base state는 그대로이므로, 낙관적으로만 존재하던 항목은 다음 렌더에서 사라지며 결과적으로 롤백됩니다.
롤백을 “명시적으로” 처리해야 하는 케이스
위 예시는 “낙관적 항목이 base state에 들어가지 않는다”는 특성을 이용해 자연스럽게 롤백됩니다. 하지만 다음과 같은 경우는 명시적 롤백이 필요합니다.
- base state 자체를 먼저 바꾸는 패턴을 쓸 때
- 여러 단계의 optimistic 업데이트가 중첩될 때
- 실패한 항목만 골라서 “재시도” 버튼을 붙이고 싶을 때
이때는 optimistic 항목에 status를 두고, 실패 시 failed로 마킹하는 방식이 실전에서 많이 쓰입니다.
type OptimisticItem = {
id: string
label: string
status: 'optimistic' | 'confirmed' | 'failed'
}
// reducer에서 상태를 합성
const [optimisticItems, dispatchOptimistic] = useOptimistic<
OptimisticItem[],
{ type: 'add'; item: OptimisticItem } | { type: 'markFailed'; id: string }
>(items, (state, action) => {
if (action.type === 'add') return [action.item, ...state]
if (action.type === 'markFailed')
return state.map(it => (it.id === action.id ? { ...it, status: 'failed' } : it))
return state
})
이렇게 하면 실패한 항목을 UI에 남겨 두고 “재시도”를 제공할 수 있습니다. 단, 서버 확정 상태와의 정합성을 유지하려면 성공 시 서버에서 받은 목록으로 base state를 갱신하는 흐름이 필요합니다.
중복 제출과 레이스 컨디션 방지
낙관적 UI에서 흔한 버그는 다음입니다.
- 버튼을 연타하면 임시 항목이 여러 개 생김
- 서버 응답 순서가 뒤바뀌어 목록 정렬이 꼬임
- 같은 요청이 두 번 전송되어 데이터가 중복 생성됨
1) UI 레벨에서 중복 제출 차단
useTransition의isPending으로 버튼 비활성화- 또는 요청마다
clientRequestId를 생성해 서버에서 멱등 처리
const clientRequestId = crypto.randomUUID()
await createComment({ postId, body, clientRequestId })
서버에서 clientRequestId에 유니크 인덱스를 걸면 중복 생성 자체를 차단할 수 있습니다. 이 방식은 낙관적 UI와 특히 궁합이 좋습니다.
2) 응답 순서 뒤바뀜 대응
서버에서 createdAt을 authoritative 하게 내려주고, 클라이언트는 그 값을 기준으로 정렬하세요. 클라이언트에서 임시로 넣는 createdAt은 어디까지나 표시용입니다.
useOptimistic와 useActionState, useFormStatus 조합
폼 제출 UX를 더 다듬고 싶다면 다음 조합이 자주 쓰입니다.
useFormStatus: 서버 액션 폼 제출 중 버튼 로딩 처리useActionState: 서버 액션 결과(성공/실패 메시지)를 상태로 받기useOptimistic: 리스트/토글 등 즉시 반응 영역 담당
다만 useOptimistic는 “합성된 상태를 보여준다”에 집중하고, 에러 메시지/검증 메시지까지 모두 해결해주진 않습니다. 폼 검증이 복잡하다면 useActionState로 서버 검증 결과를 받고, optimistic은 “성공 가능성이 높은” 상호작용에 한정하는 것이 유지보수에 유리합니다.
타입 안정성: 낙관적 모델을 분리하라
낙관적 항목은 서버 모델과 미묘하게 다릅니다.
- 임시
id가 필요함 optimistic: true같은 UI 전용 필드가 필요함- 서버 확정 전에는 일부 필드가 비어 있을 수 있음
따라서 서버 DTO 타입을 그대로 쓰기보다, Optimistic 타입을 얇게 확장하는 편이 안전합니다. TypeScript에서는 이때 satisfies를 활용하면 “필드 누락은 잡고, 추론은 유지”하기 좋아서 추천합니다.
const temp = {
id: `temp-${crypto.randomUUID()}`,
postId,
body,
createdAt: new Date().toISOString(),
authorName: 'me',
optimistic: true,
} satisfies OptimisticComment
관련 내용은 아래 글이 도움이 됩니다: TS 5.x satisfies로 타입검증·추론 동시 해결
서버 재검증과 데이터 소스 정합성
낙관적 UI는 “사용자 체감”을 빠르게 할 뿐, 정합성의 최종 책임은 서버에 있습니다.
- 서버 액션 성공 시
revalidatePath또는revalidateTag로 해당 화면의 데이터 소스를 갱신 - 클라이언트는 성공 응답으로 base state를 업데이트
- 이후 서버에서 다시 내려오는 데이터와 충돌하지 않도록 정렬/중복 제거 규칙을 일관되게 유지
특히 리스트 UI는 “서버에서 내려오는 항목”과 “클라이언트 임시 항목”이 잠깐 공존할 수 있으므로, 다음 중 하나를 명확히 선택하세요.
- 성공 시 temp를 직접 치환한다 (위 예시)
- 성공 시 서버에서 최신 목록을 다시 받아 base state를 통째로 교체한다
두 방식을 섞으면 중복 제거 로직이 복잡해집니다.
실전 체크리스트
- 낙관적 업데이트는 “실패 가능성이 낮고 롤백이 쉬운” 기능부터 적용
- 임시 항목에는
temp-...같은 접두사로 서버 ID와 구분 - 중복 생성이 치명적이면
clientRequestId로 멱등성 확보 - 성공 시 base state를 authoritative 데이터로 갱신
- 실패 시 사용자에게 재시도 경로 제공(토스트, 실패 뱃지, 재전송 버튼)
- 정렬 기준은 서버 시간을 우선(클라이언트 임시 시간은 표시용)
마무리
useOptimistic는 Next.js에서 “서버 중심 데이터 흐름”을 유지하면서도, 사용자가 체감하는 인터랙션을 SPA 수준으로 끌어올리는 도구입니다. 중요한 건 낙관적 UI를 무조건 적용하는 것이 아니라, 도메인 위험도와 실패 시 UX를 함께 설계하는 것입니다.
댓글/좋아요 같은 작은 기능부터 useOptimistic로 즉시 반응을 만들고, 멱등성·정합성·에러 처리까지 한 세트로 갖추면 운영에서도 안정적으로 확장할 수 있습니다.