본문 바로가기

공부/상태 관리

TanStack Query로 낙관적 업데이트 구현 (구인/구직 찜)

반응형

 

사용자가 버튼을 클릭했을 때, 서버 응답을 기다리느라 반응이 느리면 사용자는 해당 기능이 오류가 발생했다고 생각을 할 수도 있습니다. 그래서 이번 게시글에서는 `TanStack Query`의 낙관적 업데이트(Optimistic Update)를 활용해 서버 응답을 기다리지 않고도 즉각적으로 UI가 반응할 수 있도록 구현한 내용을 공유해보려고 합니다.

 

구현 시나리오: 구인/구직 게시물 찜하기 ✨

- 사용자가 찜 버튼을 누르면 즉시 UI 변경.

- 서버 요청 실패 시 이전 상태로 롤백.

- 찜 상태는 게시물 상세 쿼리에 반영되어야 하며, 관련 쿼리 무효화.

 

UI 컴포넌트 📖

const RecruitDetailPage = () => {
  const { favoriteMutate } = useFavoriteMutation();
  const { data } = useGetJobQuery({ id: id as string });

  // 해당 게시물의 찜 상태 
  const isFavorited: boolean = useMemo(() => data?.isFavorited, [data]);  

  // 찜하기 뮤테이션
  const handleFavorite = () => {
    const action = isFavorited ? 'delete' : 'post';

    favoriteMutate({ action, jobId: job.id, jobPostType: job.postType });
  };
return (
    <NormalButton
      onClick={handleFavorite}
      ...
    >
      <Icon name={isFavorited ? 'BookMarkOn' : 'BookMark'} />
    </NormalButton>
    ...
  );
}

 

찜 상태 확인

 

useMemo를 이용해 data가 변경될 때만 `isFavorited`를 재계산해 불필요한 리렌더링을 방지했습니다.

const { data } = useGetJobQuery({ id: id as string });

// 해당 게시물의 찜 상태 
const isFavorited: boolean = useMemo(() => data?.isFavorited, [data]);

 

찜하기 핸들러 정의

 

`isFavorited` 값에 따라 `delete` 또는 `post` 값을 동적으로 결정해 낙관적 업데이트를 합니다.

const handleFavorite = () => {
  const action = isFavorited ? 'delete' : 'post';
  favoriteMutate({ action, jobId: job.id, jobPostType: job.postType });
};

 

UI 반영(찜 아이콘 변경)

 

이 UI 변경은 서버 응답과 상관없이 낙관적으로 반영된 캐시 상태를 기반으로 동작합니다.

return (
  <NormalButton onClick={handleFavorite}>
    <Icon name={isFavorited ? 'BookMarkOn' : 'BookMark'} />
  </NormalButton>
);

 

useMutation을 이용해 구현한 낙관적 업데이트 📖

TanStack Query의 `useMutation`을 활용해 낙관적으로 업데이트하는 커스텀 훅입니다.

import { queryKeys } from '@src/services/queryKeys';
import { PostTypeEnum } from '@src/remotes/types/BaseSchema';
import { favoriteService } from '@src/services/favoriteService';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { FavoriteResponse } from '@src/remotes/types/FavoriteSchema';

interface FavoriteParams {
  jobId: number;
  action: 'post' | 'delete';
  jobPostType: PostTypeEnum;
}

// 찜하기 뮤테이션 (낙관적 업데이트)
export const useFavoriteMutation = () => {
  const queryClient = useQueryClient();

  const { mutate: favoriteMutate } = useMutation<
    FavoriteResponse, // 응답 데이터 타입
    Error, // 에러 타입
    FavoriteParams, // mutationFn 파라미터 타입
    { previousFavorites: any; jobId: number; jobPostType: PostTypeEnum } // 컨텍스트 타입
  >({
    onError: (err, variables, context) => {
      // 에러 발생 시 이전 상태로 롤백
      if (context?.previousFavorites) {
        queryClient.setQueryData(queryKeys.job.detail(context.jobId.toString()), context.previousFavorites);
      }
    },
    mutationFn: async ({ jobId, action, jobPostType }: FavoriteParams) => {
      if (action === 'post') {
        return await favoriteService.favorite.postFavorite(jobId, jobPostType); // 찜하기
      } else {
        return await favoriteService.favorite.deleteFavorite(jobId); // 찜 해제
      }
    },
    onSettled: (data, error, variables, context) => {
      const jobId = context?.jobId;
      const jobPostType = context?.jobPostType;
      if (jobId && jobPostType) {
        // 요청이 완료되면 상세 페이지, 찜 목록 쿼리 무효화
        queryClient.invalidateQueries({ queryKey: queryKeys.job.detail(jobId.toString()) });
        queryClient.invalidateQueries({
          queryKey: queryKeys.job.favoriteList(jobPostType)
        });
      }
    },
    onMutate: async ({ jobId, action, jobPostType }) => {
      // 진행 중인 쿼리 취소
      await queryClient.cancelQueries({ queryKey: queryKeys.job.detail(jobId.toString()) });

      // 이전 데이터 저장
      const previousFavorites = queryClient.getQueryData(queryKeys.job.detail(jobId.toString()));

      // UI를 즉시 업데이트
      queryClient.setQueryData(queryKeys.job.detail(jobId.toString()), (old: any) => {
        return {
          ...old,
          isFavorited: action === 'post' // 찜하기 상태 업데이트
        };
      });

      return { jobId, jobPostType, previousFavorites }; // 이전 상태를 반환
    }
  });

  return { favoriteMutate };
};

 

onMutate

낙관적 업데이트의 핵심 이라고 할 수 있는 코드입니다.

onMutate: async ({ jobId, action, jobPostType }) => {
  // 진행 중인 쿼리 취소
  await queryClient.cancelQueries({ queryKey: queryKeys.job.detail(jobId.toString()) });

  // 이전 데이터 저장
  const previousFavorites = queryClient.getQueryData(queryKeys.job.detail(jobId.toString()));

  // UI를 즉시 업데이트
  queryClient.setQueryData(queryKeys.job.detail(jobId.toString()), (old: any) => {
    return {
      ...old,
      isFavorited: action === 'post' // 찜하기 상태 업데이트
    };
  });

  return { jobId, jobPostType, previousFavorites }; // 이전 상태를 반환
}

 

동작 순서

 

1. 백그라운드에서 진행 중인 쿼리를 취소합니다.

2. 실패했을 때 롤백할 수 있도록 현재 캐시 상태를 저장합니다.

3. 사용자가 버튼을 누르자마자 `isFavorited`값을 변경해 UI를 즉시 업데이트 합니다.

4. 에러나 완료 시 활용할 수 있도록 이전 상태를 콘텍스트로 반환합니다.

 

mutationFn

사용자가 선택한 액션(post, delete)에 따라 실제 서버에 요청을 보냅니다.

mutationFn: async ({ jobId, action, jobPostType }: FavoriteParams) => {
  if (action === 'post') {
    return await favoriteService.favorite.postFavorite(jobId, jobPostType); // 찜하기
  } else {
    return await favoriteService.favorite.deleteFavorite(jobId); // 찜 해제
  }
}

 

onError

서버 요청이 실패했을 경우 `onMutate`에서 백업해둔 이전 상태로 롤백합니다.

onError: (err, variables, context) => {
  // 에러 발생 시 이전 상태로 롤백
  if (context?.previousFavorites) {
    queryClient.setQueryData(queryKeys.job.detail(context.jobId.toString()), context.previousFavorites);
  }
}

 

onSettled

서버의 최신 상태를 반영하기 위해 관련 캐시를 무효화합니다. (상세 페이지, 찜 목록)

onSettled: (data, error, variables, context) => {
  const jobId = context?.jobId;
  const jobPostType = context?.jobPostType;
  if (jobId && jobPostType) {
    // 요청이 완료되면 상세 페이지, 찜 목록 쿼리 무효화
    queryClient.invalidateQueries({ queryKey: queryKeys.job.detail(jobId.toString()) });
    queryClient.invalidateQueries({
      queryKey: queryKeys.job.favoriteList(jobPostType)
    });
  }
}

 

 

예시 화면 📖

예시 화면

 

어려웠던 점 ❓

낙관적 업데이트 흐름

onMutate -> onError -> onSettled의 흐름을 이해하는데 시간이 걸렸고

찜 여부가 포함된 상세 쿼리를 기반으로 동작하다 보니, queryKey 설계와 캐시 업데이트 범위를 명확히 해야 했습니다.

 

회고 🧐

낙관적 업데이트는 단순히 UI를 빠르게 바꾸는 기술이 아니라, 사용자 경험을 향상하는 UX 전략이라고 생각합니다.

 

그래서 성능 뿐만 아니라 사용자 경험을 개선할 수 있었던 새로운 도전이어서 이번 기능 구현은 좋은 경험이 된 것 같습니다.

 

아직 많이 부족하기 때문에 조언은 언제나 환영입니다 :)


출처 🏷️

https://tanstack.com/query/latest/docs/framework/react/guides/optimistic-updates

 

반응형