Frontend/Projects

[React] TanStack Query 낙관적 업데이트 (+ 연관 문제 해결)

joycie416 2024. 11. 10. 23:10

병원카드에 좋아요 기능을 추가했다. 실시간으로 하트를 누르면 업데이트 되도록하고 싶어서 일단 useState의 초기값을 likes 목록에 존재하는지 여부로 정한 후, 클릭하면 반대로 바뀌도록 했다. TanStack Query로 낙관적 업데이트할 수 있기 때문에 적용해보기로 했다.

 

낙관적 업에이트는 실제 데이터 업데이트가 느릴 수 있기 때문에, 그 간극을 줄이기 위해 성공할 것이라고 가정하고 변화를 주는 것이다. 이를 통해 사용자 경험을 향상시킬 수 있다.

 

1. `useMutation()`

`useMutation`는 하나의 parameter에 여러 옵션을 넣을 수 있는데, 이를 활용하면 낙관적 업데이트를 적용할 수 있다. 그 중 오늘 사용할 옵션들은 `mutationFn`, `onMutate`, `onError`, `onSettled`이다. 이중에서 당연히 많은 사람들이 `mutationFn`은 기본으로 넣고 있었을 것이다. 데이터를 변경하고 싶을 때 주로 사용하는 함수이기 때문에 실제 데이터를 변형하는 함수를 넣을 것이다. 그래서 앞으로는 `onMutate`, `onError`, `onSettled`에 대해 살펴볼 것이다.

 

2. `onMutate`

먼저 `onMutate`는 `mutationFn`이 실행되기 전에 먼저 실행되는 함수이며, mutationFn에 넣는 값과 동일한 값을 자동으로 입력받는다. 우리는 이 옵션에 이전에 불러와진 데이터에 임시로 변형을 가하는 코드를 넣어줄 것이다.

 

먼저 페이지에서 refetch 중이면 충돌이 발생할 수 있기 때문에 해당 refetch를 취소해야 한다. 아래 코드로 해당 쿼리키에 대한 refetch를 취소할 수 있다.

await queryClient.cancelQueries({ queryKey: ["user", "like", userId] });

 

이제 임시로 변형된 다음 좋아요 데이터를 넣어줘야 한다. 이때 `.setQueryData()` 메서드를 사용하는데, 쿼리키와 updater를 인자로 받는다. 

queryClient.setQueryData<Like[]>(["user", "like", userId ?? ""], (prev) => [...(prev ?? []), newLike]);

 

updater에는 업데이트될 데이터를 직접 넣어줘도 되고, 함수를 넣어줘도 된다. 이때 함수를 넣게 되면 직전 데이터를 인자로 받게 된다. 따라서 위와 같이 데이터를 업데이트할 수 있다. 만약 updater에 `undefined`를 넣게 되면 데이터가 업데이트 되지 않는다고 한다.

 

`onMutate`는 context를 반환하거나 아무것도 반환하지 않을 수 있는데, context에 이전 데이터를 넣어 추후에 `onError`나 `onSettled`에서 사용할 수 있게 할 것이다. 아래 코드를 통해 지금까지 저장된 데이터를 불러올 수 있다. 만약 TypeScript를 사용중이라면 해당 데이터에 대한 타입을 명시해주어야 한다.

const prevLikes = queryClient.getQueryData<Like[]>(["user", "like", userId ?? ""]);

 

여기서 조금 신경써야할 것은 '지금까지 저장된 데이터'를 불러온다는 것이다. 만약 `.setQueryData()` 이후에 사용한다면 해당 데이터까지 저장된다. 따라서 변형이 일어나기 전 데이터를 저장할 목적이므로 지금은 `.setQueryData()` 이전에 작성되어야 한다.

 

마지막으로 `onMutate`의 반환값이 있다면 객체여야 한다.

// 낙관적 업데이트
onMutate: async ({ hospitalInfo }: { hospitalInfo: HopsitalItem }) => {
  await queryClient.cancelQueries({ queryKey: ["user", "like", userId] });
  const prevLikes = queryClient.getQueryData<Like[]>(["user", "like", userId]);

  // 낙관적 업데이트를 위한 임시 데이터
  const newLike: Like = convertToLike(hospitalInfo);

  queryClient.setQueryData<Like[]>(["user", "like", userId], (prev) => [...(prev ?? []), newLike]);

  // context에 이전 데이터 넣어둠
  return { prevLikes };
},

 

3. `onError`

이 옵션은 `mutationFn`이 에러를 발생할 때 실행된다. 이 옵션에 사용되는 함수는 error와 variables를 인자로 받으며 원하면 `onMutate`에서 반환한 context를 사용할 수 있다. 실패한 경우 이전 데이터를 덮어씌우기로 했으므로 다음과 같이 작성했다.

// 에러가 발생하면 이전 데이터로 변경
onError: (_, __, context) => {
  if (context?.prevLikes) {
    queryClient.setQueryData<Like[]>(["user", "like", userId], context.prevLikes);
  }
},

 

4. `onSettled`

이 옵션은 `onSuccess`와 달리 에러가 발생하거나 발생하지 않아도 모두 작동하는 옵션이다. (`onSuccess`는 에러가 발생하지 않은 경우에만 실행된다.)

 

쿼리 데이터가 변경됐으므로 무효화하거나 데이터를 다시 가져오는 로직이 여기에 들어간다.

 

앞서 말했듯이 임시 데이터를 넣었기 때문에 실제 데이터를 가져오도록 `.refetchQueries()` 메서드를 사용하거나, 렌더링이 발생할 때 refetch되도록 `.invalidateQueries()` 메서드를 사용해도 된다. 마이페이지에서 좋아요 데이터를 모아볼 때에는 정확한 데이터가 필요해서 refetch가 필요하고, 병원 목록을 볼때에는 데이터를 불필요하게 요청하지 않아도 될 것이라 생각했다. 하지만 좋아요 버튼을 누르면 해당 카드가 리렌더링 되므로 invalidateQueries만 사용해도 될 것이라 생각했다.

onSettled: () => {
  queryClient.invalidateQueries({
    queryKey: ["user", "like", userId]
  });
}

 


지금부터는 낙관적 업데이트를 적용한 후에 발생한 오류에 대해 정리해보려고 한다.

 

5. 데이터가 자꾸 깜빡이는 문제

낙관적 업데이트를 적용해서 데이터가 깜빡임 없이 잘 적용될 줄 알았는데, 여전히 깜빡임이 발생했다. 이는 useQueries의 반환값 중 isFetching을 받아오지 않음으로써 해결했다. 데이터를 refetch하면 isFetching이 true가 되어 데이터 로딩 컴포넌트를 보여주는 것이었다.

 

6. 비로그인 상태에서의 오류

6-1. 쿼리 데이터가 `undefined`라 발생한 경고문

로그아웃하면 데이터가 바뀌도록 코드를 작성하는 것을 까먹고 급히 signout 로직에 "user"에 대한 모든 쿼리 데이터를 무효화하는 코드를 넣었는데 아래 경고문을 받았다.

로그인 정보를 반환하는 함수에서 로그인 정보를 반환하거나 `undefined`를 반환하도록 했는데, `undefined`를 반환하도록 해서 발생한 경고였다. 이는 해당 함수의 반환값을 `null`로 바꾸면서 해결했다. 

 

6-2. 로그아웃 시 페이지 이동 안되는 문제

로그아웃하면 홈으로 이동하도록 설정했는데, invalidateQueries 한 줄 추가했다고 홈으로 이동하지 않고, 로그인 페이지로 이동하는 오류가 발생했다.

 

현재 마이페이지에서 로그아웃 버튼을 눌러야만 로그아웃할 수 있다. 현재 코드는 마이페이지에서 "user"를 쿼리키로 쓰는 커스텀 훅을 통해 로그인 정보를 불러오고 있었는데, 내가 작성한 `.invalidateQueries()`가 "user"를 무효화하면서 발생한 오류였다.

 

해당 문제는 좋아요가 필요한 상위 page 컴포넌트에서 서버에서 직접 로그인 정보를 가져와 전달하는 방식으로 변경함으로써 해결했다.