Published on

Next.js useOptimistic로 즉시반응 UI 만들기

Authors

서버 액션이나 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 레벨에서 중복 제출 차단

  • useTransitionisPending으로 버튼 비활성화
  • 또는 요청마다 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는 “서버에서 내려오는 항목”과 “클라이언트 임시 항목”이 잠깐 공존할 수 있으므로, 다음 중 하나를 명확히 선택하세요.

  1. 성공 시 temp를 직접 치환한다 (위 예시)
  2. 성공 시 서버에서 최신 목록을 다시 받아 base state를 통째로 교체한다

두 방식을 섞으면 중복 제거 로직이 복잡해집니다.

실전 체크리스트

  • 낙관적 업데이트는 “실패 가능성이 낮고 롤백이 쉬운” 기능부터 적용
  • 임시 항목에는 temp-... 같은 접두사로 서버 ID와 구분
  • 중복 생성이 치명적이면 clientRequestId로 멱등성 확보
  • 성공 시 base state를 authoritative 데이터로 갱신
  • 실패 시 사용자에게 재시도 경로 제공(토스트, 실패 뱃지, 재전송 버튼)
  • 정렬 기준은 서버 시간을 우선(클라이언트 임시 시간은 표시용)

마무리

useOptimistic는 Next.js에서 “서버 중심 데이터 흐름”을 유지하면서도, 사용자가 체감하는 인터랙션을 SPA 수준으로 끌어올리는 도구입니다. 중요한 건 낙관적 UI를 무조건 적용하는 것이 아니라, 도메인 위험도와 실패 시 UX를 함께 설계하는 것입니다.

댓글/좋아요 같은 작은 기능부터 useOptimistic로 즉시 반응을 만들고, 멱등성·정합성·에러 처리까지 한 세트로 갖추면 운영에서도 안정적으로 확장할 수 있습니다.