공통 문제 관점의 라이브러리 별 라우팅 구현 톺아보기
예전에 멘토님께서 이런 이야기를 해주신 적이 있다. "각 라이브러리가 어떻게 라우팅을 구현하고 있는지를 외우고 있는 건 하나도 중요하지 않다. 정말 중요한 건 이들 라이브러리가 해결하고자 하는 어떤 공통된 문제가 존재한다는 것이며, 따라서 라우팅을 지원하는 라이브러리가 해결하고자 하는 문제가 무엇인지를 알아야 한다. 이걸 알고 나면 각각의 라이브러리가 이 문제를 어떻게 해결하는 지는 그냥 공식 문서 찾아보면 된다." 이 말은 비단 라우팅 뿐 아니라 '라이브러리' 전체에 동일하게 적용할 수 있는 격언이라 생각한다.
NextJS는 페이지 라우터와 앱 라우터를 지원하고, React 수준에서는 오래전부터 React-Router를 사용해 CSR 라우팅을 처리해왔다. 이들은 모두 라우팅 문제 해결이라는 공통된 목적을 가지고 있으며, 이에 따라 여러 비슷한 점을 가지고 있다(아마 아이폰과 갤럭시처럼 상호 차용해온 게 아닐까 싶다). 이 포스트에서는 해결해야 할 어떤 공통된 문제라는 관점으로 React-Router v6.4와 NextJS가 어떻게 라우팅을 구현하고 지원하는지 알아보고자 한다.
Router 구현
NextJS 라우터는 기본적으로 디렉토리 구조를 따른다. page∙app 폴더 내에 예약된 키워드(각각 index와 page)로 이름지어진 파일을 따라 서버가 자동으로 파일을 서빙한다. 반면에 React-Router는 리액트 뿐 아니라 리액트 네이티브 등의 다른 라이브러리도 지원하기 때문에 그에 맞춰 Router를 호출해야 한다.
리액트에서는 createBrowserRouter를 호출하여 라우터를 구현하게 되는데, 이 함수는 첫 번째 인자로 RouteObject로 이루어진 배열을 받으며, 두 번째 인자로 옵션 객체를 받는다. 이전 버전까지는 Route라는 컴포넌트를 사용했지만, 최신 버전에서는 RouteObject로 변경되었다. 컴포넌트가 객체로 변경되었을 뿐 처리하는 것들과 처리되는 방식은 대동소이하다.
const router = createBrowserRouter([ { path: "/", element: ( <div> <h1>Hello World</h1> <Link to="about">About Us</Link> </div> ), }, { path: "about", element: <div>About</div>, }, ]); createRoot(document.getElementById("root")).render( <RouterProvider router={router} /> );
가령 다이나믹 라우팅을 구현하는 방식을 보면 이전 버전(path="teams/:teamId")과 최신 버전(path: "teams/:teamId")이 동일하다. 콜론을 붙여서 처리하며, 이 부분에 들어오는 값은 useParams를 통해 조회할 수 있다. 앱 라우터도 이와 동일하게 path param을 조회할 수 있는 useParams를 제공하며, 페이지 라우터에서는 useRouter가 리턴하는 쿼리 객체를 통해 조회할 수 있다.
데이터 사전 조회
페이지 라우터는 getServerSideProps나 getStaticProps 등을 사용해 페이지에 필요한 데이터를 특정 시점에 미리 조회하여 클라이언트로 넘겨준다. 앱 라우터에서는 서버 컴포넌트의 등장으로 인해 최상위 서버 컴포넌트 수준에서 데이터를 사전에 조회하여 넘겨줄 수 있다. React-Router에서는 RouteObject의 loader 프로퍼티를 사용한다.
const router = createBrowserRouter([ { path: "/products/:productId", element: <ProductPage />, loader: async ({ params }) => { const response = await fetch(`/api/products/${params.productId}`); if (!response.ok) { throw new Response("Product not found", { status: 404 }); } return response.json(); } }, ]);
페이지 라우터와 앱 라우터는 사전 조회한 데이터를 props의 형태로 넘겨주지만, React-Router에서는 사전 조회한 데이터를 받아오기 위해 useLoaderData 훅을 사용하게 된다.
ErrorBoundary
라우팅을 처리하는 중 loader에서 에러가 터지면, React-Router는 errorElement나 ErrorBoundary 프로퍼티를 통해 에러 바운더리를 처리한다. 이 두 프로퍼티는 값으로 각각 ReactNode와 ComponentType을 받는다는 차이 밖에 없지만, 체감상으로는 React-Router가 errorElement를 좀 더 밀어주는 것 같다는 느낌이다.
{ errorElement={<ErrorBoundary />} ErrorBoundary={ErrorBoundary} }
앱 라우터에서는 예약된 페이지 error.tsx를 사용해 데이터 사전 조회 뿐 아니라 모든 종류의 요청에 대해 에러 바운더리를 처리하는 것으로 알고 있다. 페이지 라우터에는 해당 기능이 존재하지 않아(정확히는 함수 컴포넌트가 아니라 클래스 컴포넌트 대상으로만 존재) react-error-boundary 라이브러리를 사용해 에러 바운더리를 처리하게 된다. 관련 내용은 [ useAsync 훅의 종말 : Suspense & ErrorBoundary ] 에서 조금 더 자세히 살펴볼 수 있다.
redirect
React-Router는 redirect 함수를 호출하여 리다이렉션을 처리한다. 반면에 앱 라우터는 서버 컴포넌트와 서버 액션에서는 redirect 함수를 호출하고, 클라이언트 컴포넌트에서는 useRouter의 push 메서드를 사용한다. 비슷하게 페이지 라우터도 컴포넌트 내에서는 useRouter의 push 메서드를 사용하며, getServerSideProps 등에서는 리턴하는 객체에 redirect 프로퍼티를 추가하는 방식을 사용한다.
export async function getServerSideProps(context) { try { // 데이터 fetch const res = await fetch('https://api.example.com/data'); if (!res.ok) { throw new Error('Network response was not ok'); } const data = await res.json(); return { props: { data }, // 페이지에 전달할 데이터 }; } catch (error) { console.error('Error fetching data:', error); // 오류가 발생했을 경우 리다이렉트 return { redirect: { destination: '/error', // 리다이렉트할 경로 permanent: false, // false: 임시 리다이렉트 }, }; } }
Not-Found
존재하지 않는 경로에 접근하려 하면 페이지 라우터는 404.tsx를, 앱 라우터는 not-found.tsx를 사용해 특정 화면을 뿌려준다. React-Router는 애스터리스크('*')를 사용해 지정한 경로 외의 모든 경로에 대해 not found 처리를 하게 된다.
const router = createBrowserRouter([ { path: "*", element: <NotFound />, ]);
레이아웃 중첩
앱 라우터는 예약된 페이지 layout - template - page를 조합하여 디렉토리 구조를 따라 레이아웃을 중첩해 나가며, 특히 동일한 layout.tsx 를 사용한다면 새로 랜더링하지 않은 채 내부 컴포넌트만 변경할 수 있어 굉장히 자원효율적이다. 아쉽게도 페이지 라우터에는 이러한 기능이 존재하지 않으며, 비슷하게 구현하는 방법 ─ 혹은 꼼수 ─ 이 없는 것은 아니지만 앱 라우터의 그것 만큼 효율적이지는 못하다. NextJS를 사용하는 많은 개발자들이 앱 라우터를 선호하는 이유 중에는 이러한 레이아웃 중첩 기능도 있지 않을까 싶다.
React-Router는 레이아웃 중첩을 outlet 컴포넌트와 index 프로퍼티를 사용해 처리한다. Outlet 컴포넌트는 라우트 상의 자식 노드가 가진 element 컴포넌트를 상위 노드의 컴포넌트에서 랜더링할 수 있도록 해준다. index 프로퍼티는 라우트 경로를 새로 만들지 않으면서 공통된 레이아웃을 사용할 수 있도록 해준다.
import { Outlet } from "react-router-dom"; function Layout() { return ( <div> <header> <h1>공통 헤더</h1> {/* 여기에 네비게이션 바나 공통 UI를 추가할 수 있음 */} </header> <main> {/* Outlet은 자식 라우트의 컴포넌트를 렌더링하는 곳 */} <Outlet /> </main> <footer> <p>공통 푸터</p> </footer> </div> ); } export default Layout;
{ path: "/", // 공통 레이아웃을 사용하는 부모 경로 element: <Layout />, // 공통 레이아웃 children: [ { index: true, // 기본 경로 element: <HomePage />, }, { path: "page", // /page 경로 element: <Page />, }, { path: "dashboard", // /dashboard 경로 element: <Dashboard />, }, ], }
하지만 앱 라우터가 공통 레이아웃을 처리하는 방식이 훨씬 더 깔끔하긴 하다.
메타데이터 처리
페이지 라우터는 Head 컴포넌트를 사용하지만, 앱 라우터는 각종 메타데이터 관련 함수와 기능을 도입해 이전보다 훨씬 진보한 방식으로 다이나믹 메타데이터를 처리하고 있다. React-Router에서는 관련 기능을 직접적으로 지원하지는 않고, 대신 React-Helmet이라는 라이브러리의 도움을 받아서 처리한다.
이 외에도 각각의 라이브러리에는 라우팅을 지원하기 위한 서로 다른 여러 기능들이 있지만, 라우팅이라는 핵심적인 문제를 해결하기 위해 우리가 필요로 하는 기능은 이 정도면 충분히 다루지 않았나 생각한다.
블로그의 정보
Ayden's journal
Beard Weard Ayden