본문 바로가기

코딩/트러블슈팅

Notice와 WOD 메뉴의 공통 컴포넌트화 과정에서의 TanStack Query

📝 배경

wodLog 프로젝트에서 Notice와 WOD(WorkOut of the Day) 메뉴는 동일한 CRUD 기능을 가지고 있어 코드 중복이 존재했습니다. React를 사용하고 있으므로 코드의 재사용성 및 유지보수 편의성 향상을 위해 공통 컴포넌트로 구현을 생각하게 되었고 그때 겪었던 TanStack Query의 `useQuery`, `useSuspenseQuery`에 대해 작성해 보려 합니다.

 

각 메뉴들의 동일한 CRUD 기능을 props로 `ContentType`을 통한 동적 쿼리 처리로 새로운 게시판 기능 추가 시 쉽게 확장 가능하게 개발해야겠다는 생각을 하게 되었습니다.

 

단순히 공통 컴포넌트 개발 후 각각의 메뉴에 진입하여 사용하기만 하면 이상이 없겠지만 제가 개발한 홈페이지는 웹 사이트의 홈 화면을 보면 WOD메뉴의 상세페이지와 Notice 페이지의 리스트 페이지가 나타나야 했습니다.

구현 완료된 홈 페이지

 

 

 

🎯 WOD(WorkOut of the Day)의 특성과 구현 요구사항

여기에서 간단하게 설명하자면 WOD는 WorkOut of the Day라는 뜻으로 오늘의 운동을 말합니다. 크로스핏터라면 가장 먼저 확인하는 중요한 정보이기 때문에 홈페이지 진입하자마자 오늘의 WOD가 무엇인지 보여야 한다고 생각했습니다.

 

WOD 게시물 내용과 제목 형식

 

제가 현재 다니고 있는 박스(체육관) 기준으로 WOD는 미리 올려져 있는 게 아닌 코치님이 하루 전 날 올려주십니다. 올리시는 형식이 `WOD 진행 날짜 + WOD`이기에 현재 날짜와 코치님이 올리시는 날짜가 동일하다면 오늘의 WOD로 인식되어 홈페이지 진입 시 바로 보이게 설정해 놓았습니다. 만약 아직 등록되지 않았다면 등록되지 않았다는 내용이 보입니다.

 

  // Home
  // 홈에서 오늘 WOD 조회
  detailHome: () => ({
    queryKey: ["home"],
    queryFn: async () => {
      const data = await supabase
        .from("wod")
        .select("content")
        .like("title", `%${today}%`);
      const detailWodHome = await handleSupabaseResponse(data);
      if (detailWodHome.length === 0) {
        return null;
      }
      return deltaToHtml(detailWodHome)[0];
    },
  }),
});

WOD가 아직 등록되지 않았을 때 홈 화면에서 보이는 문구

 

 

 

🛠  문제 상황

1. 초기 구현과 발생한 문제

처음에는 아래 코드와 같이 Notice, WOD 모두 `useSuspenseQuery`를 사용하였습니다.

const query = isWodPath
  ? wodQueryKeys.detail(contentId)
  : noticeQueryKeys.detail(contentId);
const { data: detailItemsHome } = useSuspenseQuery(wodQueryKeys.detailHome());
const { data: detailItems } = useSuspenseQuery({
  queryKey: query.queryKey as QueryKey,
  queryFn: query.queryFn as QueryFunction<Notice[] | Wod[]>,
});

 

하지만 이 구현에는 다음과 같은 문제가 발생했습니다. 

`.../wod?select=*&id=eq.NaN 400 (Bad Request)`

 

홈 화면에서 보이는 WOD는 상세 페이지라 해당 페이지의 아이디 값을 가지고 조회를 해야 하는데, Notice의 경우 리스트 페이지가 보이기에 넘겨받는 id값이 없어서 `NaN` 이라 오류가 발생하게 되었습니다.

 

 

2. Tanstack Query 분석

위 문제를 해결하기 위해 처음에 든 생각은 아래와 같습니다.

 

1) `useQuery`, `useSuspenseQuery`는 무조건 실행되는 건가?

  • `useQuery`는 기본적으로 컴포넌트가 마운트 될 때 자동으로 실행. 하지만 `enabled` 옵션을 통해 실행을 제어할 수 있음
  • `useSuspenseQuery`도 마찬가지로 컴포넌트 마운트 시 자동 실행됨. 하지만 `enabled` 옵션이 없어 실행을 직접적으로 제어하기 어려움

2) `useSuspenseQuery`는 `enabled` 옵션이 없나?

  • `enabled` 옵션 제공하지 않음
    • Suspense 모드에서 작동하는 쿼리의 특성 때문
      • Suspense 모드는 데이터가 준비되지 않았을 때 렌더링을 일시 중단하고 fallback UI를 표시하는 것이 목적이기 때문에 쿼리 실행 자체를 조건부로 제어하는 `enabled` 옵션과는 개념적으로 맞지 않음
      • useSuspenseQuery | TanStack Query React Docs

3) `useQuery`에는 `enabled` 옵션이 존재하는데 이걸 쓰고 Suspense 기능은 `isLoading` 옵션을 사용해야 하는 건가?

  • `useQuery`는 `suspense`옵션을 제공하지 않음
  • useQuery | TanStack Query React Docs
  • `isLoading`은 쿼리가 현재 데이터를 가져오는 중인지를 나타내고, 데이터가 준비되면 바로 표시할 수 있음

 

3. 해결 시도

1차 시도) `useQuery`와 `enabled` 옵션 사용

  • `useQuery`가 컴포넌트 마운트 시 자동으로 실행이 되므로 `enabled`을 주며 아래와 같이 각각 작성하였습니다.
// home에서 wod 조회 시 오늘 날짜의 wod만 보여주기 위한 코드
const { data: detailItemsHome, isLoading: isLoadingHome } = useQuery({
  queryKey: query.queryKey as QueryKey,
  queryFn: query.queryFn as QueryFunction<Wod[]>,
  enabled: isHome,
});
// 메뉴의 상세 페이지
const { data: detailItems, isLoading } = useQuery({
  queryKey: query.queryKey as QueryKey,
  queryFn: query.queryFn as QueryFunction<Notice[] | Wod[]>,
  enabled: !!contentId,
});

if (isLoading || isLoadingHome) {
  return <Loader />;
}

 

이 방식은 작동했지만 중복 코드가 많았습니다.

 

2차 시도) 쿼리 로직 통합

  const query = isWodPath
    ? isHome
      ? wodQueryKeys.detailHome()
      : wodQueryKeys.detail(contentId)
    : noticeQueryKeys.detail(contentId);
  const { data: queryResult, isLoading } = useQuery({
    queryKey: query.queryKey as QueryKey,
    queryFn: query.queryFn as QueryFunction<Wod[] | Notice[]>,
    enabled: isHome || !!contentId,
  });

  if (isLoading) {
    return <Loader />;
  }

 

1차 시도에 비해서는 깔끔해졌지만, 타입 안정성과 Notice와 WOD 페이지 이 외의 확장성까지 생각하게 된다면 개선이 필요하다는 생각이 들게 되었습니다.

 

4. 최종 해결안

타입 정의와 쿼리 설정을 명확하게 분리한 구조로 만들게 되었습니다.

type ContentType = "notice" | "wod";

interface QueryConfig {
  queryKey: QueryKey;
  queryFn: QueryFunction<ContentWithUserInfo>;
}

interface ContentConfig {
  title: string;
  getHomeQuery?: () => QueryConfig;
  getDetailQuery: (contentId: number) => QueryConfig;
}

const contentConfig: Record<ContentType, ContentConfig> = {
  notice: {
    title: "Notice",
    getDetailQuery: (contentId) => ({
      queryKey: noticeQueryKeys.detail(contentId).queryKey,
      queryFn: noticeQueryKeys.detail(contentId)
        .queryFn as QueryFunction<ContentWithUserInfo>,
    }),
  },
  wod: {
    title: "WOD",
    getDetailQuery: (contentId) => ({
      queryKey: wodQueryKeys.detail(contentId).queryKey,
      queryFn: wodQueryKeys.detail(contentId)
        .queryFn as QueryFunction<ContentWithUserInfo>,
    }),
    getHomeQuery: () => ({
      queryKey: wodQueryKeys.detailHome().queryKey,
      queryFn: wodQueryKeys.detailHome()
        .queryFn as QueryFunction<ContentWithUserInfo>,
    }),
  },
};

const useContentQuery = (
  contentType: ContentType,
  isHome: boolean,
  contentId?: number
) => {
  const config = contentConfig[contentType];
  const query =
    isHome && config.getHomeQuery
      ? config.getHomeQuery()
      : contentId !== undefined
      ? config.getDetailQuery(contentId)
      : null;

  if (!query) {
    throw new Error("유효하지 않은 쿼리");
  }

  return useQuery({ ...query, enabled: isHome || !!contentId });
};

 

 

개선된 점

  1. Type 안정성
    • 명확한 인터페이스 정의로 타입 안정성 확보
    • 컴파일 타임에 잠재적 오류 감지 가능
  2. 확장성
    • 새로운 컨텐츠 타입 추가가 용이
    • 각 타입별 필요한 쿼리만 구현 가능
  3. 유지보수성
    • 쿼리 로직 중앙화
    • 중복 코드 제거
  4. 안정성
    • enabled 옵션을 통한 쿼리 실행 제어
    • 불필요한 API 호출 방지