최종 프로젝트 - 전역상태 관리 코드 수정 (!!의 쓰임)

하영·2024년 10월 28일
1

팀프로젝트

목록 보기
17/27

간단할 줄 알았는데 코드가 엄청나게 길어졌다..!
custom hook 으로 해결할 수 있을 줄 알았지만 꽤 커져서... 계속 코드분리를 하고 싶었다. 그 전에 일단 Zustand 랑 TanStack Query를 쓰기로 한 게 팀 목표여서 이 부분부터 수정해주기로 했다.

전역상태 관리 코드 수정 (Zustand, TanStack Query)

Zustand 작성하기

import { create } from "zustand";

type ScrapStore = {
  userId: string | null;
  setUserId: (id: string | null) => void;
  folderName: string;
  setFolderName: (folder: string) => void;
  isSaving: boolean;
  setIsSaving: (saving: boolean) => void;
  existingFolders: string[];
  setExistingFolders: (folders: string[]) => void;
  selectedFolder: string | null;
  setSelectedFolder: (folder: string | null) => void;
};

export const useScrapStore = create<ScrapStore>((set) => ({
  userId: null,
  setUserId: (id) => set({ userId: id }),
  folderName: "",
  setFolderName: (folder) => set({ folderName: folder }),
  isSaving: false,
  setIsSaving: (saving) => set({ isSaving: saving }),
  existingFolders: [],
  setExistingFolders: (folders) => set({ existingFolders: folders }),
  selectedFolder: null,
  setSelectedFolder: (folder) => set({ selectedFolder: folder })
}));

코드 설명

useScrapStore는 다양한 스크랩 관련 기능에서 공통적으로 사용될 상태와 이를 업데이트하는 함수를 제공한다.

  1. userId: 현재 로그인한 사용자의 ID를 저장. 로그인하지 않았다면 null 반환. setUserId를 통해 다른 컴포넌트에서 userId를 업데이트할 수 있다.

  2. folderName: 현재 사용자가 선택한 폴더의 이름을 저장. 폴더를 선택하거나 이름을 설정할 때 setFolderName을 호출해 업데이트할 수 있다.

  3. isSaving: 스크랩을 저장할 때 isSavingtrue로 설정하여 현재 저장 중임을 나타낸다. 완료 후에는 다시 false로 설정한다.

  4. existingFolders: 현재 사용자 계정에 있는 모든 폴더의 이름 목록을 저장한다. 이 목록은 setExistingFolders를 통해 설정하거나 업데이트할 수 있다.

  5. selectedFolder: 사용자가 현재 선택한 폴더를 나타내며, null일 경우 폴더가 선택되지 않은 상태를 의미한다. setSelectedFolder로 값 설정이 가능.


TanStackQuery 작성하기

import { useMutation, useQuery, useQueryClient, UseQueryResult } from "@tanstack/react-query";
import { supabase } from "@/supabase/supabase";
import { useScrapStore } from "@/store/scrapStore";
import { getUserId } from "@/serverActions/profileAction";
import { useEffect } from "react";

// 폴더 목록을 가져오는 함수
const fetchFolders = async (userId: string): Promise<string[]> => {
  const { data, error } = await supabase.from("SCRAP_TABLE").select("folder_name").eq("user_id", userId);

  if (error) throw new Error(error.message);

  // 중복 폴더 이름 제거
  return Array.from(new Set(data.map((item) => item.folder_name)));
};

// 특정 레시피가 이미 스크랩되었는지 확인하는 함수
const isAlreadyScrapped = async (recipeId: string, userId: string): Promise<boolean> => {
  const { data, error } = await supabase
    .from("SCRAP_TABLE")
    .select("scrap_id")
    .eq("scrap_id", recipeId)
    .eq("user_id", userId);

  if (error) throw new Error(error.message);
  return data && data.length > 0;
};

// 레시피의 스크랩 수를 가져오는 함수
const fetchRecipeScrapCount = async (recipeId: string): Promise<number> => {
  const { data, error } = await supabase.from("TEST_TABLE").select("scrap_count").eq("post_id", recipeId).single();

  if (error) throw new Error(error.message);
  return data?.scrap_count || 0;
};

// 스크랩 데이터 가져오기 함수
const fetchScraps = async (userId: string): Promise<Scrap[]> => {
  const { data, error } = await supabase.from("SCRAP_TABLE").select("*").eq("user_id", userId);
  if (error) throw new Error(error.message);
  return data || [];
};

interface Scrap {
  scrap_id: string;
  folder_name: string;
  scraped_recipe: string;
  created_at: string;
  updated_at: string;
}

interface UseScrapData {
  existingFolders: string[] | undefined;
  scraps: Scrap[] | undefined;
  refetchFolders: () => void;
  refetchScraps: () => void;
  incrementScrapCount: (recipeId: string) => Promise<boolean>;
  saveScrap: (params: { recipeId: string; folderName: string }) => Promise<boolean>;
  useFetchScrapCount: (recipeId: string) => UseQueryResult<number>;
}

// useScrapData 훅 정의
export const useScrapData = (): UseScrapData => {
  const { userId, setUserId } = useScrapStore();
  const queryClient = useQueryClient();

  // userId 초기화
  useEffect(() => {
    if (!userId) {
      getUserId()
        .then((id) => setUserId(id))
        .catch((error) => console.error("사용자 ID 설정 오류:", error));
    }
  }, [userId, setUserId]);

  // 폴더 목록 쿼리
  const { data: existingFolders, refetch: refetchFolders } = useQuery({
    queryKey: ["folders", userId],
    queryFn: () => fetchFolders(userId as string),
    enabled: !!userId,
    staleTime: 5 * 60 * 1000 // 5분 동안 캐싱
  });

  // 스크랩 데이터 쿼리
  const { data: scraps, refetch: refetchScraps } = useQuery({
    queryKey: ["scraps", userId],
    queryFn: () => fetchScraps(userId as string),
    enabled: !!userId
  });

  // 개별 레시피 스크랩 수를 위한 커스텀 훅
  const useFetchScrapCount = (recipeId: string) =>
    useQuery({
      queryKey: ["scrapCount", recipeId],
      queryFn: () => fetchRecipeScrapCount(recipeId),
      enabled: !!recipeId
    });

  // 스크랩 수 증가를 위한 mutation
  const { mutateAsync: incrementScrapCount } = useMutation({
    mutationFn: async (recipeId: string) => {
      const currentCount = await fetchRecipeScrapCount(recipeId);
      const newCount = currentCount + 1;

      const { error } = await supabase.from("TEST_TABLE").update({ scrap_count: newCount }).eq("post_id", recipeId);

      if (error) throw new Error(error.message);

      queryClient.setQueryData(["scrapCount", recipeId], newCount);
      return true;
    }
  });

  // 스크랩 저장 mutation
  const { mutateAsync: saveScrap } = useMutation({
    mutationFn: async ({ recipeId, folderName }: { recipeId: string; folderName: string }) => {
      if (!userId) throw new Error("로그인 된 사용자가 없습니다.");

      const alreadyScrapped = await isAlreadyScrapped(recipeId, userId);
      if (alreadyScrapped) {
        alert("이미 스크랩 한 레시피입니다.");
        return false;
      }

      const { data: recipeData, error: fetchError } = await supabase
        .from("TEST_TABLE")
        .select("*")
        .eq("post_id", recipeId)
        .single();

      if (fetchError) throw new Error(fetchError.message);

      const { error: insertError } = await supabase.from("SCRAP_TABLE").insert({
        user_id: userId,
        scrap_id: recipeId,
        folder_name: folderName,
        scraped_recipe: JSON.stringify(recipeData),
        created_at: new Date(),
        updated_at: new Date()
      });

      if (insertError) throw new Error(insertError.message);

      await incrementScrapCount(recipeId);
      refetchFolders();
      return true;
    }
  });

  return {
    existingFolders,
    scraps,
    refetchFolders,
    refetchScraps,
    incrementScrapCount,
    saveScrap,
    useFetchScrapCount
  };
};

각 함수와 훅 설명

  1. fetchFolders

    • userId를 받아 SCRAP_TABLE에서 폴더 목록을 가져온다.
    • Set을 사용해 중복 폴더를 제거한다.
  2. isAlreadyScrapped

    • recipeIduserId로 특정 레시피가 이미 스크랩되었는지 확인한다.
  3. fetchRecipeScrapCount

    • recipeId를 기준으로 TEST_TABLE에서 스크랩 수를 가져온다.
  4. fetchScraps

    • userIdSCRAP_TABLE의 스크랩 데이터를 모두 가져온다.
  5. useScrapData

    • useScrapStoreuserId를 기반으로 여러 가지 쿼리와 mutation을 설정한다.

    • existingFolders, scraps, refetchFolders, refetchScraps, incrementScrapCount, saveScrap, useFetchScrapCount 등 다양한 스크랩 관련 기능을 제공한다.


👩🏻‍💻 enabled: !!userId 의 의미

오늘 TIL을 쓴 이유이기도 한... !! 의 표현이 뭔지 궁금했다.
튜터님도 종종 !! 두개를 썼던 것 같아서 간단하게 정리해보았다.

userId가 존재할 때에만 해당 쿼리가 활성화되도록 설정하는 것!

  1. !!userIdtruthyfalsy 여부를 명확하게 판단하기 위한 방법이다.
  • !userIduserId의 부정(null이나 undefined일 경우 true)이다.
  • 다시 !를 붙여 !!userId로 만들면 userId가 존재하면 true를, 없으면 false를 반환하게 된다.
  1. enabled 옵션은 react-query의 옵션으로, true일 때에만 쿼리가 실행된다.
    • 이 코드는 userId가 존재할 때에만 쿼리가 활성화되도록 하여, userIdnull일 때 불필요한 쿼리 실행을 막아준다.

따라서 enabled: !!userIduserId가 정의된 경우에만 해당 쿼리가 실행되도록 설정하는 중요한 옵션이다.

profile
왕쪼랩 탈출 목표자의 코딩 공부기록

2개의 댓글

comment-user-thumbnail
2024년 10월 29일

!!

1개의 답글

관련 채용 정보