애드블럭 종료 후 사이트를 이용해 주세요.

ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • React useOptimistic vs React Query optimistic updates: 낙관적 업데이트, 무엇을 선택할까?
    React 2025. 9. 1. 18:59
    728x90
    반응형

    사용자가 어떤 액션을 취했을 때 서버 응답을 기다리지 않고 UI에 미리 성공한 것처럼 반영하는 것을 낙관적 업데이트라고 합니다. 

    예를 들어 SNS에서 댓글을 달면 서버 응답 전에 곧바로 댓글이 화면에 나타나고 “전송 중...”이라고 표시되는 경험을 본 적이 있을 것입니다.

    성공 시에는 그대로 두고, 실패 시에는 UI를 다시 원래대로 돌립니다. 

    이러한 기법은 미묘한 시간 지연으로 인한 답답함을 줄여 사용자 경험을 개선하지만, 동시에 실패 시 UI를 되돌리고 오류를 알리는 처리가 중요합니다.

     

    낙관적 UI 업데이트를 활용하면 서버 처리 중에도 “Sending...”과 같은 상태를 즉시 표시할 수 있습니다. 

    위 이미지는 사용자가 댓글을 작성하자마자 UI에 (Sending...) 라벨이 나타나는 예시입니다. 

    서버 응답 전에 UI가 먼저 반응하므로 사용자는 앱이 매우 빠르게 느껴집니다. 

    만약 이후 서버에서 에러가 발생하면, 해당 댓글을 UI에서 제거하고 오류 메시지를 보여주면 됩니다. 

    이러한 낙관적 업데이트 전략은 SNS, 메신저 등 대부분 성공이 예상되는 액션에서 앱의 반응성을 크게 향상시킵니다.

    1. 낙관적 업데이트의 본질: '빠르게 보이는' 기술의 모든 것

    낙관적 업데이트는 비동기 작업(주로 서버 API 요청)이 성공할 것이라고 '낙관적으로' 가정하고, 서버의 확인 응답을 받기 전에 UI를 먼저 업데이트하는 디자인 패턴입니다.

    핵심 동작 원리는 다음과 같은 단계로 이루어집니다.

    1. 사용자 액션: 사용자가 데이터를 변경하는 작업을 수행합니다. (예: 댓글 제출, 상품 찜하기)
    2. 즉각적인 UI 업데이트: 클라이언트는 서버의 응답을 기다리지 않고, 예상되는 성공 결과를 기반으로 UI를 즉시 업데이트합니다. 이때 사용되는 데이터는 '가짜지만 정확한 데이터(fake but correct data)'입니다.  
    3. 백그라운드 요청: 실제 서버로의 API 요청은 백그라운드에서 비동기적으로 전송됩니다.
    4. 서버 응답 처리:
      • 성공 시: 서버가 성공을 확인하면, 임시로 보여주던 UI 상태가 최종적으로 확정됩니다. 대부분의 경우 추가적인 UI 변경은 필요 없습니다.
      • 실패 시: 서버가 에러를 반환하면, UI는 이전 상태로 되돌아가야 합니다. 이를 롤백(Rollback)이라고 하며, 사용자에게 실패 사실을 명확히 알려주는 것이 중요합니다.

    1.2. 왜 중요한가?

    낙관적 업데이트의 가장 큰 가치는 체감 성능의 극적인 향상에 있습니다.

    실제 네트워크 지연 시간은 변하지 않지만, 사용자는 즉각적인 피드백을 통해 시스템이 매우 빠르고 반응성이 좋다고 느끼게 됩니다.

    이는 높은 사용자 만족도로 이어집니다.   

    그리고 Core Web Vitals 중 하나인 INP(Interaction to Next Paint)와 직접적인 관련이 있습니다.

    INP는 사용자의 인터랙션(클릭, 탭 등)에 대해 시각적 피드백이 얼마나 빨리 제공되는지를 측정하는 지표입니다.

    낙관적 업데이트는 로딩 상태를 보여주는 대신 즉각적으로 UI를 변경함으로써 이 지표를 크게 개선할 수 있습니다.

    우수한 INP 점수는 더 나은 사용자 경험을 의미한다고 할 수 있습니다.

    1.3. 핵심 트레이드오프: 개발 복잡성 vs. 사용자 경험

    낙관적 업데이트는 마법이 아닙니다.

    이 강력한 사용자 경험 향상 기법의 이면에는 명확한 트레이드오프가 존재합니다. 바로 데이터 일관성을 보장하기 위한 개발 복잡성의 증가입니다.

    우리는 '보장된 데이터 일관성'을 잠시 포기하는 대가로 '체감 속도'를 얻습니다.

    이 과정에서 개발자는 두 가지 상태, 즉 서버가 가진 '진짜 상태(Source of Truth)'와 클라이언트가 먼저 보여주는 '낙관적 상태'를 모두 관리해야 합니다.

    만약 서버 요청이 실패했을 때, 이 불일치를 해소하고 UI를 원래대로 되돌리는 롤백 로직을 견고하게 구현하는 것은 상당한 노력이 필요한 작업입니다.  

     

    따라서 useOptimistic과 React Query를 비교하는 것은 단순히 어떤 API가 더 편한지를 논하는 것이 아닙니다.

    이는 '어떤 도구가 이 트레이드오프를 우리 팀과 프로젝트의 상황에 맞게 가장 효과적으로 관리해주는가?'에 대한 아키텍처적 질문입니다.

    이 관점을 가지고 두 기술을 깊이 있게 파헤쳐 보겠습니다.

    2. React의 순정, useOptimistic 파헤치기

    React 19에서 정식 도입된 useOptimistic 훅은 낙관적 업데이트를 React의 핵심 기능으로 끌어들인, 매우 직관적인 해결책입니다.

    useOptimistic 훅의 시그니처는 매우 간단합니다.

    const = useOptimistic(state, updateFn);

     

    • state: '진실의 원천(Source of Truth)'이 되는 실제 상태입니다. 이 값은 부모 컴포넌트로부터 받은 props일 수도 있고, useState나 useReducer로 관리되는 상태일 수도 있습니다. 비동기 작업이 완료되면 UI는 최종적으로 이 state 값을 따르게 됩니다.
    • updateFn: 순수 함수(Pure Function)여야 하며, 현재 state와 addOptimistic 함수에 전달된 값을 인자로 받아 새로운 낙관적 상태를 반환합니다. 예를 들어, (currentComments, newComment) => [...currentComments, newComment]와 같은 형태입니다.  
    • optimisticState: UI에 실제로 렌더링되는 값입니다. 평소에는 state와 동일한 값을 유지하다가, addOptimistic이 호출되면 updateFn이 반환한 값을 즉시 반영합니다. 그리고 감싸고 있는 비동기 작업이 끝나면 자동으로 state 값으로 되돌아갑니다.
    • addOptimistic: 낙관적 업데이트를 시작하기 위해 호출하는 함수입니다.

    2.2. 본질은 'UI 데코레이터', '상태 관리자'가 아니다

    useOptimistic을 처음 접할 때 흔히 하는 오해는 이를 useState와 같은 상태 관리 훅으로 생각하는 것입니다.

    하지만 그 본질은 다릅니다.

    useOptimistic은 스스로 영속적인 상태를 만들고 관리하지 않습니다.

    대신, 기존에 존재하는 '진실의 원천' 상태를 인자로 받아, 비동기 작업이 진행되는 동안에만 일시적으로 다른 값을 보여주도록 '장식(Decorate)'하는 역할을 합니다.

     

    작동 방식을 생각해보면 명확해집니다.

    비동기 작업이 끝나면 React는 복잡한 상태 병합 로직을 수행할 필요 없이, 그저 '장식'을 떼어내고 원래의 state 변수를 다시 렌더링하기만 하면 됩니다.

    만약 비동기 작업의 성공으로 인해 원래의 state 자체가 업데이트되었다면, UI는 낙관적 상태에서 새로운 '진짜' 상태로 아주 매끄럽게 전환됩니다.

    이러한 멘탈 모델은 useOptimistic의 한계와 이상적인 사용 사례를 이해하는 데 매우 중요합니다.

    이 훅은 복잡한 상태 관리 도구가 아니라, 얇고 가벼운 UI 표현 계층으로 설계되었습니다.

    2.3. 자동 롤백의 마법

    useOptimistic의 가장 큰 매력은 자동 롤백 기능입니다.

    폼 액션과 같은 비동기 함수가 성공적으로 완료되지 않고 에러를 던지거나 끝나버리면, React는 별도의 처리 없이 즉시 optimisticState를 버리고 원래의 state 값으로 UI를 되돌립니다.

    이는 에러 핸들링 코드를 극적으로 단순화시킵니다.

    더 이상 catch 블록 안에서 setState(previousState)와 같은 코드를 수동으로 호출할 필요가 없습니다.

    state를 업데이트하는 데 실패했다는 사실 자체가 곧 롤백 메커니즘이 되는 것입니다.

    2.4. 실무 적용: Next.js 서버 액션과의 환상의 조합

    useOptimistic은 Next.js의 서버 액션(Server Actions)과 함께 사용될 때 그 진가가 드러납니다.

    서버 액션은 데이터 변경 로직을 서버에서 실행되는 비동기 함수로 정의하고, 클라이언트 컴포넌트에서 직접 호출할 수 있게 하여 API 레이어를 단순화하는 강력한 기능입니다.

     

    실시간 채팅 애플리케이션 예제를 통해 이 둘의 조합을 살펴보겠습니다.

     

    1. 서버 액션 정의

    // app/actions.ts
    'use server';
    
    import { revalidatePath } from 'next/cache';
    
    // 이 예제에서는 실제 DB 대신 임시 배열을 사용합니다.
    const messages: { text: string } = {};
    
    // 서버에서만 실행되는 비동기 함수
    export async function sendMessage(formData: FormData) {
      const messageText = formData.get('message') as string;
    
      if (!messageText) {
        throw new Error('Message cannot be empty');
      }
    
      // 1초 지연으로 네트워크 레이턴시 시뮬레이션
      await new Promise(resolve => setTimeout(resolve, 1000));
    
      messages.push({ text: messageText });
    
      // 데이터 변경 후 해당 경로의 캐시를 무효화하여 UI를 최신 상태로 업데이트
      revalidatePath('/');
    }

     

     

    2. 페이지 및 데이터 페칭

    // app/page.tsx
    
    import { Thread } from './thread';
    import { sendMessage } from './actions';
    
    // 이 예제에서는 서버 액션 파일의 messages 배열을 직접 가져옵니다.
    // 실제 앱에서는 DB에서 데이터를 조회해야 합니다.
    import { messages } from './actions'; 
    
    export default function Home() {
      return (
        <main>
          <h1>Optimistic Chat</h1>
          {/* Thread 컴포넌트에 초기 메시지 목록과 서버 액션을 전달 */}
          <Thread messages={messages} sendMessageAction={sendMessage} />
        </main>
      );
    }

     

    3. useOptimistic 사용 컴포넌트

    // app/thread.tsx
    
    'use client';
    
    import { useOptimistic, useRef, startTransition } from 'react';
    
    type Message = { text: string; sending?: boolean };
    
    export function Thread({
      messages,
      sendMessageAction,
    }: {
      messages: Message;
      sendMessageAction: (formData: FormData) => Promise<void>;
    }) {
      const formRef = useRef<HTMLFormElement>(null);
    
      const [optimisticMessages, addOptimisticMessage] = useOptimistic<Message, string>(
        messages,
        // 현재 메시지 목록(state)과 새 메시지 텍스트(newMessageText)를 받아
        // 'Sending...' 상태가 포함된 새 배열을 반환
        (currentMessages, newMessageText) =>
      );
    
      const formAction = async (formData: FormData) => {
        const messageText = formData.get('message') as string;
        if (!messageText) return;
    
        // 입력 필드를 즉시 비움
        formRef.current?.reset();
        
        // startTransition으로 UI 업데이트를 감싸 우선순위를 낮춤
        startTransition(() => {
            // 낙관적 업데이트 실행: UI가 즉시 변경됨
            addOptimisticMessage(messageText);
        });
    
        // 실제 서버 액션 호출
        await sendMessageAction(formData);
      };
    
      return (
        <div>
          {optimisticMessages.map((msg, index) => (
            <div key={index} style={{ opacity: msg.sending? 0.5 : 1 }}>
              {msg.text} {msg.sending && <small> (Sending...)</small>}
            </div>
          ))}
          <form action={formAction} ref={formRef}>
            <input type="text" name="message" placeholder="Type a message..." />
            <button type="submit">Send</button>
          </form>
        </div>
      );
    }

    2.5. 언제 사용해야 할까?

    • 이상적인 시나리오: Next.js App Router 환경에서 서버 액션을 사용한 폼(form) 기반의 데이터 제출.
    • 강점: 압도적인 단순함, 최소한의 보일러플레이트, 자동 롤백, 그리고 최신 React/Next.js 데이터 변경 모델과의 완벽한 통합.
    • 한계: 여러 컴포넌트에 걸쳐 공유되는 전역 상태에 영향을 미치는 데이터 변경이나, 서버 액션 추상화 없이 전통적인 REST/GraphQL API를 직접 호출하는 환경에서는 상대적으로 효용성이 떨어집니다.

    3. 업계 표준, React Query 낙관적 업데이트 정복하기

    React Query(현 TanStack Query)는 프론트엔드 생태계에서 서버 상태 관리의 사실상 표준으로 자리 잡았습니다.

    카카오페이, 배달의민족 등 국내 유수의 기업들이 Redux의 복잡성을 해결하고 개발 생산성과 사용자 경험을 동시에 잡기 위해 React Query를 채택했습니다.

    React Query가 제공하는 낙관적 업데이트는 useOptimistic과는 철학부터 다른, 강력하고 정교한 접근 방식을 취합니다.

    3.1. 아키텍처 접근법: 중앙 캐시 직접 조작

    useOptimistic이 컴포넌트의 state나 props를 일시적으로 장식하는 컴포넌트 범위(Component-Scoped)의 접근 방식이라면, React Query는 전혀 다른 차원에서 동작합니다.

    React Query의 핵심은 애플리케이션 전역에서 공유되는 서버 상태 캐시입니다.

    React Query로 낙관적 업데이트를 수행할 때, 우리는 queryClient.setQueryData와 같은 메서드를 사용해 이 중앙 캐시를 직접 조작합니다.

    이는 컴포넌트의 지역 상태를 변경하는 것과는 근본적으로 다릅니다.

     

    이 아키텍처의 가장 큰 장점은 애플리케이션 범위(Application-Scoped)의 업데이트가 가능하다는 점입니다.

    캐시를 직접 변경하면, 동일한 queryKey를 구독하고 있는 애플리케이션 내의 모든 useQuery 훅이 자동으로 리렌더링되어 새로운 낙관적 데이터를 즉시 화면에 반영합니다.

    예를 들어, 상품 목록 페이지에서 어떤 상품을 '찜'했을 때, 화면 다른 곳에 있는 '찜한 상품' 위젯의 숫자도 동시에, 자동으로 업데이트되는 경험을 구현할 수 있습니다.

    이것이 복잡한 애플리케이션에서 React Query가 빛을 발하는 이유입니다.

    3.2. 수동 워크플로우: onMutate → onError → onSettled

    React Query의 강력함은 개발자에게 더 많은 제어권을 주는 데서 나옵니다.

    이는 useMutation 훅의 콜백 함수들을 통해 정교한 수동 워크플로우를 직접 구현해야 함을 의미합니다.

    1. onMutate (준비 단계): mutationFn(실제 API 요청 함수)이 실행되기 직전에 호출됩니다. 낙관적 업데이트의 모든 준비 작업이 여기서 이루어집니다.
      • 쿼리 취소: await queryClient.cancelQueries({ queryKey: [...] }). 가장 중요하고 필수적인 단계입니다. 잠시 후 자세히 다루겠습니다.
      • 이전 상태 스냅샷: const previousData = queryClient.getQueryData(...)를 사용해 현재 캐시된 데이터를 백업합니다. 롤백을 위한 생명줄입니다.
      • 낙관적 업데이트 실행: queryClient.setQueryData(...)를 호출하여 캐시를 새로운 값으로 즉시 덮어씁니다.
      • 컨텍스트 반환: 스냅샷해 둔 previousData를 컨텍스트 객체에 담아 반환합니다. (return { previousData })
    2. onError (롤백 단계): mutationFn이 실패(에러 발생)했을 때 호출됩니다.
      • onMutate에서 반환한 컨텍스트 객체를 인자로 받습니다.
      • queryClient.setQueryData(..., context.previousData)를 사용해 캐시를 스냅샷해 둔 이전 상태로 되돌립니다.
    3. onSettled (정리 단계): mutationFn이 성공하든 실패하든, 작업이 완료되면 항상 호출됩니다.
      • queryClient.invalidateQueries({ queryKey: [...] })를 호출하여 해당 쿼리 키를 'stale(오래된)' 상태로 만듭니다. 이를 통해 클라이언트의 캐시 상태와 서버의 실제 상태가 최종적으로 일치하도록 보장합니다.

    3.3. 경쟁 상태(Race Condition)

    왜 onMutate에서 cancelQueries를 await까지 써가며 호출해야 할까요?

     

    위 질문은 React Query를 사용할 때 자주 등장하는 문제와 관련 있습니다.

    React Query의 강력한 기능 중 하나는 refetchOnWindowFocus, 즉 사용자가 브라우저 탭이나 창에 다시 포커스했을 때 자동으로 데이터를 새로고침하는 기능입니다.

    여기서 고질적인 경쟁 상태, 즉 UI 깜빡임(flickering) 문제가 발생할 수 있습니다.

    1. 사용자가 다른 탭을 보다가 우리 앱 탭으로 다시 돌아옵니다. refetchOnWindowFocus가 발동하여 데이터 A를 가져오는 API 요청(요청 #1)이 백그라운드에서 시작됩니다.
    2. 요청 #1이 끝나기 전, 사용자가 재빨리 데이터 A를 B로 변경하는 버튼을 클릭합니다. useMutation이 실행되고 onMutate가 호출되어 UI는 즉시 B로 낙관적 업데이트됩니다.
    3. 바로 그 순간, 뒤늦게 요청 #1의 응답(여전히 데이터 A)이 도착합니다. 이 응답은 우리가 방금 적용한 낙관적 업데이트(데이터 B)를 덮어쓰고, 캐시는 다시 A로 변경됩니다.
    4. 사용자의 화면은 B로 바뀌었다가 다시 A로 돌아가는 깜빡임을 경험합니다.
    5. 잠시 후, 데이터 변경을 위한 실제 API 요청(요청 #2)이 성공하고 onSettled에서 invalidateQueries가 호출되어 다시 데이터를 가져오면, 화면은 최종적으로 B로 변경됩니다.

    이런 문제를 막는 것이 바로 await queryClient.cancelQueries()의 역할입니다.

    onMutate 시작점에서 이 코드를 실행하면, 진행 중이던 요청 #1을 취소시켜 그 응답이 우리의 소중한 낙관적 업데이트를 덮어쓰는 문제를 원천 차단합니다.

    프론트엔드 개발자에게는 참을 수 없는 디테일이라고 생각합니다.

    3.4. 실무 적용: 복잡한 공유 데이터 관리

    상품 목록과 '찜한 상품' 위젯에서 동시에 '찜' 상태가 반영되어야 하는 시나리오를 코드로 구현해 보겠습니다.

    import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
    import axios from 'axios';
    
    // 타입 정의
    type Product = { id: number; name: string; isFavorite: boolean };
    
    // API 함수
    const fetchProducts = async (): Promise<Product> => {
      const { data } = await axios.get('/api/products');
      return data;
    };
    
    const toggleFavoriteAPI = async (product: Product): Promise<Product> => {
      const { data } = await axios.patch(`/api/products/${product.id}`, {
        isFavorite:!product.isFavorite,
      });
      return data;
    };
    
    // 커스텀 훅
    export const useProducts = () => {
        return useQuery<Product, Error>({ queryKey: ['products'], queryFn: fetchProducts });
    }
    
    export const useToggleFavoriteMutation = () => {
      const queryClient = useQueryClient();
      const productsQueryKey = ['products'];
    
      return useMutation<Product, Error, Product, { previousProducts?: Product }>({
        mutationFn: toggleFavoriteAPI,
        
        // 1. onMutate: 낙관적 업데이트 실행
        onMutate: async (toggledProduct: Product) => {
          // 1-1. 진행 중인 'products' 쿼리 취소
          await queryClient.cancelQueries({ queryKey: productsQueryKey });
    
          // 1-2. 이전 데이터 스냅샷
          const previousProducts = queryClient.getQueryData<Product>(productsQueryKey);
    
          // 1-3. 캐시를 직접 업데이트
          if (previousProducts) {
            queryClient.setQueryData<Product>(
              productsQueryKey,
              previousProducts.map(p =>
                p.id === toggledProduct.id? {...p, isFavorite:!p.isFavorite } : p
              )
            );
          }
    
          // 1-4. 컨텍스트에 스냅샷 데이터 반환
          return { previousProducts };
        },
    
        // 2. onError: 실패 시 롤백
        onError: (err, variables, context) => {
          if (context?.previousProducts) {
            queryClient.setQueryData(productsQueryKey, context.previousProducts);
          }
          // 사용자에게 에러 알림 (e.g., toast)
        },
    
        // 3. onSettled: 성공/실패 무관하게 최종 데이터 동기화
        onSettled: () => {
          queryClient.invalidateQueries({ queryKey: productsQueryKey });
        },
      });
    };

    3.5. 언제 사용해야 할까?

    • 이상적인 시나리오: axios나 fetch를 사용하는 전통적인 클라이언트 API 통신 환경에서, 서버 상태가 여러 컴포넌트에 걸쳐 공유되고 동기화되어야 할 때.
    • 강점: 비교 불가능한 강력함과 유연성, 중앙 캐시를 통한 애플리케이션 전역의 상태 일관성 보장, 복잡한 시나리오와 경쟁 상태에 대한 견고한 처리 능력.
    • 한계: useOptimistic에 비해 상대적으로 높은 코드 복잡도와 학습 곡선. 뮤테이션 생명주기에 대한 깊은 이해가 필요.

    4. 무엇을, 언제 선택할 것인가?

    이제 두가지 모두 살펴보았으니 실전에서 어떤 무기를 선택할지 결정할 시간입니다.

    코드를 작성하기 전에, 스스로에게 다음과 같은 아키텍처 질문을 던져보세요.

    4.1. 아키텍처 질문

    1. 아키텍처 (Architecture): 우리 프로젝트는 Next.js 서버 액션을 적극적으로 사용하고 있는가, 아니면 axios나 fetch를 통해 클라이언트에서 직접 API를 호출하는 전통적인 구조인가?
    2. 상태 범위 (State Scope): 지금 처리하려는 데이터 변경이 단일 컴포넌트나 그 직계 자식들에게만 영향을 미치는가? 아니면 헤더, 사이드바, 메인 콘텐츠 등 서로 다른 위젯 트리에서 동시에 상태가 반영되어야 하는가?
    3. 복잡성 (Complexity): 단순히 목록에 아이템 하나를 추가하는 간단한 작업인가? 아니면 하나의 데이터를 변경하면 다른 연관 데이터들의 상태까지 연쇄적으로 바뀌어야 하는 복잡한 작업인가?
    4. 팀 컨벤션 (Team Convention): 우리 팀은 이미 서버 상태 관리를 위해 React Query를 표준으로 도입하여 사용하고 있는가?

    이 질문들에 대한 답이 당신의 선택을 이끌어 줄 것입니다.

    서버 액션과 지역적인 업데이트라면 useOptimistic이, 전통적 API와 전역적 업데이트라면 React Query가 자연스러운 선택지가 될 것입니다.

    기준 React useOptimistic React Query Optimistic Updates
    주요 사용 사례 Next.js 서버 액션을 사용한 폼 제출 클라이언트 측 API, 여러 컴포넌트 간 공유 데이터
    롤백 방식 자동 (React가 처리) 수동 (onError 콜백에서 직접 구현)
    코드 복잡도 낮음 (Low) 중간 ~ 높음 (Medium to High)
    서버 통신 서버 액션과 강하게 결합 API 클라이언트 무관 (REST, GraphQL 등)
    전역 상태 동기화 제한적 (컴포넌트 트리에 국한) 자동 (공유된 쿼리 키를 통해)
    핵심 장점 단순함, 프레임워크 통합 강력함, 유연성, 전역 일관성

     

    반응형

    댓글

Designed by Tistory.