fetch로 시작해서 axios + TanStack Query로 변환까지

진밥·2026년 4월 13일
post-thumbnail

썸네일 이미지는 AI를 활용했지만, 글은 모두 직접 작성했습니다.


🪄 TL;DR

  • fetch, useEffect, useState를 이용하여 데이터 패칭과 응답 흐름에 따른 UI를 표현했다.
  • 화면이 많아지며 loading, error, retry 등 데이터 패칭 로직이 여러 페이지에 흩어지는 걸 느꼈다.
  • axios 인스턴스를 만들어서 요청 방식을 정리했고, 이후 TanStack Query를 도입해 서버 상태 관리 방식을 바꿨다.
  • 앱 전체를 감싸는 ReactQueryProvider를 만들고, ErrorBoundary와 Suspense로 에러/로딩 처리를 위임했다.

fetch로 빠르게 시작하기

fetch는 별도 설치 없이 사용 가능한 브라우저 기본 API다. 처음 API 호출을 구현하며 호출 흐름 자체를 빠르게 경험해보고 싶었고, 가장 부담 없이 사용할 수 있어서 선택했다.

// src/features/pages/IssuePage/IssuePage.tsx
const IssuePage: FunctionComponent = () => {
	const [issues, setIssues] = useState<IssueCardProps[]>([]);
	const [isLoading, setIsLoading] = useState(true);
    const [error, setError] = useState<string | null>(null);

	useEffect(() => {
		fetch('/issues')
			.then((res) => {
            	if (!res.ok) {
              		throw new Error(`요청 실패: ${res.status}`);
            	}
          		return res.json() as Promise<IssueCardProps[]>;
        	})
            .then((data) => {
                return setIssues(data);
            })
            .catch((err: unknown) => {
            	setError(err instanceof Error ? err.message : '알 수 없는 오류가 발생했습니다.');
        	})
      		.finally(() => setIsLoading(false));
	}, []);

코드는 문제없이 동작했다. API 패칭도 잘 되었으며, 에러와 로딩 상태 처리도 잘 되었다. 그런데 문제는 이슈 목록 / 주식 목록 / 상세 페이지 / 뉴스 목록 등 모든 페이지에서 이 패턴이 각각 필요했다는 점이었다. loading 상태 선언, error 상태 선언, try/catch/finally 조합이 페이지마다 동일하게 복제됐다. API를 가져와서 활용하는 코드보다 API 응답 처리 과정의 상태 코드가 파일에서 더 많은 부분을 차지하고 있었다.

상세 조회: 전체 불러오기 vs 하나만 불러오기

처음 API 호출을 구현하면서 마주했던 문제가 하나 더 있었다. 상세 조회 페이지에서 전체 목록을 불러온 후 find로 데이터를 찾아 보여주는 방식을 사용한 것이었다.

// 목록 전체를 가져온 다음
const issues = await getIssues()
// id가 일치하는 것 하나를 찾아서 표시하였다
const issue = issues.find((item) => item.id === id)

조금만 고민해보니 해당 방식의 문제가 보였다. 한 건을 보여주기 위해서 전체 목록을 먼저 가져와야 하고, 목록 구조가 바뀌면 상세 조회도 영향을 받는 구조였다. API로 데이터를 가져올 때부터 현재 id에 해당하는 상세 데이터만 불러와야겠다고 생각하였고, 목데이터를 구현해둔 MSW에 params를 받아 단건을 조회하는 API 로직을 추가하고, 호출부도 단건 엔드포인트로 변경하였다.

export const getIssueById = async (id: string) => {
  const { data } = await issuesAPI.get(`/${id}`)
  return data
}

작은 규모의 사이드 프로젝트에서는 사실 전체를 불러와서 한 건을 보여주는 것과, 한 건만 불러오는 것의 성능 차이를 체감하기 어렵다. 그러나 개발자로서 꼭 기억해야 할 것은, 우리가 조작하는 모든 게 다 비용이라는 것이다. 실제 사용자가 많은 서비스에서는 불필요한 로직 하나가 서버 부하의 원인이 될 수 있다. 그렇기 때문에 코드를 한 줄 작성할 때도 이게 최선의 방법이 맞을지 고민하는 습관을 길러야 한다.

fetch → axios로 바꾼 이유

TanStack Query를 도입하기 전에 먼저 fetch를 걷어내고 axios를 도입했다. 이유는 각 컴포넌트에 흩어져있는 API 요청 레이어를 한 곳에서 정리하고 싶어서였다.

axios를 선택한 이유를 fetch와 비교해 정리해보자면 다음과 같다.

1. 에러 처리 방식이 다르다

fetch는 서버가 404나 500을 돌려줘도 에러로 던지지 않는다. 그냥 성공으로 처리하기 때문에 !res.ok를 직접 확인해줘야 한다. axios는 4xx, 5xx 응답이면 알아서 catch로 넘어간다.

// fetch
const res = await fetch('/issues')
if (!res.ok) throw new Error('에러') // 매번 확인해서 에러를 던져주어야 한다

// axios
const { data } = await axios.get('/issues') // 4xx, 5xx면 알아서 catch로 간다

2. 응답 데이터를 꺼내는 방식이 다르다

fetch는 응답을 받은 뒤 .json()으로 한 번 더 파싱해야 한다. axios는 응답에서 바로 data를 꺼낼 수 있어서 코드가 간결하다.

// fetch
const res = await fetch('/issues')
const data = await res.json()

// axios
const { data } = await axios.get('/issues')

3. axios Interceptor로 공통 로직을 한 곳에서 처리할 수 있다.

요청 전 헤더 추가(Authorization 등), 응답 후 공통 에러 처리 같은 작업을 페이지마다 반복하지 않고 인스턴스 단에서 한 번만 설정할 수 있다.

이 프로젝트에서는 인증이 없어서 Interceptor를 간단하게만 사용했지만, base path를 한 곳에서 관리하고 에러 처리 방식을 일관 처리하기 위해 사용했다.

// shared/lib/apiClient.ts
export const apiClient = axios.create({
	baseURL: BASE_PATH,
});

export const createAPI = (path: string) =>
	axios.create({ baseURL: `${BASE_PATH}${path}` });

혁신적인 TanStack Query 도입하기

이제 대망의 Tanstack Query 도입이다! axios로 요청 코드는 정리되었지만, 상태 코드는 여전히 각 페이지에 남아 있었다. 언제 호출하고, 로딩 상태를 어떻게 표현하고, 에러와 재시도는 어떻게 처리할지 정해주어야 했다. axios를 이용해 요청 방식을 정리했고, 이제 가져온 데이터에 대한 책임을 정해줄 타이밍이다.

이 시점에서 활용할 수 있는 게 바로 TanStack Query다.

1. 설치하기

pnpm add @tanstack/react-query @tanstack/react-query-devtools

2. ReactQueryProvider 연결

const queryClient = new QueryClient()

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

QueryClientProvider로 감싸는 이유는 단순히 props drilling 방지 때문이 아니다. 내부적으로 React Context를 사용해서 어느 컴포넌트에서든 동일한 QueryClient에 접근할 수 있게 하기 위해서다. 이 연결이 있어야 캐시, Suspense 연동 같은 기능들이 React 트리 안에서 제대로 동작한다.

3. query key와 query hook 만들기

// features/issues/hooks/queryKeys.ts
export const ISSUE_QUERY_KEYS = {
	list: (limit?: number) => (limit ? ['issues', limit] : (['issues'] as const)),
	infinity: ['issues', 'infinity'] as const,
	detail: (id: string) => ['issues', id] as const,
};

// features/issues/hooks//useIssuesQuery.ts
export const useIssuesQuery = (limit?: number) => {
	return useSuspenseQuery({
		queryKey: ISSUE_QUERY_KEYS.list(limit),
		queryFn: () => getIssues(limit),
	});
};

query key는 캐시를 구분하는 이름표다. ['issues']와 ['issues', '1']은 다른 데이터로 관리된다. 계층 구조로 설계해두면 나중에 목록 캐시만 골라서 무효화하는 것도 가능하다.

페이지 컴포넌트는 이렇게 바뀐다.

// 이전
function IssuePage() {
  const [issues, setIssues] = useState([])
  const [isLoading, setIsLoading] = useState(false)
  const [error, setError] = useState(null)
  // ...
}

// 이후
function IssuePage() {
  const { data: issues } = useIssuesQuery()
  return <IssueList issues={issues} />
}

코드가 아주 간결해진 것만큼, 페이지가 서버 상태를 직접 들고 있지 않게 되었다는 게 가장 큰 변화이다. 이전에는 데이터를 가져와 모든 상태를 직접 처리해주었다면, 이제는 온전히 데이터를 사용하는 로직만 컴포넌트에서 작성하면 된다.

4. DevTools로 캐시 직접 확인하기

<QueryClientProvider client={queryClient}>
  {children}
  <ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>

TanStack Query와 함께 사용하면 좋은 DevTools도 추천하고 싶다. Provider 안에 위와 같이 작성해주면 된다.

그럼 화면 우측 하단에 이런 아이콘이 생긴다.

클릭하면 현재 캐시에 어떤 query key가 있는지, 상태가 fresh / stale / inactive 중 어딘지, 실제 응답 데이터가 어떻게 생겼는지 확인할 수 있다.

query key 설계가 의도대로 됐는지 직접 눈으로 볼 수 있어서, TanStack Query를 쓴다면 같이 설치하는 걸 추천한다. 로딩/에러 상태를 특정 영역 단위로 디버깅하는 것도 간편하게 가능하다.

Suspense + ErrorBoundary 연결하기

TanStack Query를 도입 후, 이제 로딩/에러 처리 방법을 결정해야 했다. 두 가지 방법이 있었다.

// 방법 1: 컴포넌트 안에서 직접 분기
function IssuePage() {
  const { data, isLoading, isError } = useIssuesQuery()
  if (isLoading) return <Loading />
  if (isError) return <ErrorFallback />
  return <IssueList issues={data} />
}

// 방법 2: Suspense + ErrorBoundary에 위임
function IssuePage() {
  const { data } = useIssuesQuery() // 로딩/에러는 상위에서 처리
  return <IssueList issues={data} />
}

방법 1은 명령적이다. 로딩일 땐 이걸 보여주고, 에러일 땐 저걸 보여줘를 직접 지정하는 방법이다.

방법 2는 선언적이다. 로딩/에러 상황을 상위 경계에 위임하고, 컴포넌트는 데이터 처리에만 집중한다. React는 선언적 프로그래밍을 지향하기 때문에 방법 2를 사용하려고 한다. 방법 2를 쓰려면 useQuery는 사용이 안 되고, useSuspenseQuery만 사용이 가능하다.

useSuspenseQuery를 쓰면 로딩 중에는 가장 가까운 fallback이 보이고, 에러가 나면 가장 가까운 로 넘어간다. 컴포넌트 안에서는 data가 항상 존재하는 게 보장된다.

AppProvider에서 이 경계를 이렇게 통합했다.

export const ErrorProvider = ({ children }: { children: ReactNode }) => {
	return (
		<QueryErrorResetBoundary>
			{({ reset }) => (
				<ErrorBoundary FallbackComponent={ErrorFallback} onReset={reset}>
					<Suspense fallback={<Loading />}>
						{children}
                    </Suspense>
				</ErrorBoundary>
			)}
		</QueryErrorResetBoundary>
	);
};

마무리

fetch -> axios로 전환해 API 요청 호출부를 정리하고, TanStack Query로 서버 상태를 분리했다.

특히 TanStack Query를 도입하기 전/후 코드를 비교해보면 정~말 큰 차이가 느껴진다. 로딩, 에러 같은 상태 관리는 Suspense와 ErrorBoundary에 위임하고 컴포넌트에서는 데이터 처리에만 집중할 수 있게 해주는 구조가 된 것이다.

다음 글에서는 무한 스크롤 구현 과정을 다루려고 한다. 처음에는 정말 막막했고 멘탈도 나갔었는데, 하나씩 해결하다 보니 결국 됐다. 트러블슈팅 포인트도 꽤 있어서 따로 정리할 예정이다.

프로젝트 진행 과정이 궁금하다면 아래 저장소에서도 함께 볼 수 있다.

stock-lens GitHub 저장소

그럼 안녕~~

profile
꼬들밥 말고 진밥

0개의 댓글