React 19 useOptimistic로 즉시 UI 피드백 제공: 낙관적 업데이트 복잡성 없이 AI 기능 상호작용에 대한 신뢰 구축

발행: (2026년 6월 6일 PM 05:47 GMT+9)
8 분 소요
원문: Dev.to

Source: Dev.to

Ugur Aslim

React 19 useOptimistic 로 즉각적인 UI 피드백 제공: 낙관적 업데이트 복잡성 없이 AI 기능 상호작용에 대한 신뢰 구축

CitizenApp에 AI 기능을 아홉 개나 배포했는데, 사용자가 Claude가 요청을 처리하는 동안 8~12초 동안 로딩 스피너를 바라보는 모습을 보았습니다. SaaS 환경에서는 그게 영원과도 같습니다. 기존 패턴—낙관적 상태를 위한 useState, 서버 호출을 위한 useEffect, 오류 시 수동 롤백—은 백엔드가 빠르더라도 느릿느릿하고 디버깅이 어려운 UI를 만들었습니다.

React 19의 useOptimistic 훅은 그 문제를 해결해 주었습니다. 단순한 추가 기능이 아니라, 지연이 불가피하지만 사용자 신뢰는 그렇지 않은 AI 중심 기능에 대한 패러다임 전환입니다.

AI 기능에 낙관적 업데이트가 중요한 이유

사용자가 AI 작업을 트리거할 때—문서 요약, 보고서 생성, 피드백 분석—즉시 무언가가 일어나길 기대합니다. 서버가 8초가 걸리더라도 UI가 멈춰서는 안 됩니다.

낙관적 업데이트는 이를 해결합니다: 요청이 성공할 것이라고 가정하고 UI를 즉시 업데이트합니다. 서버가 실패하면 롤백합니다. 사용자는 진행 상황을 보게 되고, 기능은 반응성이 있어 보입니다.

옛 패턴의 문제는 복잡성에 있습니다. 두 개의 상태 변수, 로딩 플래그, 오류 처리, 수동 롤백 로직을 모두 관리해야 했습니다. 레이스 컨디션이 발생하거나 오래된 데이터를 보여주기 쉬웠습니다.

useOptimistic을 선호하는 이유는 선언적이기 때문입니다. 낙관적 상태가 어떻게 될지 기술하면, React가 서버 응답 시 자동으로 롤백을 처리합니다. 수동 상태 정리 필요 없음. setTimeout 해킹도 없음. 레이스 컨디션도 없습니다.

useOptimistic 작동 방식

핵심 아이디어는 다음과 같습니다:

const [optimisticState, addOptimisticUpdate] = useOptimistic(
  state,
  (currentState, optimisticValue) => {
    // 즉시 새로운 상태를 반환
    return newState;
  }
);

addOptimisticUpdate(value) 를 호출하면 React는:

  • 낙관적 상태로 UI를 즉시 업데이트
  • 비동기 작업이 완료될 때까지 대기
  • 서버 응답이 오면 자동으로 롤백(또는 교체)

수동 상태 관리 필요 없음. 로딩 플래그도 없음. catch 블록에서 롤백 로직도 필요 없습니다.

실제 예시: AI 기반 문서 요약

CitizenApp의 “요약 생성” 기능을 구현한 방식을 보여드리겠습니다. 사용자는 문서를 업로드하고, Claude가 이를 분석한 뒤 요약을 즉시 표시합니다.

프론트엔드 (React 19 + TypeScript):

'use client';

import { useOptimistic, useState } from 'react';
import { generateDocumentSummary } from '@/lib/api';

interface Document {
  id: string;
  title: string;
  summary: string | null;
  isGenerating?: boolean;
}

export function DocumentCard({ document }: { document: Document }) {
  const [optimisticDocument, addOptimisticUpdate] = useOptimistic(
    document,
    (state, action: { type: string; payload: Partial<Document> }) => {
      if (action.type === 'GENERATE_SUMMARY') {
        return {
          ...state,
          ...action.payload,
          isGenerating: true,
        };
      }
      return state;
    }
  );

  const handleGenerateSummary = async () => {
    // 낙관적 업데이트: 자리표시자를 즉시 표시
    addOptimisticUpdate({
      type: 'GENERATE_SUMMARY',
      payload: {
        summary: 'Generating summary...',
      },
    });

    try {
      // Claude에 서버 호출
      const result = await generateDocumentSummary(document.id);

      // React가 자동으로 낙관적 상태를 서버 응답으로 교체
      // (form action 또는 useTransition 통합을 통해)
    } catch (error) {
      // 자동으로 원본 문서 상태로 롤백
      console.error('Failed to generate summary:', error);
    }
  };

  return (
    <div>
      <h3>{optimisticDocument.title}</h3>

      <p>{optimisticDocument.summary || 'No summary yet'}</p>

      <button onClick={handleGenerateSummary} disabled={optimisticDocument.isGenerating}>
        {optimisticDocument.isGenerating ? 'Generating...' : 'Generate Summary'}
      </button>
    </div>
  );
}

백엔드 (FastAPI + Python):

from fastapi import APIRouter, HTTPException
from anthropic import Anthropic

router = APIRouter()
client = Anthropic()

@router.post("/documents/{document_id}/summarize")
async def summarize_document(document_id: str):
    """Claude를 사용해 스트리밍 방식으로 빠른 인지 속도를 제공하는 요약 생성."""

    # 데이터베이스에서 문서 가져오기
    document = await db.documents.get(document_id)
    if not document:
        raise HTTPException(status_code=404, detail="Document not found")

    try:
        # Claude API 호출
        message = client.messages.create(
            model="claude-3-5-sonnet-20241022",
            max_tokens=500,
            messages=[
                {
                    "role": "user",
                    "content": f"Summarize this document concisely:\n\n{document.content}"
                }
            ]
        )

        summary = message.content[0].text

        # 데이터베이스에 저장
        await db.documents.update(document_id, {"summary": summary})

        return {
            "id": document_id,
            "summary": summary,
            "isGenerating": False
        }

    except Exception as e:
        # 클라이언트는 오류 응답 시 자동 롤백
        raise HTTPException(status_code=500, detail="Summary generation failed")

더 나은 패턴: useOptimistic + useTransition

진정으로 우아한 AI 상호작용을 위해서는 useOptimisticuseTransition과 함께 사용하세요. 이렇게 하면 비동기 로직을 직접 관리하지 않아도 UI 표시용 대기 상태를 얻을 수 있습니다:

'use client';

import { useOptimistic, useTransition } from 'react';

export function DocumentCard({ document }: { document: Document }) {
  const [isPending, startTransition] = useTransition();
  const [optimisticDocument, addOptimisticUpdate] = useOptimistic(
    document,
    (state, newSummary: string) => ({
      ...state,
      summary: newSummary,
    })
  );

  const handleGenerateSummary = () => {
    startTransition(async () => {
      // 낙관적: "Generating..." 즉시 표시
      addOptimisticUpdate('Generating summary...');

      try {
        const result = await generateDocumentSummary(document.id);
        // 서버 응답을 통해 영구 업데이트 (revalidatePath 등)
      } catch (error) {
        // 롤백은 자동으로 발생
      }
    });
  };

  return (
    <div>
      <p>{optimisticDocument.summary}</p>

      <button onClick={handleGenerateSummary} disabled={isPending}>
        {isPending ? 'Generating...' : 'Generate Summary'}
      </button>
    </div>
  );
}

주의점: 낙관적 상태는 네비게이션 간에 유지되지 않음

초기에 겪은 문제입니다: 요약을 낙관적으로 업데이트했는데 사용자가 페이지를 떠났다 돌아오니 낙관적 상태가 사라졌습니다. 서버에는 실제 데이터가 있었지만 UX가 끊긴 느낌이었습니다.

해결책: useOptimistic을 영구적인 상태와 항상 함께 사용하세요. 캐시 레이어(SWR, React Query, 혹은 Next.js 데이터 재검증)를 활용해 서버 응답이 새로운 진실 소스가 되도록 합니다.

// 서버 호출 후 재검증
const result = await generateDocumentSummary(document.id);
revalidatePath(`/documents/${document.id}`); // Next.js
// 또는
mutate(); // SWR 재조회

왜 SaaS에 중요한가

AI 기능은 느립니다. 이것은 현실입니다. 하지만 느림이 곧 나쁜 UX를 의미하지는 않습니다. useOptimistic을 사용하면 진행 상황과 신뢰를 즉시 보여줄 수 있어, 기능이 느리더라도 사용자 경험을 크게 향상시킬 수 있습니다.

0 조회
Back to Blog

관련 글

더 보기 »

모바일 한여름 열풍

!Cover image for Mobile Midsommer Madnesshttps://media2.dev.to/dynamic/image/width=1000,height=420,fit=cover,gravity=auto,format=auto/https%3A%2F%2Fdev-to-uploa...