React

BrowserRouter와 createBrowserRouter

GoJay 2024. 12. 3. 01:47

React Router는 리액트로 작성된 웹 애플리케이션의 라우팅 구현을 위해 사용되는 라이브러리다. 해당 라이브러리를 사용한 라우팅 구현 방법 중 BrowserRoutercreateBrowserRouter 두 가지에 대해 한번 살펴보자.

MPA와 SPA의 라우팅 컨셉

라우팅에 대해 살펴보기에 앞서, 리액트 라우팅의 특징을 잘 이해하기 위해 MPA(Multi Page Application)와 SPA(Single Page Application)에서 각각 라우팅을 어떤 식으로 처리하는지 컨셉을 살펴보자.

웹 애플리케이션 개발에서 라우팅은 사용자가 특정 경로로 접근했을 때 그 경로에 해당하는 자원을 제공해 주는 것을 의미한다. /면 홈, /dashboard면 대시보드, /dashboard/edit이라고 하면 대시보드 편집 페이지를 화면에 보여준다(경로 이름은 개발자가 설정하기 나름이다).

MPA와 SPA는 라우팅을 처리하는 컨셉에 차이가 있다. 먼저, MPA는 서로 다른 경로로 자원을 요청할 경우 요청에 대한 새로운 index.html 파일(진입점 파일)을 서버로부터 받아와 화면을 새로 그려주는 것이다. 만약 홈 화면의 경로가 /이고, 로그인 화면의 경로가 /login이라면, MPA는 사용자가 홈 화면에 있을 때랑 로그인 화면에 있을 때 서버에서는 각각 다른 index.html 파일을 보내주는 식으로 페이지 이동을 처리한다.

path에 따라 어떤 index.html 파일을 내려보내줄지를 서버에서 결정하는 방식이기 때문에, 라우팅 처리의 책임은 서버에 있다.

반면, SPA는 서버로부터 하나의 고정적인 index.html 파일을 받아오고, 이후에 사용자와의 동적인 인터랙션과, 특정 경로로의 진입 등에 대응하는 것을 전부 다 클라이언트 사이드에서 동적으로 처리하는 방식이다. SPA 방식에서 홈 화면(/)에 있다가 로그인 화면(/login)으로 이동하게 되면 index.html 파일은 고정이고, 자바스크립트로 필요한 요소만 동적으로 생성-제거한 뒤 데이터만 서버로부터 받아오는 식으로 처리한다.

사용자 접근 경로에 따른 화면 처리를 클라이언트에서 동적으로 해주는 셈이기 때문에, 라우팅 처리의 책임은 클라이언트에 있다.

리액트는 대표적인 SPA 구현을 위한 라이브러리다. 때문에, 리액트로 만들어진 웹 애플리케이션에서는 클라이언트 사이드에서 라우팅 처리를 직접 해줘야 하며, 해당 작업에 주로 사용되는 라이브러리가 React Router이다.

BrowserRoutercreateBrowserRouter

React Router는 라우팅을 처리하는 여러 기능을 제공하는 데, 대표적으로 BrowserRoutercreateBrowserRouter가 있다.

BrowserRouter

BrowserRouter는 React Router가 제공하는 가장 기본적인 라우터 구현 방식이다. 브라우저에서 제공하는 History API를 사용해 UI 업데이트 히스토리를 관리한다.

사용 방법은 먼저, BrowserRouter로 라우팅을 적용하고 싶은 리액트 요소를 <BrowserRouter> 컴포넌트로 감싸준다. 보통은 라우팅을 페이지 전체를 대상으로 하기 때문에 리액트를 처음 빌드하면 생성되는 App 컴포넌트를 통째로 감싸는 식으로 많이 사용된다.

import { createRoot } from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';

createRoot(document.getElementById('root')!).render(
    <BrowserRouter>
      <App />
    </BrowserRouter>
);

반드시 App 전체를 감싸야만 하는 건 아니다. 만약에 웹 페이지에서 가장 최상단 헤더에 있는 h1 태그는 어떤 경로로 와도 노출해야 한다고 하면 <BrowserRouter>로 그 하위 요소부터 감싸면 된다.

function App() {
  return (
    <div className="App">
      <h1>FIXED TITLE</h1>
      <BrowserRouter>
        <div>
          // ... some JSX codes
        </div>
      </BrowserRouter>
    </div>
  );
}

이렇게 지정해 주면 <BrowserRouter> 하위의 요소들이 'React Router를 통해 라우팅을 처리해주고 있다'라는 사실을 리액트가 인지하고 그에 맞는 처리를 해주게 된다.

다음으로, 실제 라우팅을 처리할 땐 Route 컴포넌트를 사용한다. Route에는 path에 경로, element에 보여질 요소를 작성하여 'path에 지정한 경로로 접근하면 element에 추가한 요소가 보여진다'라는 것을 표현해 준다

import { BrwoserRouter, Route } from 'react-router-dom'

function App() {
  // '/' 경로로 접근하면 Home 컴포넌트를 보여주기
  return (
    <BrowserRouter>
      <Route path='/' element={<Home />} />
    </BrowserRouter>
  );
}

그런데, Route만 쓰면 브라우저에 따라 알맞은 경로의 페이지를 보여주지 못할 수도 있고, 라우팅 하는 페이지가 2개 이상일 땐 특히 문제가 발생할 수 있다. 그래서, Recat Router v6 이상에서는 RouteRoutes라는 컨테이너로 감싸는 게 표준이 됐다.

import { BrwoserRouter, Route, Routes } from 'react-router-dom'

function App() {
  // '/' 경로로 접근하면 Home 컴포넌트를 보여주기
  return (
    <BrowserRouter>
      <Routes>
        <Route path='/' element={<Home />} />
        <Route path='/login' element={<Login />} />
        <Route path='/signup' element={<Signup />} />
        <Route path='/main' element={<Main />} />
        <Route path='/*' element={<NotFound />} />
      </Routes>
    </BrowserRouter>
  );
}

경로에 *를 지정해 주면 명시적으로 표현해 준 경로를 제외한 모든 경로를 의미한다. 이는 잘못된 접근을 의미하며, <Route path='*' element={<NotFound />} />와 같이 처리해서 NotFound 페이지에 잘못된 접근에 대한 예외 처리 페이지를 보여주는 식으로 처리할 수 있다.

만약에 특정 경로에 위계가 있는 컴포넌트가 라우팅에 따라 다르게 렌더링 돼야 한다면 Route 컴포넌트를 들여 쓰기에 중첩된 구조를 만들어 표현할 수 있다.

import { BrwoserRouter, Route, Routes } from 'react-router-dom';
// ... 컴포넌트 불러오기

function App() {
  // '/' 경로로 접근하면 Home 컴포넌트를 보여주기
  return (
    <BrowserRouter>
      <Routes>
        <Route path='/' element={<Home />}>
          <Route path='login' element={<Login />} />
          <Route path='signup' element={<Signup />} />
          <Route path='main' element={<Main />} />
          <Route path='*' element={<NotFound />} />
        </Route>
      </Routes>
    </BrowserRouter>
  );
}

Route를 중첩 구조로 사용했다면, 바깥 Route에 정의된 컴포넌트에선 URL 경로에 따라 그려질 하위 컴포넌트의 위치를 <Outlet /> 컴포넌트로 표시해줘야 한다.

import { Outlet } from 'react-router-dom';

function Home() {
  // '/' 경로로 접근하면 Home 컴포넌트를 보여주기
  return (
    <div>
      <h1>FIXED TITLE</h1>
      {/* 중첩된 경로에 해당하는 자식 컴포넌트가 여기에 렌더링됨 */}
      <div>
        <Outlet />
      </div>
    </div>
  );
}

BrowserRouter 컴포넌트를 사용한 라우팅 구현의 가장 큰 장점은 사용 방법이 직관적이고 쉽다는 것이다.

하지만, 많은 경로를 효과적으로 처리하거나, 조건부로 라우팅 경로를 잡아줘야 하는 경우에 BrowserRouter로만 처리하기엔 코드가 너무 길고 복잡해질 수 있다. 또한, 라우팅 시 데이터를 패칭해온다거나, API 요청 상태에 따라 비동기로 라우팅 해주거나, 에러가 날 경우 에러에 대한 처리를 해주는 데 있어 유연하게 설정을 해주는 것이 어렵다는 단점이 있다.

이런 장단점을 고려해서, BrowserRouter는 주로 규모가 작고, 라우팅 로직이 복잡하지 않으며, 소수의 페이지만을 처리해 주면 되는 가벼운 프로젝트에서 많이 사용된다.

createBrowserRouter

createBrowserRouter는 React Router v6에서 추가된 비교적 최신 문법이다. BrowserRouterRoute 컴포넌트를 통해 라우팅 경로를 처리해 준 것과 달리, createBrowserRouter는 라우팅 경로와 처리 로직을 객체 형태로 선언적으로 해줄 수 있다. 코드로 살펴보자.

아래는 createBrowserRouter를 사용해 라우팅이 필요한 경로를 객체 형태로 선언한 예시 코드이다.

import { createBrowserRouter } from 'react-router-dom';
// ... 컴포넌트 불러오기

export const router = createBrowserRouter([
  {
    path: '/',
    element: <Home />
  },
  {
    path: '/login',
    element: <Login />
  },
  {
    path: '/signup',
    element: <Signup />
  }
]);

createBrowserRouter의 인자로는 배열이 전달된다. 그리고, 배열의 요소로 객체를 넣어주는데, 객체의 프로퍼티 중 path에는 라우팅 할 경로를, element에는 라우팅 될 컴포넌트를 넣어준다.

만약에 중첩된 구조를 표현해야 한다면 children 프로퍼티를 이용해 nesting 할 수 있다.

import { createBrowserRouter } from 'react-router-dom';
// ... 컴포넌트 불러오기

export const router = createBrowserRouter([
  {
    path: '/',
    element: <Home />,
    children: {
      {
        path: 'login',
        element: <Login />
      },
      {
        path: 'signup',
        element: <Signup />
      }
    }
  }
]);

BrowserRouter와 마찬가지로, 상위 컴포넌트에 중첩된 컴포넌트가 라우팅 시 표현될 위치를 잡아주고 싶다면 <Outlet /> 컴포넌트를 사용해 주면 된다. 만약에 <Outlet /><Home /> 컴포넌트의 특정 위치에 배치하면 /login으로 접근 시 해당 위치에 <Login /> 컴포넌트가 표시된다.

createBrowserRouter는 추가적으로 페이지 라우팅 시 함께 동적으로 처리되어야 하는 추가적인 로직 및 에러 처리를 하기 위한 기타 프로퍼티들도 제공한다.

먼저 loader 프로퍼티를 설정해 주면 라우팅 시 표시돼야 하는 페이지에 필요한 데이터 패치를 바로 처리해 줄 수 있다. 해당 작업을 비동기로 처리할 수 있기 때문에, UserPage 컴포넌트에서 별도로 데이터 요청 작업을 처리할 필요 없이 데이터를 받아와 사용할 수 있다. 참고로, api 요청의 결과는 useLoaderData 훅을 통해 접근할 수 있다.

// UserPage 접근하면서 바로 비동기로 api 요청 처리
{
  path: "users",
  element: <UserPage />,
  loader: async () => {
    const response = await fetch("/api/users");
    return response.json();
  },
}

action 프로퍼티는 폼 제출 등의 동작을 처리하는 로직을 담은 함수를 값으로 갖는다. 폼 제출 시 API에 POST, PUT, DELETE 요청을 보내는 식으로 사용되며, 반환된 데이터는 컴포넌트에서 useActionData를 통해 받을 수 있다.

{
  path: "submit",
  element: <SubmitPage />,
  action: async ({ request }) => {
    const formData = await request.formData();
    return { success: true, data: Object.fromEntries(formData) };
  },
}

라우팅 페이지 렌더링 시 에러가 났을 경우 표시할 컴포넌트를 정의할 땐 errorElement를 사용한다. 사용자에게 조금 더 친절하게 해줄 에러에 대한 안내 및 처리를 라우팅 시 지정해 줄 수 있기 때문에 활용도가 높다.

{
  path: "users",
  element: <UserPage />,
  errorElement: <ErrorPage />,
  loader: async () => {
    throw new Error("Failed to load users");
  },
}

handle은 경로에 해당하는 컴포넌트에서 로직을 처리할 때 활용할 추가적인 데이터를 사용자가 임의로 정해서 보내줄 때 사용된다. 특정 로직에서 사용할 메타 데이터를 저장할 때 유용하다.

{
  path: "dashboard",
  element: <DashboardPage />,
  handle: { requiresAuth: true },
}

이렇게, 특정 값이나 로직을 나타낼 수 있는 다양한 프로퍼티들이 존재한다. BrowserRouter로 해당 프로퍼티의 동작들을 구현하는 걸 생각해 보면, 실제 컴포넌트에 가서 상황에 맞게 API 요청을 보내거나, 에러 발생 시 에러 객체를 throw 하고 별도 분기 처리를 해주는 게 필요할 것이다. createRouter는 이러한 로직 처리를 라우팅 시점에 제공된 프로퍼티에 할 수 있기 때문에 좀 더 처리가 깔끔하다.

이외에도 경로 매칭 시 대소문자를 구분할지 여부를 설정하는 caseSensitive(true면 대소문자를 구분하고, 기본값은 false임), 에러 바운더리를 제공할지를 나타내는 hasErrorBoundary, 부모 경로의 기본 경로를 지정해 주는 index(true면 부모 경로를 나타냄)까지 다양한 프로퍼티들이 제공된다.

이렇게 creatBrowserRouter는 라우팅 시 다양한 로직 처리를 위한 기능이 제공되며, 라우터 정의도 객체 스타일로 조금 더 직관적이게 할 수 있다는 점에서 장점이 많다. 그래서, 규모가 크고 라우팅해야 할 페이지가 많으며, 구조가 복잡한 라우팅을 처리할 때 적절하다.

결론

기술에 정답은 없다. 상황에 맞게 적절한 기술을 사용하면 되는 거고, 그를 위해 다양한 기술에 대해 알아야 한다. 오늘 공부한 만큼, 앞으로 여러 케이스에서 BrowserRoutercreateBrowserRouter 잘 사용해 보면서 경험을 다양하게 쌓아가야겠다. 끝.