React Router Dom 간단히 구현해보기

Gyeop
10 min readMar 2, 2022

--

아마 대부분의 웹 개발자들은 URL이 어떻게 동작하는지 알고 있겠지만, 나는 잘 모른다. 그래서 이번에 한 번 간단히 구현하면서 기록해본다.

먼저 구현에 앞서 고려해봐야할 점들은 기존의 MPA방식과는 다르게 작동하는 SPA의 특징을 이해해야한다. SPA는 Single Page Application이므로, URL이 변경될 때 마다, 페이지를 새롭게 받아와서는 안된다. 이를 바탕으로 SPA의 특성을 바탕으로 구현해야할 부분들을 생각해보자.

  1. URL이 변경되어도 새로운 페이지를 요청하지 않아야 한다.
  2. Router들은 현재의 URL의 변경을 감지할 수 있어야 한다.
  3. 현재의 URL을 읽어서 해당 컴포넌트를 렌더링해야한다.

잘게 나누어진 조건만 보면 크게 어렵지 않다. 1번부터 간단히 구현해보자.

1. URL이 변경되어도 새로운 페이지를 요청하지 않아야 한다.

React Router Dom에서는 <a> 태그 대신에 직접 제공해주는 <Link>를 사용해야한다. 그렇지 않으면 페이지 전체를 새로 요청하게 되므로 SPA의 특성이 없어진다. 그렇다면 a태그를 눌렀을 때, URL을 변경시키면서도 페이지 전체를 새로 요청하지 않는, URL만 변경시키는 기능이 필요한데, 이러한 기능이 바로 History.pushStateHistory.replaceState이다. 아래의 예제코드를 보자.

// 현재의 페이지가 <https://naver.com> 라면,
const state = {};
const url = 'hello-world';
history.pushState(state, '', url); // URL이 <https://www.naver.com/hello-world> 로 변경된다.
history.replaceState(state, null, 'bye-world'); // URL이 <https://www.naver.com/bye-world> 로 변경된다.

history.pushState는 기존에 페이지를 이동하듯이 이동 전의 URL을 history를 기록하고, 이후에 현재의 URL을 새로운 URL로 변경시킨다. 히스토리에 이전 URL이 기록되므로 뒤로가기를 자유롭게 사용할 수 있다. 실제로 URL이 “https://www.naver.com/hello-world”로 변경되지만 URL만 변경될 뿐이며, 페이지는 이동하지 않는다. URL은 브라우저상에서 이동된 것으로 처리된다. 다만 해당 페이지는 존재하지 않는 URL이므로, 새로고침을 하거나 뒤로간 후 다시 앞으로가게되면 없는 URL을 서버에 요청하는 것이므로 404가 뜰 것이다.

history.replaceState는 현재의 URL만 새로운 URL로 바꾸어준다. 마찬가지로 새로운 페이지를 요청하진 않지만, 이는 history에 기존 URL을 기록하지 않으므로 뒤로가기를 이용할 수 없다. 그 외에는 위와 같이 브라우저 상에서 URL이 이동된 것으로 인식되므로, 새로고침을 하면 없는 페이지를 호출하게 된다.

이 두 가지 방식을 이용해서 우리는 React-Router-Dom에서 제공하는 Link 태그를 만들어보자.

import React from "react";interface LinkProps extends Omit<React.AnchorHTMLAttributes<HTMLAnchorElement>, "href"> {
to: string;
}
function Link({ children, onClick, to, ...props }: LinkProps) {
const handleClick = (e: React.MouseEvent<HTMLAnchorElement, MouseEvent>) => {
e.preventDefault(); // 페이지가 이동하지 않도록 방지해주고,
window.history.pushState({}, "", to); // URL을 변경시킨다.
};
return (
<a {...props} onClick={handleClick} href={to}>
{children}
</a>
);
}
export default Link;

위와 같은 방식으로 간단히 페이지의 이동을 막지만, URL은 변경시키는 Link 태그를 만들어줄 수 있다. 실제로 사용해보면 눌러도 URL이 변경되지만, 새로운 페이지를 요청하진 않는다.

2. Router들은 현재의 URL의 변경을 감지할 수 있어야 한다.

Link태그는 완성했지만, 우리는 URL에 맞는 렌더링을 하기 위해 URL을 읽고 페이지를 렌더링할 수 있어야 한다. “URL이 변경되면 렌더링이 되어야한다.”에 초점을 두어보자. 그렇다면 유저가 Link태그를 누르는 당시에 어떠한 state를 업데이트를 해주어야 렌더링이 될 것이다. 조금 미래를 생각해보면, 나중에 URL과 일치하는 컴포넌트를 렌더링해야하는 로직이 필요해질텐데, 그렇다면 URL을 state에 저장해놓아야 할 것이고, 이를 가지고 비교해주는 로직을 추가시키면 될 것 같다. 그러기위해 pathname을 저장해주는 context를 만들어보자.

// Router.tsx
import React, { createContext, useState } from "react";
interface RouterContextType {
setCurrentPathname: React.Dispatch<React.SetStateAction<string>>;
}
export const RouterContext = createContext<RouterContextType>({} as RouterContextType);
interface RouterProps {
children: React.ReactChild;
}
const Router = ({ children }: RouterProps) => {
const [currentPathname, setCurrentPathname] = useState<string>(window.location.pathname);
return <RouterContext.Provider value={{ setCurrentPathname }}>{children}</RouterContext.Provider>;
};
export default Router;

먼저 앱 내부에서 URL이 변경됨을 모두 감지해야하므로, 앱 전체를 감싸줄 수 있는 컨텍스트를 만들어준다. 내부적으로 state에 pathname을 저장하는 간단한 ContextAPI다.

참고로 Link 태그를 눌렀을 때 컨텍스트에 저장할 정보를 업데이트 해야하므로, Link 태그도 다음과 같이 수정해준다.

// Link.tsx
import React, { useContext } from "react";
import { RouterContext } from "../../utils/Router";
interface LinkProps extends Omit<React.AnchorHTMLAttributes<HTMLAnchorElement>, "href"> {
to: string;
}
function Link({ children, onClick, to, ...props }: LinkProps) {
const { setCurrentPathname } = useContext(RouterContext); // 1. useContext로 pathname을 저장하는 함수를 불러온 후
const handleClick = (e: React.MouseEvent<HTMLAnchorElement, MouseEvent>) => {
e.preventDefault();
window.history.pushState({}, "", to);
setCurrentPathname(to); // 2. state가 변경됨을 저장한다.
};
return (
<a {...props} onClick={handleClick} href={to}>
{children}
</a>
);
}
export default Link;

3. 현재의 URL을 읽어서 해당 컴포넌트를 렌더링해야한다.

이제 전역 컨텍스트에서 현재의 pathname을 알 수 있으니, 특정 URL에 매핑되는 경우에만 컴포넌트를 보여줄 수 있는 기능을 하는 Switch 컴포넌트와 Switch 내부에서 경로를 확인시켜줄 수 있는 Route 컴포넌트를 만들어야한다. Switch 컴포넌트는 내부적으로 Route 컴포넌트를 여러 개 받는데, 순서대로 Route의 path props와 context의 currentPathname이 일치하면 해당 컴포넌트를 렌더링하면 된다.

// Switch Component
interface SwitchProps {
children: ReactElement[]; // 참고로 하나인 경우도 처리해주어야하지만, 여기선 생략한다.
}
export const Switch = ({ children }: SwitchProps) => {
const { currentPathname } = useContext(RouterContext);
let renderComponent;
for (const child of children) {
if (child.props.path === currentPathname) {
renderComponent = child;
break;
}
}
if (renderComponent) return renderComponent;
else return null;
};
// Route Component
interface RouteProps {
children: ReactElement;
path: string;
}
export const Route = ({ children }: RouteProps) => {
return children;
};

아직 dynamic Route와 관련된 부분은 구현하지 않아서 완벽히 작동하진 않겠지만, 간단한 라우터라면 충분히 작동할 것이다. 한번 테스트해보자.

import Link from "../Link";
import Router, { Route, Switch } from "../../utils/Router";
function App() {
return (
<Router>
<>
<div>
<ul>
<li>
<Link to="/123">Move to 123</Link>
</li>
<li>
<Link to="/qwer">Move to qwer</Link>
</li>
</ul>
</div>
<Switch>
<Route path="/123">
<div>여기는 /123 입니다!</div>
</Route>
<Route path="/qwer">
<div>여기는 /qwer 이에요!</div>
</Route>
</Switch>
</>
</Router>
);
}
export default App;
굉장히 잘 작동한다.

여기까지는 구현하고보니 크게 어렵진 않았는데, 아마 굉장히 세세한 부분이나 내부적으로 검증하는 로직(isValidElement 등)이나, 예외처리, 최적화 등을 아예 배제시킨 상태로 구현했기 때문에 간단하게 구현할 수 있었다고 생각한다. 사실 라이브러리를 쓰는 데에는 유용한 hooks를 제공하거나, 동적라우팅, 쿼리파라미터 처리 등 복잡한 기능을 잘 추상화해서 제공해주는데에 이점이 굉장히 크다. 그래도 내부적으로 어떻게 작동하는지는 이해하고 쓰고싶어서 기록해봤다.

전체코드는 여기서 보실 수 있습니다.

--

--