Last Updated: 2023-12-19

spa-route-tutorial-cover

vanilla js를 제외하고 vue, react, angular와 같은 프레임워크를 사용할 땐 우리는 vue-route, react-router와 같은 Third Party 라이브러리를 많이 사용한다.
그렇기 때문에 어떤 프로젝트를 하더라도 SPA (Single Page Application)에서 라우팅을 구현할 일이 없다. 그러므로 인해 라우팅을 구현해 보라는 과제를 준다면 해결하기 쉽지 않다.
애플리케이션에서 일부를 차지하는 컴포넌트가 아닌 애플리케이션 전체를 구동, 조작되어야하는 하나의 코어이기 때문이다. 이런 Third Party 시스템을 구현할 때에는 개념과 설계, 구현의 3박자가 정확하게 일치해야 하므로 쉬운 문제는 아닐 것이다.

이번 포스팅에서는 이런 경험을 바탕으로 앞으로 같은 문제를 겪는 개발자에게 도움이 되고자 Vanilla js를 사용하여 SPA (Single Page Application)에서 Routing 시스템을 구현하는 방법을 적어보자 한다.


SPA Routing

SPA는 Single Page Application으로 Front-End 개발자라면 대부분 아는 아키텍쳐이다. 또한 SPA하면 빠질 수 없는 게 라우팅 개념이다.
SPA가 널리 퍼지기 전에는 사용자가 새 페이지를 탐색하기 위해서는 해당 페이지의 문서를 서버에 요청해야 한다.
이로 인해서 웹 사이트가 다시 로드되어 요청된 콘텐츠가 최종적으로 페이지에 렌더링 된다. 이런 과정에서 페이지가 렌더링 되기 전에 사용자는 항상 몇 초 동안 빈 화면을 응시할 수밖에 없었다.

최신 웹 페이지는 이런 빈 화면에 대한 시간을 줄이기 위해 라우팅과 함께 SPA (Single Page Application)를 사용한다. 위에서 언급했듯이 vue, react, angular와 같은 많은 프레임워크가 SPA가 사용자 경험을 가져다주는 이점으로 인해 라우팅을 권장하고 많은 Third Party 용 라우팅이 개발되었다.

이 라우팅으로 인해 사용자는 새 콘텐츠가 필요할 때마다 서버에 요청할 필요가 없다. 초기 애플리케이션을 로드할 때 모든 웹 사이트 콘텐츠를 로드하고 URL 경로 이름에 따라 페이지에 올바른 콘텐츠를 동적으로 표현한다.
애플리케이션은 URL 경로의 이름을 분석하고 이 이름과 관련된 콘텐츠를 분석한다. 콘텐츠는 서버가 아닌 메모리에 저장되기 때문에 애플리케이션 내에서 페이지를 스와핑하기 때문에 사용자는 빈 화면을 볼 수 없는 것이다.

SPA 라우팅을 구현하는 방법에 있어서는 두 가지 방법이 있다.

1. history (Browser History)를 사용한 방법

2. hash (Hash History)를 사용한 방법

두 차이점은 아래와 같다.

  • history (BrowserHistory) - history.pushState API를 활용하여 페이지를 다시 로드하지 않고 URL을 탐색 할 수 있다.
  • hash (HashHistory) - url 해쉬를 사용하여 전체 url을 시뮬레이트하게되며, url이 변경될 때 페이지가 다시 로드되지 않는다. 보통 url에 #이 붙는다.

history를 사용하는 방법 (Browser History Mode)

history를 사용한 방법은 history라는 API를 사용하는 방법이며, 가장 보편적인 방법이다.

history API의 pushstate와 window 객체의 popstate 이벤트를 이용하는데 history.pushState를 통하여 새 데이터 전달을 위한 상태, 제목, url을 지정할 수 있다.

window.history.pushState({ data: 'some data' },'Some history entry title', '/some-path')
window.onpopstate = () => {
appDiv.innerHTML = routes[window.location.pathname]
}

Browser History의 url의 형태는 site/some-path와 같이 표현되지만 이 방법은 서버 측 지원이 일부 필요하다. 예를 들어 http://domain.com/site/another-path와 같이 존재하지 않는 경로로 접속할 경우 오류를 출력한다. 이런 문제를 해결하고 대체할 url은 서버에서 지정해야 한다.

hash를 사용하는 방법 (Hash History Mode)

hash를 사용하는 방법은 # 앵커를 통해 이동하는 방법으로 site/#some-path와 같이 url이 표현된다. 보통 정적 페이지에서 사용되며 블로그의 주 제목을 클릭 후 앵커 이동 시 url에 #이 붙는 모습을 볼 수 있다.

현재 url의 hash는 window.location.hash를 통하여 확인 할 수 있으며, 라우팅 시스템을 구축할 경우 이 window.location.hash를 이용하여 라우팅을 변경할 수 있다. hash가 변경될 때마다 popstate와 같이 hashchange 이벤트가 발생하기 때문에 hashchange를 통하여 라우팅을 변경할 수 있다.

window.addEventListener('hashchange', () => {
appDiv.innderHTML = routes[window.location.hash.replace('#', '')]
})

보통 hash History웹 페이지 내부에서 이동을 위할 것으로 history가 관리되지 않는다. 하지만 서버가 없는 정적 페이지 경우에는 hashHistory만으로도 충분하다.


여기까지 SPA 라우팅에 대해 알아보았다. 이제 실제로 위 두 방법인 Browser HistoryHash HistoryVanilla js를 사용하여 직접 원초적으로 구현해보도록 하자.

여기서 빌드와 로컬 서버는 Webpack을 사용하도록 하겠다.

환경 구축

spa-router-example과 같은 적당한 디렉토리를 생성하고 webpack을 설치하자.

webpack에 대해서는 Webpack 개념잡기Webpack 완전정복하기!! 포스팅을 참고하자.

npm

npm i webpack webpack-cli webpack-dev-server -D

yarn

script
yarn add webpack webpack-cli webpack-dev-server --dev

기본적인 webpack이 설치되었다면 추가로 아래 항목들도 설치하자.

webpack entry를 동적으로 html에 삽입하여 생성하기 위해 HtmlWebpackPlugincss를 위한 MiniCssExtractPlugin, 빌드 결과를 주기적으로 제거하기 위하여 CleanWebpackPlugin 역시 설치하자.

이외에 html Template를 사용하기 위해 HandleBars도 설치하도록 하자.

추가 설치

  • clean-webpack-plugin - 빌드 결과물(dist)을 초기화
  • css-loader - css 사용을 위한 로더
  • handlebars - html template를 사용하기 위한 템플릿 엔진
  • handlebars-loader - webpack에서 handlebars를 사용하기 위한 로더
  • mini-css-extract-plugin - css 결과물을 내보내기 위한 플러그인
  • html-webpack-plugin - entry를 html에 동적 삽입과 html 결과물을 내보내기 위한 플러그인

npm

script
npm i clean-webpack-plugin css-loader handlebars handlebars-loader mini-css-extract-plugin html-webpack-plugin -D

yarn

script
yarn add clean-webpack-plugin css-loader handlebars handlebars-loader mini-css-extract-plugin html-webpack-plugin --dev

모든 설치가 완료되었다면 webpack.config.js 파일을 생성하여 아래와 같이 설정하자.

webpack.config.js

const HtmlWebpackPlugin = require('html-webpack-plugin')
const MiniCssExtractPlugin = require('mini-css-extract-plugin')
const { CleanWebpackPlugin } = require('clean-webpack-plugin')

const { resolve } = require('path')

module.exports = {
entry: {
router: './router.js',
app: './index.js'
},

output: {
path: resolve(__dirname, './dist'),
filename: '[name].js'
},

plugins: [
new HtmlWebpackPlugin({
filename: 'index.html', // output file name
template: 'index.html' // template file name
}),
new MiniCssExtractPlugin({ filename: 'app.css' }),
new CleanWebpackPlugin({
cleanAfterEveryBuildPatterns: ['dist']
})
],

module: {
rules: [
{
test: /\.hbs$/,
loader: 'handlebars-loader'
},
{
test: /\.css$/,
use: [MiniCssExtractPlugin.loader, 'css-loader']
}
]
}
}

다음으로 webpack-dev-server 구동 및 빌드를 할 수 있도록 package.json에 script를 작성하자.

package.json

package.json
{
"scripts": {
"start": "webpack-dev-server",
"build": "webpack --mode=production"
}
}

index.html 및 각 페이지 생성

webpack 설정이 완료되었다면 가장 메인이 되는 index.html을 만들자.

index.html

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>spa-router-example</title>
</head>
<body>

<!--BrowserHistory-->
<div>
<!--Link-->
<div class="link-container">
<div class="link-box">
<span class="history" route="/about">History About</span>
</div>

<div class="link-box">
<span class="history" route="/home">History Home</span>
</div>
</div>

<!--Content-->
<div id="history-app"></div>
</div>

<hr/>

<!--HashHistory-->
<div>
<!--Link-->
<div class="link-container">
<div class="link-box">
<a class="hash" href="#about">Hash About</a>
</div>

<div class="link-box">
<a class="hash" href="#home">Hash Home</a>
</div>
</div>

<!--Content-->
<div id="hash-app"></div>
</div>
</body>
</html>

index.html에는 라우팅 변경을 할 수 있는 link와 라우팅에 따라서 템플릿이 삽입된 history-apphash-app이 있다. 라우팅 변경에 따라 해당 Content영역에 템플릿이 삽입될 것이다.

이제 각 라우팅을 위한 abouthome 페이지를 만들자.

이 파일들은 html이 아닌 Handlebars로 생성할 것이기 때문에 확장자는 .hbs 또는 .handlebars로 생성해야 한다.

.handlebars 확장자로 생성 시 webpack.config.js에서 handlebars-loader 영역의 정규식을 /\.handlebars$/로 수정해야 한다.

보통 ejshandleBars와 같은 템플릿 엔진을 사용하여 라우팅하지만 이게 싫다면 html 파일로 생성 후 html-loader를 사용하여도 무관하다.

pages라는 폴더를 생성하고 하위에 about.hbshome.hbs 파일을 생성하자.

pages/about.hbs

<div class="page">
<h1>About Page</h1>
</div>

pages/home.hbs

<div class="page">
<h1>Home Page</h1>
</div>

이제 보기 좋도록 style을 생성하자.

css 디렉토리를 생성하고 하위에 style.css를 생성하자.

hr {
margin-top: 50px;
}

span {
cursor: pointer;
color: blue;
text-decoration: underline;
}

span:hover {
color: #6a00ff;
}

.link-container {
text-align: center;
}

.link-box {
padding: 20px 0px 20px 0px;
display: inline-block;
width: 170px;
height: 50px;
text-align: center;
}

.page {
width: 50%;
margin: 0 auto;
height: 30vh;
border: 1px solid #4b4b4b;
display: flex;
align-items: center;
justify-content: center;
}

라우팅 생성

이제 본격적으로 라우팅을 생성해보자.

router.js 파일을 생성하고 아래와 같이 입력하자.

// template
const homeTemplate = require('./pages/home.hbs')
const aboutTemplate = require('./pages/about.hbs')

const Home = homeTemplate()
const About = aboutTemplate()

const routes = {
'/': Home,
'/home': Home,
'/about': About
}

// entry point
function initialRoutes (mode, el) {
renderHTML(el, routes['/'])

if (mode === 'history') {
window.onpopstate = () => renderHTML(el, routes[window.location.pathname])
} else {
window.addEventListener('hashchange', () => {
return renderHTML(el, getHashRoute())
})
}
}

// set browser history
function historyRouterPush (pathName, el) {
window.history.pushState({}, pathName, window.location.origin + pathName)
renderHTML(el, routes[pathName])
}

// get hash history route
function getHashRoute () {
let route = '/'

Object.keys(routes).forEach(hashRoute => {
if (window.location.hash.replace('#', '') === hashRoute.replace('/', '')) {
route = routes[hashRoute]
}
})

return route
}

// set hash history
function hashRouterPush (pathName, el) {
renderHTML(el, getHashRoute())
}

// render
function renderHTML (el, route) {
el.innerHTML = route
}

module.exports = {
initialRoutes,
historyRouterPush,
hashRouterPush
}

핵심 부분만 보자.

history 일 경우엔 historyRouterPush를 통해서 history PushState API를 사용하고 있으며 이후 template를 렌더링하고 있으며, onpopstate를 통하여 브라우저 뒤로 가기 또는 앞으로 가기에 따라 히스토리를 관리할 수 있다.
hash인 경우 hashchange 이벤트를 통하여 hash가 변경되는 것을 감지하고 hash에 따라 페이지를 렌더링하고 있다.

index.js (app.js) 생성

이제 Entry Point가 되는 index.js를 생성하자.

// css
require('./css/style.css')

// router
const {
initialRoutes,
historyRouterPush,
hashRouterPush
} = require('./router')

// app division
const historyAppDiv = document.querySelector('#history-app')
const hashAppDiv = document.querySelector('#hash-app')

// Browser History
initialRoutes('history', historyAppDiv)

// Hash History
initialRoutes('hash', hashAppDiv)

window.onload = () => {
const historyLinker = document.querySelectorAll('span.history')
const hashLinker = document.querySelectorAll('a.hash')

historyLinker.forEach(el => {
el.addEventListener('click', (evt) => {
const pathName = evt.target.getAttribute('route')

historyRouterPush(pathName, historyAppDiv)
})
})

hashLinker.forEach(el => {
el.addEventListener('click', (evt) => {
const pathName = evt.target.getAttribute('route')

hashRouterPush(pathName, hashAppDiv)
})
})
}

index.js에서는 각 html 태그에 이벤트를 생성하고 이벤트에 따라 라우팅을 변경하는 코드가 존재한다. 또한, 최초에 initialRoutes를 통해 기본 페이지를 렌더링하는 것을 볼 수 있다.

실행

작성이 모두 끝났으면 개발 모드로 실행을 해보자.

npm

script
npm run start 

yarn

script
yarn start 

빌드

빌드는 아래와 같이 실행할 수 있다.

npm

script
npm run build 

yarn

script
yarn build 

빌드를 실행하면 dist 폴더가 생기고 빌드 결과를 확인할 수 있다. 이 빌드 결과를 실행해 보기 위해서는 http-server와 같은 모듈을 설치하여 실행이 가능하다.

npm

script
npm install -g http-server

yarn

script
yarn add global http-server 

이 후 dist 경로에서 http-server를 입력하면 가상 서버를 실행 할 수 있다.


사실 라우팅이라는 개념은 단순하다.

특정 window 객체를 잘 활용하고 개념만 안다면 누구나 만들 수 있는 시스템이지만 우리는 보통 만들어져있는 모듈을 많이 쓰기 때문에 처음에는 약간 생소할 수도 있다.
생각해보면 Front-End 개발에 있어서 라우팅은 이제 중요한 부분을 차지하고 있는데도 불구하고 그저 자연스럽게 필요하니 가져다 쓰는 모듈에 불과했었다. 개념을 알지만 실제로 구현해보는 것과는 차이가 있다는 것을 다시 한 번 느끼는 기회였다.

라우팅을 만들어 볼 수 있는 기회가 생긴 것에 감사하고 필요한 이에게 도움이 되었으면 한다.

위 예제는 Github spa-router-example에 올려놨으므로 참고가 필요한 경우 사용해도 괜찮다.