대게, 프론트엔드에서 상태관리 하는 데이터를 두 가지로 구분할 수 있다.
1. 일반 데이터
- 특징:
- 앱 내부에서 바로 생성되거나 사용되는 데이터.
- 사용자 입력, UI 상태(모달 열림/닫힘, 탭 선택 등), 선택된 아이템 등.
- 비교적 단순하며, 비동기 작업이 필요하지 않음.
- useState, useReducer 훅을 이용하여 상태 관리
2. 비동기 데이터
- 특징:
- 외부 API, 데이터베이스 등에서 가져오거나 저장하는 데이터.
- 일반적으로 서버와 통신해야 하므로 비동기 작업(Promise, async/await)이 필요함.
- 단순할 경우 useEffect, fetch 를 통하여 상태 관리
- 복잡하면 SWR, TanStack Query 등 라이브러리를 이용하여 상태 관리
프론트엔드에서 외부 API를 사용하여 데이터를 받아오는 경우는 일반적이므로 비동기 데이터를 상태 관리 하는 역량은 필수적이라고 할 수 있다. 이번 글에서는 비동기 데이터를 상태 관리하는 방법과 대표적인 라이브러리인 TanStack Query에 대해 간략히 알아보겠다. 개인적으로, TanStack Query는 프론트엔드 개발자라면 이제는 필수적으로 알아야 되는 라이브러리라고 생각한다.
참고로, TanStack Query의 예전 이름은 React Query 였고, 실제로 지금도 많은 사람들이 React Query 라는 이름에 더욱 익숙하다. 필자 역시 그렇다. 과거의 리액트만 지원했던 React Query가 이제는 Svelt, Vue 등 다양한 SPA 프레임워크를 지원하게 된 이후, 이름을 TanStack Query로 변경했다고 한다.
1️⃣ 초기 세팅
🔨 서버 세팅
먼저, 외부 API를 받아오기 위해 로컬에서 서버를 세팅해주자.
다음과 같이 루트 디렉토리 하위에 server, client 디렉토리를 분리해 주었다.
.
├─ client
└─ server
server 디렉토리로 이동한 후, 관련 라이브러리를 설치한다.
cd /server
npm i express cors
server 디렉토리 하위에 app.js 파일을 만들고 다음과 같이 세팅해주었다.
// ./server/app.js
const express = require('express');
const app = express();
const cors = require('cors');
const port = 3000;
// 미들웨어 설정
app.use(express.json()); // JSON 파싱
app.use(express.urlencoded({ extended: true })); // URL-encoded 파싱
app.use(cors()); // cors 방지
const todoList = [
{
id: 1,
content: '밥 먹기',
},
{
id: 2,
content: '공부하기',
},
{
id: 3,
content: '잠 자기',
},
];
app.get('/', (req, res) => {
res.send(todoList);
});
app.listen(port, () => {
console.log(`서버가 http://localhost:${port} 에서 실행 중입니다`);
});
node 명령어로 서버를 실행시켜준다.
🔧 클라이언트 세팅
이제 클라이언트 초기 세팅을 하자. Vite를 이용하여 리액트 프로젝트를 생성하겠다.
cd ../client
npm create vite@latest
npm i
초기 Vite로 리액트 세팅을 했을 때 생성된 불필요한 코드와 파일들을 삭제해준다.
또한, 편의상 StrictMode 를 해제하였다.
그리고 App.jsx 코드를 다음과 같이 입력하였다.
import { useEffect, useState } from 'react';
import './App.css';
function App() {
const [todoList, setTodoList] = useState([]);
useEffect(() => {
const fetchTodoList = async (url) => {
const response = await fetch(url);
const todoList = await response.json();
setTodoList(todoList);
};
fetchTodoList('http://localhost:3000');
}, []);
return (
<div>
<h1>TodoList</h1>
<ul>
{todoList.map((todo) => (
<li key={todo.id}>{todo.content}</li>
))}
</ul>
</div>
);
}
export default App;
이제 npm run dev 명령어로 클라이언트 서버를 실행시켜주면 다음과 같이 화면이 렌더링 된다.
위의 클라이언트 코드를 보면, useEffect 와 fetch의 조합으로 비동기 데이터를 클라이언트로 받아온 뒤, useState를 통해 상태 관리 하는 것을 알 수 있다. 아주 단순한 코드이다.
2️⃣ useFetch 커스텀 훅
허나, 위의 단순한 코드는 대표적으로 두 가지 문제점이 있다.
- 서버로부터 데이터를 받아오는 사이에 보여줄 UI가 존재하지 않음. 즉, 로딩 UI가 존재하지 않음.
- 만약, 데이터를 받아오는 것에 실패한다면 보여줄 UI가 존재하지 않음.
백분이 불어일견, 한번 잘못된 URL로 요청을 해보자.
useEffect(() => {
const fetchTodoList = async (url) => {
const response = await fetch(url);
const todoList = await response.json();
setTodoList(todoList);
};
fetchTodoList('http://localhost:3001'); // 포트 번호를 3001로 수정
}, []);
이번에는 정상적인 URL로 요청하지만 데이터를 받아오는 시간, 즉 로딩 시간을 길게 해보자.
서버에서 요청을 받으면 2초 후에 데이터를 전송하도록 코드를 수정하겠다.
./server/app.js
app.get('/', (req, res) => {
setTimeout(() => {
res.send(todoList);
},2000);
});
이제 새로고침을 하면 다음과 같은 화면을 볼 수 있다.
위의 두 문제를 해결하는 방법은 간단하다. 로딩 중이나 에러가 발생했을 때 보여줄 UI를 만들면 된다.
따라서, 다음과 같은 useFetch 커스텀 훅으로 해결 가능하다.
물론 아래 useFetch 로직에는 몇 가지 개선점이 있지만, 대략적인 흐름 차원에서만 보면 된다.
import { useEffect, useState } from 'react';
export const useFetch = (url) => {
const [data, setData] = useState(null);
const [isLoading, setIsLoading] = useState(true); // 로딩 중임을 나타낼 state
const [error, setError] = useState(null); // 에러를 나타낼 state
useEffect(() => {
const fetchData = async () => {
try {
setIsLoading(true);
setError(null);
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
setData(data);
setIsLoading(false);
} catch (error) {
setError(error.message);
setIsLoading(false);
}
};
fetchData();
}, [url]);
return { data, isLoading, error };
};
import './App.css';
import { useFetch } from './useFetch';
function App() {
const {
data: todoList,
isLoading,
error,
} = useFetch('http://localhost:3000');
return (
<div>
<h1>TodoList</h1>
{isLoading && <span>Loading...</span>}
{error && <span>{error}</span>}
{!isLoading && !error && (
<ul>
{todoList?.map((todo) => (
<li key={todo.id}>{todo.content}</li>
))}
</ul>
)}
</div>
);
}
export default App;
아래는 위의 useFetch를 적용했을 때의 결과이다.
이전의 결과와 다르게 로딩 UI를 넣음으로써, 보다 개선된 UX를 제공할 수 있다.
3️⃣ TanStack Query 도입
위의 예시에서 useFetch 훅을 사용하여 데이터 이외에 isLoading, error 라는 추가적인 상태를 직접 관리해 주었다. 그러나, 개발자 입장에서 관리해야될 상태가 늘어나는 것은 여간 좋은 일이 아니다. TanStack Query 라이브러리는 이러한 상황에서 상태 관리를 간소화 하고, 데이터의 캐싱, 중복 요청 방지 등 여러 강력한 기능들을 제공해준다.
이번에는 useFetch 훅을 사용하지 않고 TanStack Query를 사용하여 수동으로 상태 관리를 하지 않고 위의 예시를 리팩토링 해보자.
가장 먼저, 라이브러리를 설치한다.
npm i @tanstack/react-query
main.jsx에서 queryClient 인스턴스를 생성하고 QueryClientProvider 이름의 Context 컴포넌트로 App 컴포넌트를 감싸고 props로 생성한 인스턴스를 전달한다. 이때, queryClient 인스턴스는 TanStack Query의 모든 쿼리와 캐시를 관리하는 인스턴스이다.
참고로, TanStack Query에서 '쿼리'는 서버에 특정 데이터를 요청하고 가져오는 작업 즉, 패칭 작업이라고 생각하면 된다.
// main.jsx
import { createRoot } from 'react-dom/client';
import './index.css';
import App from './App.jsx';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
const queryClient = new QueryClient(); // 인스턴스 생성
createRoot(document.getElementById('root')).render(
// Context 컴포넌트로 감싸기
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
);
이제 App.jsx 로 돌아가서 useQuery 훅을 사용하여 다음과 같이 비동기 데이터를 처리할 수 있다.
// App.jsx
import { useQuery } from '@tanstack/react-query';
import './App.css';
function App() {
const fetchTodos = async () => {
const response = await fetch('http://localhost:3000');
return response.json();
};
const {
isLoading,
data: todoList,
isError,
error,
} = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
});
return (
<div>
<h1>TodoList</h1>
{isLoading && <span>Loading...</span>}
{isError && <span>{error.message}</span>}
{!isLoading && !error && (
<ul>
{todoList?.map((todo) => (
<li key={todo.id}>{todo.content}</li>
))}
</ul>
)}
</div>
);
}
export default App;
하나씩 살펴보면, 위의 코드에서 useQuery는 queryKey, queryFn 이름의 프로퍼티를 갖는 객체를 매개변수로 받는다.
queryKey 는 쿼리를 식별하는 역할을 한다. 현재 예제에서는 큰 의미를 갖지 않는다. 후에 좀 더 자세히 다루겠다.
queryFn 은 데이터를 가져오는 함수를 프로퍼티의 value로 받는다. 위의 예시에서는 fetchTodos 함수를 value로 받았다.
useQuery는 isLoading, data, isError, error 등등 여러 프로퍼티를 갖는 하나의 객체를 반환한다.
각각의 의미를 정리해보자.
isLoading
- 첫 번째 데이터 로딩 시에만 true
- 데이터가 없고 처음으로 fetch하는 중일 때만 true
data
- 성공적으로 받아온 데이터
- 아직 데이터를 받아오지 않았다면 undefined
isError
- 에러가 발생했을 때 true
- fetch 실패, 데이터 파싱 오류 등이 발생했을 때
error
- 구체적인 에러 정보를 담은 객체
- isError가 true일 때만 값이 존재
- 주로 error.message로 에러 메시지 표시
참고로 그 외로 isFetching, isSuccess, status 등 다양한 프로퍼티가 있다. 워낙 많은 프로퍼티를 제공하기에 보통 위의 코드처럼 필요한 프로퍼티만 구조분해할당으로 가져온다.
이제 TanStack Query 라이브러리를 적용한 결과를 살펴보자.
이번에는 잘못된 URL로 요청을 해보자.
시간이 조금 걸리는 이유는 기본적으로 TanStack Query는 데이터 요청에 실패했을 시, 1초 간격으로 3번의 재시도를 한다.
순서를 정리해보면 다음과 같다.
- 첫 요청 실패
- 1초 후 첫 번째 retry
- 1초 후 두 번째 retry
- 1초 후 세 번째 retry
- 최종 실패
만약 기본 설정을 바꾸고 싶으면 main.jsx에서 queryClient 인스턴스를 다음과 같이 생성하면된다.
import { createRoot } from 'react-dom/client';
import './index.css';
import App from './App.jsx';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 0, // 재시도 x
},
},
});
createRoot(document.getElementById('root')).render(
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
);
참고로, retry뿐만 아니라 다양한 기본 옵션들을 수정할 수 있으니 참고하자.
useFetch를 사용했을 때와 비교해보자. 확실히 TanStack Query를 사용하니 useState를 통해 isLoading, error 등 부가적인 상태를 관리할 필요가 전혀 없어졌다. 심지어, useQuery 훅 하나만으로 여러 상태들을 제공해주며 기존 useFetch 커스텀 훅에는 존재하지 않았던 재시도 기능까지 자동적으로 지원을 해준다.
여기까지만 살펴보아도 TanStack Query가 비동기 상태 관리에 있어서 얼마나 유용한 라이브러리인지 체감이 될 것이다.
TansStack Query는 이외에도 캐싱, 리패칭, 무한스크롤 등 다양한 기능들을 제공한다. 위의 TanStack Query의 예시는 아주 단순한 예시일 뿐이다.
'React' 카테고리의 다른 글
[React] TanStack-Query 의 useMutation 훅과 낙관적(optimistic) 업데이트 구현하기 (0) | 2025.02.19 |
---|---|
[React] TanStack-Query의 useInfiniteQuery 훅을 이용하여 무한스크롤 구현하기 (1) | 2025.01.10 |
[React] 바닐라JS로 가상돔(VirtualDOM) 만들기 (0) | 2024.10.02 |