React Query API 관리 리팩토링

송연지·2025년 1월 22일
2

트러블슈팅

목록 보기
16/32

React Query를 사용한 Guestbook API 관리 리팩토링

현 프로젝트에서 React Query로 API 호출 로직을 리팩토링하면 다음과 같은 이점이 있습니다:

  1. 자동 캐싱 및 무효화:
    • React Query는 API 데이터를 캐싱하여 중복 호출을 방지합니다.
    • 특정 조건에서 캐시를 자동으로 무효화하여 최신 상태를 유지할 수 있습니다.

캐싱이란 특정 데이터의 복사본을 저장하여 이후 동일한 데이터의 재접근 속도를 높이는 것을 말한다!

React-Query는 캐싱을 통해 동일한 데이터에 대한 반복적인 비동기 데이터 호출을 방지하고, 이는 불필요한 API 콜을 줄여 서버에 대한 부하를 줄이는 좋은 결과를 가져온다.

💡 최신의 데이터인지 어떻게 판별하는데??

❔여기서 궁금한 것은 데이터가 최신의 것인지 아닌지에 대한 것이다.

만일 서버 데이터를 불러와 캐싱한 후, 실제 서버 데이터를 확인했을 때 서버 상에서 데이터의 상태가 변경되어있다면, 사용자는 실제 데이터가 아닌 변경 전의 데이터를 바라볼 수밖에 없게 된다. 이는 사용자에게 잘못된 정보를 보여주는 에러를 낳는다.

💡 참고로, React-Query에서는 최신의 데이터를 fresh한 데이터, 기존의 데이터를 stale한 데이터라고 말한다!!

  1. 간소화된 코드:

    • useQuery, useMutation 등을 사용하여 선언적 코드를 작성할 수 있습니다.
    • 상태 관리와 API 호출 로직이 통합됩니다.
  2. 로딩 및 에러 처리의 일관성:

    • React Query는 로딩 상태(isLoading)와 에러 상태(error)를 기본 제공하며, 이를 활용하여 UI를 간단히 구성할 수 있습니다.

    👉 즉, Client 데이터는 상태 관리 라이브러리가 관리하고, Server 데이터는 React-Query가 관리하는 구조라고 생각하면 된다!! 이를 통해 우리는 Client 데이터와 Server 데이터를 온전하게 분리할 수 있다.

🔎  물론 여기서 React-Query가 가져온 Server 데이터를 상태 관리 라이브러리를 통해 전역 상태로 가져올 수도 있는 건 사실이다. 그러나 refetch가 여러 번 일어나는 상황에 매번 Server 데이터를 전역 상태로 가져오는 것이 옳은지 판단하는 것은 여러분의 몫이다. 개발하는 서비스의 상황에 맞게 잘 선택해보도록 하자!!


기존 코드의 문제점

현재 방식

const response = await apiRequest<PageResponse<GuestbookItem>>({
  param: `api/v1/guestbooks/my?page=${page}&pageSize=${pageSize}`,
  method: 'get',
});

장점

  • 중앙 집중화된 apiRequest로 호출 관리가 용이.
  • 에러 처리와 공통 설정(헤더, 인터셉터 등) 적용 가능.

단점

  • 중복 호출 방지 로직 없음.
  • 여러 페이지에서 동일 데이터를 관리하기 어려움.
  • 캐싱/무효화 및 로딩 상태 처리 수작업 필요.

React Query로의 전환

1. API 호출 로직 분리

서비스 레이어를 별도 관리하여 API 호출을 추상화합니다.

// api/guestbookService.ts
import { apiRequest } from './apiRequest';
import type { GuestbookRequest, PageResponse, GuestbookItem } from '@/types';

export const guestbookService = {
  getMyGuestbooks: (page: number, pageSize: number) => {
    return apiRequest<PageResponse<GuestbookItem>>({
      param: `api/v1/guestbooks/my?page=${page}&pageSize=${pageSize}`,
      method: 'get',
    });
  },

  getAllGuestbooks: (page: number, pageSize: number) => {
    return apiRequest<PageResponse<GuestbookItem>>({
      param: `api/v1/guestbooks/all?page=${page}&pageSize=${pageSize}`,
      method: 'get',
    });
  },

  createGuestbook: (gatheringId: number, data: GuestbookRequest) => {
    return apiRequest({
      param: `api/v1/guestbooks/${gatheringId}`,
      method: 'post',
      body: data,
    });
  },

  updateGuestbook: (gatheringId: number, guestbookId: number, data: GuestbookRequest) => {
    return apiRequest({
      param: `api/v1/guestbooks/${gatheringId}/${guestbookId}`,
      method: 'put',
      body: data,
    });
  },

  deleteGuestbook: (gatheringId: number, guestbookId: number) => {
    return apiRequest({
      param: `api/v1/guestbooks/${gatheringId}/${guestbookId}`,
      method: 'delete',
    });
  },
};

2. React Query 훅 작성

useGuestbooks

// hooks/useGuestbooks.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { guestbookService } from '@/api/guestbookService';
import type { GuestbookRequest } from '@/types';

export const useGuestbooks = (page = 0, pageSize = 10) => {
  const queryClient = useQueryClient();

  // 조회
  const { data, isLoading, error } = useQuery({
    queryKey: ['guestbooks', page, pageSize],
    queryFn: () => guestbookService.getMyGuestbooks(page, pageSize),
  });

  // 생성
  const createMutation = useMutation({
    mutationFn: ({ gatheringId, data }: { gatheringId: number; data: GuestbookRequest }) =>
      guestbookService.createGuestbook(gatheringId, data),
    onSuccess: () => {
      queryClient.invalidateQueries(['guestbooks']);
    },
  });

  // 수정
  const updateMutation = useMutation({
    mutationFn: ({ gatheringId, guestbookId, data }: { gatheringId: number; guestbookId: number; data: GuestbookRequest }) =>
      guestbookService.updateGuestbook(gatheringId, guestbookId, data),
    onSuccess: () => {
      queryClient.invalidateQueries(['guestbooks']);
    },
  });

  // 삭제
  const deleteMutation = useMutation({
    mutationFn: ({ gatheringId, guestbookId }: { gatheringId: number; guestbookId: number }) =>
      guestbookService.deleteGuestbook(gatheringId, guestbookId),
    onSuccess: () => {
      queryClient.invalidateQueries(['guestbooks']);
    },
  });

  return {
    guestbooks: data?.content || [],
    pagination: {
      currentPage: data?.currentPage || 0,
      totalPages: data?.totalPages || 0,
      totalElements: data?.totalElements || 0,
    },
    isLoading,
    error,
    createGuestbook: createMutation.mutate,
    updateGuestbook: updateMutation.mutate,
    deleteGuestbook: deleteMutation.mutate,
  };
};

useAllGuestbooks

export const useAllGuestbooks = (page = 0, pageSize = 10) => {
  const { data, isLoading, error } = useQuery({
    queryKey: ['allGuestbooks', page, pageSize],
    queryFn: () => guestbookService.getAllGuestbooks(page, pageSize),
  });

  return {
    guestbooks: data?.content || [],
    pagination: {
      currentPage: data?.currentPage || 0,
      totalPages: data?.totalPages || 0,
      totalElements: data?.totalElements || 0,
    },
    isLoading,
    error,
  };
};

3. 컴포넌트에서 사용

// GuestbookTab.tsx
import React, { useState, useCallback } from 'react';
import { useGuestbooks } from '@/hooks/useGuestbooks';
import Loading from '@/components/Loading';
import WrittenGuestbooks from '@/components/WrittenGuestbooks';
import AvailableGuestbooks from '@/components/AvailableGuestbooks';

export default function GuestbookTab() {
  const [page, setPage] = useState(0);
  const { guestbooks, pagination, isLoading, createGuestbook, updateGuestbook, deleteGuestbook } = useGuestbooks(page);

  const handleModalSubmit = useCallback(async (data: { content: string; rating: number }) => {
    try {
      if (isEditMode) {
        await updateGuestbook({ gatheringId, guestbookId, data });
      } else {
        await createGuestbook({ gatheringId, data });
      }
    } catch (e) {
      console.error(e);
    }
  }, [createGuestbook, updateGuestbook]);

  return (
    <div>
      {isLoading ? (
        <Loading />
      ) : (
        <>
          <WrittenGuestbooks guestbooks={guestbooks} />
          <AvailableGuestbooks onWriteClick={() => {}} />
        </>
      )}
    </div>
  );
}

결론

React Query로 리팩토링하면 다음과 같은 이점이 있습니다:

  1. 코드 간소화:
    • 상태 관리 로직 감소.
    • API 호출, 로딩 처리, 에러 처리 코드가 간결해짐.
  2. 데이터 캐싱 및 동기화:
    • 중복 요청 방지.
    • 데이터 변경 시 자동 캐시 무효화.
  3. 확장성:
    • 페이지네이션, 무한 스크롤(useInfiniteQuery) 구현이 간단.
  4. 테스트 용이성:
    • 비즈니스 로직이 서비스 레이어에 집중되어 단위 테스트 작성이 쉬움.

    Zustand vs React Query 비교

FeatureZustandReact Query
주요 목적클라이언트 상태 관리서버 상태 관리
데이터 캐싱수동으로 구현 필요자동으로 데이터 캐싱
데이터 무효화커스텀 로직 필요자동으로 무효화 (invalidateQueries 사용)
로딩 및 에러 상태 관리수동으로 상태 변수 추가 필요내장 로딩 및 에러 상태 지원 (isLoading, error)
페이지네이션/무한 스크롤커스텀 구현 필요내장 지원 (useInfiniteQuery)
중복 호출 방지직접 관리 필요캐싱된 데이터를 활용해 중복 호출 방지
사용 예시UI 상태 (모달, 토스트, 필터)서버 데이터 (API 호출, CRUD 작업, 실시간 데이터 동기화)
확장성단순한 상태 관리에서 유리복잡한 데이터 의존성과 동기화가 필요한 경우 적합

이를 통해 유지보수성과 생산성을 동시에 높일 수 있습니다. Zustand 같은 상태 관리 라이브러리는 로컬 UI 상태에만 사용하는 것이 더 적합해집니다.

profile
프론트엔드 개발쟈!!

0개의 댓글