History API와 라우팅
pushState·replaceState·popstate로 새로고침 없이 URL과 뒤로가기를 제어해 SPA 라우터의 원리를 구현합니다.
우리는 9장의 앞선 절을 통해 Fetch API로 서버와 통신하고, 로컬 스토리지와 세션 스토리지로 클라이언트 측 데이터를 관리하는 방법을 학습했습니다.
이제 웹 애플리케이션의 중요한 사용자 경험 요소 중 하나인 내비게이션(Navigation)에 대해 다룰 차례입니다.
전통적인 웹 페이지는 링크를 클릭하거나 폼을 전송할 때마다 서버로부터 새로운 HTML 문서를 받아와 전체 페이지를 새로고침(Full Page Reload)했습니다.
이는 사용자 경험 측면에서 화면 깜빡임이나 속도 저하를 야기할 수 있습니다.
현대 웹 개발에서는 이러한 단점을 극복하고 데스크톱 애플리케이션처럼 부드러운 사용자 경험을 제공하기 위해 Single Page Application (SPA)이 대세가 되었습니다.
SPA는 최초 한 번만 페이지를 로드하고, 이후에는 필요한 데이터만 비동기적으로 서버에서 받아와 페이지의 특정 부분만 업데이트합니다.
이때 중요한 문제가 발생하는데, 페이지 전체를 새로고침하지 않으면서도 URL을 변경하고, 브라우저의 뒤로가기/앞으로가기 버튼을 정상적으로 동작하게 하는 것입니다.
이러한 문제를 해결해 주는 것이 바로 History API입니다.
이 절에서는 History API의 주요 메서드를 정리하고, 이를 바탕으로 SPA에서 클라이언트-사이드 라우팅(Client-Side Routing)을 구현하는 흐름을 살펴보겠습니다.
전통적인 웹 페이지 내비게이션의 한계
전통적인 웹 페이지는 <a> 태그를 클릭하거나, location.href를 변경하거나, 폼을 제출할 때마다 브라우저가 새로운 HTTP 요청을 서버로 보내고, 서버는 해당 URL에 맞는 새로운 HTML 파일을 응답합니다.
이 과정에서 브라우저는 현재 페이지를 완전히 버리고 새로운 페이지를 처음부터 다시 그립니다.
단점- 느린 속도: 매번 페이지 전체를 새로 로드해야 하므로, 네트워크 지연이 발생합니다.
- 화면 깜빡임: 페이지 전체가 다시 그려지면서 일시적인 화면 깜빡임이 발생하여 사용자 경험을 저해합니다.
- 불필요한 리소스 재로드: CSS, JavaScript 파일 등 변경되지 않은 리소스도 매번 다시 로드될 수 있습니다.
Single Page Application (SPA)
SPA는 이름 그대로 하나의 HTML 페이지 위에서 실행되는 웹 애플리케이션입니다.
초기 로드 시 필요한 모든 리소스(HTML, CSS, JS)를 한 번만 다운로드하고, 이후 사용자 상호작용에 따라 필요한 데이터만 서버와 비동기적으로 통신(Fetch API 사용)하여 페이지의 내용물을 동적으로 변경합니다.
SPA의 핵심 과제 중 하나는 페이지 전체를 새로고침하지 않으면서도, 사용자가 다른 페이지로 이동하는 것처럼 보이게 하고, URL을 변경하며, 브라우저의 뒤로가기/앞으로가기 버튼을 정상적으로 동작하도록 하는 것입니다.
이를 클라이언트-사이드 라우팅(Client-Side Routing)이라고 합니다.
클라이언트-사이드 라우팅은 서버에 새로운 페이지를 요청하는 대신, 자바스크립트를 사용하여 현재 페이지의 URL을 변경하고, 그 URL에 맞는 컴포넌트를 동적으로 렌더링하는 방식으로 작동합니다.
이때 핵심적인 역할을 하는 것이 바로 History API입니다.
History API: 브라우저 히스토리 조작
History API는 웹 브라우저의 세션 히스토리(방문 기록)를 자바스크립트로 조작할 수 있도록 해주는 웹 API입니다.
이를 통해 페이지를 새로고침하지 않고도 URL을 변경하고, 사용자가 뒤로가기/앞으로가기 버튼을 눌렀을 때 적절한 동작을 수행하도록 만들 수 있습니다.
History API는 전역 window.history 객체를 통해 접근할 수 있습니다.
history.pushState()
history.pushState(state, title, url) 메서드는 브라우저의 세션 히스토리에 새로운 항목을 추가합니다.
이는 마치 사용자가 새로운 페이지로 이동한 것처럼 보이지만, 실제로는 페이지 새로고침이 일어나지 않습니다.
state: 새로운 히스토리 엔트리에 연결될 상태 객체입니다. 나중에popstate이벤트가 발생했을 때 이 상태 객체를 통해 데이터를 전달받을 수 있습니다.title: 역사적인 두 번째 인자로 현재는 사용되지 않습니다. 빈 문자열을 전달할 수 있으며 문서 제목은document.title로 별도로 바꿉니다.url: (선택 사항) 변경될 새로운 URL입니다. 현재 출처(origin)와 동일해야 합니다. 이 URL은 브라우저의 주소 표시줄에 표시되지만, 서버에는 요청을 보내지 않습니다.
// 현재 URL: http://localhost:8080/
console.log("현재 URL:", window.location.href);
// 'about' 페이지로 이동하는 것처럼 보이게 URL 변경
history.pushState({ page: 'about' }, 'About Page', '/about');
console.log("변경 후 URL:", window.location.href); // 결과: http://localhost:8080/about
// 'products' 페이지로 이동하는 것처럼 보이게 URL 변경
history.pushState({ page: 'products', category: 'electronics' }, 'Products Page', '/products/electronics');
console.log("변경 후 URL:", window.location.href); // 결과: http://localhost:8080/products/electronics
// 실제 페이지 이동 없이 URL만 변경됨pushState는 URL을 변경하지만, popstate 이벤트는 발생시키지 않습니다.
history.replaceState()
history.replaceState(state, title, url) 메서드는 pushState()와 유사하지만, 새로운 항목을 추가하는 대신 현재 히스토리 엔트리를 대체합니다.
이는 사용자가 현재 페이지에 머물면서 URL만 변경하고 싶을 때 유용합니다 (예: 검색 필터를 적용하여 URL만 업데이트).
// 현재 URL: http://localhost:8080/search?q=apple
console.log("현재 URL:", window.location.href);
// 검색어만 변경하고 싶을 때 (뒤로가기 시 이전 검색 결과로 돌아가기 위함이 아니라 현재 페이지 그대로 유지)
history.replaceState({ query: 'banana' }, 'Search for Banana', '/search?q=banana');
console.log("변경 후 URL:", window.location.href); // 결과: http://localhost:8080/search?q=banana
// replaceState도 popstate 이벤트를 발생시키지 않습니다.그 외 메서드: 히스토리 이동
이 메서드들은 브라우저의 뒤로가기/앞으로가기 버튼과 동일한 기능을 수행합니다.
history.back(): 뒤로가기history.forward(): 앞으로가기history.go(delta):delta값만큼 히스토리 스택을 이동합니다.history.go(0): 현재 페이지 새로고침history.go(-1):history.back()과 동일history.go(1):history.forward()와 동일
// 예시 시나리오:
// 1. 초기 로드: / (index.html)
// 2. history.pushState({}, '', '/about'); // URL: /about
// 3. history.pushState({}, '', '/contact'); // URL: /contact
// 현재 /contact
console.log("현재:", window.location.href); // 결과: /contact
history.back(); // 비동기 탐색 요청: 완료 후 /about으로 이동
console.log("뒤로가기 후:", window.location.href);
history.forward(); // 비동기 탐색 요청: 바로 위 back의 완료를 기다리지 않음
console.log("앞으로가기 후:", window.location.href);
// history.go(-2); // 두 단계 전 항목으로 이동 (위 시나리오의 초기 URL은 /)back()과 forward()는 비동기 요청입니다. 바로 뒤의 로그가 이동 후 URL이라는 보장은 없으며, 연속 호출 결과를 확인하려면 popstate를 기준으로 관찰해야 합니다.
popstate 이벤트
사용자가 브라우저의 뒤로가기 또는 앞으로가기 버튼을 클릭하여 URL이 변경될 때 popstate 이벤트가 발생합니다.
이 이벤트는 window 객체에서 리스닝할 수 있으며, 이때 event.state를 통해 pushState 또는 replaceState로 저장했던 state 객체에 접근할 수 있습니다.
// 라우터 역할을 하는 함수
function handleRoute(event) {
const currentState = event.state; // pushState/replaceState로 전달했던 상태 객체
const currentPath = window.location.pathname;
console.log(`[popstate 이벤트 발생] URL: ${currentPath}, State:`, currentState);
// 여기에서 currentPath에 따라 적절한 UI 컴포넌트를 렌더링하는 로직 구현
if (currentPath === '/') {
renderHomePage();
} else if (currentPath === '/about') {
renderAboutPage();
} else if (currentPath.startsWith('/products')) {
renderProductsPage(currentPath);
} else {
renderNotFoundPage();
}
}
// popstate 이벤트 리스너 등록
window.addEventListener('popstate', handleRoute);
// 초기 페이지 로드 시에도 현재 URL에 맞는 콘텐츠를 렌더링
function initialRender() {
console.log("[초기 렌더링]", window.location.pathname);
handleRoute({ state: history.state }); // 초기 상태는 history.state로 접근
}
// 실제 렌더링 함수 (예시)
function renderHomePage() { document.getElementById('content').innerHTML = '<h1>환영합니다! 홈 페이지입니다.</h1>'; }
function renderAboutPage() { document.getElementById('content').innerHTML = '<h1>회사 소개 페이지입니다.</h1><p>우리는 이런 회사입니다.</p>'; }
function renderProductsPage(path) { document.getElementById('content').innerHTML = `<h1>제품 페이지입니다.</h1><p>현재 경로: ${path}</p>`; }
function renderNotFoundPage() { document.getElementById('content').innerHTML = '<h1>404 Not Found</h1><p>페이지를 찾을 수 없습니다.</p>'; }
// HTML 구조: <div id="content"></div>
// 초기 렌더링 호출
initialRender();
// 예시를 위한 수동 라우팅 (실제로는 <a> 태그 클릭 등을 통해 발생)
setTimeout(() => {
console.log("\n--- 'About'으로 pushState ---");
history.pushState({ page: 'about' }, 'About Page', '/about');
handleRoute({ state: history.state }); // 수동으로 콘텐츠 렌더링 트리거
}, 1000);
setTimeout(() => {
console.log("\n--- 'Products'로 pushState ---");
history.pushState({ page: 'products' }, 'Products Page', '/products/shoes');
handleRoute({ state: history.state }); // 수동으로 콘텐츠 렌더링 트리거
}, 2000);
setTimeout(() => {
console.log("\n--- 뒤로가기 버튼 클릭 (실제 브라우저 동작) ---");
// 브라우저의 뒤로가기 버튼을 누르는 것을 시뮬레이션
history.back(); // 이 호출이 popstate 이벤트를 발생시킵니다.
}, 3000);
setTimeout(() => {
console.log("\n--- 뒤로가기 버튼 클릭 (실제 브라우저 동작) ---");
history.back(); // 또 한 번 뒤로가기
}, 4000);
setTimeout(() => {
console.log("\n--- 앞으로가기 버튼 클릭 (실제 브라우저 동작) ---");
history.forward(); // 앞으로가기
}, 5000);pushState나 replaceState 호출만으로는 popstate 이벤트가 발생하지 않습니다.
이 절처럼 state를 저장한 기록을 탐색하는 예제에서는 브라우저의 뒤로가기/앞으로가기 버튼이나 history.back(), history.forward(), history.go()로 이동한 목적지의 popstate를 받아 화면을 복원합니다.
따라서 pushState를 호출한 후에는 해당 URL에 맞는 콘텐츠를 수동으로 렌더링해야 합니다.
다음 다이어그램은 링크 클릭으로 URL을 바꾸는 흐름과 브라우저 히스토리를 탐색하는 흐름을 분리해 보여줍니다.
History 탐색과 화면 동기화 — 상태 추적
새 push/replace와 뒤로 popstate는 다른 기록 변화이며 화면/title/focus는 앱이 갱신한다. 고정 URL/history/UI 상태로 기록 증가 없는 복귀를 보여 준다.
SEQUENCE · WEB PLATFORM CONTRACT
History 탐색과 화면 동기화
새 링크는 pushState/replaceState 뒤 라우터가 화면을 그린다. 뒤로·앞으로 이동은 popstate를 읽고 기존 기록에 맞춰 화면을 복원한다.
앱 참여자 안에 라우터·그리기·제목·포커스 처리를 묶었습니다. 본문의 확장 코드가 이 네 책임을 연결합니다. 기록 쓰기와 popstate 복원은 다른 경로이며, 뒤로 복원하면서 기록을 다시 늘리지 않습니다.
새 탐색은 history를 쓰지만 traversal은 popstate를 읽는다. pushState/replaceState 자체는 popstate를 발생시키지 않는다. 이 라우터 예제는 두 경로 모두 화면·문서 제목·포커스를 같은 목적지에 맞춘다.
이 예의 조건
- 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
- 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
- 원래 본문의 구현과 도표가 설명한 설계 보강을 분리한다. 서사에 포함한 확장 동작은 assumptions에 명시하고 이미 구현된 안전장치로 주장하지 않는다.
| 장면 | 현재 관찰값 | 설명 | 변화 이유 |
|---|---|---|---|
| 현재 entry | history: 현재 entry / UI: 현재 location | URL과 현재 화면이 맞춰져 있다 | 주소와 화면의 소유 계약을 정한다 |
| push 또는 replace | history: 추가/교체 / popstate: 이 호출 자체로 안 발생 | 새 entry 또는 현재 entry를 갱신하고 앱이 화면을 동기화한다 | URL API가 자동 router 렌더를 보장하지 않는다 |
| POP 복원 | history: 이전 entry / UI: 복원 결과 | 뒤로 이동 이벤트로 이전 URL의 화면/title/focus를 복원한다 | 복원 과정에서 새 entry를 다시 push하지 않는다 |
History 탐색과 화면 동기화 — 상태 추적
새 push/replace와 뒤로 popstate는 다른 기록 변화이며 화면/title/focus는 앱이 갱신한다. 고정 URL/history/UI 상태로 기록 증가 없는 복귀를 보여 준다.
SEQUENCE · WEB PLATFORM CONTRACT
History 탐색과 화면 동기화
새 링크는 pushState/replaceState 뒤 라우터가 화면을 그린다. 뒤로·앞으로 이동은 popstate를 읽고 기존 기록에 맞춰 화면을 복원한다.
앱 참여자 안에 라우터·그리기·제목·포커스 처리를 묶었습니다. 본문의 확장 코드가 이 네 책임을 연결합니다. 기록 쓰기와 popstate 복원은 다른 경로이며, 뒤로 복원하면서 기록을 다시 늘리지 않습니다.
새 탐색은 history를 쓰지만 traversal은 popstate를 읽는다. pushState/replaceState 자체는 popstate를 발생시키지 않는다. 이 라우터 예제는 두 경로 모두 화면·문서 제목·포커스를 같은 목적지에 맞춘다.
이 예의 조건
- 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
- 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
- 원래 본문의 구현과 도표가 설명한 설계 보강을 분리한다. 서사에 포함한 확장 동작은 assumptions에 명시하고 이미 구현된 안전장치로 주장하지 않는다.
| 장면 | 현재 관찰값 | 설명 | 변화 이유 |
|---|---|---|---|
| 현재 entry | history: 현재 entry / UI: 현재 location | URL과 현재 화면이 맞춰져 있다 | 주소와 화면의 소유 계약을 정한다 |
| push 또는 replace | history: 추가/교체 / popstate: 이 호출 자체로 안 발생 | 새 entry 또는 현재 entry를 갱신하고 앱이 화면을 동기화한다 | URL API가 자동 router 렌더를 보장하지 않는다 |
| POP 복원 | history: 이전 entry / UI: 복원 결과 | 뒤로 이동 이벤트로 이전 URL의 화면/title/focus를 복원한다 | 복원 과정에서 새 entry를 다시 push하지 않는다 |
클라이언트-사이드 라우팅 구현의 원리
History API와 라우팅에서는 저장 위치, 요청 흐름, 화면 전환 결과를 연결합니다.
History API를 활용한 클라이언트-사이드 라우팅의 일반적인 원리는 다음과 같습니다.
링크 클릭 가로채기: 아래 코드는 모든 링크가 같은 출처의 SPA 경로라고 가정한 최소 예제입니다. 실제 라우터에서는 외부 링크·다운로드·새 탭·보조 키 클릭을 제외한 내부 탐색만 가로채야 합니다.
document.querySelectorAll('a').forEach(link => {
link.addEventListener('click', function(event) {
event.preventDefault(); // 기본 동작(페이지 새로고침) 방지
const path = this.getAttribute('href');
history.pushState({}, '', path); // URL 변경
handleRoute({ state: history.state }); // 변경된 URL에 맞는 콘텐츠 렌더링
});
});popstate 이벤트 처리: popstate 이벤트가 발생하면 (사용자가 뒤로가기/앞으로가기 버튼 클릭 시) 현재 URL(window.location.pathname)을 확인하고, 그에 맞는 컴포넌트나 콘텐츠를 렌더링합니다.
초기 로드 시 처리: 페이지가 처음 로드될 때도 현재 URL에 맞는 콘텐츠를 렌더링해야 합니다.
현대 웹 개발에서는 React Router, Vue Router, Next.js 등의 프레임워크/라이브러리가 이 모든 라우팅 로직을 추상화하여 제공하므로 개발자가 직접 History API를 다룰 일이 많지는 않습니다.
하지만 이들이 내부적으로 어떻게 동작하는지 이해하는 것은 SPA의 동작 원리를 파악하는 데 매우 중요합니다.
직접 주소를 입력하거나 새로고침할 때는 클라이언트 라우터 이전에 서버의 HTML 응답 흐름도 함께 확인해야 합니다.
SPA 직접 접근 복원 경계 — 상태 추적
직접 문서 요청은 서버 분기를 먼저 거쳐 shell 뒤 client router가 원 pathname을 읽는다. HTTP 응답과 찾을 수 없음 view가 서로 다른 시점에 확정됨을 요청 하나로 추적할 가치가 있다.
ARCHITECTURE · SERVER / CLIENT ROUTING
SPA 직접 접근 복원 경계
주소 입력·새로고침은 서버부터 시작한다. 정적 SPA 서버는 Asset/API를 따로 응답하고 문서 경로에만 앱 shell을 보내 클라이언트 라우터가 화면을 복원하게 한다.
서버는 Asset/API를 전용 경로로 종결하고 문서 경로에만 앱 shell을 제공합니다. 클라이언트는 원래 경로를 읽어 화면을 고릅니다. 미일치 화면을 그린다는 사실과 최초 HTTP 상태는 서로 다릅니다.
SPA fallback은 모든 404를 index.html로 바꾸는 규칙이 아니다. Asset/API를 먼저 종결하고 document route만 client router로 넘겨야 직접 접근이 복원된다.
이 예의 조건
- 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
- 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 원도표 설명 | SPA fallback은 모든 404를 index.html로 바꾸는 규칙이 아니다. Asset/API를 먼저 종결하고 document route만 client router로 넘겨야 직접 접근이 복원된다. |
| 장면 | 현재 관찰값 | 설명 | 변화 이유 |
|---|---|---|---|
| 직접 경로 요청 | host: 요청 분류 / app: 아직 시작 전 | 브라우저가 pathname의 document를 요청한다 | client route 선언은 host 응답 전에 실행되지 않는다 |
| 문서와 router | HTTP: 설정에 따른 200/404 / router: 현재 경로 매칭 | 허용 app 경로에 shell을 반환하고 router가 현재 pathname을 읽는다 | asset/API 요청과 app shell fallback을 구분한다 |
| 화면 결과 | UI: route 또는 not-found / HTTP: 이미 반환된 status | client not-found UI는 문서 status를 자동으로 변경하지 않는다 | 화면의 404 표시와 HTTP 404는 서로 다른 관찰 대상이다 |
SPA 직접 접근 복원 경계 — 상태 추적
직접 문서 요청은 서버 분기를 먼저 거쳐 shell 뒤 client router가 원 pathname을 읽는다. HTTP 응답과 찾을 수 없음 view가 서로 다른 시점에 확정됨을 요청 하나로 추적할 가치가 있다.
ARCHITECTURE · SERVER / CLIENT ROUTING
SPA 직접 접근 복원 경계
주소 입력·새로고침은 서버부터 시작한다. 정적 SPA 서버는 Asset/API를 따로 응답하고 문서 경로에만 앱 shell을 보내 클라이언트 라우터가 화면을 복원하게 한다.
서버는 Asset/API를 전용 경로로 종결하고 문서 경로에만 앱 shell을 제공합니다. 클라이언트는 원래 경로를 읽어 화면을 고릅니다. 미일치 화면을 그린다는 사실과 최초 HTTP 상태는 서로 다릅니다.
SPA fallback은 모든 404를 index.html로 바꾸는 규칙이 아니다. Asset/API를 먼저 종결하고 document route만 client router로 넘겨야 직접 접근이 복원된다.
이 예의 조건
- 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
- 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 원도표 설명 | SPA fallback은 모든 404를 index.html로 바꾸는 규칙이 아니다. Asset/API를 먼저 종결하고 document route만 client router로 넘겨야 직접 접근이 복원된다. |
| 장면 | 현재 관찰값 | 설명 | 변화 이유 |
|---|---|---|---|
| 직접 경로 요청 | host: 요청 분류 / app: 아직 시작 전 | 브라우저가 pathname의 document를 요청한다 | client route 선언은 host 응답 전에 실행되지 않는다 |
| 문서와 router | HTTP: 설정에 따른 200/404 / router: 현재 경로 매칭 | 허용 app 경로에 shell을 반환하고 router가 현재 pathname을 읽는다 | asset/API 요청과 app shell fallback을 구분한다 |
| 화면 결과 | UI: route 또는 not-found / HTTP: 이미 반환된 status | client not-found UI는 문서 status를 자동으로 변경하지 않는다 | 화면의 404 표시와 HTTP 404는 서로 다른 관찰 대상이다 |
라우팅은 URL과 화면만 맞추면 끝나는 기능처럼 보이지만, 실제 사용성은 제목, 포커스, 스크롤 위치, 404 처리까지 함께 맞아야 좋아집니다.
앞의 SPA 직접 접근 복원 경계 다이어그램은 작은 라우터에서 놓치기 쉬운 서버 fallback과 404 처리 경계를 정리합니다.
History API와 SPA 라우팅 정리
이번 절에서는 Single Page Application (SPA)의 핵심 내비게이션 기술인 History API와 클라이언트-사이드 라우팅(Client-Side Routing)을 정리했습니다.
SPA는 페이지 전체를 새로고침하지 않고 URL과 화면 상태를 함께 바꾸는 방식으로 동작합니다.
pushState(), replaceState(), back()/forward()/go()와 popstate 이벤트를 함께 보면 브라우저 방문 기록을 어떻게 제어하는지 이해할 수 있습니다.
History API를 이해하면 모던 웹 프레임워크의 라우팅 시스템이 URL 변경, 뒤로 가기, 상태 복원 문제를 어떻게 처리하는지 파악할 수 있습니다.
직접 라우터를 구현하지 않더라도 이 API의 동작 원리는 라우팅 버그와 새로고침 문제를 분석할 때 기준이 됩니다. 따라하기: 기록과 화면을 한 번씩만 바꾸기
앞의 코드는 URL에 맞는 내용만 바꾸는 최소 라우터입니다. History 그림의 title·focus까지 연결하려면 앱이 그 처리를 추가해야 합니다. 아래는 타이머 예제와 섞지 않고 새 main.js에서 실행하는 확장입니다. 같은 출처의 전용 학습 페이지에 <nav><a href="/">홈</a> <a href="/about">소개</a> <a href="/products">제품</a></nav><main id="route-view" tabindex="-1"></main>을 만들고 이 파일 하나를 연결합니다. 직접 주소 입력의 서버 fallback 설정은 이 자바스크립트가 만들어 주지 않습니다.
const routeView = document.getElementById('route-view');
const routes = {
'/': { title: '홈', content: '홈 페이지입니다.' },
'/about': { title: '소개', content: '소개 페이지입니다.' },
'/products': { title: '제품', content: '제품 목록입니다.' }
};
function renderRoute() {
const page = routes[location.pathname]
?? { title: '찾을 수 없음', content: '일치하는 화면이 없습니다.' };
routeView.textContent = page.content;
document.title = page.title;
routeView.focus();
}
document.querySelector('nav').addEventListener('click', event => {
const link = event.target.closest('a');
if (!link || event.defaultPrevented || event.button !== 0
|| event.ctrlKey || event.metaKey || event.shiftKey || event.altKey
|| link.hasAttribute('download')
|| (link.target && link.target !== '_self')) return;
const url = new URL(link.href);
if (url.origin !== location.origin || !Object.hasOwn(routes, url.pathname)
|| url.hash) return;
event.preventDefault();
history.pushState({ page: url.pathname }, '', url.href);
renderRoute();
});
window.addEventListener('popstate', renderRoute);
renderRoute();홈에서 소개, 제품 링크를 차례로 누르면 홈·소개·제품이라는 기록이 생깁니다. 링크 처리에서는 pushState 뒤 직접 그리지만, 뒤로 이동할 때는 popstate가 renderRoute만 호출합니다. 복원하면서 pushState를 다시 실행하면 되돌아갈 기록을 새로 늘리는 문제가 생깁니다. 수정키·새 창·다운로드·앱 밖 경로·fragment 링크는 이 예제의 가로채기 대상에서 제외했습니다. 현재 nav 안에는 다른 자식 요소를 넣지 않은 a 태그만 있다는 DOM 조건입니다.
replaceState는 새 항목을 추가하는 대신 현재 항목의 URL과 state를 교체합니다. History 인터페이스의 pushState·replaceState 계약을 참고하세요. 뒤로·앞으로 이동은 호출 직후 결과를 동기 출력한다고 가정하지 말고, popstate에서 복원된 경로를 관찰합니다.
독립 연습
학습 페이지의 첫 기록이 홈 하나라고 가정합니다. 소개로 pushState, 제품으로 pushState한 뒤 현재 제품 기록을 replaceState({ page: '/products' }, '', '/products?sort=price')로 바꿉니다. 각 호출 뒤 renderRoute도 실행합니다. 이어 뒤로 한 번, 앞으로 한 번 이동하고 각각의 popstate 처리가 끝났습니다. 기록의 수, 두 목적지 URL, 화면 제목을 예상하세요. 마지막 URL을 새로고침할 때 필요한 서버 책임도 설명하세요.
새 항목과 현재 항목 교체를 나누는 풀이
기록은 홈·소개·제품?sort=price의 세 개입니다. replace는 네 번째 항목을 만들지 않습니다. 뒤로 이동한 목적지는 /about이고 제목은 소개, 앞으로 이동한 목적지는 /products?sort=price이고 제목은 제품입니다. 이 라우터는 pathname을 사용하므로 쿼리가 있어도 제품 화면을 고릅니다. 새로고침은 서버에 문서 요청을 보내므로 서버가 이 문서 경로에 앱 shell을 제공해야 합니다. Asset/API 오류까지 같은 shell로 바꾸는 규칙은 필요하지 않습니다. 클라이언트의 찾을 수 없음 화면과 HTTP 404 상태도 구별합니다.