React로 웹앱을 만들 때 react-router
라이브러리를 사용하면 쉽게 선언적으로 SPA를 만들 수 있다. 자주 사용하는 라이브러리이지만 내부적으로 어떻게 구현되어 있는지는 몰랐었는데 원티드 프리온보딩 챌린지 과제를 통해 직접 만들어 볼 기회가 생겼다.
실제 라이브러리는 지원하는 기능이 많고 복잡한데, 그 중에서 핵심이 되는 기능만 간단하게 구현해 보려고 한다. 전체 코드는 여기에서 볼 수 있다.
<Router>
<Route path='/' component={<Root />} />
<Route path='/about' component={<About />} />
</Router>
<Router>
와 <Route>
컴포넌트를 만든다.useRouter
훅을 구현한다.<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의 하위 컴포넌트에서 path
와 setPath
에 접근할 수 있도록 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>
는 단순히 path
와 component
를 가지고 있는 데이터 역할을 한다고 볼 수도 있겠다.
그러면 최종 코드는 다음과 같아졌다.
// main.tsx
<Router>
<Routes>
<Route path='/' component={<Root />} />
<Route path='/about' component={<About />} />
</Routes>
</Router>
예전에 쇼핑몰 SPA 구현하면서 Vanilla JavaScript로 SPA를 한 번 만들어 봤었는데 React Router는 또 다른 방식이라 색다르게 다가왔다.
두 방식의 차이를 간단하게 정리하자면 다음과 같다.
이렇게 직접 React Router를 구현해 봤는데 간단한 기능만 하는 Router다 보니 코드는 간결한데 동작원리를 알 수 있어서 재미있었다. 자주 사용하는 라이브러리의 핵심 기능만을 추려서 직접 구현해보는 것은 깊은 이해에 도움을 주고 또 재미를 주는 것 같다.
프론트엔드 생태계는 굉장히 빨리 변화하고 진화한다고 체감한다. 그래서 재미있는 것도 있지만 새로운 기술들이 마구마구 쏟아질 때마다 새롭게 공부해야 한다는 사실에 두려워지기도 한다. 이럴 때 일수록 근간은 우리가 알고 있는 것에 지나지 않는다는 것을 상기시켜야 신기술에 대한 두려움이 사라지는 것 같다. 새로운 기술들이 우리가 알고 있는 것으로부터 만들어졌음을 알기 위해서는 내부 코드를 들어가보고 단순화 해서 직접 만들어보는 것이 중요하다고 느꼈다.