라우팅은 간단하게 표현하는 페이지를 이동하는 규칙이다. 특정 자원의 위치로 네트워크 요청을 보내고 그 위치에 해당하는 자원을 받는 행위가 라우팅이다. 네트워크에서 라우팅은 특정 위치로 통신하기 위한 경로를 선택하는 프로세스이지만 이 글에서는 앱 내부의 라우팅에 대해 이야기하겠다.
root.com
root.com/products
root.com/products/1
MPA에서는 루트 도메인에 속하는 하위 도메인이 달라질 때마다 서버에서 다른 HTML을 브라우저에 전달하는 것으로 페이지 라우팅이 이루어진다.
하지만 SPA에서는 하나의 HTML에서 라우팅이 이루어져야한다. SPA는 단일 페이지에서 작동하는 애플리케이션이기 때문이다. SPA는 현재 요청을 하는 경로에 따라 렌더링 중인 HTML 내부의 구성이 변해야한다.
그렇다면 SPA에서 할 일은 간단하다. 현재 URL을 식별하고 그에 매칭되는 컴포넌트를 브라우저가 렌더링하도록 HTML을 조작하면 되는 것이다.
// pages.js
export function renderHome() {
const main = document.querySelector('#main');
const header = new Header();
const homeContents = new HomeContents();
main.innerHTML = ``;
main.appendChild(header.DOMElement);
main.appendChild(homeContents.DOMElement);
}
export function renderProducts() {
const main = document.querySelector('#main');
const header = new Header();
const products = new Products();
main.innerHTML = ``;
main.appendChild(header.DOMElement);
main.appendChild(products.DOMElement);
}
위와 같이 페이지를 렌더링하는 로직을 구현했다고 가정해보자. main이라는 id를 가진 DOM element의 내부에서 라우팅이 일어난다. 페이지 전환이 발생할 때는 기존에 main이 렌더링하고 있었던 innerHTML을 초기화하고 새로운 컴포넌트들을 등록한다.
// router.js
const routes = {
'/': renderHome,
'/products': renderProducts,
};
function render() {
const { pathname } = window.location;
const renderPage = routes[pathname];
container.DOMElement.innerHTML = '';
if (!renderPage) {
render404();
return;
}
renderPage();
}
현재 path를 window의 location 객체에서 추출하고 routes 객체에 따라 페이지 렌더링 함수를 호출하는 render 함수이다. render 함수는 매칭되는 path가 없다면 404 페이지를 렌더링하고 그렇지 않다면 매칭되는 페이지 렌더링 함수를 호출한다.
// index.js
function setRouter() {
window.addEventListener('popstate', async () => {
render();
});
render();
}
setRouter();
앱이 실행될 때 엔트리 포인트가 되는 index 파일은 다음과 같은 작업을 수행한다. 뒤로가기 또는 앞으로가기 이벤트가 발생했을 때를 처리해주기 위해 각 이벤트에 이벤트 핸들러를 부착하고 현재 path에 따라 페이지를 렌더링하기 위해 router 파일에서 정의한 render 함수를 호출한다.
위의 예시는 아주 간단한 라우팅 방식이고 라우터 객체도 없다. 실제로는 custom-link나 URL을 가공하는 경우도 있기 때문에 모든 상황을 고려한다면 라우터 객체는 아주 무거운 기능들을 수행한다. 물론 핵심적인 기능은 현재 path에 따라 올바른 컴포넌트를 페이지에 렌더링하는 것이다.
리액트 생태계에서는 리액트 라우터라는 독보적인 라이브러리가 존재한다. 이 단락에서는 위의 vanilla SPA 라우팅, 그리고 리액트 라우터의 동작을 참고하여 간단하게 리액트 라우터를 모방해보겠다.
function App() {
return
<Router>
<Routes>
<Route path="/admin" element={<Admin />} />
<Route path="/products" element={<Products />} />
<Route path="/" element={<Home />} />
</Routes>
</Router>
}
동작은 바닐라에서와 유사하다. Route 컴포넌트에 있는 path에 따라 element props로 받는 컴포넌트를 렌더링하면 되는 것이다.
function ContextProvider({ children }) {
const [path, setPath] = useState(window.location.pathname);
const changePath = (path) => {
setPath(path);
window.history.pushState(path, '', path);
};
const contextValue = {
path,
changePath,
};
useEffect(() => {
const handlePopState = (event: PopStateEvent) => {
setPath(event.state || '/');
};
window.addEventListener('popstate', handlePopState);
return () => {
window.removeEventListener('popstate', handlePopState);
};
}, []);
return (
<routerContext.Provider value={contextValue}>
{children}
</routerContext.Provider>
);
}
export default ContextProvider;
function Router({ children }: RouterProps) {
return <ContextProvider>{children}</ContextProvider>;
}
라우터에서 사용할 context API이다. 동작에 따라 path state가 변하고 해당 변화에 따라 리렌더링이 발생한다.
function Routes({ children }) {
const { path } = useContext(routerContext);
let currentRoute;
Children.forEach(children, (element) => {
const { path, element } = element.props;
if (isCurrentRoute(path)) {
currentRoute = component;
}
});
if (!currentRoute) {
return null;
// 또는 404페이지 렌더링
}
return currentRoute;
}
메인이 될 동작을 수행하는 Routes 컴포넌트이다. Routes 컴포넌트는 Route 컴포넌트를 children으로 가지고, 해당 Route 컴포넌트들은 path와 element props를 가지고 있다.
context로 받은 path와 순회 중인 Route 컴포넌트의 path props를 보고 어떤 컴포넌트가 렌더링될지 찾아내는 컴포넌트이다.
동작 자체는 간단하지만 path variable이나 query string의 처리 또는 유효성을 검증하는 로직들이 추가된다면 컴포넌트의 동작은 복잡해진다.
다음 포스팅에서는 실제 라우터 객체를 구현해 어떻게 동작되는지 면밀하게 파악해 볼 예정이다.
좋은 글 감사합니다!