글을 시작하며
이 글은 도서 프레임워크 없는 프론트엔드 개발의 6장. 라우팅을 참고해서 작성되었습니다.
라우팅 동작에 대한 궁금증
프론트엔드 프레임워크가 제공하는 주요 기능 중 하나는 라우팅입니다. 라우팅은 URL과 뷰를 연결하는 방식을 의미합니다. 편하게 사용하던 라우팅 기능이 내부적으로 어떻게 동작하는지 궁금해서, 단일 페이지 애플리케이션에서 라우팅을 구현하는 두 가지 방법을 살펴보았습니다.
이 과정에서 제가 궁금했던 부분은 다음과 같습니다.
URL을 포함한 경로 목록을 어떻게 가져올까요?
경로가 변경되었을 때 어떻게 경로와 연결된 뷰를 렌더링할 수 있을까요?
이 두 가지 질문을 염두에 두고, 라우팅 구현 방식을 살펴보겠습니다.
SPA(Single Page Application) 라우팅 구현 방식
해시(Fragment) 식별자 이용한 방식
http://localhost:3000/#list
와 같이 해시(#) 식별자를 사용해서, 동일 페이지 내에서 섹션을 이동하는 방식을말합니다. 이 방식은 페이지 리로드 없이 섹션과 이동이 가능합니다.
이 구현 방식은 다음과 같이 동작합니다.
라우트를 추가합니다.
라우트에 진입했을 때, 해당 라우트 정보에 정의된 컴포넌트를 렌더링합니다.
아래 예제 코드를 통해 구체적으로 살펴보겠습니다.
DOM 요소에 flagment 지정하는 로직
<a href="#/">Go To Index</a>
<a href="#/list">Go To List</a>
<a href="#/dummy">Go To Dummy</a>
라우트를 매핑하고 해시 정보 변경을 감지하기 위해 이벤트 핸들러 등록하는 로직
router
.addRoute("/", pages.home)
.addRoute("/list", pages.list)
.addRoute("/list/:id", pages.detail)
.setNotFound(pages.notFound)
.listen();
라우터 동작 방식
경로 등록 :
addRoute
메서드 호출하여 URL 패턴과 컴포넌트를 등록합니다. URL 패턴에서:id
와 같은 동적 매개변수는 정규식을 사용해 변환됩니다.경로 매칭 및 매개변수 추출 :
listen
메서드를 통해 URL의 해시값 변경을 감지합니다.checkRoutes
메서드를 통해 현재 경로와 매칭되는 경로를 찾습니다. 경로가 매칭되면extractUrlParams
를 통해 URL에서 동적으로 매개변수를 추출해서 컴포넌트에 전달합니다.컴포넌트 렌더링 : 매칭되는 경로가 있으면 해당 경로의 컴포넌트가 URL 매개변수와 함께 실행되고, 매칭되는 경로가 없으면
setNotFount
가 실행됩니다.
코드의 흐름을 기억하면서 내부 로직을 더 자세히 살펴봅시다.
// :id와 같은 매개변수 이름 추출
const ROUTE_PARAMETER_REGEXP = /:(\w+)/g;
const URL_FRAGMENT_REGEXP = `([^\\/]+)`;
// 라우터 생성 함수
export default () => {
const routes = [];
let notFoundHandler = () => {};
// 각 프래그먼트별로 컴포넌트를 매핑한 정보를 routes 배열에 추가
const addRoute = (fragment, component) => {
const params = [];
const parsedFragment = fragment
.replace(ROUTE_PARAMETER_REGEXP, (_, paramName) => {
params.push(paramName);
return URL_FRAGMENT_REGEXP;
})
.replace(/\//g, `\\/`);
routes.push({
testRegExp: new RegExp(`^${parsedFragment}$`), // 정확한 매칭을 위해 $ 추가
fragment,
component,
params,
});
return router; // 체이닝 지원
};
// 매칭되는 라우트가 없는 경우 실행할 핸들러 설정
const setNotFound = (handler) => {
notFoundHandler = handler;
return router; // 체이닝 지원
};
// 해시 정보가 변경되었을 때 라우트를 체크하는 로직 실행
const listen = () => {
window.addEventListener("hashchange", checkRoutes);
if (!window.location.hash) {
window.location.hash = "#/";
}
checkRoutes(); // 초기 렌더링
return router; // 체이닝 지원
};
// URL 매개변수 추출
const extractUrlParams = (route, windowHash) => {
if (route.params.length === 0) return {};
const params = {};
const matches = windowHash.match(route.testRegExp);
matches.shift(); // 전체 매칭 제거
matches.forEach((paramValue, index) => {
const paramName = route.params[index];
params[paramName] = paramValue;
});
return params;
};
// 현재 프래그먼트와 일치하는 경로 찾기 및 컴포넌트 렌더링
const checkRoutes = () => {
const { hash } = window.location;
const currentRoute = routes.find((route) => route.testRegExp.test(hash));
if (!currentRoute) {
notFoundHandler();
return;
}
const urlParams = extractUrlParams(currentRoute, hash);
currentRoute.component(urlParams);
};
const router = {
addRoute,
setNotFound,
listen,
};
return router;
};
해시 방식의 한계
브라우저의 기본 동작으로 페이지 리로드 없이 섹션 간 이동을 처리할 수 있지만 같은 페이지 내에서만 동작하기 때문에 페이지 전환이 불가합니다.
사용자의 탐색 히스토리 제어가 어렵습니다.
그러면 어떤 대안이 있을까요? 다음에 설명할 History API
는 해시 없이 브라우저 기록을 제어하며 페이지 전환처럼 동작할 수 있습니다.
History API를 이용한 방식
History API
를 활용하면 브라우저의 탐색 히스토리를 스택(stack)으로 관리하기 때문에, URL에 맞는 뷰를 동적으로 업데이트할 수 있습니다. 해시 방식과 다르게 URL 매핑하는 추가 작업을 따로 해주지 않아도 됩니다. 왜냐하면 실제 페이지 전환을 처리하는 것처럼 동작하기 때문입니다.
아래 예제 코드를 통해 구체적으로 살펴보겠습니다.
data-navigation 속성이 사용된 이유
<a>
태그를 클릭하면 기본적으로 URL이 변경되고 브라우저가 새로운 페이지(index.html
)를 로드합니다. 하지만 단일 페이지 애플리케이션에서는 페이지 리로드 없이 내부 탐색을 처리하기 위해, 하이퍼 링크가 아닌 라우팅 동작을 하도록 설정하기 위해서 data-navigation이 추가되었습니다. 이 속성을 사용하면, 클릭 이벤트 발생 시 모든 data-navigation
속성을 가지는 요소에서 DOM 기본 동작(링크 이동)을 비활성화하여 대신 라우팅 동작을 처리할 수 있습니다.
<a data-navigation href="/">Go To Index</a>
<a data-navigation href="/list">Go To List</a>
<a data-navigation href="/list/1">Go To List Detail With Id 1</a>
라우트를 매핑하고 이벤트 핸들러 등록하는 로직
router
.addRoute("/", pages.home)
.addRoute("/list", pages.list)
.addRoute("/list/:id", pages.detail)
.addRoute("/list/:id/:anotherId", pages.anotherDetail)
.setNotFound(pages.notFound)
.listen();
const ROUTE_PARAMETER_REGEXP = /:(\w+)/g;
const URL_FRAGMENT_REGEXP = `([^\\/]+)`;
const INTERVAL_TIME = 250; // URL 변경을 확인할 간격
// 라우터 생성 함수
export default () => {
const routes = [];
let notFoundHandler = () => {};
let lastPathname = "";
const addRoute = (fragment, component) => {
const params = [];
const parsedFragment = fragment
.replace(ROUTE_PARAMETER_REGEXP, (_, paramName) => {
params.push(paramName);
return URL_FRAGMENT_REGEXP;
})
.replace(/\//g, `\\/`);
routes.push({
testRegExp: new RegExp(`^${parsedFragment}$`),
fragment,
component,
params,
});
return router;
};
const setNotFound = (handler) => {
notFoundHandler = handler;
return router;
};
const navigator = (path) => {
// 브라우저 히스토리 스택에 새로운 항목 추가
window.history.pushState(null, null, path);
};
const listen = () => {
checkRoutes();
// URL 변경을 감지할 수 있는 DOM 이벤트가 없어서 setInterval 사용
window.setInterval(checkRoutes, INTERVAL_TIME)
};
const extractUrlParams = (route, pathname) => {
if (route.params.length === 0) return {};
const matches = pathname.match(route.testRegExp);
if (!matches) return {};
matches.shift();
matches.forEach((paramValue, index) => {
const paramName = route.params[index];
params[paramName] = paramValue;
});
return params;
};
const checkRoutes = () => {
const { pathname } = window.location;
if (lastPathname === pathname) return;
lastPathname = pathname;
const currentRoute = routes.find((route) =>
route.testRegExp.test(pathname)
);
if (!currentRoute) {
notFoundHandler();
return;
}
const urlParams = extractUrlParams(currentRoute, pathname);
currentRoute.component(urlParams);
};
const router = {
addRoute,
setNotFound,
navigator,
listen,
};
return router;
};
마무리
프레임워크 없는 프론트엔드 책을 통해 프레임워크를 구성하는 핵심 요소 중 하나인 라우팅에 대해 학습할 수 있었습니다. 프레임워크에서 기능을 풀어내는 방식은 다르겠지만, 해당 기능이 어떤 개념을 기반으로 동작하는지 알게 되어서 좋았습니다. 다음에는 DOM을 어떻게 업데이트하고 변경 사항을 반영하는지, 렌더링에 대해 글을 작성할 예정입니다.