본문 바로가기

cs/react

[React] useMutation

목차

  1. useMutation이란
  2. useMutation 예시
  3. useMutation과 useOptimistic 비교

useMutation이란

TanStack-Query에서 서버 데이터의 변경을 요청할때 사용하는 훅

 

useMutation은 주로 낙관적 업데이트를 통해 사용자 UI개선을 목적으로 사용되는 훅입니다.

좋아요나 읽음 처리 등의 UI 변경사항을 즉각적으로 적용할때 사용됩니다.

 

다른 TanStack-Query와 다르게 query key를 직접적으로 사용하지는 않지만, useQuery, invalidateQueries 등과 같은 훅을 적절히 사용하여 쿼리를 제어하여 쿼리 캐시의 동기화를 보장해줍니다.

구조

const {mutate, data, isError, isSuccess, ...} = useMutation({
	mutationFn, onMutate, onError, onSuccess, onSettled, ...
})
  • 매개변수
    • mutationFn
      • required 옵션
      • 비동기 작업을 수행하고 Promise를 반환하는 함수
      • mutationFn:(variables, context) => Promise<Data>
    • onMutate
      • optional 옵션
      • mutation함수가 실행되기 전에 실행되는 함수, 낙관적 업데이트시 주로 사용
      • 해당 함수에서 반환된 값은 onError, onSettled 함수의 매개변수로 전달
      • onMutate: (variables, context) => Promise<mutateResult> | void
    • onError
      • optional 옵션
      • mutation함수에서 오류 발생시 실행, 오류 메세지를 매개변수 전달받음
      • onError:(err, onMutateResult, context) => Promise<unknown> | unknown
    • onSuccess
      • optional 옵션
      • mutation함수에서 성공시 실행, mutation함수 결과를 전달받음
      • onSuccess:(data,variables,onMutateResult) => Promise<unknown> | unknown
    • onSettled
      • optional 옵션
      • mutation함수의 성공여부에 상관없이 실행
      • onSettled:(data,error,variables,onMutateResult) => Promise<unknown> | unknown
    • gcTime
      • 비활성된 캐시 데이터가 메모리에 남아있는 시간(ms)
      • 최대 24일, Infinity설정시 시간 설정 비활성화
  • 결과
    • mutate
      • mutate함수를 호출하는 함수
      • 인자값으로 mutateFn의 매개변수값을 전달
    • data
      • mutate에서 마지막으로 성공된 데이터
    • isError, isSuccess
      • 상태값
      • boolean

useMutation예시

무한 스크롤로 알림 정보를 조회해서 데이터를 캐시에 저장한 쿼리가 있다고 가정하겠습니다.

각각의 알림 정보를 클릭했을 때 읽음 처리를 낙관적 업데이터로 구현하기 위해 useMutation을 사용하여 구현한 예시입니다.

 

import {startTransition, useEffect, useOptimistic, useState, useTransition} from "react";
import {type InfiniteData, useMutation, useQueryClient} from "@tanstack/react-query";

interface HisPushItemProps{
    item: PushNoticeListDto;
    index: number;
    handleClickItem: (item: PushNoticeListDto) => void;
}

export default function HIS_PUSH_ITEM_01({item, index, handleClickItem} : HisPushItemProps) {
    const queryClient = useQueryClient();

    const {mutate} = useMutation({
    
        //4. 비동기 함수 처리
        mutationFn: (body: {msgSeqNo: number}) => PushApi.setNoticeRead(body),
        
        //3. mutate이 호출되었을때 낙관적 업데이트를 처리할 함수
        onMutate: async(newRead) => {
            // 3-1. 진행 중인 refetch 취소 (낙관적 업데이트 값을 덮어쓰지 않도록)
            await queryClient.cancelQueries({ queryKey: ['pushs'] });

            // 3-2. 이전 값 스냅샷 저장
            // 쿼리에 저장된 query key는 'pushs'
            const previousPushs = queryClient.getQueryData(['pushs']);

            // 3-3. 캐시 데이터를 직접 수정 (낙관적 업데이트 핵심)
            // (페이징으로 가져온 데이터에서 파라미터 값으로 받은 값이랑 동일한걸 Y로 바꿔주는거라고 대충 생각하시면됩니다.)
            queryClient.setQueryData(['pushs'], (old: InfiniteData<ResponsePageDto<PushNoticeListDto>>) => (
                {
                ...old,
                pages: old.pages.map((page: ResponsePageDto<PushNoticeListDto>) => ({
                    ...page,
                    contents: page.contents.map((p: PushNoticeListDto) =>
                        p.msgSeqNo === item.msgSeqNo ? { ...p, recvYn: 'Y' } : p
                    ),
                })),
            }));

            // 3-4. 이전 값을 반환
            // onError, onSettled의 매개변수로 전달되는 값
            return { previousPushs };
        },
        // 6. 에러 발생 시 원래 데이터로 복구
        onError: (err, newRead, context) => {
            queryClient.setQueryData(['pushs'], context?.previousPushs);
        },
        // 5. 완료 후(성공/실패 상관없이) 서버와 동기화
        onSettled: () => {
            queryClient.invalidateQueries({ queryKey: ['pushs'] });
        }
    })


    //1.클릭 이벤트
    const handleOnClick = async() => {

        //2. 아직 읽지 않은 item인 경우 useMutation의 mutate함수 호출
        if(item.recvYn === 'N') {
            mutate({msgSeqNo: item.msgSeqNo})
        }
        else {
            handleClickItem(item);
        }
    }

    return (
        <div
            key={index}
            className={`his-box ${optimisticRead === 'N' ? '-new' : ''}`}
            onClick={handleOnClick}
        >
            <div className="his-box-title">
                <span className="category">{item.sclasCdNm}</span>
                <span className="date">{item.regDtm}</span>
            </div>
            <div className="his-box-text">
                {item.msgSumrCtt}
            </div>
        </div>
    )
}

 

동작 흐름

  1. item 클릭
  2. item이 읽음 처리가 아닌 경우 useMutation의 mutate함수 호출
    • 인자로 주어지는 값은 mutationFn과 onMutate의 매개변수로 전달
  3. onMutate 호출
    • 비동기 동작 함수(mutationFn) 전에 실행되어 실행
    • 실행되고 있는 쿼리를 취소하고 오류 발생시 쿼리 롤백을 위한 기존 캐시 데이터를 반환합니다.
    • 쿼리 캐시는 매개변수로 받아온 seq값과 동일한 값을 찾아 수정 (낙관적 업데이트)
  4. mutateFn호출
    • 실제 item의 서버 데이터를 읽음처리하는 비동기 작업
  5. onSettled 호출
    • 실패, 성공 여부에 상관없이 데이터의 동기화를 위해 invalidateQueries 사용해서 데이터 상태 최신화
  6. onError 호출
    • 만약 mutateFn이 실패했을 경우 onMutate에서 전달한 이전 쿼리 캐시 값으로 다시 원복

useMutation과 useOptimistic 비교

useMutation이 TanStack-Query에서 낙관적 업데이트를 목적으로 주로 사용한다고 많은 설명이 되어있습니다.

그런데 낙관적 업데이트 하니까 생각하는 훅이 있었는데 react에서 제공하는 useOptimistic 훅이었습니다.

낙관적업데이트하면 useOptimistic 훅이 더 많이 나오는데.. useOptimistic 이 아니라 useMutation 훅을 사용하는 이유가 무엇인지 궁금했습니다.

 

(내용이 많이 없어서 AI의 내용을 많이 참고했기 때문에 올바르지 않은 내용이 있을 수도 있습니다.)


 

두 훅은 낙관적 업데이트를 통해 사용자의 UI 개선을 목적으로 사용하는 건 맞습니다.

 

하지만 차이라고 하면 낙관적 업데이트를 사용해야하는 상황이 서로 다르다는 겁니다.

 

useOptimistic을 사용하는 경우

  • 단순한 컴포넌트
    • 캐시를 건드릴 필요없는 가벼운 UI 업데이트
    • 예를 들어 한 화면의 즐겨찾기 등과 같이 컴포넌트에서 하나의 상태의 업데이트를 해야할 때
  • 서버 액션
    • Next.js와 같은 환경에서 서버 함수로 쓸 때

useMutation을 사용하는 경우

  • 캐시 데이터를 사용하는 경우
    • 각각의 실행 상태(isPending)를 독립적으로 관리하기 때문에 데이터가 안정적
  • 데이터 정합성
    • 캐시 업데이트에 대한 안정성 때문에 다른 컴포넌트에서 캐시를 조회할때 꼬이지 않음

 

즉, 컴포넌트의 단일 상태로 관리되는 경우 useOptimistic을 사용하고 쿼리 캐시를 제어해야하는 경우 useMutation을 사용하는게 좋습니다.


왜 캐시를 제어할 땐 useOptimistic이 아닌 useMutation을 쓰는게 좋을까?

 

useMutation도 query key를 정의하는 곳이 없이 내부에서 getQuery나 invalidateQueries를 사용해서 제어를 하는데, useOptimistic도 똑같이 구현할수 있는거 아닌가? 하고 만들어 봤는데 동작 자체는 동일했습니다.

 

 

useOptimistic으로 낙관적 업데이트 구현 코드

더보기
import * as React from "react";
import type {PushNoticeListDto} from "@platform/push/model/PushNoticeListDto";
import {startTransition, useEffect, useOptimistic, useState, useTransition} from "react";
import LogUtil from "@util/LogUtil";
import {PushApi} from "@platform/push/api/PushApi";
import {type InfiniteData, useMutation, useQueryClient} from "@tanstack/react-query";
import type {ResponsePageDto} from "@platform/common/model/ResponsePageDto";

interface HisPushItemProps{
    item: PushNoticeListDto;
    index: number;
    handleClickItem: (item: PushNoticeListDto) => void;
}

export default function HIS_PUSH_ITEM_01({item, index, handleClickItem} : HisPushItemProps) {
    const queryClient = useQueryClient();


    const [optimisticRead, setOptimisticRead] = useOptimistic(item.recvYn, (prev, newValue:string) => newValue)
    const [isPending, startTransition] = useTransition();

    // [useOptimistic 이용한 낙관적 업데이트]
    const testClick = ({msgSeqNo}: {msgSeqNo: number}) => {
        startTransition(async() => {
            setOptimisticRead('Y');

            let previousPushs
            try{
                const body = {
                    msgSeqNo: msgSeqNo
                }
                await  PushApi.setNoticeRead(body)

                await queryClient.cancelQueries({ queryKey: ['pushs'] });

                // 이전 값 스냅샷 저장
                previousPushs = queryClient.getQueryData(['pushs']);

                // 2. 캐시 데이터를 직접 수정 (낙관적 업데이트 핵심)
                queryClient.setQueryData(['pushs'], (old: InfiniteData<ResponsePageDto<PushNoticeListDto>>) => (
                    {
                        ...old,
                        pages: old.pages.map((page: ResponsePageDto<PushNoticeListDto>) => ({
                            ...page,
                            contents: page.contents.map((p: PushNoticeListDto) =>
                                p.msgSeqNo === item.msgSeqNo ? { ...p, recvYn: 'Y' } : p
                            ),
                        })),
                    }));
            }
            catch (err) {
                queryClient.setQueryData(['pushs'], previousPushs);
            }
            queryClient.invalidateQueries({ queryKey: ['pushs'] });
        })
    }

    // [useMutation을 이용한 낙관적 업데이트]
    // const {mutate} = useMutation({
    //     mutationFn: (body: {msgSeqNo: number}) => PushApi.setNoticeRead(body),
    //     onMutate: async(newRead) => {
    //         // 진행 중인 refetch 취소 (낙관적 업데이트 값을 덮어쓰지 않도록)
    //         await queryClient.cancelQueries({ queryKey: ['pushs'] });
    //
    //         // 이전 값 스냅샷 저장
    //         const previousPushs = queryClient.getQueryData(['pushs']);
    //
    //         // 2. 캐시 데이터를 직접 수정 (낙관적 업데이트 핵심)
    //         queryClient.setQueryData(['pushs'], (old: InfiniteData<ResponsePageDto<PushNoticeListDto>>) => (
    //             {
    //             ...old,
    //             pages: old.pages.map((page: ResponsePageDto<PushNoticeListDto>) => ({
    //                 ...page,
    //                 contents: page.contents.map((p: PushNoticeListDto) =>
    //                     p.msgSeqNo === item.msgSeqNo ? { ...p, recvYn: 'Y' } : p
    //                 ),
    //             })),
    //         }));
    //
    //         return { previousPushs };
    //     },
    //     // 에러 발생 시 원래 데이터로 복구
    //     onError: (err, newRead, context) => {
    //         queryClient.setQueryData(['pushs'], context?.previousPushs);
    //     },
    //     // 완료 후(성공/실패 상관없이) 서버와 동기화 (선택 사항)
    //     onSettled: () => {
    //         queryClient.invalidateQueries({ queryKey: ['pushs'] });
    //     }
    // })


    const handleOnClick = async() => {

        if(item.recvYn === 'N') {
            // mutate({msgSeqNo: item.msgSeqNo})
            testClick({msgSeqNo: item.msgSeqNo})
        }
        else {
            handleClickItem(item);
        }
    }

    return (
        <div
            key={index}
            className={`his-box ${optimisticRead === 'N' ? '-new' : ''}`}
            onClick={handleOnClick}
        >
            <div className="his-box-title">
                <span className="category">{item.sclasCdNm}</span>
                <span className="date">{item.regDtm}</span>
            </div>
            <div className="his-box-text">
                {item.msgSumrCtt}
            </div>
        </div>
    )
}

 

구현 자체는 동일하게 가능하지만 useMutation 사용을 지향하는 이유는 표준화된 생태계와 안정성입니다.

 

  1. 선언적 상태 관리 vs 절차적 명령형 로직
    • useOptimistic을 이용한 구현은 try catch, 스냅샷, rollback, invalidate를 순차적으로 모두 작성해야합니다.
    • 하지만 useMutation은 스냅샷 및 낙관적 업데이트(onMutate), 오류 캐치(onError), invalidate(onSettled) 등과 같이 특정 행동에 대해서 선언만 하면 됩니다.
    • 그렇기 때문에 중복 로직을 방지하는 등 가독성과 유지보수가 좋습니다.
  2. 레이스 컨디션(race condition) 처리
    • 다중 요청을 처리할때 useMutation은 각각의 실행 상태(isPending)를 독립적으로 관리하기 때문에 캐시 제어에 최적화 되어있습니다.
  3. 상태(state) 집중화
    • useMutation은 상태를 전역적으로 제공합니다.
    • isPending(처리중 여부), isError(오류 여부), variables(요청 데이터) 등의 상태 제공

 

즉, 캐시를 이용한 낙관적 업데이트는 useMutation이 좀더 최적화되어있고 관리하기 편하다는 장점을 가지고 있습니다.

 

상황에 따라 낙관적 업데이트는 useOptimistic과 useMutation 사용을 판단하면 좋을것 같습니다.

 


참고

https://tanstack.com/query/latest/docs/framework/react/reference/useMutation#usemutation

 

useMutation | TanStack Query React Docs

tsx const { data, error, isError, isIdle, isPending, isPaused, isSuccess, failureCount, failureReason, mutate, mutateAsync, reset, status, submittedAt, variables, } = useMutation( { mutationFn, gcTime...

tanstack.com

https://velog.io/@won11/React-Query-useMutation%EC%9D%98-%ED%95%84%EC%9A%94%EC%84%B1

 

[React Query] useMutation의 필요성

전의 포스팅처럼 useQuery를 이용해 서버 데이터를 가져오고 장바구니에서 해당 상품을 추가하고 삭제하는 함수를 만들어 적용해보았는데 실시간으로 UI가 업데이트가 되지 않는 문제점이 발생했

velog.io

https://junhee1203.tistory.com/22

 

[React] TanStack-Query 의 useMutation 훅과 낙관적(optimistic) 업데이트 구현하기

TanStack-Query는 비동기 데이터를 효율적으로 상태 관리 할 수 있도록 강력한 여러 기능을 제공하는 라이브러리다. 그 중 대표적인 훅 중 하나로 useQuery 가 있다. useQuery는 서버의 데이터를 조회하

junhee1203.tistory.com

 

'cs > react' 카테고리의 다른 글

[React] 에러 핸들링 전략 (ErrorBoundary)  (0) 2026.03.22
[React] shadcn & tailwind  (0) 2026.01.30
[React] ErrorBoundary  (0) 2026.01.23
[React] Suspense  (1) 2026.01.22
[React] TanStack Query (V5)  (1) 2026.01.20