avatar
Ganymedian

#6-2. Suspense and Next

4 months ago
·
37 min read

2024년 7월 15일 오후 1:57

  1. Workthrough for prev. project

  2. Code Splitting

  3. Suspense

  4. React.lazy

  5. Suspense 와 함께 사용되는 hooks

  6. Async React Router

  7. React Conf. 2024 와 근황 이것저것

  8. Final Project

  9. 마치면서…

  10. BaaS : BackEnd as a Service Providers

.

.

Async React Router

이제 React Router 의 라우팅 포인트 컴포넌트들을 코드 스플리팅 할 차례입니다. 우리의 학습과정에서 난이도 최종 보스라고나 할까요..

우리가 작업했던 RRD 프로젝트 의 router.tsxloader 의 코드와 구조가 상당히 복잡하게 얽혀있는데, 이를 Suspense 와 코드 스플리팅으로 구성이 가능할까요?

.

.

Hi-Jacking - Loader

RRD 에 Suspense 를 적용하려면, 우선 RRD loader 를 hi-jacking 해야만 합니다.

Suspense 는 프라미스를 받기 때문에, 지난 장에서 작업했던 loader 의 리턴 형식으로는 Suspense 가 resolve 될 수 없습니다. 때문에 loader 는 프라미스를 리턴하는 형식으로 변형되어야 합니다. 그리고 이에 따르는 변화가 한가지 더 있습니다. 프라미스가 리턴되는 형식으로 loader() 를 변형시켰을 때에는, useNavigation 을 비롯한, RRD 의 다수 내부 상태관리 훅들이 제대로 작동하지 않게 된다는 것이죠.
하지만 이 문제라면, loading state 는 Suspense 가 관리해주고, errorerrorElenemt 로 전파되기 때문에 문제될 건 없습니다. 이 점에 대해서는 뒷 부분에서 더 깊숙이 다뤄보겠습니다.

ps. 최신 버전인 "react-router-dom": "^6.25.1" 에서, 프라미스를 리턴하는 loader() 에 대해서도 useNavigation 의 내부 상태가 이전과 다름 없이 loading 상태를 반영하고 있는 것이 확인되었습니다. 공식문서의 언급은 찾아보지 못했습니다만, 필요할 경우 useNavigation 의 내부 상태를 참조하는 코드도 아직 유효하게 사용할 수 있어 보입니다.

.

.

Suspense & Await

RRD 프로젝트의 라우팅 포인트 컴포넌트에 Suspense 를 사용할 수 있도록 만들어주려면 이런 작업을 해야 합니다.

  1. loader 의 리턴 형식을 defer({Promise}) 로, Promise 자체를 리턴하는 형태로 바꿔줍니다. defer 는 RRD 에서 제공하는 프라미스 지연 펑션입니다.

  2. Suspense 가 감시하는 프라미스의 resolve 는, RRD 에서 제공하는 Await 컴포넌트에서 이루어집니다.

  3. 하이재킹 된 loader() 의 코드, 즉 useLoaderData() 가 리턴하는 밸류는 프라미스 자체이기 때문에, resolve 된 이후에야 비로소 그 data 를 사용할 수 있게 됩니다. 그리고, Suspense 의 resolve 는 Await 컴포넌트에서 이루어지기 때문에, data 를 참조하는 작업은 JSX 내부에서만 이루어지게 됩니다. 이 사실이 의미하는 중요한 문제는 다시, 상황이 다음과 같음을 의미합니다:

  4. useLoaderData() 로부터 기대하는 data 를 참조하는 사전 작업이 필요할 때, 본체 컴포넌트에서는 이를 작업해 줄 수 없거나, 있다고 해도 코드가 난삽해지는 결과를 피할 수 없습니다.

  5. 따라서, 리졸브 된 이후에 확보된 data 를 자식 컴포넌트로 넘겨줘서 그 곳에서 작업하도록 구성해줘야 한다는 것입니다.

.

.

RRD 프로젝트를 Suspense 로 리팩토링

그럼 이제부터, 지난 장에서 작업했던 RRD 프로젝트를, Suspense 를 사용하는 코드로 하나씩 리팩토링해보겠습니다. 먼저, 비교적 단순한 /users 라우팅 컴포넌트 부터 시작해 보겠습니다.

1. Loader

loader 가 프라미스의 resolve 를 연기하고, 프라미스 자체를 리턴하도록 바꿔줍니다. resolve 된 데이터가 아니라, 프라미스 자체를 리턴해야 합니다.

// /src/pages/Posts.tsx
import { defer, Link, useLoaderData, useSearchParams } from "react-router-dom";

// loader 가 Suspense-Await 에서 관리되므로, 이제 cancelToken 을 제거합니다.
// cancelToken 이 필요 없어졌으므로, try-catch 도 필요 없어졌습니다.
const loader = () => {
  const usersRequest = async () => {
    const config = { method: "GET" };

    const userResult = await axiosRequest({
      endPoint: "/users",
      config,
    });
    const userData: UserType = userResult?.data;

    return userData;
  };

	// defer 는 프라미스의 resolve 를 연기하고 프라미스를 리턴합니다.
  return defer({ usersPromise: usersRequest() });
};
  • RRD 의 defer 는 프라미스의 resolve 를 연기하고 프라미스 자체를 리턴할 수 있도록 해줍니다.

이전 버전의 loader() 에서 크게 바뀐 점은 없습니다. 다만, 데이터를 직접 리턴하던 이전 버전의 방식에서, 프라미스 펑션 자체를 리턴하는 코드로, 리턴 방식만 바뀌었죠.

2. useLoaderData 리턴 값의 리졸브

이제, useLoaderData() 는 프라미스 자체를 리턴하기 때문에, data 를 사용하려면 먼저 프라미스를 resolve 해줘야 합니다. RRD 프로젝트에서 Suspense 의 리졸브는, RRD 에서 제공하는, <Await /> 컴포넌트에서 처리됩니다.

// /src/pages/Users.tsx
// <Await resolve={users} errorElement={<div>Error occurred</div>}>
// 위 코드에서, resolve 에는, resolve 할 프라미스를 담은 변수를, errlrElement 에는
// reject 또는 에러가 throw 되었을 때, 에러가 전파될 곳을 지정합니다.
const Users: React.FC = () => {
  const { usersPromise } = useLoaderData() as UseLoaderDataType;
  return (
    <div className="bg-background min-h-screen p-8">
      <h1 className="text-4xl font-bold mb-8">Users</h1>
      <Suspense fallback={<LoadingMain />}>
        <Await resolve={usersPromise} errorElement={<div>Error occurred</div>}>
          {(resolvedUsers) => {
            //console.log("Users data loaded:", resolvedUsers);
            return (
              <div className="grid gap-8 md:grid-cols-2 lg:grid-cols-3">
                {resolvedUsers.map((user: UserType) => (
                  <Link
                    key={user.id}
                    to={`/users/${user.id}`}
                    className=" transition-colors duration-300"
                  >
                    <div
                      key={user.id}
                      className="bg-background rounded-lg shadow-md overflow-hidden hover:shadow-lg transition-shadow duration-300 border border-accent"
                    >
                      <div className="bg-foreground p-4">
                        <h2 className="text-xl font-semibold text-background">
                          {user.name}
                        </h2>
                      </div>
                      <div className="p-4 space-y-2 bg-accent">
                        <div className="text-navy-600">
                          <span className="font-medium">Email:</span>{" "}
                          {user.email}
                        </div>
                        <div className="text-navy-600">
                          <span className="font-medium">Address:</span>{" "}
                          {user.address.street}
                        </div>
                        <div className="text-navy-600">
                          <span className="font-medium">Phone:</span>{" "}
                          {user.phone}
                        </div>
                        <div className="text-navy-600">
                          <span className="font-medium">Website:</span>{" "}
                          {user.website}
                        </div>
                      </div>
                    </div>
                  </Link>
                ))}
              </div>
            );
          }}
        </Await>
      </Suspense>
    </div>
  );
};
  • Suspense 와 Await 의 문맥상 구조만 이해한다면 이해하는 데에 큰 어려움이 없을 것 같습니다.

  • Suspense 이하 Await 노드에서 loader() 에서 보내준 usersPromise 프라미스가 resolve 되는 동안, Suspense 의 fallback 이 렌더링 됩니다.

  • Await 는, loader() 에서 보내준 프라미스인 usersPromise 의 리졸브를 시도하고,

    • 실패시에 errorElement 로 에러를 전파하고,

    • 성공(resolve) 시에 이하 코드를 진행시킵니다.

3.리졸브드 데이터 참조작업

하지만, data 를 참조해서 사전 작업이나 effect 작업을 해줘야 하는 상황이라면 어떻게 해야 할까요? 갑자기 난이도가 급상승하는 느낌입니다만, 이럴 땐 자식 컴포넌트에게 리졸브된 데이터를 넘겨줘서 참조작업이 이루어지도록 하면 간단하게 해결할 수 있습니다.

페이지네이션이 필요한, Posts 컴포넌트가 어떻게 리팩토링 되는지를 보면서 하나씩 이해해보죠.

// /src/pages/Posts.tsx
// 페이지네이션, setCurrentPage 등의 데이터 참조 작업을 자식 컴포넌트에게 전가합니다.
export type PostType = {
  userId: number;
  id: number;
  title: string;
  body: string;
};

export const ITEMS_PER_PAGE = 10;

const Posts: React.FC = () => {
  const { postsPromise } = useLoaderData() as { postsPromise: Promise<PostType> };
  const [searchParams, setSearchParams] = useSearchParams();
  const currentPage = Number(searchParams.get("page") || "1");

  return (
    <div className="bg-accent w-full min-h-screen p-8">
      <h1 className="text-4xl font-bold mb-8">Posts</h1>
      <Suspense fallback={<LoadingMain />}>
        <Await resolve={postsPromise} errorElement={<div>Error occurred</div>}>
          {(resolvedData) => {
            console.log(resolvedData);
            return (
              <PostsContent
                posts={resolvedData}
                currentPage={currentPage}
                setSearchParams={setSearchParams}
              />
            );
          }}
        </Await>
      </Suspense>
    </div>
  );
};

const loader = () => {
  const postsRequest = async () => {
    const config = { method: "GET" };

    const postsResult = await axiosRequest({
      endPoint: "/posts",
      config,
    });
    const postsData: PostType[] = postsResult?.data;

    console.log(postsData);
    return postsData;
  };

  return defer({ postsPromise: postsRequest() });
};

const PostsRoute = {
  element: <Posts />,
  loader,
};

export default PostsRoute;

// 이 컴포넌트는 별로 한 일도 없죠? 
// Suspense 의 리졸브에 자식 컴포넌트로 리졸브된 데이터를 전달해주었습니다. 

그리고, 졸지에 노가다를 떠안게된 자식 컴포넌트입니다.

// /src/pages/posts/PostsContent.tsx

const PostsContent: React.FC<{
  posts: PostType[];
  currentPage: number;
  setSearchParams: SetURLSearchParams;
}> = ({ posts, currentPage, setSearchParams }) => {
  const [currentPosts, setCurrentPosts] = useState<PostType[]>([]);

  useEffect(() => {
    const indexOfLastPost = currentPage * ITEMS_PER_PAGE;
    const indexOfFirstPost = indexOfLastPost - ITEMS_PER_PAGE;
    setCurrentPosts(posts.slice(indexOfFirstPost, indexOfLastPost));
  }, [currentPage, posts]);

  const totalPages = Math.ceil(posts.length / ITEMS_PER_PAGE);

  const handlePageChange = (pageNumber: number) => {
    setSearchParams({ page: pageNumber.toString() });
  };

  return (
    <>
      <div className="space-y-4 mb-8">
        {currentPosts.map((post) => (
          <div
            key={post.id}
            className="bg-background rounded-lg shadow-md overflow-hidden hover:shadow-lg transition-shadow duration-300"
          >
            <Link
              to={`/posts/${post.id}`}
              className="flex items-center p-4 hover:bg-accent transition-colors duration-300"
            >
              <span className="font-medium w-12 flex-shrink-0">{post.id}</span>
              <h2 className="text-md font-medium hover:text-indigo-700 transition-colors duration-300 flex-grow">
                {post.title}
              </h2>
            </Link>
          </div>
        ))}
      </div>
      <Pagination
        currentPage={currentPage}
        totalPages={totalPages}
        onPageChange={handlePageChange}
      />
    </>
  );
};

export default PostsContent;
  • <PostsContent /> 컴포넌트는, 부모 컴포넌트의 Suspense 가 리졸브 된 이후에 렌더링 되므로, 내부에 특별히 비동기 로직은 없습니다.

  • 페이지 이동 이벤트에는, handlePageChange 가 트리거되어 해당 url 로의 라우팅이 발생하고, 부모 컴포넌트는 당 페이지에 해당하는 파라메터를 다시 자식인 <PostsContent /> 컴포넌트에게 전달합니다.

이렇게 매우 복잡할 것 같은, 페이지네이션을 포함한 컴포넌트와 그 loader()의 Suspense 까지 알아보았습니다.

이제, 이 컴포넌트를 코드 스플리팅 하고 lazy 로딩 해주는, 다음 관문으로 이동해 보죠.

.

.

Code Splitting for router.tsx

그런데, 리팩토링 한 Posts.tsx 를 코드 스플리팅 하려면, 뭔가 문제가 생긴다는 것을 직감하게 됩니다.

바로, loaderPosts.tsx 내부에 같이 자리하고 있다는 점이 코드 스플리팅을 어렵게 만든다는 사실이죠.

본래 loader 는 라우터 레벨에서 확보되고, 라우팅 포인트 컴포넌트 에서 useLoaderData()loader 를 트리거 하는 구조인데, 그동안 우리가 사용해온 방식은, loader라우팅 포인트 컴포넌트 에 위치시켜서 사용해왔죠. 편리한 잇점이 있는 방식이지만, 코드 스플리팅해야 하는 상황에서는 사용할 수 없는 방식입니다. 분리되어 빌드되는 외부의 chunk 스크립트에 loader 가 위치하기 때문에, router.tsx 는 loader() 를 확보할 수 없게 됩니다.

이제, 코드 스플리팅이 필요하고 lazy 로드가 필요한 컴포넌트에 대해서는 loader 를 별도의 모듈로 분리해야 합니다. 점점 복잡계로 진화해가는 기분이 들지만, 최대한 정리하고 간소화 하도록 노력해봅시다.

loader 를 관리하는 폴더 /src/loaders 를 생성하고, 각 컴포넌트에서 필요한 loader 를 생성해줍니다.

그리고 router.tsx 에서 loader 를 import 해오고, 컴포넌트를 lazy import 해옵니다.

// /src/router.tsx
...

import usersLoader from "./loaders/usersLoader";
import userLoader from "./loaders/userLoader";
import postsLoader from "./loaders/postsLoader";
import postLoader from "./loaders/postLoader";

const Posts = lazy(() => import("./pages/Posts"));
const Post = lazy(() => import("./pages/Post"));
const Users = lazy(() => import("./pages/Users"));
const User = lazy(() => import("./pages/User"));

const routes: RouteObject[] = [
  {
    path: "/",
    element: <RootLayout />,
    children: [
      { path: "*", element: <NotFound404 /> },
      {
        errorElement: <ErrorPage />,
        children: [
          { path: "/", element: <Home /> },
          {
            path: "/popup",
            element: <Popup />,
            children: [{ path: "/popup/dialog", element: <DialogPopup /> }],
          },
          {
            path: "/posts",
            children: [
              { index: true, element: <Posts />, loader: postsLoader },
              { path: ":postId", element: <Post />, loader: postLoader },
            ],
          },
          {
            path: "/users",
            children: [
              { index: true, element: <Users />, loader: usersLoader },
              { path: ":userId", element: <User />, loader: userLoader },
            ],
          },
          { path: "/todos", ...TodosRoute },
          { path: "/signin", element: <Signin /> },
        ],
      },
    ],
  },
];

export const router = createBrowserRouter(routes);

이렇게, Suspense 와 코드 스플리팅은 일단 완료되었습니다. 하지만, 여기까지만 해줘서는 /users 와 /posts 에서 코드 스플리팅이 정상적으로 작동하지 않고 오류가 발생합니다. 스플리팅 된 chunk 파일이 컴포넌트의 렌더링이 시도되기 이전에 제 때 다운로드 되지 않기 때문인데요.. 바로 이 때문에, Nested Route 환경에서는, <Outlet /> 컴포넌트에도 Suspense 를 감싸주어야 합니다. 라우팅 방식을 곰곰히 생각해보면 왜 그런지 이해할 수 있습니다만, 일단 그렇게 외우고 넘어갑시다.

하지만, 단순하게 RootLayout.tsx<Outlet /> 컴포넌트를 Suspense 로 감싸주는 것 만으로는 다양한 라우팅과 로딩 처리방식에 대한 요청을 모두 처리해줄 수는 없습니다. 코드 스플리팅 하지 않은 컴포넌트들도 있을 수 있기 때문이고, 추후에 프로젝트에 추가될 일부 컴포넌트들은 Suspense 대신에 useNavigation.state 의 관리방식을 더 선호하게 될 지도 모릅니다.

.

.

loading 처리의 두가지 방법 정리

useNavigation.state

  • 코드 스플리팅 불가

  • loader() 에서 프라미스가 아닌, 데이터를 리턴해줘야 함

  • layout 에서 <Outlet /> 을 조건부 렌더링 해줌으로써 로딩 상태를 표현

Suspense - Await

  • 코드 스플리팅 가능

  • loader() 에서 프라미스를 리턴해야 함

  • 컴포넌트 코드 내에서 Suspense - fallback 으로 로딩 상태를 표현

  • 프라미스는 <Await resolve={promise} 구문에서 리졸브된다.

.

.

RootLayout 변경

이제, RootLayout 이 useNavigation.stateSuspense 의 로딩 상태를 모두 참조하고 분기해줄 수 있도록 구조를 변경해줍니다.

// /src/layout/RootLayout.tsx
import { Outlet, ScrollRestoration, useNavigation } from "react-router-dom";
import NavBar from "./header/NavBar";
import Footer from "./footer/Footer";
import RootFrame from "./RootFrame";
import LoadingMain, { LoadingFallback } from "../components/main/LoadingMain";
import MainFrame from "./main/MainFrame";
import { ThemeProvider } from "@/context/ThemeProvider";
import { AuthProvider } from "@/context/AuthProvider";
import { Suspense } from "react";
import { useCustomLoading } from "@/hooks/useCustomLoading";

const RootLayout = () => {
  const { activeLoader, setIsSuspenseLoading, isSuspenseActive } =
    useCustomLoading();

  return (
    <AuthProvider>
      <ThemeProvider defaultTheme="dark" storageKey="vite-ui-theme">
        <RootFrame>
          <NavBar />
          <ScrollRestoration />
          <MainFrame className="flex flex-col">
            {activeLoader === "navigation" ? (
              !isSuspenseActive ? (
                <LoadingMain message="useNavigationState" />
              ) : (
                <Outlet />
              )
            ) : (
              <Suspense
                fallback={
                  <LoadingFallback
                    setIsSuspenseLoading={setIsSuspenseLoading}
                    message="Suspense loading"
                  />
                }
              >
                <Outlet />
              </Suspense>
            )}
          </MainFrame>
          <Footer />
        </RootFrame>
      </ThemeProvider>
    </AuthProvider>
  );
};

export default RootLayout;

그리고, 요청받은 로딩 상태를 어디에서 관리하고 있는지를 리턴하는 커스텀 훅 useCustomLoading 을 /hooks 폴더에 만들어 줍시다. 이 커스텀 훅은, 프로젝트의 RootLayout.tsx 에서만 사용될 것이고, 앞으로의 Async React-Router 프로젝트에서 계속해서 사용될 것입니다.

// /src/hooks/useCustomLoading.tsx
import { useEffect, useRef, useState } from "react";
import { useNavigation } from "react-router-dom";

export default function useCustomLoading() {
  const { state } = useNavigation();
  const [isSuspenseLoading, setIsSuspenseLoading] = useState(false);
  const [activeLoader, setActiveLoader] = useState<string | null>(null);
  const navigationStartTimeRef = useRef<number | null>(null);

  useEffect(() => {
    if (state === "loading" && !isSuspenseLoading) {
      navigationStartTimeRef.current = Date.now();
      setActiveLoader("navigation");
    } else if (state === "idle") {
      const navigationDuration =
        Date.now() - (navigationStartTimeRef.current || 0);

      // 매우 짧은 시간 내에 loading에서 idle로 변경된 경우, 이를 Suspense로 간주
      if (navigationDuration < 50 && isSuspenseLoading) {
        setActiveLoader("suspense");
      } else if (!isSuspenseLoading) {
        setActiveLoader(null);
      }

      navigationStartTimeRef.current = null;
    }
  }, [state, isSuspenseLoading]);

  useEffect(() => {
    if (isSuspenseLoading) {
      setActiveLoader("suspense");
    } else if (state !== "loading") {
      setActiveLoader(null);
    }
  }, [isSuspenseLoading, state]);

  return {
    activeLoader,
    setIsSuspenseLoading,
    isSuspenseActive: activeLoader === "suspense",
  };
}

이제, RootLayout.tsx 는,

  • useNavigation.state 가 리턴하는 로딩상태에 따라 <LoadingMain /> 를 렌더링 해주고,

  • Suspense 의 로딩 상태에 따라 <LoadingFallback /> 를 렌더링해주도록 추가해주었습니다.

  • <LoadingFallback /><LoadingMain /> 를 재사용 함으로써 UI 의 일관성을 유지시켰습니다.

그리고, 여기서 한번 더 간조하지만 잊지 말아야 할 것은, Suspense 로 <Outlet /> 을 감싸 준 부분이에요. Nested Route 환경에서, 레이아웃에서 Suspense 로 <Outlet /> 을 감싸주지 않으면, 컴포넌트 단에서의 Suspense-Await 가 자기 차례를 못 찾고 에러를 발생시킵니다.

.

.

useAsyncValue

Suspense - Await 에서 리졸브 된 프라미스의 데이터를 하위 컴포넌트에게 넘겨줘서 추가 작업을 해야할 때, 이 때 아직 프라미스는 리졸브되기 이전 상태이기 때문에, 앞선 예제들에서는 Await 내부에서 arrow function 콜백을 사용해서 프롭으로 넘겨줄 데이터를 확보했었죠.

// 
const Posts: React.FC = () => {
  const postsPromise = useLoaderData() as {
    initialData: Promise<PostType[]>;
    fetchMore: (start: number, end: number) => Promise<PostType[]>;
  };
  const [searchParams, setSearchParams] = useSearchParams();
  const currentPage = Number(searchParams.get("page") || "1");

  return (
    <div className="bg-accent w-full min-h-screen p-8">
      <h1 className="text-4xl font-bold mb-8">Posts</h1>
      <Suspense fallback={<LoadingMain />}>
        <Await
          resolve={postsPromise.initialData}
          errorElement={<AsyncErrorHandler />}
        >
          {(resolvedData) => {
            console.log(resolvedData);
            return (
              <PostsContent
                initialPosts={resolvedData}
                currentPage={currentPage}
                setSearchParams={setSearchParams}
                fetchMore={postsPromise.fetchMore}
              />
            );
          }}
        </Await>
      </Suspense>
    </div>
  );
};

하지만, useAsyncData() 훅을 사용하면 코드를 좀 더 간결화 할 수 있습니다.

자식 컴포넌트에서는 RRD 에서 제공하는 useAsyncData() 훅으로 데이터를 받아올 수 있습니다.

마치, loader()useLoaderData() 와의 관계 비슷한데요, 자식 컴포넌트에서는 리졸브된 프라미스의 데이터를, useAsyncData() 훅으로 받아올 수 있습니다. 애로우 펑션에 프롭을 주렁주렁 달아주는 코드 보다는 훨씬 깔끔하겠죠.

// /src/pages/User.tsx
import React, { Suspense } from "react";
import { UserType } from "./Users";
import { Await, useAsyncError, useLoaderData } from "react-router-dom";
import { PostType } from "./Posts";
import LoadingMain from "@/components/main/LoadingMain";
import UserContent from "./users/UserContent";
import UserPosts from "./users/UserPosts";

type UserLoaderDataType = {
  userPromise: Promise<UserType>;
  postsPromise: Promise<PostType[]>;
};

const User: React.FC = () => {
  const { userPromise, postsPromise } = useLoaderData() as UserLoaderDataType;

  return (
    <div className="bg-background min-h-screen p-8">
      <div className="max-w-3xl mx-auto">
        <Suspense fallback={<LoadingMain />}>
          <Await resolve={userPromise} errorElement={<AsyncErrorHandler />}>
            <UserContent />
          </Await>
          <Await resolve={postsPromise} errorElement={<AsyncErrorHandler />}>
            <UserPosts />
          </Await>
        </Suspense>
      </div>
    </div>
  );
};

그리고, useAsyncValue() 를 사용해서 데이터를 가져옵니다.

// /src/pages/users/UserPosts.tsx
import React from "react";
import { PostType } from "../Posts";
import { Link, useAsyncValue } from "react-router-dom";

type UserPostsObjectType = {
  posts: PostType[];
};

const UserPosts = () => {
	// useAsyncValue() 로 Await resolve={postsPromise} 의 데이터를 확보합니다.
	// useAsyncValue() 는 언제나 직계 상위 Await resolve={promise} 를 참조합니다.
  const { posts } = useAsyncValue() as UserPostsObjectType;
  console.log(posts);

  return (
    <>
      <h2 className="text-2xl font-bold text-foreground mb-4">Posts</h2>
      <div className="bg-accent rounded-lg shadow-md overflow-hidden">
        <ul className="divide-y divide-indigo-100">
          {posts.map((post: PostType) => (
            <li
              key={post.id}
              className="p-4 hover:bg-indigo-500 transition-colors duration-100"
            >
              <Link
                to={`/posts/${post.id}`}
                className="text-foreground hover:text-indigo-300"
              >
                {post.title}
              </Link>
            </li>
          ))}
        </ul>
      </div>
    </>
  );
};

export default UserPosts;

이렇게 우리의 Async React-Router 프로젝트가 최종적으로 완성되었습니다. 코드 스플리팅 하지 않은 컴포넌트에서는 useNavigation.state 의 로딩 처리가 작동하고 있고, 코드 스플리팅과 Suspense 로 처리해준 컴포넌트에서는 Suspense 의 로딩 관리가 잘 작동하고 있습니다.

.

.

코드 스플리팅-Suspense 와 useNavigation 의 혼합 사용

서두에서도 잠깐 고민을 해봤지만, 코드 스플리팅에 정답은 없습니다. 필요에 따라, 상황과 판단에 따라 스플리팅 하든, 본체에 포함시키든, 유연하게 판단하면 될 것입니다.

그리고 지난 장에서 배웠던, useNavigation 의 로딩 처리도 사실 나무랄 데 없이 깔끔하죠. Suspense 를 익혔다고, useNavigation 의 로딩 관리를 모두 버릴 필요는 없습니다. 필요에 따라 코드를 스플리팅 하고, Suspense 를 사용하는 컴포넌트의 로딩은 Suspense 에서 담당 하도록 해주고, 스플리팅 할 필요 없는 컴포넌트의 로딩은 useNavigation 에 처리를 맡기도록, 유연하게 접근하는 것이 실용적입니다. 실제로 많은 프로젝트에서 이러한 방식이 권장되기도 하는데요. 주의해야 할 점은, 양쪽의 UI/UX 가 통일된 일관성을 갖춰야 한다는 점입니다.

다른 모든 문제들에 있어서도 마찬가지 이겠습니다만, 무엇보다 중요한 것은 명확한 가이드라인이겠죠. 언제 Suspense 를 사용하고, 언제 useNavigation 을 사용하게 될 것인지 가이드라인만 명확하다면 유연한 혼용이 생산성을 높이는데에 큰 도움이 될 것입니다.

이것으로, 제가 준비한 주제들은 모두 끝이 났습니다.

.

.

.

React Conf. 2024 와 근황 이것저것

지난 5월에는 리액트 컨퍼런스 2024 가 열렸습니다. 그리고 얼마전에 개별 시연 동영상들이 공식 채널에서 모두 공개 되었죠. 컨퍼런스 자체는 시간이 좀 지나긴 했지만, 개별 시연 영상들은 아직 따끈따끈하기에, 몇가지 느낀 점들을 공유해봅니다.

이번 컨퍼런스에서는, React 19 에서 예정된 useActionStateuseFormState 가 가장 큰 관심을 모았던 것 같습니다. 현재 가장 인기있는 라이브러리 중 하나인 react-query 를, 어쩌면 온전히 대체할 수 있을 정도의 강려크한 인상을 주었죠.

하나 더, 재미있었던 장면을 꼽자면… 컨퍼런스의 시연 중에, 프로젝트에서 사용된 useState 의 카운트가 0 이 되자, 청중들이 크게 환호하며 박수치는, 인상적인 장면이 있었습니다. 우리가 배워온 코드에서도 useState 의 사용빈도가 시간이 갈수록 줄어들고 있다는 것을 많은 분들이 느끼셨을 것 같은데요. 이제 useState 와 useEffect 는 리액트 개발자들 사이에서 verbose code 가 되어가고 있는 것 같습니다. 상태관리, 이펙트 관리는 각 라이브러리에게 맡기고, 리액트 개발자는 라이브러리들을 효율적으로 제어하는 방향으로 트렌드가 형성되어 가고 있는 것 같습니다. useState 지못미

그리고 또 하나의 인상적인 장면으로는, Ryan Florence 가 보여준 시연에서, React 19 & React-Router 7 과 Vite config 으로, React-Router 의 loader 가 Server Side Rendering 이 가능하다는 것을 보여준 장면입니다. SPA 앱에서 말이죠. 이것은, SSR/ SSG 아키텍쳐가 라우팅 레벨에까지 이르렀다는 의미이기도 한데요. 개별 loader 가 SSR/ SSG 완료된 후에 CDN 으로 공급될 수도 있다는 의미이기도 하겠습니다. 어쩌면, 언젠가는 Astro 의 Server Islands 아키텍쳐와 React-Router 가 한 곳에서 만나게 될 지도 모르겠군요.

.

.

React19 에서 바뀌거나 추가되는 것들

useActionState

const [state, formAction]  = useActionState(action, initialState, permalink?)

useFormStatus

const { pending, data, method, action } = useFormStatus();

useOptimistic

const [optimisticState, addOptimistic] = useOptimistic(state, updateFn);

use

const value = use(resource);

.

.

.

Final Project

지난 장에서 진행했던 프로젝트를, Suspense 와 코드 스플리팅을 적용한 프로젝트로 리팩토링 해봅시다.

useNavigation 의 편리함도 남겨두기 위해, 일부 페이지의 로딩은 useNavigation.state 가 담당하는 것으로 구성해보죠.

  1. /users, /posts, /user, /post 에 대한 라우팅과 목적 컴포넌트들을 모두 Suspense & lazy 로 코드 스플리팅 하십시오.

  2. /todos 에 대한 라우팅 방식은 지난 5장의 방법을 그대로 남겨두고, 로딩은 useNavigation 이 담당하는 방식이 되도록 합니다. 그리고 결과 확인을 위해, loader() 에 인위적인 응답 지연을 1초간 발생시킵니다. 두가지 로딩 관리 방식을 병행하면서 그 차이점과 관리방법을 경험해보죠.

어느덧 마지막 태스크가 됐네요.

완성된 코드와 데모가

https://stackblitz.com/~/github.com/KangWoosung/suspense-exercise

여기 올려져 있습니다. StackBlitz 에서 지난번의 RRD 관련 문제를 픽스해준 모양이네요. 지금은 RRD 프로젝트가 StackBlitz 에서도 잘 작동합니다. *.초기 로딩에 최소 몇 분 소요됩니다. 앱 화면이 뜰 때까지 기다려줍시다.

앱의 외형만 보면, 지난 장의 태스크에서 달라진 게 없죠. 하지만 내부적인 메카니즘에는 큰 변화가 있었습니다. 앱의 최적화 고민이 시작되는 지점에 이제 막 도착했다는 느낌이랄까요.. 리액트 개발에서는 향상된 사용자 경험 이야말로 가장 중요하게 생각되고 있는 개념 중 하나이죠. 그런 의미에서, 구조화된 로딩 관리와 에러 처리는 아무리 강조해도 지나치지 않겠습니다.

이번 과제도 꼭 직접 고민하고 작업해보시길 권장합니다. 기술이라는 건 눈으로만 습득되는 게 아니니까요. 아무쪼록, 코드 스플리팅과 lazy-Suspense-Await 가 왜 필요한지, 어떻게 앱을 최적화 하고 사용자 경험을 향상시킬 수 있는지를 경험할 수 있는 시간이 되기를 바라봅니다.

.

.

.

마치면서…

이상으로, React App Project 에서 배워야 할 것들은 거의 골고루 다뤄본 것 같습니다. 사실, 이번 장에서는 Portal 부터 정리하고 진도를 나가려던 계획이었지만, 그렇게 되지 못했습니다. 웹 개발에서 가장 자주 사용되는 element/ component 는 사실 Modal 이겠죠. Form 이 없는 프로젝트는 있을 수 있어도, 모달 팝업이 없는 프로젝트는 못 본 것 같으니까요. 그래서 재사용 가능한 Modal 컴포넌트를 Portal 과 함께 구현해보는 기록은 남기고 싶었지만, 시간과 분량상 그렇게 하지 못했습니다. 이 주제는 언제가 될지 모르지만 다시 정리해보는 시간을 가져보기로 하죠. 좀 더 깊이 있거나, 시간을 갖고 익숙해질만한 연습 프로젝트도 풍성하거나, 했다면 더 좋았을 텐데.. 하는 아쉬움도 남습니다만, 아무래도 현실 생활인이 쪼개서 쓰는 시간으로는 한계가 있을 수 밖에 없겠죠. 아쉬워도 이정도로 만족하면서 마무리 해야 할 것 같습니다.

이제, React full stack 으로 가는 여정에서, 다음으로 익혀야 할 것은, ORM 과 퍼블리싱 정도일 것 같습니다. 백엔드는 쉽게 하려면 한없이 쉽게 해결 할 수 있겠죠. 쉽고 편리한 BaaS 가 많아진 세상입니다. Firebase 와 Vercel 같은 클라우드를 선택한다면, 우리가 배운 FE 기술 스택에 약간의 미들웨어만 추가해줘도 별다른 어려움이 없겠지만, 스타트업이 핸들해야 할 DB 의 규모가 팽창한다던가, Prisma/ Drizzle 등의 ORM 과 함께 직접 서버나 Docker 를 구성해야 한다면, 그 여정에서 더 배워야 할 것들을 만나게 될 것입니다.

SPA 영역에서의 개발이 방향이라면 React 를 계속 진행하시고, 믹스드 웹 의 개발이 방향이라면 NextJS 쪽으로 학습을 이어가시는 것도 좋겠습니다. 방향을 결정하실 때에는 State of React 같은 보고서도 꼭 참고하시고요… 가급적이면 널리 보급되고 성장세인 기술을 먼저 고려해보기를 권하고 싶습니다. FE 프레임웍 영역에서는 생태계의 활력이 곧 생산성으로 이어지고 있으니까요.

마지막으로, 몇가지 백엔드 클라우드 프로바이더 들을 소개하면서 마칠까 합니다.

.

.

.

BaaS : BackEnd as a Service Providers

Vercel

이미 굳이 소개가 필요 없을 정도로 유명해졌죠? NextJS 를 발표하면서 글로벌 기업으로서의 입지가 다져졌습니다. Vercel 에서 깃헙 Repository 를 임포트하면 수십 초 만에 퍼블리싱이 완료되고, 깃헙 브랜치와 연동 되기 때문에, 유지보수도 깃 관리만으로 쉽게 해결됩니다.

무료 사용 캐퍼도 충분해서, 개발 단계와 스타트업 시작 단계에서는 비용 없이도 충분히 사용할 수 있는 서비스입니다. 추천!

  • 서버액션 종량제

  • CDN Publish

  • DB

Neon DB

무료로 시작할 수 있고, 테스트와 스타트업을 준비하기에는 충분한 트래픽과 리소스를 무료로 사용할 수 있습니다. 다양한 DBMS 를 제공하고 있어서, 입맛에 맞는 DBMS 를 선택할 수 있습니다. 관리도 잘 되고 있어서, 단 몇 초 만에 지난 30일, 24시간 또는 심지어 1분 전의 어느 시점으로든 데이터베이스를 복원할 수 있다고 합니다.

  • 종량제

  • DB

  • PreReq. ORM

Hostinger

저렴한 비용의 글로벌 클라우드 서버 호스팅 업체죠. 한국어 버전도 있는 것으로 압니다. BaaS 는 아니지만, 클라우드 서버 임대 방식이고, 월 1만원대 부터 시작할 수 있어요.

  • 정액제

  • OS/ DB 를 선택해서 설치

  • PreReq. Docker

Supabase

최근 인지도 급상승중이죠. 자주 Firebase 와 비교되는데, 둘의 성격이 좀 다릅니다. 충분한 무료사용 용량을 제공하고 있고, 유료 플랜도 부담이 적습니다.

  • 서버용량 종량제 + MAU

  • PostgreSQL DB (0.5GB Free/ 8GB $25)

  • Realtime DB

  • PreReq. SQL (optional)

.

.

.

긴 글, 긴 시간 따라오시느라 고생하셨습니다.

여름의 썸머에 종료의 fin 을 찍으며..







....