[JS] 프레임워크 없이 SPA 만들기 - 1 (router)
![[JS] 프레임워크 없이 SPA 만들기 - 1 (router) 글의 썸네일"](/_next/image?url=%2Fassets%2Fimages%2Fthumbnails%2Fjs.png&w=2048&q=75)
소스코드
ChangJuneKim/vaniila-spa at router-1 (github.com)
#들어가며
현대 프론트엔드 개발의 주요 트렌드를 살펴보면, React
, Vue
, Angular
, Svelte
등 다양한 프레임워크와 라이브러리가 눈에 띈다. 이런 도구들은 개발 과정을 훨씬 간편하고 효율적으로 만들어주기 때문에 많은 개발자들의 사랑을 받고 있다. 그런데, 그런 도구들을 전혀 사용하지 않고 오직 순수한 자바스크립트만으로 SPA(Single Page Application)
를 만든다면 어떨까? 일부는 "정말 필요한 걸까?" 라는 의문을 품을 수도 있다. 그렇지만, 항상 기본을 다져보는 것은 중요하다고 생각한다.
프레임워크나 라이브러리 없이 SPA를 만들어보는 과정은, 사용하던 도구들이 어떤 역할을 하는지, 그리고 그 도구들 없이는 어떻게 웹 페이지를 구현해야 하는지의 근본을 이해하는 계기가 될 것이다.
기본부터 시작하는 것은 요리를 배울 때 재료의 본질부터 파악하는 것과 비슷하다고 생각한다. 어떤 재료가 어떤 맛을 내는지, 어떻게 조합해야 맛있는 음식이 되는지를 알게 되면, 나중에는 더 다양하고 복잡한 요리도 손쉽게 만들 수 있다. 마찬가지로, 이런 기본적인 지식은 나중의 개발 과정에서 큰 도움이 될 것이다.
그러면, 바닐라 자바스크립트만을 활용하여 SPA를 구현하는 방법에 대해 함께 알아보도록 하자.
참고로 위 영상으로 학습을 했지만, (SPA에 대한 블로그 글을 검색해보면 거의 이 영상으로 공부를 시작하는 듯) 추상클래스를 사용하는 부분이나 일반 Class를 사용하는 부분을 함수형으로 변경 하고
layout.js
를 작성해서 <nav/>
태그를 만드는 등 여러가지를 바꿔가면서 학습해보았다.#서버 구축하기
SPA만들기 위한 첫번째 스텝은 서버를 구축하는 것이다. SPA는 하나의 HTML파일을 기반으로 자바스크립트를 통해 동적으로 화면을 변경하는 방식이다. 그렇기 때문에 단순하게 HTML 파일과, 정적 리소스(이미지, CSS, JS 등)를 제공해주는 간단한 서버를 만들어보자.
Node.js와 Express라는 프레임워크를 사용해서 만들어 볼 것이다.
터미널에 다음 명령어를 입력해서 package.json
을 생성하고 Express를 설치하자.
npm init -y
npm install express
npm init -y
npm install express
그 다음, 프로젝트 폴더 안에 server.js
라는 파일을 만들고, 다음과 같은 코드를 작성한다.
const express = require("express");
const path = require("path");
const app = express();
// 1)
app.use("/static", express.static(path.resolve(__dirname, "frontend", "static")));
// 2)
app.get("/*", (req, res) => {
res.sendFile(path.resolve(__dirname, "frontend", "index.html"));
});
// 3
app.listen(process.env.PORT || 3000, () => console.log("Server running..."));
const express = require("express");
const path = require("path");
const app = express();
// 1)
app.use("/static", express.static(path.resolve(__dirname, "frontend", "static")));
// 2)
app.get("/*", (req, res) => {
res.sendFile(path.resolve(__dirname, "frontend", "index.html"));
});
// 3
app.listen(process.env.PORT || 3000, () => console.log("Server running..."));
코드의 의미는 다음과 같다.
express
와path
라는 모듈을 불러온다.express
는 웹 서버를 만들기 위한 모듈이고,path
는 파일 경로를 다루기 위한 모듈이다.express()
함수를 호출하여app
이라는 객체를 생성한다. 이 객체는 웹 서버의 기능을 가지고 있다.
app.use("/static", express.static(path.resolve(__dirname, "frontend", "static")))
app.use("/static", express.static(path.resolve(__dirname, "frontend", "static")))
라는 코드는/static
으로 시작하는 요청에 대해frontend/static
폴더 안에 있는 정적 리소스를 제공하겠다는 의미이다. 예를 들어,/static/logo.png
라는 요청이 들어오면frontend/static/logo.png
파일을 보내준다.app.get("/*", (req, res) => {...})
라는 코드는 모든 요청에 대해frontend/index.html
파일을 보내준다. 이것은 SPA의 핵심 원리인데, 하나의 HTML 파일만으로 모든 화면을 구성하기 때문에 서버에서는 항상 같은 HTML 파일을 제공해주면 된다.app.listen(process.env.PORT || 3000, () => console.log("Server running..."))
app.listen(process.env.PORT || 3000, () => console.log("Server running..."))
라는 코드는 웹 서버를 실행시키고, 환경 변수에 PORT 값이 있으면 그 값을 포트 번호로 사용하고, 없으면 3000번 포트를 사용하겠다는 의미이다. 웹 서버가 실행되면 콘솔에 “Server running…” 이라고 출력된다.
이제 터미널에 다음 명령어를 입력해서 웹 서버를 실행시킨다.
node server.js
node server.js
`package.json에 명령어를 만들어서 npm start 로 실행해도 된다.
{
"name": "server",
"version": "1.0.0",
"description": "",
"main": "server.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"start": "node server.js" // 이 부분
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"express": "^4.18.2"
}
}
{
"name": "server",
"version": "1.0.0",
"description": "",
"main": "server.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"start": "node server.js" // 이 부분
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"express": "^4.18.2"
}
}
아직은 html파일과 js파일이 없기 때문에 http://localhost:3000 으로 접속해도 아무것도 보이지 않을 것이다.
#기본 파일 생성하기
frontend 폴더를 만들고 index.html
파일을 생성하자. 그리고 렌더링에 필요한 js와 css의 파일을 아래와 같은 형태로 생성하자.
frontend -- static ---- js ------ index.js ------ router.js ---- css ------ style.css (스타일은 자유)
#index.html
<!doctype html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0"
name="viewport">
<meta content="ie=edge" http-equiv="X-UA-Compatible">
<title>싱글 페이지 어플리케이션 튜토리얼</title>
<script src="static/js/index.js" type="module"></script> <!-- 1 -->
</head>
<body>
<header>
<nav class="nav">
<a class="nav__link" data-link href="/">홈</a>
<a class="nav__link" data-link href="/posts">게시글</a>
<a class="nav__link" data-link href="/settings">설정</a>
</nav>
</header>
<div id="root"></div> <!-- 2 -->
</body>
</html>
<!doctype html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0"
name="viewport">
<meta content="ie=edge" http-equiv="X-UA-Compatible">
<title>싱글 페이지 어플리케이션 튜토리얼</title>
<script src="static/js/index.js" type="module"></script> <!-- 1 -->
</head>
<body>
<header>
<nav class="nav">
<a class="nav__link" data-link href="/">홈</a>
<a class="nav__link" data-link href="/posts">게시글</a>
<a class="nav__link" data-link href="/settings">설정</a>
</nav>
</header>
<div id="root"></div> <!-- 2 -->
</body>
</html>
- 스크립트를 module 타입으로 불러온다. 이 js 파일에서 SPA를 구현하기 위한 코드를 작성할 것이다.
- 우리 웹사이트를 렌더링할 root 라는 아이디를 가진 div를 심어준다.
스크립트 파일이 잘 불러와졌는지 알 수 있도록. index.js
파일에 콘솔을 출력해보자.

#router.js
작성
export const router = async () => {
const routes = [
{ path: "/", view: () => console.log("홈") },
{ path: "/posts", view: () => console.log("게시글") },
{ path: "/settings", view: () => console.log("설정") },
{ path: "/not-found", view: () => console.log("404 페이지") },
];
// 1)
const potentialMatches = routes.map((route) => {
return {
...route,
isMatch: location.pathname === route.path,
};
});
// 2)
let match = potentialMatches.find((potentialMatch) => potentialMatch.isMatch);
// 3)
if (!match) {
match = {
path: routes.at(-1).path,
view: routes.at(-1).view,
isMatch: true,
};
}
console.log(match.view());
};
export const router = async () => {
const routes = [
{ path: "/", view: () => console.log("홈") },
{ path: "/posts", view: () => console.log("게시글") },
{ path: "/settings", view: () => console.log("설정") },
{ path: "/not-found", view: () => console.log("404 페이지") },
];
// 1)
const potentialMatches = routes.map((route) => {
return {
...route,
isMatch: location.pathname === route.path,
};
});
// 2)
let match = potentialMatches.find((potentialMatch) => potentialMatch.isMatch);
// 3)
if (!match) {
match = {
path: routes.at(-1).path,
view: routes.at(-1).view,
isMatch: true,
};
}
console.log(match.view());
};
router 라는 비동기 함수를 만들자. (현재는 비동기 처리가 없지만, 추후에 뷰 로딩이나 API 호출과 같은 비동기 작업을 추가할 수 있도록 준비)
potentialMatches
- 현재의
location.pathname
를 이용해서 사용자가 접근하려는 URL 경로와 정의된routes
들 중 어느 경로와 일치하는지 검사 - 검사 후
route
에isMatch
(일치하는지)를 추가한 객체를 리턴
match
potentialMatches
배열을 사용해서 현재 URL과 일치하는 경로를 찾는다.
- 일치하는 경로가 없다면 마지막경로(404 페이지) 로 설정된다.
#index.js
import { router } from "./router.js";
document.addEventListener("DOMContentLoaded", router);
import { router } from "./router.js";
document.addEventListener("DOMContentLoaded", router);
DOM이 로드되면 router 함수가 실행되도록 하자.

#문제점
각 path마다 다른 콘솔 내용이 출력되는 건 좋지만, 링크를 클릭했을 때 새로고침이 되는 문제점이 있다.
router.js
에 navigateTo
함수를 작성하자.
export const navigateTo = (url) => {
history.pushState({}, "", url);
router();
};
//... 이전 코드
// export const router = async ....
export const navigateTo = (url) => {
history.pushState({}, "", url);
router();
};
//... 이전 코드
// export const router = async ....
history.pushState({}, "", url)
을 사용해서 브라우저의 히스토리에 현재 페이지를 저장한다.
history.pushState란?
history.pushState()
는 웹 브라우저의 세션 기록에 항목을 추가할 수 있게 해주는 메서드이다.
이 메서드를 사용하면 페이지를 새로고치지 않고도 URL을 변경할 수 있다. (SPA 에서 UX를 향상 시키기위해 활용된다.)
history.pushState()
의 기본 구조
history.pushState(state, unused, url);
history.pushState(state, unused, url);
- state 현재 url에 연결된 상태 객체이다. poptstate 이벤트가 발생할 때, event.state로 사용될 수 있다.
- unused 이제는 쓸모없는 값이란다. 호환성을 위해서 빈 문자열을 넣으면 안전하다고 한다.

- url 브라우저 주소창에 표시될 URL
// index.js
document.addEventListener("DOMContentLoaded", () => {
// 1)
document.body.addEventListener("click", (e) => {
// 2)
if (e.target.matches("[data-link]")) {
e.preventDefault();
// 3)
navigateTo(e.target.href);
}
});
router();
});
// index.js
document.addEventListener("DOMContentLoaded", () => {
// 1)
document.body.addEventListener("click", (e) => {
// 2)
if (e.target.matches("[data-link]")) {
e.preventDefault();
// 3)
navigateTo(e.target.href);
}
});
router();
});
- 이벤트 위임을 활용해서
<body/>
에 이벤트를 등록한다. - 만약 클릭된 요소가
data-link
속성을 가진 요소라면 우리의 a 태그처럼<a class="nav__link" data-link href="/">홈</a>${html}
기본 새로고침 동작을 막고(e.preventDefault()${js}
) - 클릭된 링크의
href
값을navigateTo
함수에 전달해서 해당 경로로 네비게이션 한다.
여기 까지 작성하고 나면 a
태그를 클릭 했을 때 잘 동작하지만, 브라우저의 앞으로 가기, 뒤로가기 버튼을 눌렀을 때는 원하는 동작을 하지 않는다.
history
가 변한걸 감지하지 못하기때문인데 이 때는 window
에 popstate
이벤트를 등록해서 처리하면 된다.
// popstate 이벤트 등록
window.addEventListener("popstate", router);
// 이전 코드 ...
document.addEventListener("DOMContentLoaded", () => {
...
// popstate 이벤트 등록
window.addEventListener("popstate", router);
// 이전 코드 ...
document.addEventListener("DOMContentLoaded", () => {
...
#마치며
지금까지 바닐라 JS만을 활용해서 간단한 SPA 라우터를 구축하는 방법에 대해 알아보았다. history.pushState()
와 같은 HTML5 History API의 활용법을 배움으로써, 프레임워크나 라이브러리에 의존하지 않고도 SPA의 핵심 기능 중 하나인 새로고침 없이 페이지 전환하는 기능을 구현할 수 있었다.
물론, 지금 구축한 라우터는 아주 기본적인 수준이라고 생각한다. 실제로 잘 알려진 라이브러리들은 훨씬 많은 기능과 세부 설정이 있겠지만 기본 원리를 이해하는 건 복잡한 프레임워크나 라이브러리를 사용할 때도 큰 도움이 될 것 같다.
다음 글에서는 이번에 구축한 라우터를 확장하고 view를 콘솔출력이 아닌 실제 페이지를 렌더링 하는 방법에 대해서 알아보자.