VanillaJS를 이용한 SPA에서 라우팅을 구현하는 방법 (feat. history API)

부제: 프로그래머스 프론트엔드 데브매칭 과제 후기

Bok Jiho
11 min readAug 29, 2022

바로 그저께인 8월 27일, 프로그래머스에서 진행한 2022 하반기 프론트엔드 데브매칭에 참여했다. 일단은. 내가 공부할 부분이 아직 너무도 많다는 걸 느꼈다. 첫 요구사항부터 쩔쩔매다가 시간을 다 보냈고, 마지막 한 시간 정도는 시험 전부터 물이랑 아메리카노를 너무 많이 마신 탓에 화장실에 가고 싶은 데 참느라 죽을 뻔 했다. 🫠

그동안 리액트로 프로젝트를 만드는 게 익숙해진 탓에 라우팅은 react-router-dom 라이브러리를 이용하면 깊게 생각하지 않아도 쉽게 구현할 수 있었다. 이번에 데브매칭 과제를 하면서 Vanilla Javascript를 이용한 SPA에서 라우팅을 구현하는 방식에 대해서 생각을 해보게 됐는데, 이 과정에서 다음 질문이 생겼다.

SPA에서 라우팅할 때 index.html 페이지 하나만 두고 FeedPage.js, HomePage.js 이런 식의 페이지 뷰어 컴포넌트를 번갈아서 index.html에 렌더링을 하면서, 실제 페이지를 reload 하지는 않지만 주소창의 URL을 변경해주는 것인지?

일단, 기존에 내가 알고 있던 SPA의 라우팅 개념을 떠올리며 시험 과정에서 내가 구현한 라우팅 구조는 대략 이렇다.

  1. index.html 에서 모듈 스크립트 태그로 index.js 파일을 삽입한다.
  2. index.js 에서 HTML의 루트 div 요소에 부여된 .app 클래스를 통해 해당 요소를 DOM 으로 불러온 다음, 그 안에 App 컴포넌트를 렌더링 한다.
  3. App 컴포넌트 내부에서 모든 페이지에 동일하게 포함되는 GNB 같은 공통 컴포넌트와, 현재 페이지(= 뷰 컴포넌트)를 렌더링해준다.
  4. 이 때, 라우팅 될 페이지 정보는 App 컴포넌트에 상태 값으로 저장한다.

→ 이 방식으로 구현을 하게 되면 주소창의 URL은 바뀌지 않는 상태로 다른 페이지들을 하나의html 파일(index.html)에 나타낼 수 있고, 하나의 index 파일에서 동적으로 뷰 컴포넌트가 렌더링 된다는 점에서 SPA의 특징을 만족하는 애플리케이션이라고 볼 수 있다. 확신이 없던 부분은 4번이었는데, 리액트에서 새로운 페이지로 이동할 때 분명히 주소창에 있는 URL이 변경되었던 걸로 알고 있다. 루트 DOM 을 다루는 App 컴포넌트에서 현재 route 정보를 상태 값으로 관리하면 주소창의 URL을 바꿀 필요가 없지 않나?

그렇다면 리액트 같은 SPA 에서는 주소창의 URL이 어떤 과정을 통해서 바뀌는 건가?

리액트에서 라우터가 동작하는 원리와 SPA가 무엇인지에 대해 더 확실하게 이해하기 위해 정리하게 된 내용이다.

SPA 작동 원리 — 간단 ver

첫 로딩할 때 서버로부터 entry point 역할을 하는 HTML 파일(주로 index.html)과 각종 리소스 파일들을 다운받고, 이후에 URL 의 pathname에 따라 동적으로 각 페이지를 화면에 렌더링해준다. 이 때, 사용자가 보기엔 URL과 페이지가 바뀌는 것으로 보이지만, 실제로 index.html 안에서 이 모든 라우팅이 일어난다.

이미지 출처: https://medium.com/@bryanmanuele/how-i-implemented-my-own-spa-routing-system-in-vanilla-js-49942e3c4573

SPA 라우팅을 구현하는 2가지 방법

  1. history API 를 이용한 방법
  2. hash 를 이용한 방법

history API를 이용한 라우팅

  • history API의 pushstate 로 새 데이터 전달을 위한 상태, 제목, url을 지정할 수 있음
  • window 객체의 popstate 이벤트로 브라우저의 뒤로가기 기능 구현
window.history.pushState({ data: 'some data' }, 'Some history entry title', '/some-path');window.onpopstate = () => {
appDiv.innerHTML = routes[window.location.pathname]
}

pushState 메소드로 변경된 path의 정보를 history에 저장한다. 이후, window의 popstate 이벤트가 실행될 때 루트 DOM 요소(appDiv)의 innerHTML이 바뀌는 코드를 넣어주면 라우팅 기능을 구현할 수 있다.

(1)

window.history : history 객체의 참조를 반환한다. 현재 브라우저의 세션 히스토리, 즉 현재 탭/프레임에서 방문한 페이지 내역을 조작할 수 있는 인터페이스를 제공해준다. (출처: MDN)

(2)

pushState(state object, title, URL):

  • state object: pushState 를 통해 새로 생성되는 history 엔트리와 관련된 객체
  • title: ''를 담으면 된다; 현재 사파리를 제외한 모든 브라우저에서 무시되는 파라미터
  • URL (optional): history에 담길 새로운 URL이자, 브라우저 주소창에 입력되는 path. 현재 웹 페이지와 같은 origin을 가진 절대경로를 입력하거나, 상대경로를 입력해야 한다.
// pushState 이벤트 사용 예시let stateObj = {
foo: "bar",
}
history.pushState(stateObj, "", "bar.html");

https://mozilla.org/foo.html 에서 위의 pushState 메소드를 호출하면, 주소창의 URL이 https://mozilla.org/bar.html로 바뀌지만, 브라우저가 bar.html을 로드하거나, bar.html 파일의 존재 여부를 확인하지는 않는다.

(3)

popstate 이벤트: 브라우저에서 history 엔트리에 변경사항이 생길 때 호출되는 이벤트

단, history.pushState() 또는 history.replaceState()는 popstate 이벤트를 발생시키지 않는다. 브라우저의 뒤로가기 버튼이나 history.back() 호출 등을 통해서만 발생된다. (출처: MDN)

hash를 이용한 라우팅

  • 앵커 # 를 통해 이동하는 방법으로, site/#some-path 같은 url로 표현된다.
  • 보통 정적인 페이지에서 사용되며, 사이트의 주 제목을 클릭한 후 앵커 이동시 url에 #이 붙는 모습을 볼 수 있다.
  • window.location.hash : 현재 URL의 해시
  • 해시가 변경될 때 마다, popstate 와 같이 hashchange 이벤트가 발생하기 때문에 이를 통해 라우팅을 구현할 수 있다.
window.addEventListener('hashchange', () => {
appDiv.innerHTML = routes[window.location.hash.replace('#', '')];
}
  • hash history는 웹 페이지 내부에서 이동하는 경우에 사용하기 때문에 history가 관리되지 않는다.

라우터 구현하기 — history API 이용

  1. router.js 파일에 각 템플릿 페이지에 대한 라우트 경로를 매치해주는 routes 객체 생성
routes = { 
'/': homePage,
'/user': userPage,
'/library': libraryPage,
}

2. 각 템플릿 페이지 생성 후 router.js 에서 페이지 컴포넌트를 변수로 선언해서 불러온다

  • 템플릿 페이지는 주로 handlebar 또는 ejs 템플릿을 활용한다.
  • 예시:
import HomePage from './pages/HomePage.ejs';const homePage = new HomePage({ initialState });

3. routes가 변경됨에 따라 올바른 페이지 템플릿을 화면에 렌더링해준다.

const appDiv = document.querySelector('.app');const renderHTML = (pathName) => {
appDiv.innerHTML = routes[pathName];
}

4. 브라우저의 history 에 사용자가 애플리케이션에서 이동한 페이지 기록을 담고, 화면에 페이지를 렌더링해준다.

const onNavigate = (pathName) => {
window.history.pushState(
{},
'',
window.location.origin + pathName
);
renderHTML(pathName);
}
  • 브라우저에서 뒤로가기 기능이 작동하게 만드려면 history.pushState 과정이 꼭 필요하다.
  • GNB 같은 컴포넌트에서 페이지 이동(라우팅) 동작을 수행할 때, onNavigate('/') 처럼 사용하면 된다.

5. 이후에, 사용자가 브라우저에서 뒤로가기 동작을 했을 때도 올바른 페이지를 화면에 나타내도록 설정한다.

  • window의 popstate 이벤트 핸들러 내부에서 처리
window.onpopstate = () => {
appDiv.innerHTML = routes[window.location.pathname];
}

결론 및 마무리

SPA에서는 하나의 HTML 파일만 사용하는지?

⇒ YES.

라우팅 할 때 주소창의 URL을 변경하면 어떤 식으로 작동하는 건지? 실제로 페이지 reload는 일어나지 않는 게 맞는지?

⇒ SPA에서 주로 history API를 이용해서 pushstate, popstate 이벤트로 구현함. 실제로 reload 되지 않는다!

react-router-dom<Link>를 사용하면 HTML의 앵커 요소(<a> 태그)처럼 페이지를 이동하게 만들 수 있다. 차이점은, Link를 사용하면 실제로 페이지가 reload 되지 않은 체로 렌더링되는 UI만 달라진다. 또한, react-router-dom 라이브러리는 history 라이브러리를 활용하기 때문에, VanillaJS에서 history를 이용해서 라우팅을 구현할 때와 내부적으로 유사하게 작동한다.

사용 예:

export const Home = () => (
<div>
Home Component
<ul>
<li>
<Link to=”/items”>Items</Link>
</li>
<li>
<Link to=”/category”>Category</Link>
</li>
</ul>
</div>
);

GNB 같은 모든 페이지에 공통적으로 포함되는 컴포넌트는 App 단계에서 렌더링을 해야 하는지? 아님 각 페이지 컴포넌트 내부에서 렌더링을 하는지?

⇒ 라우터를 생성하는 여러 예시 코드들을 살펴본 결과, 대부분 App 단계에서 부모 요소 하나를 두고, 그 자식 요소로 GNB 컴포넌트와 라우터에 따라 렌더링되는 페이지 뷰 컴포넌트를 같이 렌더링해주는 것으로 보였다. 근데 이건 애플리케이션 마다 페이지를 구성하는 요소가 다르니까, 각자의 상황에 맞춰서 렌더링하면 될 것 같다.

SPA의 장점과 단점

마지막으로 한 번 더 정리하고 넘어가보는 SPA의 장점/단점

✨ SPA의 장점:

  • 서버로부터 하나의 HTML 파일만 다운로드 하면 됨
  • 페이지 이동 속도가 빨라서 UX 측면에서 좋음 — 기존의 웹 페이지는 새로운 경로(url)로 이동할 때 마다 새로운 HTML 파일과 그에 따른 리소스를 서버에서 다운로드 받아야 했기 때문에, 빈 페이지가 화면에 띄워져 있는 시간이 길어질 때가 있었음

✨ SPA의 단점:

  • SEO 측면에서 안 좋음 — DOM이 자바스크립트 파일을 다운 받고, 파싱하면서 형성되기 때문에 검색엔진이 내용을 파악하는 데 어려움이 있음
  • 첫 로딩 시간이 너무 길어질 수 있음

🔥 마무리 / 느낀 점

검색하고 내용을 정리하다 보니 react router를 공부할 때 history 객체는 필수로 알고 넘어가야 하는 개념이라고 한다. 평소에 대충 공부하고 넘긴 것에 대한 대가를 이번에 치른 것 같다. 🥲

그리고 요즘 선언적 프로그래밍 방식이랑 추상화하며 코드를 작성하는 방법에 대해 이 곳 저 곳에서 접하게 되면서 내 코드가 “구린” 코드라는 걸 뼈저리게 느끼는 중이다. 코드의 반복을 줄이고 적절한 변수명을 짓는 걸론 턱 없이 부족하다.

아무튼 한 가지(+@)는 얻어 가는 테스트였기 때문에 의미가 있었다고 생각한다. 나 자신 앞으로도 화이팅.. ✊✊✊

--

--

Bok Jiho
Bok Jiho

Written by Bok Jiho

Web Front-End Engineer (to be 😏)

No responses yet