React Query with Loader of React Router DOM

Gomao·2023년 11월 1일
4

Web programming

목록 보기
12/16

0. 서론

만약 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. Loader에 대해 간단히 알아보자

참고자료 1번 링크를 참고하자.
다음과 같이 설명되어 있다.
Each route can define a "loader" function to provide data to the route element before it renders.

그러니까, 라우터가 렌더링 되기 전에 로더에 먼저 접근해서, 로더에 선언된 함수를 모두 거치고 라우터로 넘어간다는 것이다.

크게 두 가지 메리트가 있을 것으로 생각된다.

  1. 라우터 접근 권한을 관리하는 경우, 로더에서 특정한 로직을 통과하지 못한다면 라우터 접근 자체를 막아버릴 수 있음
  2. 데이터 pre-fetching을 하여 렌더링 시 처음부터 비동기 데이터를 온전하게 가지고 있을 수 있음

여기서 두 번째 경우에 조금 더 집중해서 접근을 시도한다.

Server State관리에는 Tanstack Query를 사용하도록 한다.

2. 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의 개념에 대해 짚고 넘어가야겠지?

Server State

서버 상태는 다음과 같은 조건을 만족한다.

  • 내가 클라이언트 단에서 컨트롤하거나 소유할 수 없는 곳에서 관리됨
  • 데이터 Fetch 혹은 Update 시 비동기 요청이 필요함
  • 다른 사람들과도 공유되는 데이터로, 내가 모르는 순간에도 정보가 바뀔 수 있음
  • 신경쓰지 않고 있으면 데이터가 만기도래(원본이 이미 바뀌어버려 신뢰도를 잃음) 될 가능성이 존재함

이러한 특성으로 인해 다음과 같은 문제를 해결해야만 한다.

  • 데이터 캐싱 및 동일한 데이터에 대한 중복된 요청 제거
  • 데이터가 오래된 시점 판단 / 백그라운드에서 오래된 데이터 업데이트
  • pagination 또는 lazy loading(지연 로딩) 등 성능 최적화 관리
  • 서버 상태의 memory와 garbage collection 관리
  • query result에 대한 Memoizing 처리

React Query를 쓰면 왜 좋은거지?

가령 예를 들어서, 내가 관리하는 사이트에서 서버에 저장된 상품 리스트를 보던 중에
서버POST요청을 보내서 새로운 상품을 등록하는 작업을 하였다.
그러면 새로운 상품이 등록되는 순간 기존에 보고 있던 상품 리스트는 최신의 데이터가 아니게 된다.

리액트 어플리케이션에서는 이것을 화면에 즉각 반영하기 위해 일반적으로 useEffect와 같은 훅을 사용해서 특정 데이터에 변화가 생김을 감지하면 다시 GET 요청을 보내서 새 데이터를 가져와서 컴포넌트를 리렌더링 시킬 것이다.

그런데 만약 데이터를 받아오는 컴포넌트와 데이터를 수정하는 컴포넌트가 다르다면?

그러면 우리는 전역 상태를 사용하거나, props-drilling을 사용해서 해당 상태를 컴포넌트 간에 공유해야 한다.
하지만 React-Query를 사용하면 그럴 필요가 없다.

  1. useQuery기능을 이용해서 데이터를 패칭한다. 이때 unique한 queryKey를 설정해둔다.
  2. useMutation기능을 이용해서 서버POST 요청을 보낸다.
  3. 만약 2항에서 보낸 POST요청에 대해 서버에서 SUCCESS응답을 받았다면 invalidateQueries기능을 이용해서 1항에서 사용한 queryKey를 사용하던 컴포넌트에서 사용하던 데이터와 연결을 강제로 끊어버린다.
  4. 데이터와 연결이 끊어진 컴포넌트는 자동으로 다시 useQuery기능을 이용해서 설정된 queryKey새 데이터를 받아온다.
  5. 새 데이터를 감지한 컴포넌트가 리렌더링된다.

이렇게 되면 전역 상태관리나 useState/useEffect 없이 변경된 데이터가 화면에 즉각 적용된다.

최종적으로 클라이언트에서 관리하는 데이터는 recoil과 같은 전역 상태관리 툴을 사용하여 관리하고,
서버에서 관리하는 데이터는 React Query로 관리함으로써 데이터 관리를 조금 더 효율적으로 할 수 있게 된다.

그리고 또, React-Query의 최대 장점이라 할 수 있는 것 중 하나가 바로 Caching(캐싱) 기능이다.

React Query의 Caching LifeCycle

  1. A 쿼리 인스턴스가 마운팅 된다.
  2. 네트워크에서 데이터를 fetching하고 A 쿼리 키(key)로 캐싱한다.
  3. 이 데이터는 fresh 상태에서 staleTime 이후 stale(상한?) 상태로 변한다.
  4. A 쿼리 인스턴스가 언마운트 된다.
  5. 캐시는 cacheTime만큼 유지된 후 garbage collector에 수집된다.
  6. cacheTime이 지나기 전에 A 쿼리 인스턴스가 새로이 마운팅되면 fetch가 실행되고 fresh 상태의 데이터를 가져오는 동안 캐시 데이터를 보여준다.

이해를 조금 더 잘 돕기 위해 아래 그림을 참고하면 좋다.

여기까지 배경지식에 대해 알아봤으니, 이제 두 스택을 활용하여 간단한 프로젝트를 시작해보도록 하자.

3. 프로젝트 시작

1) 서버 만들기

일단 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);

대략적으로 설명하자면, paramsquery를 받아서 서버에 정상적으로 parameter들이 전달되고 있는 지 확인해서 돌려주는 서버이다.

api call 요청 주소는 localhost:8080/api/:oid?query 가 될 것이다.

2) 클라이언트 만들기

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 를 참고하기 바란다.


4. Something Wrong!!!

자 여기까지 봤을 때 문제점을 이미 발견했다면 React Query에 대한 상당한 이해도를 지녔다고 할 수 있다.

그러니까, 잘못 작성된 코드가 있다는 것이다.

일단 뭐가 잘못된건지 알기 위해 코드를 실행해보자.

5. TrobleShooting

먼저 테스트 페이지에 접근하면 당연히 queryString이 존재하지 않고, params는 1로 강제로 지정한 상태이다.
이제 여기서 버튼을 눌러 queryString을 변화시켜 보자.

oid는 당연히 1로 지정해 줬으니 1이 나오는데 Loader에서 URL이 변화되고 있는 것을 인식하고 있음에도 불구하고,
계속해서 API 요청에는 queryString이 포함되지 않은 채 보내지고 있다.

여기서 페이지를 새로고침 한 번 해준다.

어? 바뀐 queryString이 적용이 되었다.

그러니까 정리해보면 "새로고침" 하지 않으면 변경된 queryString이 적용되지 않는다.

React에서 새로고침 하지 않으면 데이터가 갱신되지 않는 문제는 흔히 겪는 상황이다!

하지만 invalidateQueries 설정을 해줘도 새로고침 하지 않으면 변경된 queryString이 적용되지 않는다.

help me!

React Query의 maintainer인 TkDodo의 블로그에 찾아가 직접 질문해 보았다.

이런저런 방법을 제안 받았지만, 문제가 해결되지 않았다.

Thinking

  1. 새로고침 하지 않으면 갱신이 안 된다. 새로고침 하면 갱신이 된다.
  2. 분명히 Loader function에서 변경된 url을 인식 하고 있다.
  3. 그런데 API Call은 새로고침 하지 않으면 바뀐 주소로 보내지 않는다.

React-Query의 사용 미숙으로 인해 안 되는 것인지 의심되어,
전부 useState, useEffectaxios를 컴포넌트에서 직접 사용하여 데이터를 패칭하도록 해 보았다.

=> 안 된다. 같은 증상이다.

사실 이런 문제를 보다 쉽게 해결하기 위해 사용하는 것이 React-Query인데,
뭔가 캐싱된 데이터를 계속 사용하고 있다는 생각이 든다.

React-Query가 동작하는 구조를 다시 살펴보았다.
queryCache.. 그러니까 queryKey를 사용해서 데이터를 구분한다
그러니까 queryKey에 관련된 문제일 가능성이 높다.

Solving

queryKey는 식별자로서 unique 한 값을 지정해야 한다.
만약 query가 다른 변수로 인한 종속성을 가진다면 queryKey에 그 변수를 모두 배열로 포함해야 한다.

실제로 내가 사용한 queryKey는 다음과 같다.

queryKey: ["test", oid], // Wrong!!

음?

생각 해보면, 지금 변화하고 있는 값은 oid가 아니라 queryString이다.

queryKey: ["test", oid, queryString], // Correct!!

그러니까 종속성 배열에 queryString도 포함되어야 한다는 것이다.

콘솔 창을 자세하게 보자.

Loader에서 인식한 URL name에 맞게 API Call이 보내지고 있는 것을 확인할 수 있다.

6. 이제 Loader에서 가져온 preFetched Data를 컴포넌트에서 사용해보자.

다음과 같이 코드를 수정해준다.

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을 변화시켜 본다.

=> 문제가 해결되었다.

7. Summary

어쩌다 보니, 조금 많은 내용을 담고 있는 포스팅이 되었다.

내가 작업하던 페이지에서 같은 문제가 발생하여 해당 문제 해결을 위해 시작한 포스팅으로,

다른 코드로 인해 로직이 꼬이는 것을 방지하기 위해 프로젝트를 미니멀라이즈 하여 재구성하여 사용하였다.

이번 Trouble Shooting을 통해서 Loader와 React Query에 대한 지식이 좀 는것 같아서 나름 만족스럽다.

그럼 다음 포스팅으로 돌아오도록 하겠다 😀

profile
코딩꿈나무 고마오

1개의 댓글

comment-user-thumbnail
2023년 11월 9일

굉장히 재밌게 읽었습니다

답글 달기