만약
React Query
,Server State
,React Router DOM의 Loader
에 대해 충분한 지식을 가지고 있다고 생각한다면, 바로 3. 프로젝트 시작 으로 넘어갈 것을 추천한다.
React Router DOM
의 최신 버전에는 Loader
라는 좋은 기능이 업데이트 되어 있다.
기본적으로 React
로 만든 프로젝트는 CSR
방식이기 때문에, 서버에서 빈 페이지와 Javascript, CSS를 전달받아 말 그대로 클라이언트 사이드에서 렌더링을 실시한다.
여기서 주목해야 할 것은 빈 페이지
를 가져온다는 것인데, 즉 사용자가 처음 맞이하는 화면에는 아무런 정보도 없다 라는 것이다.
React
로 개발하다 보면, REST API를 통해 HTTP 요청을 보내고 받아온 데이터를 페이지에 렌더링 할 일이 굉장히 많은데, 이때 처리를 잘못 해주면 다음과 같은 오류를 수없이 많이 보게 된다.
cannot read properties of undefined
분명히 데이터를 동기 처리까지 해주면서 받아 왔는데, 데이터 객체에 접근해서 map
이나 filter
와 같은 함수를 사용하려 하면 위와 같은 경고 문구가 발생한다.
콘솔을 한번 찍어보면 왜 그런지 어느 정도는 알 수 있다.
console.log(data)
...
undefined
undefined
data : ...
data : ...
데이터 패칭이 이루어지고 있는 그 짧은 시간동안 아직 pending
상태의 데이터가 undefined
상태로 인식되고,
undefined
에서 프로퍼티에 접근을 하려고 하니 문제가 되는 것이다.
SSR
에서는 같은 문제가 발생하지 않을 것이다
서버에서 다 조립해서 오니까.
하지만 우리는 지금 React
를 사용해서 CSR
방식의 사이트를 만들어야 한다.
이 때 v6 라우터의 Loader
기능이 강력한 도움을 제공한다.
참고자료
1. https://reactrouter.com/en/main/route/loader
2. https://tkdodo.eu/blog/react-query-meets-react-router
참고자료 1번 링크를 참고하자.
다음과 같이 설명되어 있다.
Each route can define a "loader" function to provide data to the route element before it renders.
그러니까, 라우터가 렌더링 되기 전에 로더에 먼저 접근해서, 로더에 선언된 함수를 모두 거치고 라우터로 넘어간다는 것이다.
크게 두 가지 메리트가 있을 것으로 생각된다.
pre-fetching
을 하여 렌더링 시 처음부터 비동기 데이터를 온전하게 가지고 있을 수 있음여기서 두 번째 경우에 조금 더 집중해서 접근을 시도한다.
Server State
관리에는 Tanstack Query
를 사용하도록 한다.
참고자료 2번 링크를 참고하자.
예전에는 React Query라는 이름으로 운영하던 패키지이나, 이제 React 뿐만 아니라 다른 프레임워크도 지원을 시작하며 이름을 Tanstack Query로 변경하였다.
Tanstack Query는 특히 Server State관리에 매력적인 라이브러리이다.(제작자 피셜)
React Query는 Web application에서의 server state를 fetching, caching, synchronizing, updating 할 수 있도록 지원하는 library 이다. React application에서 전역 상태관리 없이 데이터를 Fetch, Cache, Update할 수 있다.
왜지?
먼저 Server State
의 개념에 대해 짚고 넘어가야겠지?
서버 상태는 다음과 같은 조건을 만족한다.
이러한 특성으로 인해 다음과 같은 문제를 해결해야만 한다.
가령 예를 들어서, 내가 관리하는 사이트에서 서버에 저장된
상품 리스트
를 보던 중에
서버
에 POST
요청을 보내서 새로운 상품을 등록하는 작업을 하였다.
그러면 새로운 상품이 등록되는 순간 기존에 보고 있던 상품 리스트
는 최신의 데이터가 아니게 된다.
리액트 어플리케이션에서는 이것을 화면에 즉각 반영하기 위해 일반적으로 useEffect
와 같은 훅을 사용해서 특정 데이터에 변화가 생김을 감지하면 다시 GET
요청을 보내서 새 데이터를 가져와서 컴포넌트를 리렌더링 시킬 것이다.
그런데 만약 데이터를 받아오는 컴포넌트와 데이터를 수정하는 컴포넌트가 다르다면?
그러면 우리는 전역 상태를 사용하거나, props-drilling을 사용해서 해당 상태를 컴포넌트 간에 공유해야 한다.
하지만 React-Query
를 사용하면 그럴 필요가 없다.
useQuery
기능을 이용해서데이터
를 패칭한다. 이때 unique한queryKey
를 설정해둔다.useMutation
기능을 이용해서서버
에POST
요청을 보낸다.- 만약 2항에서 보낸
POST
요청에 대해서버
에서SUCCESS
응답을 받았다면invalidateQueries
기능을 이용해서 1항에서 사용한queryKey
를 사용하던 컴포넌트에서 사용하던데이터
와 연결을 강제로 끊어버린다.데이터
와 연결이 끊어진 컴포넌트는 자동으로 다시useQuery
기능을 이용해서 설정된queryKey
로새 데이터
를 받아온다.새 데이터
를 감지한 컴포넌트가 리렌더링된다.
이렇게 되면 전역 상태관리나 useState/useEffect
없이 변경된 데이터가 화면에 즉각 적용된다.
최종적으로 클라이언트에서 관리하는 데이터는 recoil
과 같은 전역 상태관리 툴을 사용하여 관리하고,
서버에서 관리하는 데이터는 React Query
로 관리함으로써 데이터 관리를 조금 더 효율적으로 할 수 있게 된다.
그리고 또, React-Query
의 최대 장점이라 할 수 있는 것 중 하나가 바로 Caching(캐싱)
기능이다.
이해를 조금 더 잘 돕기 위해 아래 그림을 참고하면 좋다.
여기까지 배경지식에 대해 알아봤으니, 이제 두 스택을 활용하여 간단한 프로젝트를 시작해보도록 하자.
일단 Server State
를 다뤄야 하니, 서버가 필요하다.
Node.js
를 사용하여 간단한 서버를 만들어 주자.
//server.js
import express from "express";
import cors from "cors";
import { Router } from "express";
const app = express();
app.listen(8080, function () {
console.log('listening on 8080')
});
const main = Router();
main.get("/", (req, res) => {
res.send("root page");
});
const testRouter = Router();
testRouter.get("/:oid", async (req, res) => {
try {
const { oid } = req.params
const query = req.query
if(query.name){
function returnData(oid, query) {
if(query.name === "1") return "query is 1"
return "query is maybe 2"
}
const data = returnData(oid,query)
res.send(`oid is ${oid}, ${data}`)
}
else {
res.send(`oid is ${oid}, there is no query.`)
}
}
catch {
res.status(402).send("something wrong")
}
})
app.use(cors());
app.use(express.json());
app.use("/api", testRouter);
app.use("/", main);
대략적으로 설명하자면, params
와 query
를 받아서 서버에 정상적으로 parameter들이 전달되고 있는 지 확인해서 돌려주는 서버이다.
api call 요청 주소는 localhost:8080/api/:oid?query
가 될 것이다.
queryString을 변화시키는 페이지이다.
queryString 관리에는 useSearchParams
훅을 사용하였다.
// Test.tsx
import { useSearchParams } from "react-router-dom";
export default function Test() {
const [searchParams, setSearchParams] = useSearchParams();
const handler = (number: string) => {
searchParams.delete("name");
searchParams.append("name", number);
setSearchParams(searchParams);
};
return (
<div className="flex flex-col">
<button onClick={() => handler("1")}>queryString to 1</button>
<button onClick={() => handler("2")}>queryString to 2</button>
</div>
);
}
React Router DOM의 Loader 함수를 정의하는 페이지이다.
API 통신에 axios
를 사용하고, 데이터 관리에 tanstack query
를 사용하였다.
코드에 대한 설명은 주석으로 대체하였다.
// test.loader.ts
import type { QueryClient } from "@tanstack/query-core";
import axios from "axios";
const QueryTesterApi = (oid: string, queryString?: string) => ({
queryKey: ["test", oid], // unique Key 값을 부여하였다.
queryFn: async () => {
let url = `http://localhost:8080/api/${oid}`;
if (queryString) url += `?${queryString}`;
console.log(url); // 제대로 된 주소로 요청되고 있는 지 확인을 위한 console
return await axios.get(url);
},
});
export const MyLoader =
(queryClient: QueryClient) =>
async ({ request: { url } }: any) => {
console.log("this is Loader"); // Loader에서 일어나는 로직임을 알려주는 분기점
const oid = "1"; // 현재 단계에서 Params는 1로 고정한다.
if (url.split("?")[1]) { // 왜 이렇게 쓰는지 궁금하다면 위에 있는 url을 console 찍어보길 권한다.
console.log("in Loader... changed URL", url.split("?")[1]);
const data = await queryClient.ensureQueryData(
QueryTesterApi(oid, url.split("?")[1]),
);
console.log("my data", data.data); // 서버에서 받은 데이터
console.log("request URL", data.request.responseURL); // 서버에 요청된 데이터
return data;
}
console.log("no query string now...");
return await queryClient.ensureQueryData(QueryTesterApi(oid));
};
마지막으로 React 라우팅을 위해 App.tsx를 구성해주자.
이때 React Router DOM v6의 nested Router를 사용하였다.
import { Link, Outlet, RouterProvider, createBrowserRouter } from "react-router-dom";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import Test from "@components/Test";
import { MyLoader } from "./loader/test.loader";
// React query의 QueryClient를 생성하고, staleTime을 설정해준다.
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 25,
},
},
});
// Nested Router 사용!
const routers = createBrowserRouter([
{
path: "/",
element: <DefaultLayout />,
errorElement: <div>something wrong</div>,
children: [
{
index: true,
element: (
<div>
<Link to="test">move to TEST PAGE</Link>
</div>
),
},
{
path: "test",
element: <Test />,
loader: MyLoader(queryClient), // <-------------- 바로 이 기능이 Loader 이다. queryClient를 인수로 받는다.
},
],
},
]);
function DefaultLayout() {
return (
<div>
<div>headers</div>
<Outlet />
</div>
);
}
export default function App() {
return (
<>
<div className="App">
<QueryClientProvider client={queryClient}> // React query 사용을 위해서 이렇게 라우터를 감싸줘야 한다.
<RouterProvider router={routers} /> // Nested Router를 적용하는 방법이다.
</QueryClientProvider>
</div>
</>
);
}
전체 코드는 https://github.com/gboycdw/TanstackQuery 를 참고하기 바란다.
자 여기까지 봤을 때 문제점을 이미 발견했다면 React Query
에 대한 상당한 이해도를 지녔다고 할 수 있다.
그러니까, 잘못 작성된 코드가 있다는 것이다.
일단 뭐가 잘못된건지 알기 위해 코드를 실행해보자.
먼저 테스트 페이지에 접근하면 당연히 queryString이 존재하지 않고, params는 1로 강제로 지정한 상태이다.
이제 여기서 버튼을 눌러 queryString을 변화시켜 보자.
oid
는 당연히 1로 지정해 줬으니 1이 나오는데 Loader에서 URL이 변화되고 있는 것을 인식하고 있음에도 불구하고,
계속해서 API 요청에는 queryString이 포함되지 않은 채 보내지고 있다.
여기서 페이지를 새로고침 한 번 해준다.
어? 바뀐 queryString이 적용이 되었다.
그러니까 정리해보면 "새로고침" 하지 않으면 변경된 queryString이 적용되지 않는다.
React에서 새로고침 하지 않으면 데이터가 갱신되지 않는 문제는 흔히 겪는 상황이다!
하지만 invalidateQueries 설정을 해줘도 새로고침 하지 않으면 변경된 queryString이 적용되지 않는다.
React Query
의 maintainer인 TkDodo
의 블로그에 찾아가 직접 질문해 보았다.
이런저런 방법을 제안 받았지만, 문제가 해결되지 않았다.
- 새로고침 하지 않으면 갱신이 안 된다. 새로고침 하면 갱신이 된다.
- 분명히 Loader function에서 변경된 url을 인식 하고 있다.
- 그런데 API Call은 새로고침 하지 않으면 바뀐 주소로 보내지 않는다.
React-Query
의 사용 미숙으로 인해 안 되는 것인지 의심되어,
전부 useState
, useEffect
및 axios
를 컴포넌트에서 직접 사용하여 데이터를 패칭하도록 해 보았다.
=> 안 된다. 같은 증상이다.
사실 이런 문제를 보다 쉽게 해결하기 위해 사용하는 것이 React-Query
인데,
뭔가 캐싱된 데이터를 계속 사용하고 있다는 생각이 든다.
React-Query
가 동작하는 구조를 다시 살펴보았다.
queryCache
.. 그러니까 queryKey
를 사용해서 데이터를 구분한다
그러니까 queryKey
에 관련된 문제일 가능성이 높다.
queryKey
는 식별자로서 unique 한 값을 지정해야 한다.
만약 query
가 다른 변수로 인한 종속성을 가진다면 queryKey
에 그 변수를 모두 배열로 포함해야 한다.
실제로 내가 사용한 queryKey는 다음과 같다.
queryKey: ["test", oid], // Wrong!!
음?
생각 해보면, 지금 변화하고 있는 값은 oid
가 아니라 queryString
이다.
queryKey: ["test", oid, queryString], // Correct!!
그러니까 종속성 배열에 queryString
도 포함되어야 한다는 것이다.
콘솔 창을 자세하게 보자.
Loader에서 인식한 URL name에 맞게 API Call이 보내지고 있는 것을 확인할 수 있다.
다음과 같이 코드를 수정해준다.
import { useLoaderData, useSearchParams } from "react-router-dom";
import { MyLoader, QueryTesterApi } from "../loader/test.loader";
import { useQuery } from "@tanstack/react-query";
export default function Test() {
const [searchParams, setSearchParams] = useSearchParams();
// 추가된 코드 : Loader에서 가져온 initialData를 읽는 코드
const initialData = useLoaderData() as Awaited<ReturnType<ReturnType<typeof MyLoader>>>;
// 추가된 코드 : 초기값을 Loader에서 가져온 데이터로 하되, query를 구독하기 위한 코드
const { data } = useQuery({...QueryTesterApi("1", searchParams.toString()), initialData});
const handler = (number: string) => {
searchParams.delete("name");
searchParams.append("name", number);
setSearchParams(searchParams);
};
// 데이터 확인을 위한 console
console.log("초기데이터", initialData);
console.log("쿼리데이터", data);
return (
<div className="flex flex-col">
<button onClick={() => handler("1")}>queryString to 1</button>
<button onClick={() => handler("2")}>queryString to 2</button>
// 추가된 코드 : 서버에서 읽어온 데이터를 렌더링하기 위한 코드
{data && (
<div className="flex justify-center ">
<div className="w-fit bg-blue-50 border rounded-lg">{data.data}</div>
</div>
)}
</div>
);
}
그리고 이제 신나게 queryString을 변화시켜 본다.
=> 문제가 해결되었다.
어쩌다 보니, 조금 많은 내용을 담고 있는 포스팅이 되었다.
내가 작업하던 페이지에서 같은 문제가 발생하여 해당 문제 해결을 위해 시작한 포스팅으로,
다른 코드로 인해 로직이 꼬이는 것을 방지하기 위해 프로젝트를 미니멀라이즈 하여 재구성하여 사용하였다.
이번 Trouble Shooting을 통해서 Loader와 React Query에 대한 지식이 좀 는것 같아서 나름 만족스럽다.
그럼 다음 포스팅으로 돌아오도록 하겠다 😀
굉장히 재밌게 읽었습니다