Recent Posts
Recent Comments
Link
Today
Total
«   2026/10   »
일 월 화 수 목 금 토
1 2 3
4 5 6 7 8 9 10
11 12 13 14 15 16 17
18 19 20 21 22 23 24
25 26 27 28 29 30 31
관리 메뉴

Jiseong's Web Dev blog

optimistic update를 사용하면서 고민했던 상황 본문

React

optimistic update를 사용하면서 고민했던 상황

jijiseong 2024. 6. 9. 04:32

😙 Intro

우리 프로젝트에는 찜 기능이있다.
찜하기 기능 수행 후, react-query의 queryClient.invalidateQueries를 사용해 페이지를 재요청하는 로직이 있다.
찜 버튼 상태를 업데이트 하기 위해서이다.
하지만, 백엔드에서, 겨우 찜하기 하나 한다고 페이지 전체를 재요청하는건 비효율적이고, 서버에 부하가 올 수 있다고 피드백을 받았다.

😕 기존 로직의 문제점

[
    {
      id: 1,
      name: 사과,
      price: 5000,
      isWished: false  // -> 찜하기 수행후 true 로 업데이트 예정
    },
      {
      id: 2,
      name: 사과,
      price: 5000,
      isWished: false
    },
      ...
]

한 페이지에 여섯 개의 상품이 있다고 가정하자.

  1. 가장 왼쪽 위 상품(id: 1)을 찜한다.
  2. id: 1 인 상품의 isWished를 업데이트하기 위해서 react-query의 cache를 invalidate 한다.

찜한 상품과 상관없는 나머지 상품들도 다시 요청 받아오는 비효율적인 로직이 생긴다.
백엔드에 부하 역시 유발할 수 있다.

🧐 해결 과정

시도 1. 프론트엔드의 상태로만 찜 버튼 색을 제어하자

백엔드에서 받은 isWished 값은 useState의 초기값에만 사용하고,
이후에는 클라이언트단의 isWished 상태를 사용한다.

function Product() {
    const [isWished, setIsWished] = useState(백엔드.isWished);

    return (
        // ...
        <HeartButton active={isWished} />
        // ...
    );
}

문제점이있다.
react-query 상태에 저장된 값은 업데이트 되지않는다
다른 페이지에서 이 데이터를 사용하게 된다면, 유저에게 stale한 데이터를 제공하게 된다.

시도 2. react-query의 상태를 수정하자.

우리 프론트엔드 팀원들에게 문제점을 공유하여 도움을 받았다.

팀원분들의 도움으로 setQueriesData를 사용하면 어느정도 해결이 될 것 같은 생각이 들었다.

고민 1. 하지만 결국엔 에러상황도 고려하여야 하므로 invalidate를 해야하지 않나?

찜하기 요청 수행 후 요청이 에러가 난다면 이전 상태로 되돌려야한다는 생각이 강했다.
유저에게 무조건 올바른, 최신의 데이터를 보여줘야한다고 생각했기때문이다.
하지만 팀원분들의 '에러의 경우는 고려하지 않아도 되지않나'라는 식의 의견을 듣고 생각이 바뀌었다.

에러 상황의 경우 토스트 팝업만 띄워주고, 찜 버튼의 색은 크게 신경 쓰지 않아도 될 것 같았다.
에러 상황을 토스트 팝업으로 인지 시키는 것이다.

코드로 구현해보자.

useMutateion({
    mutateFn,
    onSuccess,
    onError,
    onMutate: () => {
      queryClient.setQueriesData(
        { queryKey: storeQueryKey.lists() },
        (oldData) =>
          updateInfiniteQueryCache(oldData, { key: 'boardId', value: productId }, { isWished: true })
      );    
    }
});

queryClient.setQueriesData의 첫번째 인자에는 QueryFilter를 받는다.

두번째 인자로 update function을 받는다.

import { Cursor } from '@/shared/types/response';
import { InfiniteData } from '@tanstack/react-query';


export const updateInfiniteQueryCache = <T>(
  oldData: InfiniteData<Cursor<T[]>> | undefined,
  target: { value: number; key: keyof T },
  newItem: Partial<T>
) => {
  if (!oldData) return oldData;

  const newData = { ...oldData };
  newData.pages = newData.pages?.map((page) => {
    const itemIndex = page.content.findIndex((item) => item[target.key] === target.value);
    if (itemIndex !== -1) {
      const updatedPage = { ...page };
      updatedPage.content = [...page.content];
      updatedPage.content[itemIndex] = {
        ...updatedPage.content[itemIndex],
        ...newItem
      };
      return updatedPage;
    }
    return page;
  });

  return newData;
};

InfiniteQuery를 사용한 데이터를 수정할 수 있는 함수를 구현했다.
모든 페이지를 순환하면서 target.key, target.value를 기준으로 타겟 아이템의 인덱스를 찾는 로직이다.

인터페이스 부터 살펴보자.

export const updateInfiniteQueryCache = <T>(
  oldData: InfiniteData<Cursor<T[]>> | undefined,
  target: { value: number; key: keyof T },
  newItem: T
) => {
	//...
}

T 는 InfiniteQueryData의 한 페이지의 한 요소의 인터페이스이다. 예를 들면 상품 오브젝트의 인터페이스가 될 수 있다.
target.value는 id 값이 들어와야하므로 number
target.key는 상품 오브젝트의 key값을 뜻한다.
target 값으로 여러 페이지를 순환하면서 특정 요소를 찾아낸다.
newItem은 수정한 T 오브젝트이다.

  const newData = { ...oldData };
  newData.pages = newData.pages?.map((page) => {

값을 수정하기위해 복사하고, 모든 페이지를 순환한다.

const itemIndex = page.content.findIndex((item) => item[target.key] === target.value);

page에서, target 값을 기반으로 item의 index를 찾아낸다.

    if (itemIndex !== -1) {
      const updatedPage = { ...page };
      updatedPage.content = [...page.content];
      updatedPage.content[itemIndex] = {
        ...updatedPage.content[itemIndex],
        ...newItem
      };
      return updatedPage;
    }
    return page;
  });

  return newData;

itemIndex를 찾았다면 새로운 값으로 업데이트하여 반환한다.