시작하는 중
리액트에서 ajax 요청 다루기 본문
import React, {useState, useEffect} from 'react';
import axios from 'axios';
export default function App() {
const [resData, setResData] = useState(null);
useEffect(() => {
axios.get('어떤 URL').then((res) => setResData(res.data));
}, []);
...
내가 흔하게 짜던 코드이다. 3번째 리액트 프로젝트인데 2번째 프로젝트에서 이렇게 하는게 맘에 안들어서 loader 깔짝이다가 redux랑 같이 어떻게 사용할 수 있을까 하다가 끝나버렸는데 이번에는 좀 다르게 해보려고 한다.
? 뭐가 문제일까
useEffect 훅은 react.dev의 useEffect에서
useEffect는 컴포넌트를 외부 시스템과 동기화할 수 있는 React Hook입니다.
사실 useEffect의 취지에 어긋낫다고는 볼 수 없다. side effect들을 처리하기 위해 사용하는 훅이 바로 useEffect이니깐
그렇지만, 세가지 이유로 맘에 들진 않는다.
1. 단순한 데이터를 받는데 렌더링이 2번 일어난다.
axios 요청을 보내고 데이터를 jsx에 반영하는데 있어서 두번의 렌더링이 일어난다.
초기 렌더링 1번, 이 렌더링이 끝난 뒤에 이뤄지는 useEffect 로직 처리의 state dispatch 함수의 실행 후 1번
axios 요청 하나를 위해서 총 2번의 렌더링이 이뤄진다.
2. jsx 구문에 조건문을 넣지 않으면 오류가 나타난다.
export default function App() {
...
return (
<div>
{resData && <h1>{resData.title}</h1>}
</div>);
}
이렇게 조건을 걸어두지 않으면 렌더링시 오류가 난다.
3. useEffect가 복잡해지면?
이 예제는 단순한 예제이다.
import React, { useEffect, useState } from 'react';
import getArticleDetail from '../api/article';
export default function ArticleDetail() {
const [articleData, setArticleData] = useState(null);
useEffect(() => {
async function fetchArticleData() {
const data = await getArticleDetail();
setArticleData(data);
}
fetchArticleData();
}, []);
return (
<div>
{articleData && (
<>
<h1>{articleData.title}</h1>
<p>{articleData.content}</p>
<p>{articleData.creator}</p>
</>
)}
</div>
);
}
이 코드에서 axios 요청에 대한 에러 처리하거나 여러 axios 요청을 보내야하면 상당히 지저분해진다.
이를 함수화하여 따로 빼낸다고 하더라도, useEffect의 의존성 배열들에 ajax 요청 + 여러가지 연산들이 추가된다면, 원하는 동작만을 위해 여러 조건들이 추가된다.
즉, useEffect에 ajax 요청을 의존하게 된다면, useEffect에서 다뤄야하는 일들이 너무 많아진다.
이를 분기하기 위해서 여러 조건문들을 처리하다보면 코드의 가독성이 떨어지고 예기치 않은 동작이 일어날 수 있다.
위의 예제에서 jwt token을 다뤄서, token이 있다면 article 정보를 받아올 수 있고, 없다면 token을 받아와서 다시 보내야한다면
import React, { useEffect, useState } from 'react';
import getArticleDetail from '../api/article';
import getToken from '../api/member';
async function mappingData(asyncFunc, setterFunc, token = null) {
let data;
if (token) data = await asyncFunc(token);
else data = await asyncFunc();
setterFunc(data);
}
export default function ArticleDetail() {
const [articleData, setArticleData] = useState(null);
const [token, setToken] = useState(null);
useEffect(() => {
if (token) mappingData(getArticleDetail, setArticleData, token);
else mappingData(getToken, setToken);
}, [token]);
return (
<div>
{articleData && (
<>
<h1>{articleData.title}</h1>
<p>{articleData.content}</p>
<p>{articleData.creator}</p>
</>
)}
</div>
);
}
만약 useEffect에서 추가적인 연산을 해야한다면? ajax 요청이 몇개가 더 붙어버리면?
지금은 token의 초깃값은 null이지만, props를 통해 받아올 수 있거나 상태 라이브러리로 관리하는 것이라면?
token을 내가 직접 파싱해야한다면? 저장되있는 토큰이 만료된 상태라면?
이러한 비즈니스 로직들이 추가될수록 더더욱 유지보수하기 힘들어질 것이라고 생각하고, 실제로 그렇다..
with 리액트 라우터 6.4
리액트 라우터의 6.4버전에서는 loader라는 기능을 지원하고 있다. 이 loader 함수는 promise 객체를 리턴하는 함수여야하며, 이 promise가 resolve된 다음에야 해당 컴포넌트가 렌더링된다.
// router.js
...
{ path: 'article/:id', element: <ArticleDetail />, loader: () => {} },
...
이런식으로 사용하는 형식이다. loader는 import를 통해서 가져와서 함수자체를 loader에 매핑하면 된다.
loader 내에서 ajax 요청을 보내면 이 함수가 완료되기 전에는 렌더링 되지 않게 할 수 있다.
// router.jsx
import React from 'react';
import { createBrowserRouter } from 'react-router-dom';
import ArticleDetail, {loader as articleDetailLoader} from './Pages/ArticleDetail';
const router = createBrowserRouter([
{
path: '/',
element: <App />,
children: [
{ path: 'article/:id', element: <ArticleDetail />, loader: articleDetailLoader },
],
},
]);
// ArticleDetail.jsx
export async function loader() {
// ajax 요청 처리
}
하지만 이도 온전하진 못하다...
loader 자체는 순수함수이다.
loader 자체는 순수함수이기 때문에, React에 제공하는 훅 기능을 사용할 수 없다.
생각해보면, 훅이라는 것 자체가 리액트에서 제공하는 상태와 관련된 API 이니깐 리액트 밖에서는 사용할 수 없다.
컴포넌트 형태로 관리되는 리덕스, 리코일 같은 것도 역시 사용할 수 없다.
정리
useEffect
- 상태 라이브러리의 값을 참조하기 쉽다.
- 구현이 쉽다.
- useEffect에 복잡한 로직이 들어가거나 해야하는 일이 추가될 수록 유지보수가 힘들어진다.
- 렌더링시에 해당 데이터를 사용할 경우, jsx에 조건문이 있어야한다.
- 불필요한 리렌더링을 야기할 수 있다.
react router의 loader
- 해당 loader가 끝나지 않는다면 렌더링되지 않기에 조건문이나 useEffect같은 코드가 필요없어진다.
- 데이터를 불러오는 것에 대한 리렌더링이 발생하지 않는다.
- loader 순수함수이기에 훅을 사용할 수 없다.
loader에 훅을 사용하지 못한다고, 훅이 필요하면 useEffect에서 보낸다.
여기서 끝낼꺼면 2번째 프로젝트가 다른게 없다. 이런건 이미 했었음
라우터를 파헤쳐보는 과정에서 router는 결국 컴포넌트 형태로 제공된다는 것을 파악했다. 그렇다면 이 라우터를 컴포넌트 감싸서 해당 컴포넌트에서 훅을 인자로 넘기는 형태는 어떨까??
...
export default CustomRouterProvider() {
const router = {[
...
]};
return <RouterProvider router={router} />
}
...
이게 된다면.. 이 CustomRouterProvider()라는 컴포넌트 함수에서 state를 가져와서 붙인다거나 할 수 있지 않을까?
나는 리코일에서 관리하는 accessToken을 가져오고 싶었다.
// router.jsx
import React from 'react';
import { createBrowserRouter, RouterProvider } from 'react-router-dom';
import { useRecoilState } from 'recoil';
import ArticleDetail, { loader as dataLoader } from './Pages/ArticleDetail';
export default function CustomRouterProvider() {
const [accessToken, setAccessToken] = useRecoilState(accessTokenState);
const router = createBrowserRouter([
{
path: '/',
element: <App />,
errorElement: <NotFound />,
children: [
{
path: 'tmp',
element: <ArticleDetail />,
loader: dataLoader(accessToken, setAccessToken),
},
],
},
]);
return <RouterProvider router={router} />;
}
이렇게 할 수 있을 듯 싶다. 여기서 loader를 어떻게 만들까 고민했는데 두가지를 고민했다.
- 고차함수
- 일반 함수로 가져와서 loader에 익명함수 안에서 실행하면서 setAccessToken을 여기서 하기
자바스크립트의 함수는 일급함수이니깐, 함수형 프로그래밍 강의도 들었으니깐 고차함수로 하기로 했다.
// ArticleDetail.jsx
...
export const loader = (token, setter) => async () => {
let data;
if (!token) {
data = await getToken();
setter(data);
return 0;
}
data = await getArticleDetail(token);
return data;
};
getToken과 getArticleDetail은 axios 요청을 보내는 것이다.
axios 요청은 서버가 없어서 그냥 setTimeout으로 만들었다.
// ArticleDetail.jsx
...
const getArticleDetail = async (token) => {
const data = await axios(token);
return data;
};
const getToken = async () => {
const data = await axios();
return data;
};
const axios = (param = null) => {
return new Promise((resolve) => {
setTimeout(() => {
if (param) {
resolve({
title: 'title',
content: 'content',
creator: 'creator',
});
} else resolve('액세스토큰');
}, 100);
});
};
이렇게 하면 실행 순서는 다음과 같다.
1. loader 함수 실행되고 token값을 검사
2. token이 없다면 getToken을 받아옴
2-1. token을 받아와서 리코일의 accessTokenState의 상태를 업데이트 한다.
2-2. loader 함수 1차 종료 후 상태 업데이트로 인해 loader 재실행
3. token가 있다면 getArticleDetail를 받아오고 loader 종료
이렇게 끝난다면 참 좋은데.. 예상대로 recoil의 상태가 변경되어 CustomRouterProvider가 두 번 실행된다.. 내가 걱정인 것은 createRouterBrowser에 달려있는 함수들이 참 많은데, 지금은 괜찮아도 router 배열이 길어진다면 렌더링이 오래걸릴 것 같아서 좋지 않을 것이라고 생각했다.
지금 이건 useEffect 안써보겠다고 라우터를 다시 한번 만드는 것과 다름이 없다.
뿐만 아니라, router 객체 자체가 변한다는 것이다.
그 증거로, loader의 스코프가 변하고 있다.
[[scopes]]가 변하는 모습
어떻게 해야할까 방황하던 도중 하나의 글을 만났다.
바로 react-query를 활용하는 것
리액트 쿼리는 다른 상태 라이브러리와는 다르게, QueryClient라는 저장소 자체를 제공한다는 것이 크다. 또한, 이 쿼리 클라이언트에 접근하여 데이터를 다룰 수 있는 API를 제공한다.
중요한 것은 QueryClient자체는 상태가 아닌 객체라는 것이다.
때문에 다음과 같은 것이 가능하다.
// router.jsx
...
export const queryClient = new QueryClient();
const router = createBrowserRouter([
{
path: '/',
element: <App />,
errorElement: <NotFound />,
children: [
{
path: 'tmp',
element: <ArticleDetail />,
loader: dataLoader(queryClient),
},
...
// main.jsx
...
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
);
...
이렇게 하면 하나의 클라이언트로 참조할 수 있다. 리코일과는 다르게 직접 쿼리 클라이언트 인스턴스 자체에 접근할 수 있다는 것이다.
리코일의 아톰을 console.log를 해본 결과이다.
리액트 쿼리의 쿼리 클라이언트를 console.log한 결과이다.
다시 한번 더 떠올려야하는 것은 리코일은 상태 관리 라이브러리이고, 리액트 쿼리는 아니다. 리액트 쿼리가 컴포넌트 형태로 제공되는 것은 훅을 사용할 수 있게 하기 위함이고, 리액트 쿼리의 클라이언트 자체는 객체이기에 직접 볼 수 있는 것이다. 이게 왜 좋은 것이냐면, 다음과 같은 것들이 가능해진다.
// ArticleDetail.jsx
...
export const loader = (queryClient) => async () => {
const tokenQuery = tokenQueryInstance();
const token = queryClient.getQueryData(tokenQuery.queryKey) ?? (await queryClient.fetchQuery(tokenQuery));
const articleDataQuery = articleDataQueryInstance(token);
console.log(queryClient);
return queryClient.getQueryData(articleDataQuery.queryKey) ?? (await queryClient.fetchQuery(articleDataQuery));
};
const tokenQueryInstance = () => ({
queryKey: ['token'],
queryFn: async () => getToken(),
});
const articleDataQueryInstance = (token) => ({
queryKey: ['article', 'detail'],
queryFn: async () => getArticleDetail(token),
});
...
이렇게 쉽게 할 수 있다...
사실 오랜시간 삽질했던 것 같다. 첫 리액트 프로젝트 때는 액세스 토큰을 리덕스와 함께 로컬스토리지에 저장하고 JSON으로 저장된 토큰을 파싱해서 사용했었다.
두번째 리액트 프로젝트 때는 비슷한 상황에 놓여있었는데 고민하다가 그냥 useEffect에서 사용했었다.
이번에 진짜 꼭 개선하고 싶었는데 할 수 있어서 재밌었다.
reference
'react' 카테고리의 다른 글
react router의 원리 (0) | 2023.05.04 |
---|---|
yarn dev를 하면 vite에서 일어나는 일 (0) | 2023.05.02 |
useState(함수)??? (0) | 2023.04.20 |
내 코드 리팩토링 하기 (0) | 2023.04.14 |
react router v6.4에 생긴 기능들 (0) | 2023.04.12 |