글 목록으로개발 경험

리뷰 좋아요의 목록과 상세 캐시를 함께 갱신하기

좋아요 조작을 목록과 상세 캐시에 함께 반영하는 모습을 그린 일러스트

글의 주제를 설명하려고 만든 일러스트예요. 실제 제품 화면이나 정확한 구성도는 아니에요.

병원 리뷰는 목록에서도, 상세 화면에서도 볼 수 있어요. 그런데 좋아요를 누르면 버튼을 누른 쪽만 바뀌고 있었어요. 각 ReviewItem이 서버에서 받은 isLiked와 likeCount를 자기 로컬 상태로 복사해 두고, 그 값만 수정했기 때문이에요.

tsx
function ReviewItem({ review }: { review: Review }) {
  const [isLiked, setIsLiked] = useState(review.like);
  const [likeCount, setLikeCount] = useState(review.likeCnt);
 
  const handleChange = async (checked: boolean) => {
    const previous = { isLiked, likeCount };
 
    setIsLiked(checked);
    setLikeCount((count) => count + (checked ? 1 : -1));
 
    try {
      await fetchReviewLike({
        reviewId: review.reviewId,
        feedbackType: checked ? 'LIKE' : 'UNLIKE',
      });
    } catch {
      setIsLiked(previous.isLiked);
      setLikeCount(previous.likeCount);
    }
  };
 
  return <LikeSwitch like={isLiked} likeCnt={likeCount} onChange={handleChange} />;
}

버튼은 바로 반응했어요. 하지만 목록과 상세가 서로 다른 값을 들고 있으니 같은 리뷰도 화면에 따라 좋아요 수가 달라질 수 있었어요. 실패했을 때도 각 컴포넌트가 자기 상태만 되돌렸고요.

리뷰 응답은 이미 TanStack Query 캐시에 있었어요. 그 값을 다시 복사하지 않고 화면이 캐시를 구독하게 하면, 관련 캐시를 함께 바꾸고 실패 시 함께 복구할 수 있었어요. 아래 코드는 프로젝트에서 사용한 TanStack Query v5로 작성했어요.

캐시를 직접 바꿨다면 롤백도 직접 해야 해요

캐시로 옮겼다고 실패 처리가 사라지는 건 아니었어요. onMutate에서 setQueryData로 값을 바꿔도 TanStack Query가 이전 값을 따로 보관해 주지는 않아요. 요청이 실패하면 돌려놓을 값을 제가 먼저 저장해야 했어요.

그래서 요청 전후의 처리를 다음 순서로 묶었어요.

  1. 진행 중인 refetch를 취소해요.
  2. 변경 전 캐시를 snapshot으로 저장해요.
  3. 캐시를 낙관적으로 변경해요.
  4. onMutate에서 snapshot을 반환해요.
  5. 요청이 실패하면 onError에서 snapshot을 다시 넣어요.
  6. 성공과 실패 모두 invalidateQueries로 서버 값과 다시 맞춰요.

onMutate가 돌려준 snapshot을 onError에서 꺼내 복구하는 방식이에요. 공식 문서의 낙관적 갱신 예제도 이 흐름을 사용해요.

Query Key의 범위를 먼저 정하기

좋아요를 누른 화면의 캐시만 바꾸면 처음 문제와 같아져요. 목록은 병원과 페이지 크기에 따라 여러 개가 생기고, 상세는 또 다른 key를 써요. 우선 같은 종류의 목록을 한꺼번에 찾을 수 있도록 key를 계층으로 나눴어요.

typescript
const reviewKeys = {
  all: ['review'] as const,
  infinite: ['review', 'infinite'] as const,
  detail: (reviewId: number) =>
    ['review', 'fetchReviewsById', reviewId] as const,
};

예를 들어 다음 쿼리는 파라미터가 달라도 모두 reviewKeys.infinite에 포함돼요.

text
['review', 'infinite', { hospitalId: 1, size: 3 }]
['review', 'infinite', { hospitalId: 1, size: 20 }]
['review', 'infinite', { hospitalId: 2, size: 10 }]

reviewKeys.infinite로 찾으면 size를 하나로 고정하지 않아도 돼요. 현재 캐시에 있는 목록 중 어떤 페이지 크기를 쓰든 같은 갱신 함수를 적용할 수 있어요.

리뷰 한 건을 바꾸는 순수 함수

목록과 상세에서 좋아요 수를 다르게 계산하지 않도록, 리뷰 한 건을 바꾸는 함수를 먼저 만들었어요. 대상 ID가 아니거나 이미 같은 좋아요 상태라면 원래 객체를 그대로 돌려줘요. 같은 상태를 다시 적용해도 숫자가 중복으로 바뀌지 않게 한 거예요.

tsx
import type { InfiniteData, QueryKey } from '@tanstack/react-query';
 
type Review = TReviewDTO;
type ReviewInfiniteData = InfiniteData<
  TGlobalListResponse<TReviewDTO[]>
>;
type ReviewDetailResponse = TGlobalResponse<TReviewDetailDTO>;
 
type ReviewLikeVariables = {
  reviewId: number;
  feedbackType: 'LIKE' | 'UNLIKE';
};
 
function updateReviewLike<T extends Review>(
  review: T,
  { reviewId, feedbackType }: ReviewLikeVariables,
): T {
  if (review.reviewId !== reviewId) return review;
 
  const nextLike = feedbackType === 'LIKE';
 
  // 같은 상태를 다시 반영할 때 숫자가 중복으로 변하지 않게 한다.
  if (review.like === nextLike) return review;
 
  return {
    ...review,
    like: nextLike,
    likeCnt: Math.max(0, review.likeCnt + (nextLike ? 1 : -1)),
  };
}

무한 목록에서는 각 페이지의 list를 돌면서 이 함수를 적용해요. 바뀌지 않은 리뷰와 페이지 응답의 나머지 값은 그대로 남겨요.

typescript
function updateReviewList(
  oldData: ReviewInfiniteData | undefined,
  variables: ReviewLikeVariables,) {
  if (!oldData) return oldData;
 
  return {
    ...oldData,
    pages: oldData.pages.map((page) => ({
      ...page,
      data: {
        ...page.data,
        list: page.data.list.map((review) =>
          updateReviewLike(review, variables),
        ),
      },
    })),
  };
}

목록과 상세 캐시를 snapshot으로 남기기

복구할 때도 목록과 상세를 같이 다뤄야 해요. onMutate에서는 진행 중인 refetch부터 취소했어요. 그 요청이 늦게 끝나서 방금 바꾼 값을 덮을 수 있기 때문이에요. 그다음 key별로 이전 목록과 상세 응답을 저장하고, 두 캐시를 바꾼 뒤 snapshot을 반환했어요.

tsx
type ReviewLikeContext = {
  listSnapshots: Array<[
    QueryKey,
    ReviewInfiniteData | undefined,
  ]>;
  detailKey: QueryKey;
  detailSnapshot: ReviewDetailResponse | undefined;
};
 
export function useReviewLike() {
  const queryClient = useQueryClient();
 
  const mutation = useMutation<
    void,
    Error,
    ReviewLikeVariables,
    ReviewLikeContext
  >({
    mutationKey: ['fetchReviewLike'],
    mutationFn: fetchReviewLike,
 
    onMutate: async (variables) => {
      const detailKey = reviewKeys.detail(variables.reviewId);
 
      // 이미 진행 중인 refetch가 낙관적 값을 덮지 않게 한다.
      await Promise.all([
        queryClient.cancelQueries({ queryKey: reviewKeys.infinite }),
        queryClient.cancelQueries({ queryKey: detailKey }),
      ]);
 
      const listSnapshots =
        queryClient.getQueriesData<ReviewInfiniteData>({
          queryKey: reviewKeys.infinite,
        });
 
      const detailSnapshot =
        queryClient.getQueryData<ReviewDetailResponse>(detailKey);
 
      queryClient.setQueriesData<ReviewInfiniteData>(
        { queryKey: reviewKeys.infinite },
        (oldData) => updateReviewList(oldData, variables),
      );
 
      queryClient.setQueryData<ReviewDetailResponse>(
        detailKey,
        (oldData) => {
          if (!oldData) return oldData;
 
          return {
            ...oldData,
            data: updateReviewLike(oldData.data, variables),
          };
        },
      );
 
      return {
        listSnapshots,
        detailKey,
        detailSnapshot,
      };
    },
 
    onError: (error, _variables, context) => {
      context?.listSnapshots.forEach(([queryKey, oldData]) => {
        queryClient.setQueryData(queryKey, oldData);
      });
 
      if (context) {
        queryClient.setQueryData(
          context.detailKey,
          context.detailSnapshot,
        );
      }
 
      toast.warning(SafedocError.getMessage(error));
    },
 
    onSettled: () =>
      queryClient.invalidateQueries({ queryKey: reviewKeys.all }),
  });
 
  return {
    handleLike: mutation.mutateAsync,
    isLikePending: mutation.isPending,
  };
}

실패하면 onError가 저장한 값을 각 key에 다시 넣어요. 성공과 실패 뒤에는 onSettled에서 서버 값을 다시 조회하고요. 여기서 invalidateQueries의 Promise를 반환하면 그 조회가 끝날 때까지 mutation의 pending 상태를 유지할 수 있어요.

getQueriesData가 해주는 일과 해주지 않는 일

getQueriesData({ queryKey: reviewKeys.infinite })는 해당 prefix로 현재 캐시에 존재하는 쿼리를 반환해요. 새로운 목록 캐시를 만들거나 앞으로 생길 화면까지 자동으로 동기화하지는 않아요.

그래서 목록 key가 같은 prefix를 쓰도록 맞췄고, 다른 key를 쓰는 상세는 별도로 갱신했어요. 나중에 좋아요를 표시하는 캐시가 더 생기면 그 캐시도 이 처리에 넣어야 해요. 마지막 invalidateQueries는 클라이언트에서 계산한 좋아요 수를 서버 값과 다시 맞추는 단계예요.

연속 클릭은 별도로 처리해야 해요

여기까지는 한 번의 조작을 다룬 코드예요. 좋아요와 취소를 연달아 누르면 먼저 보낸 요청이 나중에 끝날 수도 있어요. snapshot을 저장하는 것만으로 요청 순서까지 정해지지는 않아요.

이 화면에서는 요청이 진행되는 동안 같은 버튼을 비활성화하는 방법이 가장 단순해요.

tsx
const { handleLike, isLikePending } = useReviewLike();
 
<LikeSwitch
  {...review}
  disabled={isLikePending}
  onChange={(checked) =>
    handleLike({
      reviewId: review.reviewId,
      feedbackType: checked ? 'LIKE' : 'UNLIKE',
    })
  }
/>

연속 조작을 허용해야 한다면 review ID별 요청 직렬화, 마지막 의도만 반영하는 방식, 서버의 멱등성까지 함께 설계해야 해요. 이는 캐시 snapshot만으로 해결되는 문제가 아니에요.

여러 캐시에 같은 리뷰가 있을 때

확인할 때는 목록의 아이콘이 빨리 바뀌는 것만 보면 부족해요. size가 다른 목록과 상세에 같은 리뷰를 넣고, 응답이 오기 전에 함께 바뀌는지 봐야 해요. 실패시키면 각각 저장해 둔 값으로 돌아와야 하고요.

진행 중이던 refetch가 중간에 값을 덮는 경우와, 이미 좋아요인 리뷰에 같은 상태를 다시 적용하는 경우도 확인할 대상이에요. 좋아요 수가 두 번 늘거나 음수가 되어서는 안 되고, 재조회가 끝나면 서버의 값과 일치해야 해요.

로컬 상태를 캐시로 옮기면서 좋아요 한 번에 바뀌는 범위도 달라졌어요. 버튼 하나의 상태를 고치는 대신, 그 리뷰를 보여 주는 목록과 상세를 같은 순서로 갱신하게 됐어요.

text
사용자 입력
  -> 관련 refetch 취소
  -> 목록과 상세 cache snapshot 저장
  -> 목록과 상세 cache 즉시 변경
  -> 요청 실패 시 snapshot 직접 복구
  -> 성공/실패 후 서버 데이터 재검증

참고: TanStack Query Optimistic Updates