아마 대부분의 웹 개발자들은 URL이 어떻게 동작하는지 알고 있겠지만, 나는 잘 모른다. 그래서 이번에 한 번 간단히 구현하면서 기록해본다.
먼저 구현에 앞서 고려해봐야할 점들은 기존의 MPA방식과는 다르게 작동하는 SPA의 특징을 이해해야한다. SPA는 Single Page Application이므로, URL이 변경될 때 마다, 페이지를 새롭게 받아와서는 안된다. 이를 바탕으로 SPA의 특성을 바탕으로 구현해야할 부분들을 생각해보자.
- URL이 변경되어도 새로운 페이지를 요청하지 않아야 한다.
- Router들은 현재의 URL의 변경을 감지할 수 있어야 한다.
- 현재의 URL을 읽어서 해당 컴포넌트를 렌더링해야한다.
잘게 나누어진 조건만 보면 크게 어렵지 않다. 1번부터 간단히 구현해보자.
1. URL이 변경되어도 새로운 페이지를 요청하지 않아야 한다.
React Router Dom에서는 <a>
태그 대신에 직접 제공해주는 <Link>
를 사용해야한다. 그렇지 않으면 페이지 전체를 새로 요청하게 되므로 SPA의 특성이 없어진다. 그렇다면 a
태그를 눌렀을 때, URL을 변경시키면서도 페이지 전체를 새로 요청하지 않는, URL만 변경시키는 기능이 필요한데, 이러한 기능이 바로 History.pushState
와 History.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를 제공하거나, 동적라우팅, 쿼리파라미터 처리 등 복잡한 기능을 잘 추상화해서 제공해주는데에 이점이 굉장히 크다. 그래도 내부적으로 어떻게 작동하는지는 이해하고 쓰고싶어서 기록해봤다.
전체코드는 여기서 보실 수 있습니다.