Study/Next.js

TanStack Query로 좋아요 상태 동기화하기: Query Key 설계부터 에러 처리까지

haseulla 2025. 12. 30. 21:36

들어가며

이번에 진행하고 있는 개인 프로젝트인 전시 공연을 한눈에 볼 수 있는 플랫폼에서 '좋아요 기능'을 구현하면서 마주한 문제들과 해결 과정을 공유합니다. 단순해 보이는 좋아요 기능이지만, 실제로는 상태 동기화, 에러 처리, 캐시 관리 등 고려할 것이 많았습니다.

좋아요 기능이 적용된 전시회 상세페이지

1. 요구사항 정리

구현해야 할 기능:

  • 좋아요 추가/취소
  • 실시간 좋아요 상태 확인 (하트 버튼)
  • 비로그인 사용자 처리
  • 중복 추가 방지
  • 여러 페이지에서 상태 동기화 (상세 페이지 ↔ 좋아요 목록)

기술 스택: Next.js 16 (App Router), TanStack Query, TypeScript

2. 왜 TanStack Query를 선택했는가?

고민했던 방법들

1) useState + useEffect 조합?

useEffect(() => {
fetch(/api/wishlist/check/${id})
.then(res => res.json())
.then(data => setIsWishlisted(data.isWishlisted))
.catch(err => setError(err));
}, [id]);

이렇게 작업했을 때는 관리해야하는게 너무 많았습니다.

  • 로딩 상태(isLoading)
  • 에러 상태(error)
  • 데이터(isWishlisted)
  • 캐싱 로직

컴포넌트마다 이 보일러 플레이트를 반복해야하는 문제가 있었습니다.

 

3) TanStack Query - 최종 선택

const { data, isLoading, error } = useQuery({
  queryKey: ['wishlist', 'check', id],
  queryFn: () => wishlistAPI.check(id),
});

선택 이유:

  1. 서버 상태 관리에 특화: 로딩, 에러, 캐싱이 자동
  2. 중복 요청 방지: 같은 queryKey면 여러 곳에서 호출해도 1번만 요청
  3. 자동 캐싱: 한 번 가져온 데이터는 재사용
  4. 상태 동기화: queryKey가 같으면 모든 컴포넌트가 같은 데이터 공유

위와 같은 이유로 Tanstack Query를 사용하여 좋아요 기능을 구현하기로 결정했습니다.

Context API도 고려했지만
Context는 클라이언트 상태(테마, 언어 설정 등)에 적합하고,
서버 상태를 관리하려면 결국 위의 로딩/에러/캐싱 로직을
Context 안에서 직접 구현해야 해서 TanStack Query를 선택했습니다.

3. Query Key 설계: 효율적인 캐시 관리의 핵심

계층 구조로 설계한 이유

const WISHLIST_KEYS = {
  all: ["wishlist"] as const,
  lists: () => [...WISHLIST_KEYS.all, "list"] as const,
  check: (itemId: string) => [...WISHLIST_KEYS.all, "check", itemId] as const,
};

이건 파일 시스템 구조와 비슷합니다:

wishlist/
├── list/           ← 전체 좋아요 목록
└── check/
    ├── item-123/   ← 특정 아이템의 좋아요 상태
    └── item-456/

왜 이렇게 했을까?

문제 상황: 좋아요를 추가했을 때 업데이트해야 할 곳이 2군데입니다:

  1. 상세 페이지의 하트 버튼 → ["wishlist", "check", "item-123"]
  2. 좋아요 목록 페이지 → ["wishlist", "list"]

비효율적인 방법:

// 모든 캐시를 무효화하면?
queryClient.invalidateQueries({ queryKey: ["wishlist"] });

결과:

  • item-123, item-456, item-789... 모든 아이템 상태 refetch
  • 불필요한 서버 요청 수십 개 발생

효율적인 방법:

// 필요한 것만 무효화
queryClient.invalidateQueries({
  queryKey: ["wishlist", "list"] // 목록만
});
queryClient.invalidateQueries({
  queryKey: ["wishlist", "check", "item-123"] // 이 아이템만
});

결과: 서버 요청 2번만!

 

invalidateQueries의 동작 원리

invalidateQueries는 "이 데이터는 낡았으니 다시 가져와!" 라고 표시하는 역할입니다.

4. 핵심 Hook 구현

4-1. 좋아요 상태 확인: useCheckWishList

export const useCheckWishList = (itemId: string) => {
  return useQuery({
    queryKey: WISHLIST_KEYS.check(itemId),
    queryFn: () => wishlistAPI.check(itemId),
    staleTime: 0,  // 항상 최신 상태 확인
    retry: true,  // 실패 시 재시도 
    refetchOnWindowFocus: false, // 탭 전환 시 refetch 안 함
  });
};

옵션 선택 이유:

  • staleTime: 0: 좋아요 상태는 실시간성이 중요 (다른 탭에서 변경 가능)
  • retry: false: 401 에러(비로그인)는 재시도해도 계속 실패
  • refetchOnWindowFocus: false: 불필요한 요청 방지

4-2. 좋아요 추가: useAddWishList

export const useAddWishList = () => {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: wishlistAPI.add,

    onSuccess: (_, variables) => {
      // 2곳의 캐시 무효화
      queryClient.invalidateQueries({
        queryKey: WISHLIST_KEYS.lists()
      });
      queryClient.invalidateQueries({
        queryKey: WISHLIST_KEYS.check(variables.item_id),
      });

      toast.success("좋아요 리스트에 추가되었습니다.");
    },

    onError: (error: ApiError) => {
      // 에러별 처리 (다음 섹션에서 설명)
    },
  });
};

5. 세밀한 에러 처리

좋아요 기능에서 발생할 수 있는 에러들은 다음과 같습니다.

5-1. 401 에러: 비로그인 사용자

if (error.status === 401) {
  router.push("/login");
  return;
}

왜 toast 없이 바로 리다이렉트?

  • 로그인 페이지 자체가 충분한 피드백
  • "로그인이 필요합니다" 메시지는 불필요한 중복

5-2. 409 에러: 중복 추가

if (error.status === 409) {
  toast.error("이미 좋아요 리스트에 담긴 프로그램입니다.");
  return;
}

발생 시나리오:

  • 네트워크 지연으로 버튼 2번 클릭
  • 다른 탭에서 이미 추가했는데 캐시가 아직 갱신 안 되는 경우

5-3. 기타 에러

toast.error(error.message || "오류가 발생했습니다.");

6. 실제 사용: 컴포넌트에서 연결하기

export default function ExhibitionDetail({ id }: { id: string }) {
  const { data: checkData } = useCheckWishList(id);
  const { mutate: addWishlist, isPending: addPending } = useAddWishList();
  const { mutate: removeWishlist, isPending: removePending } = useRemoveWishList();

  const isWishlisted = checkData?.isWishlisted ?? false;
  const isPending = addPending || removePending;

  const handleToggle = () => {
    if (isWishlisted) {
      removeWishlist(id);
    } else {
      addWishlist({ item_id: id, item_type: "exhibition" });
    }
  };

  return (
    <Button onClick={handleToggle} disabled={isPending}>
      {isWishlisted ? "♥" : "♡"}
    </Button>
  );
}

7. 개선할 점: Optimistic Update

현재 방식의 한계:

  • 서버 응답을 기다려야 하트가 채워짐 (약간의 딜레이)
  • 네트워크가 느리면 사용자 경험 저하

다음 단계: Optimistic Update를 적용하면 버튼 클릭 즉시 UI가 반영됩니다.

// 다음 글에서 다룰 예정
onMutate: async (newItem) => {
  // 즉시 UI 업데이트
  queryClient.setQueryData(['wishlist', 'check', id], { isWishlisted: true });

  // 실패하면 롤백
}

8. 마치며

이번 구현을 통해 배운 것:

  • Query Key는 계층적으로 설계해야 효율적
  • invalidateQueries는 필요한 최소한만 무효화
  • 에러는 상황별로 다르게 처리
  • 서버 상태 관리는 TanStack Query에 맡기기

이후에는 Optimistic Update를 적용해 더 빠른 UX를 만들어보겠습니다!


관련 링크: