React Router 직접 만들어보기

엘리(Ellie)·2023년 7월 6일
2
post-thumbnail

React로 웹앱을 만들 때 react-router 라이브러리를 사용하면 쉽게 선언적으로 SPA를 만들 수 있다. 자주 사용하는 라이브러리이지만 내부적으로 어떻게 구현되어 있는지는 몰랐었는데 원티드 프리온보딩 챌린지 과제를 통해 직접 만들어 볼 기회가 생겼다.

실제 라이브러리는 지원하는 기능이 많고 복잡한데, 그 중에서 핵심이 되는 기능만 간단하게 구현해 보려고 한다. 전체 코드는 여기에서 볼 수 있다.

구현 목표

<Router>
  <Route path='/' component={<Root />} />
  <Route path='/about' component={<About />} />
</Router>
  1. 위 코드가 동작하도록 <Router><Route> 컴포넌트를 만든다.
  2. 다른 페이지로 이동할 수 있도록 useRouter 훅을 구현한다.
  3. 브라우저 인터페이스(뒤로가기/앞으로가기 버튼)를 이용해 페이지를 이동할 수 있다.

구현 과정

<Route> 구현

먼저 <Route>를 살펴보면 path에 해당하는 component를 가지고 있다. 그렇다면 현재 url이 path와 일치한다면 component를 렌더링하도록 해 보자.

// components/Route.tsx

const Route = ({ path, component }: Props) => {
  if (path === window.location.pathname) {
    return component;
  } else {
    return null;
  }
};

그러면 이제 지정한 path에 따라 지정한 component를 그릴 수 있다.

// main.tsx

<>
  <Route path='/' component={<Root />} />
  <Route path='/about' component={<About />} />
</>

지금의 <Route>는 초기 url에 대해서만 렌더링이 결정되는데, url이 바뀔 때 마다 현재 url에 따라 리렌더링 하게 만들어야 한다.
그러려면 현재 path를 상태로 가지고 있고, 모든 <Route>에서 path를 받아 리렌더링 하면 될 것 같다.

<Router> 구현

path 상태는 자연스럽게 모든 <Route>의 부모인 <Router>가 가지고 있으면 될 것 같았다. 다음과 같이 <Router>를 작성 해 보자.

// components/Router.tsx

const Router = ({ children }: Props) => {
  const [path, setPath] = useState(window.location.pathname);
  
  return children;
}

여기서 한 가지 문제가 생겼다. Router는 자식 컴포넌트를 구체적으로 알지 못하는데 어떻게 path를 props로 전달할 수 있을까? 이럴 때 사용할 수 있는 것이 Context API이다.

Router의 하위 컴포넌트에서 pathsetPath에 접근할 수 있도록 RouterContext를 만들어주자.

// context/RouterContext.ts

type Type = {
  path: string;
  changePath: (path: string) => void;
};

const RouterContext = createContext<Type>({
  path: '',
  changePath: () => {},
});

그리고 Router에서 RouterContext를 적용한다.

// components/Router.tsx

const Router = ({ children }: Props) => {
  const [path, setPath] = useState(window.location.pathname);

  const changePath = useCallback((path: string) => {
    window.history.pushState('', '', path);
    setPath(path);
  }, []);

  return (
    <RouterContext.Provider value={{ path, changePath }}>
      {children}
    </RouterContext.Provider>
  );
};

여기에서 setPath를 통해 path를 변경시킬 때, window의 history를 바꾸는 코드를 넣어야 url과 path의 상태를 동기화 시킬 수 있다.

이제 <Route>path를 받도록 고쳐준다.

// components/Route.tsx

const Route = ({ path, component }: Props) => {
  const { path: currentPath } = useContext(RouterContext);
  
  if (path === currentPath) {
    return component;
  } else {
    return null;
  }
};

이제 현재 path가 바뀜에 따라 Route에 설정한 컴포넌트가 화면에 그려진다.

// main.tsx

<Router>
  <Route path='/' component={<Root />} />
  <Route path='/about' component={<About />} />
</Router>

useRouter 구현

이제 페이지에서 다른 페이지로 이동하는 useRouter 훅을 구현할 차례이다. useRouter 훅은 아래 코드처럼 단순히 push 함수를 통해 페이지를 이동하는 기능을 제공한다.

const Root = () => {
  const { push } = useRouter();

  return (
    <div>
      <h1>root</h1>
      <button onClick={() => push('/about')}>about</button>
    </div>
  );
};

위에서 path 상태에 따라 화면이 바뀌는 기능을 구현했으므로, useRouter 훅에서는 단순히 path 상태를 바꾸는 push 함수를 제공하면 될 것 같다.

// hooks/useRouter.ts

const useRouter = () => {
  const { path, changePath } = useContext(RouterContext);

  const push = (nextPath: string) => {
    // path가 같으면 무시한다.
    if (path === nextPath) return;
    changePath(nextPath);
  };

  return { push };
};

지금의 useRouter만 보면 굳이 훅으로 분리할 필요가 없어 보이지만 역할을 추상화 시킬 수 있다는 점에서 그대로 놔 두었다.

뒤로가기 / 앞으로가기

이제 마지막으로 브라우저 인터페이스인 '뒤로가기', '앞으로가기' 기능을 통해 url이 변경되는 경우를 처리해야 한다.

'뒤로가기' & '앞으로가기' 기능은 브라우저 세션의 history를 탐색하는 기능인데, 세션의 history를 탐색할 때마다 popstate 이벤트가 발생한다.

이 이벤트 핸들링은 라우팅의 최상위 컴포넌트인 <Router>에서 해 주자.

// components/Router.tsx

const Router = ({ children }: Props) => {
  ...

  const changePath = useCallback((path: string) => {
    window.history.pushState({ path }, '', path);
    setPath(path);
  }, []);
  
  useEffect(() => {
    const popStateHandler = (e: PopStateEvent) => {
      setPath(e.state.path);
    };

    window.addEventListener('popstate', popStateHandler);

    return () => window.removeEventListener('popstate', popStateHandler);
  }, []);

  ...
};

우리는 PopStateEvent가 발생할 때 그 시점의 url로 path 상태를 바꿔주려고 한다. 그러기 위해 history.pushState의 첫 번째 인자인 state를 활용하자.
history.pushState - MDN

history.pushState(state, title[, url]);

코드를 보면 changePath에서 history.pushState의 첫 번째 인자로 path 정보를 넘겨주었다. 이렇게 하면 페이지를 이동 할 때 이동하려는 path 정보가 history에 함께 저장되고, history를 탐색할 때 PopStateEvent에 담겨져 넘어온다. 우리는 이 정보를 받아서 path 상태를 변경하면 된다.

이렇게 해서 브라우저 '뒤로가기' / '앞으로가기' 기능까지 처리했다!

리팩터링

기능은 다 구현했지만 한 가지 마음에 걸리는 부분이 있다. 모든 <Route>에서 path를 구독하고 있기 때문에 path가 변경되면 모든 <Route>가 리렌더링 되는데, <Route>가 많아질수록 비효율이 발생할 것 같았다.

이를 해결하기 위해 <Route>를 감싸는 <Routes> 컴포넌트를 만들어주었다. <Routes> 컴포넌트의 역할은 path에 대응하는 <Route>를 찾아 해당 component를 렌더링 하는 것이다. 그렇게 하면 <Route> 마다 path를 보고 직접 렌더링을 할지 말지 결정하는 것이 아니라 <Routes>에서 한 번에 어떤 컴포넌트를 보여줄지 결정하는 방식이 된다.

// components/Routes.tsx

const Routes = ({ children }: Props) => {
  const { path } = useContext(RouterContext);

  let component: ReactNode = <div>Not Found</div>;

  for (const route of children) {
    if (route.props.path === path) {
      component = route.props.component;
      break;
    }
  }

  return component;
};

이렇게 하면 path에 해당하는 route를 찾지 못했을 때 보여줄 Not Found 페이지도 처리할 수 있다.

그럼 이제 필요 없어진 <Route> 내부 코드를 정리하자.

// components/Route.tsx

const Route = (_: Props) => null;

이제 <Route>는 단순히 pathcomponent를 가지고 있는 데이터 역할을 한다고 볼 수도 있겠다.

그러면 최종 코드는 다음과 같아졌다.

// main.tsx

<Router>
  <Routes>
    <Route path='/' component={<Root />} />
    <Route path='/about' component={<About />} />
  </Routes>
</Router>

맺으며

예전에 쇼핑몰 SPA 구현하면서 Vanilla JavaScript로 SPA를 한 번 만들어 봤었는데 React Router는 또 다른 방식이라 색다르게 다가왔다.
두 방식의 차이를 간단하게 정리하자면 다음과 같다.

  • Vanilla JS의 SPA
    : 커스텀 이벤트를 사용해 url 변경 이벤트를 만들고, 라우팅을 처리하는 곳에서 이벤트를 받아서 path에 해당하는 UI를 그리는 방식
  • React의 SPA
    : path를 상태로 관리하고, 그 상태의 변화에 따라 컴포넌트를 다시 그리는 방식

찐막

이렇게 직접 React Router를 구현해 봤는데 간단한 기능만 하는 Router다 보니 코드는 간결한데 동작원리를 알 수 있어서 재미있었다. 자주 사용하는 라이브러리의 핵심 기능만을 추려서 직접 구현해보는 것은 깊은 이해에 도움을 주고 또 재미를 주는 것 같다.

프론트엔드 생태계는 굉장히 빨리 변화하고 진화한다고 체감한다. 그래서 재미있는 것도 있지만 새로운 기술들이 마구마구 쏟아질 때마다 새롭게 공부해야 한다는 사실에 두려워지기도 한다. 이럴 때 일수록 근간은 우리가 알고 있는 것에 지나지 않는다는 것을 상기시켜야 신기술에 대한 두려움이 사라지는 것 같다. 새로운 기술들이 우리가 알고 있는 것으로부터 만들어졌음을 알기 위해서는 내부 코드를 들어가보고 단순화 해서 직접 만들어보는 것이 중요하다고 느꼈다.

profile
신기하고 재미있는 것 만들기를 좋아합니다 :)

0개의 댓글