React-query 에러 해결하기~!

김인태·2024년 11월 14일
0
post-thumbnail

✅ 개요

식사하고 나른한 오후를 회사에서 보내고 있던 도중, 이사님에게 청천벽력 같은 소식을 들었습니다..

이사님 曰 : “인태님 이거 a를 클릭하고 b를 클릭했는데 a의 내용이 나오는데요?”

앗차차.. 분명히 캐싱문제일 것이다 라고 직감한 저는 “알겠습니다 빨리 해결할게요!” 라고 말씀드리고는 바로 작업에 들어갔습니다.

제 생각은 이러했습니다

a를 클릭하고 b가 나온다 ? → 전에 있던 내용이 나온다 → 캐싱문제다 → 캐싱 라이브러리 쓰는건 react-query 밖에 없으니까

훅을 확인해 봐야겠다.

라는 생각으로 hook 을 까보고 뭐가 문제지? 라고 생각하고, 네트워크 탭을 열어보았습니다

정확히 b의 정보를 요청하고 있는 것 아니겠습니까?

그래서 저의 동료이자 스승님이신 claude 선생님에게 여쭈어 보았더니..

queryKey를 잘못 설정한거다! 라고 말씀을 주셨습니다.

그 코드로 해결했고, 또한 react-query 에 대한 이해가 부족했다! 라고 느꼈습니다.

사실 tanstack query 가 공식문서가 친절하다고하던데.. 저는 잘 모르겠더라구요? 제 영어 실력이 시원찮아 그럴 수도 있겠지만..

결론적으로는 부족함을 느꼈기 때문에!

어디서 문제가 생겼고? 어떻게 해결했고? react-query에 대해서 알아보자!

라는 취지에서 이 포스팅을 올립니다 😎

어디서 문제가 생겼고 / 어떻게 해결했는가

전에 쓰던 리액트 hook과 혼동을 야기하지 않기 위해서 Query Provider도 같이 올리겠습니다.

// QueryProvider.tsx

// src/providers/QueryProvider.tsx
import {
  QueryCache,
  QueryClient,
  QueryClientProvider,
} from "@tanstack/react-query";
import { AxiosError } from "axios";
import type { PropsWithChildren } from "react";

const handleError = (error: unknown) => {
  if (error instanceof AxiosError) {
    const status = error.response?.status;

    switch (status) {
      case 400:
        window.location.href = "/error/token-expired";
        break;
      case 403:
        window.location.href = "/error/guest";
        break;
    }
  }
};

const queryClient = new QueryClient({
  queryCache: new QueryCache({
    onError: handleError,
  }),
  defaultOptions: {
    queries: {
      retry: 1,
      refetchOnWindowFocus: false,
      staleTime: 5 * 60 * 1000,
    },
    mutations: {
      retry: 1,
    },
  },
});

function QueryProvider({ children }: PropsWithChildren) {
  return (
    <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
  );
}

export default QueryProvider;

// useGetTimelineData.tsx

import { getInspectionTimeline } from "@/api/timeline/api";
import { useQuery } from "@tanstack/react-query";
import { useState } from "react";

export const useGetTimelineData = ({
  token,
  id,
}: {
  token: string;
  id: number;
}) => {
  const [progress, setProgress] = useState(0);
  const { data, isLoading } = useQuery({
    queryKey: ["timeline"],
    queryFn: () => {
      return getInspectionTimeline({
        token,
        id,
        onProgress: setProgress,
      });
    },
  });

  return { data, isLoading, progress };
};
 

이 hook은 id와 토큰을 받아서 데이터를 요청하고 거기에 따른 요청과 로딩 상태를 리턴하는 hook 입니다.

현재 queryKey 가 [”timeline”]으로 설정되어 있습니다.

그러면 여기서 생각해볼 수 있는 것은 id로 구분해서 요청을 보내는데 ‘같은’ queryKey로

함수를 실행시키고 있었던 것이죠.

// 처음에 실행했을 때
 
queryKey : ‘timeline’ , queryFn : fn(1)

//두 번째 실행했을 때

queryKey : ‘timeline’ , queryFn : fn(2)

이런식으로 말이죠!

그러면 react-query 에서는 같은 key로 요청한 데이터를 보여줍니다.. 그것이 캐싱이니까..

그럼 어떻게 해결했을까요?

아주 간단합니다

import { getInspectionTimeline } from "@/api/timeline/api";
import { useQuery } from "@tanstack/react-query";
import { useState } from "react";

export const useGetTimelineData = ({
  token,
  id,
}: {
  token: string;
  id: number;
}) => {
  const [progress, setProgress] = useState(0);
  const { data, isLoading } = useQuery({
  //id만 queryKey 배열 안에 넣어줌
    queryKey: ["timeline", id],
    queryFn: () => {
      return getInspectionTimeline({
        token,
        id,
        onProgress: setProgress,
      });
    },
  });

  return { data, isLoading, progress };
};

참 쉽죠? 근데 왜 이걸 놓쳤을까 참 아쉽더라구요.. 그래서 좀 더 깊게 들어가서 어떻게 사용되는지

정확히 알아봐야겠다고 생각을 했습니다.

👽 React-query 를 좀 더 알아보자!

🤠 React-Query 왜 써요?

저는 이 글을 포스팅하기 전에는 두 가지 이유로 쓰고 있었습니다

  1. 클라이언트 상태와 서버 상태를 분리하기 위해서.
  2. useEffect와 같은 방법으로 귀찮게 서버요청하기 싫음.
// 예시 
useEffect(() => {
  setIsLoading(true);
  fetch('/api/data')
    .then(res => res.json())
    .then(data => setData(data))
    .catch(err => setError(err))
    .finally(() => setIsLoading(false));
}, []);

// react-query 사용하니 편-안
const { data, isLoading, error } = useQuery(['data'], fetchData);

리액트 쿼리에 대한 핵심적인 기능은 알고 있지만 거기에 대한 깊은 이해는 없고,

일단 서버 상태와 클라이언트 상태를 분리한다!

라는 것만 알고 있었습니다 그래서 이유를 정확히 알고싶었습니다.

공식문서와 claude를 참고해서 한 번 적어보겠습니다.

클라이언트 상태와 서버상태

클라이언트 상태?

예시 코드

// 클라이언트 상태의 예시
const [isModalOpen, setIsModalOpen] = useState(false);  // 모달 열림/닫힘
const [selectedTab, setSelectedTab] = useState('home'); // 현재 선택된 탭
const [formData, setFormData] = useState({             // 폼 입력 데이터
  name: '',
  email: ''
});
  • 클라이언트에서만 관리되는 상태
  • 동기적으로 즉시 접근/수정 가능
  • 항상 최신 상태 보장
  • 다른 사용자와 공유되지 않음

서버상태?

예시 코드

// 서버 상태 관리의 예시
const { data: user } = useQuery({
  queryKey: ['user', userId],
  queryFn: () => fetchUser(userId)
});

const { data: posts } = useQuery({
  queryKey: ['posts'],
  queryFn: fetchPosts,
  staleTime: 1000 * 60 // 1분
});
  • 통제하거나 소유할 수 없는 위치에 유지됨.
  • 가져오기 및 업데이트를 위해 비동기 API가 필요함.
  • 공유 소유권을 의미하며 사용자 모르게 다른 사람이 변경할 수 있음.
  • 조심하지 않으면 잠재적으로 애플리케이션에서 "오래된" 상태가 될 수 있음.

위의 서버 상태 특성을 고려하며 진행하면 다음과 같은 부분을 생각해볼 수 있습니다.

  • 캐싱
  • 동일한 데이터에 대한 여러 요청을 단일 요청으로 중복 제거
  • 백그라운드에서 “오래된” 데이터 업데이트
  • 데이터가 “오래된” 시점을 아는 방법
  • 가능한 빨리 업데이트 반영하기
  • 페이지 분할, 지연 로딩 데이터와 같은 성능 최적화
  • 서버 상태의 메모리 관리 및 가비지 수집
  • 구조적 공유(이전 상태와 새로운 상태간에 변경되지 않은 부분을 공유하는 최적화 기술)를 통한 쿼리 결과 메모하기

위와 같은 부분을 리액트 쿼리를 사용하면 좀 더 쉽게 고려하면서 프로덕트를 만들 수 있습니다.

그래서 리액트 쿼리가 신선한 데이터를 줄 수 있고, 메모리도 아껴주고, 최적화도 해주는데

서버상태랑 클라이언트 상태를 왜 분리하는거에요?

👻 서버상태와 클라이언트 상태 왜 분리합니까!

  1. 관심사의 분리
    1. 코드 구조가 깔끔해집니다.
    2. 그로인해 상태의 출처와 목적이 명확해집니다.
  2. 데이터 수명주기 관리가 용이해집니다.
    1. 서버상태 : 외부에서 관리되며, 여러 클라이언트간 공유됨
    2. 브라우저 세션 내에서만 유효하며 페이지 리로드시 초기화
  3. 상태 업데이트 전략을 각각에 맞게 최적화 가능합니다.
    1. 서버상태 : 캐싱, 재검증, 에러처리 등..
    2. 클라이언트 : 즉각적인 ui 반응과 사용자 경험에 초점
  4. 디버깅과 유지보수가 수월해집니다.
    1. 문제가 서버인지 클라이언트인지 빠르게 파악 가능
  5. 애플리케이션의 확장성이 좋아짐
    1. 서버 상태 관리 방식을 변경하더라도 클라이언트 상태에는 영향이 없음
    2. 새로운 기능 추가 시 관련 상태를 적절한 카테고리에 쉽게 추가 가능함.

위와 같은 이유를 보니 서버의 상태와 클라이언트 상태가 충분히 필요하다고 느껴지지 않나요?

결론적으로는 각자에 걸맞는 전략이 있기 때문에 꼭 분리 하셔야돼요~

다음으로는 제가 이번에 따끈따끈하게 디버깅한 useQuery와 QueryKey에 대해서 알아보고자 합니다.

한 번 알아보죠~

👀 useQuery?

useQuery는 react-query 에서 아주 기본적인 함수이면서 가장 많이 쓰이는 함수입니다.

옵션에 대한 설명과 쓰임새들을 한 번 알아보죠

주요 옵션

{
  // 쿼리를 식별하는 고유 키, 동적으로 넣을 수도 있다! ['abc', id, ... ] 이런식으로!
  queryKey: unknown[],
  // 데이터를 가져오는 비동기 함수 
  queryFn: () => Promise<T>,
  // 특정한 조건에서 실행하고 싶을 때 쓰는 옵션 
  enabled: boolean,
  // 데이터가 'stale' 상태가 되기까지의 시간
  // staleTime 동안은 데이터가 fresh하다고 간주되고 이 시간 동안은 refetch가 발생하지않음.
  staleTime: number,
  // 사용하지 않는 캐시 데이터가 메모리에서 제거되기까지의 시간
  gcTime: number,
}

데이터 상태관련

{
  // 기본 상태값들
  data: T, // 쿼리 함수가 반환한 데이터
  status: 'pending' | 'error' | 'success', // 쿼리의 현재 상태
  fetchStatus: 'fetching' | 'paused' | 'idle', // 데이터 페칭의 현재 상태
  
  // 시간 관련 정보
  dataUpdatedAt: number, // 마지막으로 데이터가 업데이트된 타임스탬프
  errorUpdatedAt: number, // 마지막 에러가 발생한 타임스탬프

  // 에러 관련
  error: null | Error, // 발생한 에러 객체
  failureCount: number, // 쿼리 실패 횟수
  failureReason: null | Error, // 쿼리 실패 이유
}

불리언 상태 플래그

{
  // 로딩 관련
  isLoading: boolean, // 첫 로딩 중인지 여부
  isFetching: boolean, // 데이터를 가져오는 중인지 여부
  isPending: boolean, // 대기 중인지 여부
  isInitialLoading: boolean, // 초기 로딩 중인지 여부
  
  // 성공/에러 관련
  isSuccess: boolean, // 쿼리가 성공적으로 완료됐는지
  isError: boolean, // 에러가 발생했는지
  isLoadingError: boolean, // 로딩 중 에러가 발생했는지
  isRefetchError: boolean, // 리페치 중 에러가 발생했는지

  // 데이터 상태 관련
  isFetched: boolean, // 한번이라도 데이터를 가져왔는지
  isFetchedAfterMount: boolean, // 마운트 후 데이터를 가져왔는지
  isStale: boolean, // 데이터가 오래된 것으로 간주되는지
  isPlaceholderData: boolean, // 현재 표시 중인 데이터가 임시 데이터인지
  isPaused: boolean // 쿼리가 일시 중지됐는지
}

재시도 관련 옵션

{
  retry: boolean | number | (failureCount, error) => boolean, // 재시도 설정
  retryDelay: number | (retryAttempt: number) => number, // 재시도 간격
  retryOnMount: boolean, // 마운트 시 실패한 쿼리 재시도 여부
}

⛔️주의! :

재시도 : 실패한 요청의 복구를 위한 것

리패치 : 데이터의 최신화를 위한 것.

리패치 관련 옵션

{
  refetchInterval: number | false, // 주기적 리페치 간격
  refetchIntervalInBackground: boolean, // 백그라운드에서도 리페치할지
  refetchOnMount: boolean | 'always', // 마운트시 리페치 여부
  refetchOnWindowFocus: boolean | 'always', // 윈도우 포커스시 리페치 여부
  refetchOnReconnect: boolean | 'always', // 재연결시 리페치 여부
}

데이터 관련 옵션

{
  initialData: T | () => T, // 초기 데이터
  placeholderData: T | () => T, // 임시 데이터
  select: (data: T) => U, // 데이터 변환 함수
  structuralSharing: boolean, // 구조적 공유 사용 여부
}

기타 옵션

{
  networkMode: 'online' | 'always' | 'offlineFirst', // 네트워크 모드
  throwOnError: boolean | (error, query) => boolean, // 에러를 throw할지 여부
  meta: Record<string, unknown>, // 추가 메타데이터
  notifyOnChangeProps: string[] | 'tracked', // 변경 알림을 받을 속성들
}

❗️ Query key

전체적인 내용을 토대로 쿼리키에 대해서 정리하자면, Tanstack Query 는 기본적으로 쿼리 캐싱을 쿼리 키에 따라서 관리하고, 여러 문자열과 중첩된 객체의 배열처럼 복잡할 수도 있다!

라는게 저의 결론이고 조금 더 사용하는 방법을 알아볼까요?

간단한 쿼리키

  • 일반 목록
  • 비계층적 리소스

위형태의 데이터를 다룰 때 유용합니다.

// 단순한 문자열 키
useQuery({ queryKey: ['todos'] })

// 배열 형태의 키
useQuery({ queryKey: ['todo', 5] })

// 객체를 포함한 키
useQuery({ 
  queryKey: ['todos', { status: 'done', userId: 1 }]
})

변수가 있을 때?

쿼리키를 만들 때 단순히 ['todos']처럼 하나의 문자열로만 표현하기 어려운 경우가 있습니다. 이럴 때 배열을 활용해서 더 자세한 정보를 표현할 수 있는데, 주로 두 가지 상황에서 유용합니다

  • 계층적 데이터를 다룰 때
  • 추가 검색 조건이나 필터가 필요할 때
  1. 계층적 데이터를 다룰 때
// 특정 사용자의 게시글을 가져오는 경우
useQuery({ 
  queryKey: ['users', userId, 'posts'],
  queryFn: () => fetchUserPosts(userId)
})

// 특정 게시글의 댓글을 가져오는 경우
useQuery({ 
  queryKey: ['posts', postId, 'comments'],
  queryFn: () => fetchPostComments(postId)
})
  1. 추가 검색 조건이나 필터가 필요할 때
// 할일 목록을 상태별로 필터링하는 경우
useQuery({
  queryKey: ['todos', { status: 'active', priority: 'high' }],
  queryFn: () => fetchTodos({ status: 'active', priority: 'high' })
})

// 페이지네이션이 있는 상품 목록
useQuery({
  queryKey: ['products', { page: 1, pageSize: 20, category: 'electronics' }],
  queryFn: () => fetchProducts({ page: 1, pageSize: 20, category: 'electronics' })
})

쿼리키를 식별하고 캐싱하기 위해서 고유한 문자열로 변환하는 과정을 직렬화(Serialization)라고 합니다.

어떻게 중복되는 쿼리를 식별하는지 알아보겠습니다.

// 이 두 쿼리키는 같은 것으로 취급됨
useQuery({ 
  queryKey: ['todos', { status: 'done', userId: 1 }]
})
useQuery({ 
  queryKey: ['todos', { userId: 1, status: 'done' }] 
  // 객체 속성 순서가 달라도 동일하게 취급
})

// 하지만 이것은 다른 쿼리키로 취급됨
useQuery({ 
  queryKey: ['todos', { status: 'done', userId: '1' }] // userId가 문자열
})

🚫 직렬화 주의 사항

// ❌ 잘못된 사용: 함수는 직렬화할 수 없음
useQuery({ 
  queryKey: ['todos', { filter: () => {} }]
})

// ❌ 잘못된 사용: 순환 참조가 있는 객체
useQuery({ 
  queryKey: ['todos', { 
    circular: { self: self } 
  }]
})

// ✅ 좋은 사용: 직렬화 가능한 값들만 사용
useQuery({ 
  queryKey: ['todos', { 
    status: 'done',
    userId: 1,
    category: ['work', 'important']
  }]
})

동적으로 쿼리 키에 변수 넣기

쿼리키는 데이터를 고유하게 유지하게 하기 때문에 쿼리 함수에서 사용하는 ‘모든’ 변수를 포함해야 합니다.

아까는 queryKey에 모든 변수를 포함하지 않았기 때문에 query 함수가 고유하지 않았고

그에 따라 같은 부분이 계속 요청되었던 것입니다..

function Todos({ todoId }) {
  const result = useQuery({
    queryKey: ['todos', todoId],
    queryFn: () => fetchTodoById(todoId),
  })
}

🔅 결론

자 이렇게 해서 에러를 해결했던 방법과 서버상태와 클라이언트 상태를 왜 분리해야하는지 그리고 그 개념,

useQuery 옵션들과 queryKey에 대해서 알아보았습니다.

꽤나 두서 없지만 정리하면서 큰 도움이 되었습니다.

react-query 문서를 찾아보면서 왜 쓰는지 이해할 수 있었습니다 :)

profile
새로운 걸 배우는 것을 좋아하는 프론트엔드 개발자입니다!

0개의 댓글