React 19 useOptimistic 완벽 가이드: 상태 일관성을 유지하는 낙관적 UI 패턴

Maru

@maru

React 19 useOptimistic 완벽 가이드: 상태 일관성을 유지하는 낙관적 UI 패턴

React 19 useOptimistic 완벽 가이드: 상태 일관성을 유지하는 낙관적 UI 패턴

네트워크 속도가 비약적으로 빨라진 오늘날에도 클라이언트와 서버 사이의 물리적인 거리로 인한 지연 시간은 완벽하게 제거할 수 없습니다. 사용자가 대화창에 메시지를 입력하고 전송 버튼을 누르거나, 게시글에 좋아요를 표시할 때마다 매번 서버의 응답을 기다렸다가 화면을 갱신해야 한다면 아주 미세한 지연조차 사용자에게는 불필요한 답답함으로 다가옵니다.

이러한 문제를 우아하게 극복하기 위해 등장한 기법이 바로 낙관적 UI 패턴입니다. 서버에 요청을 보냄과 동시에 작업이 성공할 것이라 가정하고 화면에 선제적으로 변경 사항을 반영하는 방식입니다. 하지만 이 직관적인 패턴을 기존 리액트 환경에서 제대로 구현하기란 무척 까다로웠습니다. 비동기 요청을 보내기 전의 원래 상태를 직접 백업해두어야 하고, 네트워크 에러가 발생하면 이전 상태로 안전하게 되돌리는 복구 로직을 수동으로 작성해야 했기 때문입니다. 그뿐만 아니라 여러 비동기 요청이 순차적으로 중첩될 때 발생하는 경쟁 상태까지 관리하려 들면, 컴포넌트 내부 코드는 금세 복잡한 상태 정보가 얽히고설킨 늪으로 빠지기 일쑤였습니다.

리액트 19에서는 이러한 번거로운 상태 추적 문제를 우아하게 걷어내기 위해 useOptimistic이라는 강력한 내장 훅을 새롭게 선보였습니다. 이 훅은 리액트의 액션 및 트랜지션 라이프사이클과 완벽히 맞물려 작동하며, 개발자가 직접 구질구질한 에러 복구 로직을 짜지 않아도 프론트엔드 렌더러 수준에서 임시 상태를 가볍게 적용한 뒤 알아서 제자리로 되돌려줍니다.

이번 글에서는 기존의 수동 방식이 왜 그토록 수많은 개발자를 괴롭혔는지 먼저 되짚어보고, useOptimistic의 핵심적인 동작 메커니즘을 차근차근 알아보겠습니다. 나아가 리액트 19의 또 다른 핵심 제어 장치인 useActionState와 결합하는 실전 프로덕션 패턴부터 실무에서 반드시 마주치게 되는 트랜지션 경계 함정까지 함께 다루며, 사용자에게 끊김 없고 견고한 인터페이스를 선사하는 길잡이를 제공해 드리겠습니다.

기존의 낙관적 업데이트가 개발자를 괴롭혔던 이유

React 19 이전에는 낙관적 UI를 구현하는 과정이 매우 번잡했습니다. 개념 자체는 간단합니다. 서버의 응답을 기다리지 않고 화면을 먼저 업데이트한 뒤, 백엔드 요청이 실패하면 원래대로 되돌리는 것입니다. 하지만 이 직관적인 시나리오를 코드로 구현하는 순간, 개발자는 온갖 예외 상황과 복잡한 상태 관리에 직면하게 되었습니다.

기존 방식의 첫 번째 문제는 수동 롤백의 정교함이었습니다. 비동기 요청이 실패했을 때 화면을 이전 상태로 되돌리려면, 요청을 보내기 직전의 상태를 어딘가에 안전하게 백업해 두어야 했습니다. 얕은 복사만으로는 해결되지 않는 깊은 객체 구조나 배열 상태라면 백업 로직 자체가 비대해졌고, 사소한 참조 복사 실수로 인해 이전 데이터가 오염되는 일도 흔했습니다. 에러가 발생했을 때 백업해 둔 상태를 복원하는 과정에서 또 다른 상태 업데이트가 겹치면 화면이 일시적으로 깜빡이거나 엉뚱한 데이터를 보여주는 상태 불일치 버그가 자주 발생했습니다.

더 큰 문제는 네트워크 경쟁 상태였습니다. 예를 들어 사용자가 게시글의 좋아요 버튼을 빠르게 연속으로 누르는 상황을 생각해 보겠습니다. 클라이언트에서는 좋아요 추가, 취소, 다시 추가에 대응하는 비동기 요청이 순차적으로 발생합니다. 하지만 네트워크 환경에 따라 두 번째 취소 요청의 응답이 세 번째 추가 요청보다 늦게 도착할 수 있습니다.

이 경우 개발자가 별도의 방어 코드를 작성하지 않았다면, 마지막에 도착한 취소 응답이 최종 상태로 덮어쓰여 사용자의 최종 의도인 좋아요 추가 상태와 어긋나게 됩니다. 이를 해결하기 위해 요청마다 고유한 식별자를 부여하거나, 네트워크 요청을 취소하는 복잡한 제어 로직을 직접 구현해야 했습니다.

결국 사소한 인터페이스 개선 하나를 위해 수많은 상태 변수와 복잡한 비동기 흐름 제어 코드가 얽히게 되며, 이는 전체 애플리케이션의 유지보수성을 떨어뜨리는 주범이 되었습니다. 바로 이러한 고통스러운 배경 속에서, 낙관적 업데이트의 생명주기를 프레임워크 수준에서 우아하게 추상화하기 위해 등장한 도구가 바로 React 19의 useOptimistic입니다.

useOptimistic 훅의 기본 구조와 동작 원리

React 19가 선보인 useOptimistic 훅은 기존의 복잡했던 상태 동기화와 에러 롤백 메커니즘을 프론트엔드 엔진 내부로 내재화하여 완전히 자동화합니다. 이 훅의 본질은 실제 데이터인 '신뢰할 수 있는 단일 원천'과 화면에 즉각적으로 보여줄 '임시 상태'를 영리하게 분리하는 데 있습니다.

API 구조와 파라미터 이해하기

useOptimistic 훅의 기본 사용법은 단순하지만 강력합니다. 기본적인 호출 구조는 다음과 같습니다.

javascript
const [optimisticState, addOptimistic] = useOptimistic(
  state,
  (currentState, optimisticValue) => {
    // 새로운 낙관적 상태를 계산하여 반환합니다.
    return nextState;
  }
);

이 훅은 두 개의 인자를 받으며, 결과물로 현재 렌더링에 적용할 상태와 임시 상태를 추가할 업데이트 함수를 배열 형태로 반환합니다.

  • 기준 상태: 첫 번째 인자로 전달하는 값으로, 서버 응답이나 부모 컴포넌트의 상태처럼 동기화가 완료된 실제 원천 데이터입니다. 진행 중인 비동기 작업이 없을 때 이 훅은 항상 이 기준 상태를 그대로 반환합니다.
  • 업데이트 함수: 두 번째 인자로 전달하는 순수 함수입니다. 기존 상태와 임시 데이터를 인자로 받아 새로운 화면을 그릴 낙관적 상태를 계산하여 반환합니다. 마치 상태 관리 라이브러리의 리듀서와 유사한 방식으로 작동합니다.
  • 낙관적 상태: 컴포넌트가 화면을 그릴 때 참조해야 하는 결과값입니다. 비동기 작업이 없을 때는 기준 상태와 동일하지만, 비동기 작업이 시작되면 업데이트 함수가 계산한 임시 상태로 즉시 변경됩니다.
  • 임시 상태 추가 함수: 이 함수를 호출하여 화면에 즉각적으로 반영할 임시 데이터를 전달합니다. 호출 시 전달한 값은 업데이트 함수의 두 번째 인자로 주입됩니다.

핵심 동작 원리: 트랜지션 컨텍스트와 라이프사이클

이 훅이 개발자의 수동 롤백 개입 없이 부드럽게 동작할 수 있는 비결은 바로 트랜지션 컨텍스트와의 강력한 결합에 있습니다. useOptimistic은 단순히 상태를 바꾸는 독립적인 장치가 아니라, React의 비동기 트랜지션 내부에서 생명주기가 관리되는 특수한 상태 인터페이스입니다.

동작 과정은 다음과 같은 3단계 흐름으로 이어집니다.

  1. 임시 상태 트리거: 사용자의 상호작용이 일어나면 비동기 액션이 시작됩니다. 이 액션 함수 내부에서 임시 상태 추가 함수를 호출하면서 임시 값을 주입합니다.
  2. 즉각적인 렌더링: React는 비동기 요청의 응답을 기다리지 않고, 즉시 업데이트 함수를 실행하여 임시 상태를 만들어내고 화면을 다시 그립니다. 사용자에게는 지연 시간이 전혀 느껴지지 않는 즉각적인 반응성이 제공됩니다.
  3. 자동 동기화 및 정리: 비동기 액션 처리가 최종적으로 종료되면, React는 트랜지션의 완료를 감지하고 임시로 가해졌던 상태 변화를 완전히 걷어냅니다. 그리고 서버의 응답 결과가 반영되어 새롭게 업데이트된 기준 상태로 화면을 최종 일치시킵니다. 만약 비동기 요청이 실패했다면 원래 상태 그대로 화면이 유지되므로, 개발자가 에러 처리를 위해 롤백 코드를 작성할 필요가 전혀 없습니다.

결과적으로 개발자는 복잡한 예외 처리나 임시 상태 백업용 변수를 선언하는 번거로운 작업에서 벗어나, 데이터의 흐름을 선언적으로 정의하는 데만 집중할 수 있게 됩니다.

useActionState와 결합하는 실전 프로덕션 패턴

프로덕션 환경에서 useOptimistic은 단독으로 쓰이기보다, React 19에서 함께 도입된 또 다른 상태 제어 장치인 useActionState와 결합할 때 강력한 시너지를 냅니다. 두 훅이 결합하면 클라이언트의 즉각적인 피드백과 서버의 실제 응답 데이터를 하나의 유기적인 상태 머신으로 관리할 수 있게 됩니다.

이 구조의 핵심은 역할 분담에 있습니다. useActionState는 서버로부터 검증을 마친 최종 결과물, 즉 신뢰할 수 있는 단 하나의 원천 상태를 관리합니다. 반면 useOptimistic은 비동기 통신이 완료되기 전까지 해당 상태 위에 덧씌워지는 가상의 임시 화면 레이어를 담당합니다.

특히 대규모 트래픽이 발생하는 서비스나 실시간 댓글, 메시지 피드 등을 설계할 때 흔히 마주치는 고질적인 버그가 있습니다. 서버 응답이 뒤늦게 도착하면서 낙관적으로 그려둔 데이터와 서버에서 새로 받은 데이터가 충돌해 화면에 중복 아이템이 일시적으로 노출되거나(이른바 유령 데이터 현상), 정렬 순서가 뒤틀리는 현상입니다.

이를 우아하게 해결하기 위한 프론트엔드 설계 표준이 바로 클라이언트 고유 식별자 공유 기법입니다. 클라이언트에서 먼저 고유한 UUID를 생성하여 이를 낙관적 상태의 식별자로 사용하고, 동일한 UUID를 서버로 보낼 전송 데이터에 포함하는 것입니다. 서버가 데이터베이스에 새 레코드를 생성할 때 클라이언트가 보내온 UUID를 그대로 기본 키로 사용하게 만들면, 서버가 최종 상태를 다시 전달했을 때 클라이언트 뷰 레이어가 ID 불일치로 인한 화면 깜빡임이나 데이터 중복 없이 완벽히 동일한 요소로 인식하여 부드럽게 렌더링 상태를 이어받을 수 있습니다.

이 두 가지 훅과 고유 식별자 기법을 결합하여 실무에서 즉시 활용할 수 있는 TypeScript 기반의 완전한 댓글 시스템 예제를 살펴보겠습니다.

tsx
"use client";

import { useActionState, useOptimistic, useRef } from "react";

// 데이터 구조 정의
interface Comment {
  id: string;
  text: string;
  isSending?: boolean;
}

interface CommentState {
  comments: Comment[];
  error: string | null;
}

// 1. 임시 서버 액션 시뮬레이션 함수
// 실무에서는 외부 파일에 정의하거나 실제 'use server' 지시어를 사용하는 서버 액션으로 구현합니다.
async function createCommentAction(prevState: CommentState, formData: FormData): Promise<CommentState> {
  const text = formData.get("text") as string;
  const id = formData.get("id") as string;

  try {
    // 2초의 네트워크 지연 시간 구현
    await new Promise((resolve) => setTimeout(resolve, 2000));

    if (!text || text.trim() === "") {
      throw new Error("댓글 내용을 올바르게 입력해주세요.");
    }

    const newComment: Comment = { id, text };
    
    return {
      comments: [...prevState.comments, newComment],
      error: null,
    };
  } catch (err: any) {
    return {
      ...prevState,
      error: err.message || "댓글 전송 중 알 수 없는 오류가 발생했습니다.",
    };
  }
}

export function CommentSection({ initialComments }: { initialComments: Comment[] }) {
  // 2. useActionState의 실제 뼈대 상태를 감싸서 실시간 업데이트용 가상 상태 구성\
  const [optimisticComments, addOptimisticComment] = useOptimistic<Comment[], Comment>(
    state.comments,
    (currentList, newComment) => [...currentList, newComment]
  );

  // 3. useActionState로 신뢰할 수 있는 최종 서버 상태 관리
  const [state, submitAction, isPending] = useActionState<CommentState, FormData>(
    async (prevState, formData) => {
      const text = formData.get("text") as string;
      
      // 4. 클라이언트 단에서 고유 ID 선제적 생성
      const tempId = crypto.randomUUID();

      // 5. 서버 통신을 시작하기 직전에 즉시 화면에 임시 데이터를 반영
      addOptimisticComment({ 
        id: tempId, 
        text, 
        isSending: true 
      });

      // 동일한 ID를 FormData에 포함하여 서버 데이터베이스에서도 사용하도록 강제
      formData.append("id", tempId);

      // 서버 비동기 함수 실행 및 최종 결과 반환
      return await createCommentAction(prevState, formData);
    },
    { comments: initialComments, error: null }
  );

  const formRef = useRef<HTMLFormElement>(null);

  // 폼 제출 핸들러
  const handleSubmit = (formData: FormData) => {
    submitAction(formData);
    // 제출 즉시 사용자가 입력한 필드를 초기화하여 반응성 극대화
    formRef.current?.reset();
  };

  return (
    <div className="w-full max-w-md p-6 bg-white rounded-lg shadow-sm border border-gray-100">
      <h3 className="text-lg font-semibold text-gray-800 mb-4">댓글</h3>
      
      <ul className="space-y-3 mb-6 max-h-80 overflow-y-auto">
        {optimisticComments.map((comment) => (
          <li
            key={comment.id}
            className={`p-3 rounded-lg border transition-all duration-200 ${
              comment.isSending
                ? "border-gray-200 bg-gray-50 text-gray-400 border-dashed"
                : "border-gray-100 bg-white text-gray-700"
            }`}
          >
            <div className="flex justify-between items-center">
              <span className="text-sm font-medium">{comment.text}</span>
              {comment.isSending && (
                <span className="text-xs font-semibold px-2 py-0.5 rounded-full bg-gray-200 text-gray-500 animate-pulse">
                  전송 중
                </span>
              )}
            </div>
          </li>
        ))}
      </ul>

      {state.error && (
        <div className="p-3 mb-4 text-sm text-red-600 bg-red-50 rounded-lg">
          {state.error}
        </div>
      )}

      <form ref={formRef} action={handleSubmit} className="flex gap-2">
        <input
          name="text"
          placeholder="댓글 내용을 작성해 주세요"
          disabled={isPending}
          className="flex-1 px-4 py-2 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50"
          required
        />
        <button
          type="submit"
          disabled={isPending}
          className="px-4 py-2 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 disabled:opacity-50"
        >
          등록
        </button>
      </form>
    </div>
  );
}

이 패턴이 동작하는 원리를 단계별로 추적해 보면 아주 명확한 단방향 동기화 주기가 보입니다.

첫째, 사용자가 입력 상자에 텍스트를 적고 등록 버튼을 누르는 순간, handleSubmit이 작동하면서 폼이 비동기 트랜지션 내부에서 제출됩니다. 둘째, useActionState의 첫 매개변수로 작성된 비동기 처리 함수가 실행됩니다. 이 함수 안에서 가장 먼저 수행되는 것은 클라이언트 단의 UUID 생성과 addOptimisticComment 호출입니다. 이 시점에 화면에는 아직 서버로부터 응답을 받지 않았으나, 흐릿하게 처리된 댓글 요소가 즉각 생성됩니다. 셋째, 서버에 전송할 양식 데이터 뒤에 이 미리 할당해 둔 ID를 숨겨서 동봉한 다음, 네트워크 비동기 통신을 전송합니다. 넷째, 서버가 요청을 성공적으로 받아 동일한 ID로 레코드를 기록한 뒤 새 결과 목록을 돌려주면, useActionState는 이 새로운 실제 상태를 받아 컴포넌트를 갱신합니다. 이때 React의 렌더 엔진은 뒤에서 돌고 있던 트랜지션이 무사히 정착했음을 확인하고, 임시로 덧대두었던 낙관적 레이어를 폐기합니다. 만약 예외가 발생해 서버 처리 과정에서 오류가 반환된다 하더라도, 트랜지션이 끝나는 타이밍에 맞춰 낙관적 값은 가차 없이 폐기되며 state.error 영역에만 에러 메시지가 떠 자연스러운 롤백이 구현됩니다.

이처럼 두 훅의 결합은 복잡한 상태 분기를 개발자가 손수 추적하며 제어해야 했던 시절의 고통을 획기적으로 줄여줍니다. 그러나 이 모든 내부 동작의 기반에는 리액트 프론트엔드가 트랜지션의 시작과 완료 시점을 정밀하게 추적할 수 있어야 한다는 절대적인 전제 조건이 숨어 있습니다. 이 메커니즘을 제대로 숙지하지 않은 채 비동기 함수 바깥에서 값을 갱신하면 예기치 못한 상태 고착이나 화면 깨짐 현상에 부딪힐 수 있습니다. 이어지는 장에서는 프로덕션 환경에서 가장 흔히 마주치는 트랜지션 경계 유실 현상과, React의 내부 렌더 엔진이 어떻게 이 흐름을 청소하는지 그 내부 구동 원리를 자세히 뜯어보겠습니다.

프로덕션 환경의 함정: 트랜지션 경계와 일관성 유지

앞서 실전 코드 예제에서 살펴본 것처럼 useOptimisticuseActionState를 함께 사용하면 클라이언트와 서버의 상태를 매끄럽게 연결할 수 있습니다. 하지만 이 우아한 설계 뒤에는 프로덕션 환경에서 많은 개발자가 무심코 빠지는 치명적인 함정이 숨어 있습니다. 바로 화면에 반영된 임시 상태가 실제 데이터가 업데이트된 이후에도 원래대로 돌아오지 않거나 영구히 남아있는 버그입니다.

이러한 현상이 발생하는 원인을 파악하려면 React 19가 임시 상태를 자동으로 되돌리는 내부 동작 메커니즘을 명확히 이해해야 합니다.

트랜지션이라는 숨은 제어 장치

useOptimistic이 반환하는 업데이트 함수는 호출되는 즉시 화면을 갱신하지만, 이 임시 상태의 수명은 무한하지 않습니다. React는 이 임시 상태가 언제까지 유지되어야 하는지 알아야 하며, 비동기 작업이 성공하든 실패하든 결국에는 이를 폐기하고 실제 서버에서 온 신뢰할 수 있는 데이터로 화면을 대체해야 합니다.

이때 수명을 결정하는 기준이 바로 트랜지션 경계입니다. useOptimistic은 단독으로 상태의 수명을 계산하지 못합니다. 반드시 startTransition이나 폼 액션과 같은 트랜지션 컨텍스트 내부에서 실행되어야만 정상적인 라이프사이클이 작동합니다.

렌더링 사이클에서 벌어지는 일

트랜지션 내부에서 비동기 액션과 낙관적 업데이트가 실행될 때, React의 내부 엔진은 다음과 같은 단계를 거쳐 상태 일관성을 유지합니다.

  1. 트랜지션 시작: 비동기 함수가 실행되면서 트랜지션이 활성화됩니다. React는 이 트랜지션 내부에서 발생하는 상태 업데이트를 감시하기 시작합니다.
  2. 낙관적 상태 반영: 개발자가 업데이트 함수를 호출하여 임시 데이터를 주입하면, React는 기존 소스 상태에 이 임시 데이터를 적용하여 가상의 상태를 계산하고 즉시 화면을 다시 그립니다.
  3. 비동기 작업 추적: React는 트랜지션 내부의 비동기 작업이 완료될 때까지 기다립니다. 이 과정에서 다른 상태 업데이트가 발생하더라도 낙관적 업데이트 큐를 순서대로 계산하여 렌더링을 유지합니다.
  4. 자동 롤백 및 데이터 동기화: 비동기 작업이 마침내 종료되면 트랜지션도 완료 상태로 전환됩니다. 이때 React는 이전에 적용했던 낙관적 업데이트를 메모리에서 깨끗하게 지워버립니다. 그리고 서버 응답을 반영한 최신 상태를 바탕으로 화면을 다시 그려 자연스럽게 최종 데이터로 전환합니다.

트랜지션 경계를 벗어났을 때의 재앙

만약 일반적인 클릭 이벤트 핸들러나 비동기 콜백 바깥에서 업데이트 함수를 직접 호출하면 어떻게 될까요?

React는 트랜지션 컨텍스트가 없기 때문에 이 낙관적 업데이트가 어떤 비동기 작업과 연관되어 있는지 전혀 알지 못합니다. 추적할 비동기 컨텍스트 자체가 존재하지 않으므로, 작업의 시작과 끝을 인지할 수 없습니다. 결국 React는 임시 상태를 언제 폐기해야 하는지 모르는 상태에 빠지게 되고, 자동 롤백 메커니즘이 전혀 작동하지 않아 가짜 데이터가 화면에 영구적으로 고착되는 유령 UI 상태가 발생합니다.

따라서 useOptimistic을 적용할 때는 단순히 상태를 변경하는 것에 그치지 않고, 해당 변경을 일으키는 트리거가 반드시 React가 관리하는 트랜지션의 영향력 아래에 있는지를 검증하는 설계가 필수적입니다.

마무리: 부드럽고 견고한 사용자 경험 설계하기

useOptimistic은 단순히 화면에 가짜 데이터를 먼저 보여주는 인터페이스 구현 도구가 아닙니다. 프론트엔드 개발자를 오랫동안 괴롭혔던 수동 롤백 로직과 클라이언트-서버 간의 임시 상태 불일치 문제를 React 엔진 수준에서 우아하게 처리해 주는 강력한 상태 관리 장치입니다.

이 패턴을 성공적으로 도입하기 위해 기억해야 할 핵심은 역할의 분리와 트랜지션 경계의 명확성입니다. useActionState를 통해 서버가 보증하는 최종 신뢰 원천을 유지하고, useOptimistic으로 비동기 요청 중의 임시 상태를 관리하는 구조는 코드의 복잡성을 낮추고 예측 가능성을 극대하게 높여줍니다. 단, 트랜지션 내부에서 비동기 연산이 진행되는 동안만 임시 상태가 유지된다는 React 19의 내부 동작 방식을 항상 명심해야 합니다. 트랜지션 컨텍스트가 제대로 유지되지 않거나 예외 처리가 누락되면 임시 상태가 정상적으로 정리되지 않고 화면에 남아있는 고스트 UI 버그로 이어질 수 있기 때문입니다.

낙관적 UI는 모든 상황에서 만능 해결책이 될 수는 없습니다. 결제나 중요한 정보의 영구 삭제처럼 정밀한 서버 측 검증과 즉각적인 결과 신뢰성이 필수적인 기능보다는, 좋아요 클릭, 북마크 추가, 실시간 대화창 메시지 전송과 같이 빈도가 높고 실패 확률이 낮은 일상적인 사용자 상호작용에서 진가를 발휘합니다.

이번 가이드를 통해 React 19의 새로운 액션 시스템과 낙관적 UI 흐름을 설계하는 직관을 얻으셨기를 바랍니다. 다음 파트에서는 대규모 프로덕션 환경에서 발생할 수 있는 미세한 데이터 경쟁 상태 대응 기법과, 전역 상태 라이브러리와의 유기적인 설계 전략을 바탕으로 깊이 있는 프론트엔드 엔지니어링 실무 패턴을 이어 나가겠습니다.

(수정됨)