바닐라 자바스크립트로 SPA 만들고 이해하기
June 9, 2026
React의 동작 방식인 SPA(Single Page Application)를 바닐라 자바스크립트로 직접 구현해보면서 내부 동작 원리를 이해해보자.
MPA vs SPA
| MPA | SPA | |
|---|---|---|
| 페이지 이동 | 서버에서 HTML 전체를 새로 받아옴 | JS가 DOM을 교체, 새로고침 없음 |
| 초기 로딩 | 빠름 | 느릴 수 있음 (JS 번들 로드) |
| 이후 전환 | 매번 새로고침 발생 | 빠름 |
| SEO | 유리 | 불리 (SSR로 보완 가능) |
| 코드 중복 | 페이지마다 중복 발생 | 컴포넌트 재사용 가능 |
컴포넌트와 모듈
UI를 기능 단위로 쪼갠 독립적인 조각을 컴포넌트라고 한다. 사용자와 상호작용하는 UI 부분 — 버튼, 탭, 콘텐츠 영역 같은 것들이다. 재사용이 가능하고, 독립적으로 관리할 수 있어서 유지보수가 편하다. 반면 UI는 아니지만 중복되는 기능 코드는 모듈로 분리한다. 이 데모에서는 NavBar, Content가 컴포넌트고, api 호출 같은 기능 코드가 모듈에 해당한다.
각 파일은 export/import로 연결한다.
src/
components/
NavBar.js // 탭 버튼
Content.js // 페이지 내용
App.js // 상태 관리
index.js // 진입점
index.html
상태(State)란?
상태는 웹 페이지 내에서 변화하는 모든 데이터다. 사용자가 클릭한 버튼, 입력한 값, API 응답 데이터 등 — 값이 바뀌는 것이면 전부 상태다. 아래 코드에서는 "현재 어떤 페이지인가(currentPage)"가 상태다.
상태가 언제, 어떤 값으로 바뀌는지를 관리하는 것을 상태 관리라고 한다.
상태 관리를 쓰지 않으면 데이터가 바뀔 때마다 DOM을 하나하나 찾아서 직접 바꿔야 한다. 중복 코드가 많아지고 불필요한 리렌더링이 생겨서 성능이 나빠진다.
상태 관리의 핵심은 App이 state를 들고, 자식 컴포넌트에 내려주는 구조다. state가 바뀌면 필요한 컴포넌트만 다시 렌더링한다.
this.setState = (newState) => {
this.state = { ...this.state, ...newState };
nav.setState(this.state.currentPage);
content.setState(this.state.currentPage);
};바닐라 JS에는 useState 같은 게 내장되어 있지 않아서 state, setState, render를 직접 구현해야 한다.
React는 이걸 내장으로 제공한다.
SPA 구현하기
index.js
진입점. #app 요소를 잡아서 App에 넘긴다.
import App from './App.js';
const $app = document.getElementById('app');
new App($app);App.js
상태 관리 핵심. currentPage state를 들고 NavBar, Content에 내려준다.
탭 클릭 시 history.pushState로 URL을 바꾸고 setState로 state를 업데이트한다.
import NavBar from './components/NavBar.js';
import Content from './components/Content.js';
export default function App($app) {
this.state = {
currentPage: window.location.pathname.replace('/', '') || 'home',
};
const nav = new NavBar({
$app,
initialState: this.state.currentPage,
onClick: (page) => {
history.pushState(null, null, `/${page}`);
this.setState({ currentPage: page });
},
});
const content = new Content({
$app,
initialState: this.state.currentPage,
});
this.setState = (newState) => {
this.state = { ...this.state, ...newState };
nav.setState(this.state.currentPage);
content.setState(this.state.currentPage);
};
window.addEventListener('popstate', () => {
const page = window.location.pathname.replace('/', '') || 'home';
this.setState({ currentPage: page });
});
}this.state— 현재 URL 경로 기반으로 초기값 설정- NavBar, Content 인스턴스 생성 시
initialState와onClick전달 this.setState— state 업데이트 후 각 컴포넌트에 내려줌history.pushState— 새로고침 없이 URL만 변경popstate이벤트 — 뒤로가기/앞으로가기 처리
NavBar.js
탭 UI 담당. 클릭 이벤트를 App에서 받아온 onClick으로 올려보낸다.
setState가 호출되면 render()로 active 탭을 다시 그린다.
export default class NavBar {
constructor({ $app, initialState, onClick }) {
this.state = initialState;
this.onClick = onClick;
this.$target = document.createElement('nav');
this.$target.className = 'nav-bar';
$app.appendChild(this.$target);
this.pages = [
{ id: 'home', label: 'Home' },
{ id: 'about', label: 'About' },
{ id: 'posts', label: 'Posts' },
];
this.render();
}
template() {
return this.pages
.map(
({ id, label }) =>
`<div id="${id}" class="${this.state === id ? 'active' : ''}">${label}</div>`
)
.join('');
}
render() {
this.$target.innerHTML = this.template();
this.$target.querySelectorAll('div').forEach((elm) => {
elm.addEventListener('click', () => {
this.onClick(elm.id);
});
});
}
setState(newState) {
this.state = newState;
this.render();
}
}this.state— App에서 받은initialState로 초기값 설정$app.appendChild— #app 안에 nav 태그 직접 생성해서 붙임template()— 현재 state와 일치하는 탭에active클래스 부여render()— 탭 클릭 시onClick(elm.id)으로 App에 이벤트 전달setState()— 새 state 받아서render()재호출
Content.js
페이지 내용 담당. state에 따라 해당 페이지 데이터를 렌더링한다.
const pages = {
home: {
title: 'SPA란 무엇인가?',
body: `Single Page Application의 약자로, 하나의 HTML 페이지에서
JavaScript가 화면을 동적으로 교체하는 방식입니다.`,
},
about: {
title: '왜 SPA를 쓰는가?',
body: `전통적인 MPA는 페이지 이동 시 서버에서 HTML 전체를 새로 받아옵니다.
SPA는 필요한 데이터만 받아와 DOM을 업데이트하므로 지연 시간이 줄어듭니다.`,
},
posts: {
title: 'MPA vs SPA',
body: `MPA: 페이지 이동 시 서버에서 HTML 전체를 받아옴 → 새로고침 발생
SPA: 최초 1회만 HTML을 받고, 이후엔 JS로 화면을 교체 → 새로고침 없음`,
},
};
export default class Content {
constructor({ $app, initialState }) {
this.state = initialState;
this.$target = document.createElement('main');
this.$target.className = 'content';
$app.appendChild(this.$target);
this.render();
}
template() {
const page = pages[this.state] || pages['home'];
return `
<h1>${page.title}</h1>
<p>${page.body}</p>
`;
}
render() {
this.$target.innerHTML = this.template();
}
setState(newState) {
this.state = newState;
this.render();
}
}pages— 각 페이지 타이틀과 내용을 담은 객체. 클래스 밖에 선언해 고정 데이터로 관리template()— 현재 state에 해당하는 페이지 데이터로 HTML 생성render()— template() 결과를$target에 삽입setState()— 새 state 받아서render()재호출
History API
history.pushState(null, null, '/about') — 새로고침 없이 URL만 변경한다. 뒤로가기/앞으로가기 누르면 popstate 이벤트가 발생하고, 그때 state를 URL 기준으로 업데이트한다.
React와 비교하기
| 바닐라 JS | React |
|---|---|
this.state | useState |
this.setState | setState (훅) |
this.render() 직접 호출 | state 변경 시 자동 리렌더 |
history.pushState | useRouter / <Link> |
바닐라로 직접 짠 이 구조가 React에서는 이렇게 대응된다.