History API와 라우팅
pushState·replaceState·popstate로 새로고침 없이 URL과 뒤로가기를 제어해 SPA 라우터의 원리를 구현합니다.
9장의 앞 글들에서는 Fetch API로 서버와 통신하고, 로컬 스토리지와 세션 스토리지로 브라우저에 데이터를 저장하는 방법을 익혔습니다. 이번에는 웹 애플리케이션의 사용 경험을 크게 좌우하는 내비게이션(navigation), 즉 화면 사이의 이동을 다룹니다.
전통적인 웹 페이지는 링크를 누르거나 폼을 제출할 때마다 서버에서 새 HTML 문서를 받아 페이지 전체를 새로 불러왔습니다. 그래서 이동할 때마다 화면이 깜빡이고 느려지기 쉬웠습니다. 이를 피하려고 처음에 한 번만 페이지를 불러오고, 그 뒤로는 필요한 데이터만 받아 화면의 일부를 바꾸는 싱글 페이지 애플리케이션(single page application, SPA)이 널리 쓰이게 되었습니다. 그런데 SPA에는 풀어야 할 문제가 있습니다. 페이지를 새로 불러오지 않으면서도 주소를 바꾸고, 브라우저의 뒤로 가기와 앞으로 가기 버튼이 제대로 동작해야 합니다. 이 문제를 푸는 것이 History API입니다.
전통적인 내비게이션과 SPA
전통적인 웹 페이지에서는 <a> 태그를 누르거나 location.href를 바꾸거나 폼을 제출하면, 브라우저가 서버에 새 HTTP 요청을 보내고 서버는 그 URL에 맞는 새 HTML 문서를 돌려줍니다. 브라우저는 지금의 페이지를 완전히 버리고 새 페이지를 처음부터 다시 그립니다.
이 방식에는 몇 가지 단점이 있습니다. 매번 페이지 전체를 받아야 하므로 네트워크를 기다리는 시간이 길어지고, 페이지 전체가 다시 그려지면서 화면이 깜빡입니다. 바뀌지 않은 CSS나 자바스크립트 파일도 다시 불러오거나 다시 실행해야 할 수 있습니다.
SPA는 이름 그대로 한 번 불러온 HTML 문서 하나를 유지한 채 동작하는 웹 애플리케이션입니다. 처음 불러올 때 필요한 HTML, CSS, 자바스크립트를 받고, 그 뒤로는 사용자의 동작에 따라 Fetch API로 필요한 데이터만 받아 화면의 내용을 바꿉니다. 이때 서버에 새 페이지를 요청하는 대신, 자바스크립트로 주소를 바꾸고 그 주소에 맞는 화면을 그리는 것을 클라이언트 라우팅(client-side routing)이라고 합니다.
History API
History API는 브라우저의 세션 기록(session history), 즉 지금 탭의 방문 기록을 자바스크립트로 다루게 해 주는 웹 API입니다. 페이지를 새로 불러오지 않고 주소를 바꾸거나, 뒤로 가기와 앞으로 가기를 눌렀을 때 알맞은 화면을 보여 줄 수 있습니다. window.history 객체로 접근합니다.
이 글의 예제는 주소를 실제로 바꾸므로 파일을 더블 클릭해 연 file: 주소가 아니라, 편집기의 로컬 HTTP 서버로 연 학습용 페이지에서 실행하세요.
history.pushState()
history.pushState(state, title, url)는 세션 기록에 새 항목을 추가합니다. 새 페이지로 이동한 것처럼 주소가 바뀌지만, 실제로 페이지를 다시 불러오지는 않습니다.
첫 번째 인수 state는 새 기록 항목에 연결해 둘 상태 객체로, 나중에 그 항목으로 돌아왔을 때 다시 꺼낼 수 있습니다. 두 번째 인수 title은 역사적인 이유로 남아 있을 뿐 지금은 쓰이지 않으므로 빈 문자열을 넘기고, 문서 제목은 document.title로 따로 바꿉니다. 세 번째 인수 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()를 호출하면 주소는 곧바로 바뀌지만 화면은 그대로입니다. 예제에서 두 번째 인수로 넘긴 'About Page', 'Products Page'도 무시되므로 탭의 제목은 바뀌지 않습니다. 바뀐 주소에 맞는 화면을 그리고 document.title로 제목을 바꾸는 일은 앱이 직접 해야 합니다.
history.replaceState()
history.replaceState(state, title, url)는 pushState()와 비슷하지만, 새 항목을 추가하는 대신 지금 기록 항목을 바꿉니다. 검색 필터를 바꿨을 때처럼 지금 화면에 머물면서 주소만 갱신하고, 뒤로 가기를 눌렀을 때 이전 필터로 돌아가지 않게 하고 싶을 때 씁니다.
// 현재 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)는 브라우저의 뒤로 가기, 앞으로 가기 버튼과 같은 일을 합니다. history.go(-1)은 back()과, history.go(1)은 forward()와 같고, history.go(0)은 지금 페이지를 새로 불러옵니다.
// 예시 시나리오:
// 1. 초기 로드: / (index.html)
// 2. history.pushState({}, '', '/about'); // URL: /about
// 3. history.pushState({}, '', '/contact'); // URL: /contact
// 현재 /contact
console.log("현재:", window.location.href); // 결과: http://localhost:8080/contact
history.back(); // 비동기 탐색 요청: 완료 후 /about으로 이동
console.log("뒤로가기 후:", window.location.href);
history.forward(); // 비동기 탐색 요청: 바로 위 back의 완료를 기다리지 않음
console.log("앞으로가기 후:", window.location.href);
// history.go(-2); // 두 단계 전 항목으로 이동 (위 시나리오의 초기 URL은 /)이 메서드들은 이동을 요청할 뿐 그 자리에서 이동을 끝내지 않습니다. 그래서 back() 바로 다음 줄의 로그는 아직 이동하기 전의 주소를 출력할 수 있습니다. 이동이 끝난 결과는 다음에 볼 popstate 이벤트에서 확인해야 합니다.
popstate 이벤트
사용자가 뒤로 가기나 앞으로 가기를 누르거나 history.back() 같은 메서드로 기록을 이동하면, 이동이 끝난 뒤 window에 popstate 이벤트가 발생합니다. event.state로 그 항목에 pushState()나 replaceState()로 저장해 둔 상태 객체를 꺼낼 수 있습니다.
// 라우터 역할을 하는 함수
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);이 예제는 1초 간격으로 두 번 pushState()를 호출한 뒤, 뒤로 두 번, 앞으로 한 번 이동합니다. 주의할 점은 pushState()나 replaceState()를 호출하는 것만으로는 popstate가 발생하지 않는다는 것입니다. 그래서 pushState()를 호출한 뒤에는 handleRoute()를 직접 불러 화면을 그리고, history.back()처럼 기록을 이동한 경우에만 popstate가 handleRoute()를 불러 줍니다.
새 탐색과 기록 이동
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하지 않는다 |
위 그림은 사용자, 앱, 세션 기록 사이의 두 가지 흐름을 보여 줍니다. 앱에는 라우터, 화면 그리기, 문서 제목, 포커스 처리가 함께 묶여 있습니다. 사용자가 새 링크를 누르면 앱이 pushState()로 기록에 새 항목과 상태를 쓰고, 새 화면을 그린 뒤 제목과 포커스를 맞춥니다.
사용자가 뒤로 이동하면 기록이 popstate로 목적지의 상태를 앱에 알려 주고, 앱은 그 기록에 맞게 화면을 복원합니다. 이때는 기록을 새로 쓰지 않습니다. 그림의 설명처럼 새 탐색은 기록을 쓰고, 기록 이동은 popstate로 기록을 읽는 서로 다른 경로이며, pushState()와 replaceState() 자체는 popstate를 발생시키지 않습니다. 두 경로 모두 화면과 문서 제목, 포커스를 같은 목적지에 맞춰야 합니다.
클라이언트 라우팅의 원리
History API로 클라이언트 라우팅을 만드는 원리는 세 가지입니다. 먼저 내부 링크를 클릭하면 기본 동작인 페이지 이동을 막고, pushState()로 주소를 바꾼 뒤 그 주소에 맞는 화면을 그립니다. 다음으로 popstate가 발생하면 window.location.pathname으로 지금 주소를 읽어 맞는 화면을 그립니다. 마지막으로 페이지를 처음 불러왔을 때도 지금 주소에 맞는 화면을 그립니다.
첫 번째 원리를 가장 간단하게 구현하면 다음과 같습니다. 앞의 handleRoute()와 함께 쓰는 예제로, 페이지의 모든 링크가 같은 출처의 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에 맞는 콘텐츠 렌더링
});
});실제 라우터라면 외부 사이트로 가는 링크, 파일 다운로드 링크, 새 탭으로 여는 링크, Ctrl이나 Shift 같은 보조 키를 누른 클릭은 가로채지 말고 브라우저의 기본 동작에 맡겨야 합니다. React Router, Vue Router, Next.js 같은 라이브러리와 프레임워크는 이런 처리를 모두 대신해 주므로 History API를 직접 다룰 일은 많지 않습니다. 하지만 그 내부가 어떻게 동작하는지 알면 라우팅 버그나 새로고침 문제를 분석하기 훨씬 쉬워집니다.
기록과 화면을 한 번씩만 바꾸기
다음은 제목과 포커스까지 맞추고, 가로채지 말아야 할 링크를 걸러 내는 확장 라우터입니다. 앞의 타이머 예제와 섞지 말고 새 main.js에서 실행합니다. 같은 출처의 학습용 페이지에 <nav><a href="/">홈</a> <a href="/about">소개</a> <a href="/products">제품</a></nav><main id="route-view" tabindex="-1"></main>을 만들고 이 파일 하나만 연결하세요.
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() 다음에 renderRoute()를 직접 부르지만, 뒤로 이동할 때는 popstate가 renderRoute()만 부릅니다. 복원하면서 pushState()를 다시 호출하면 되돌아갈 기록을 새로 쌓아 버리기 때문입니다.
renderRoute()는 화면의 내용과 함께 document.title을 바꾸고 main 요소로 포커스를 옮깁니다. 페이지를 새로 불러오지 않으면 스크린 리더 사용자는 화면이 바뀐 것을 알기 어려우므로, 포커스를 새 내용으로 옮겨 알려 주는 것입니다. 보조 키를 누른 클릭, 새 창 대상, 다운로드 링크, 앱 밖의 경로, #이 붙은 링크는 가로채지 않습니다. 이 예제는 nav 안에 다른 자식 요소가 없는 a 태그만 있다고 가정합니다.
더 깊이: History API 다음에 나온 Navigation API
이 글의 라우터는 링크 클릭을 가로채는 코드와 popstate를 받는 코드를 따로 두고, 가로채지 말아야 할 링크를 직접 걸러 냈습니다. 이런 수고를 줄이려고 나온 것이 Navigation API입니다. MDN은 이 API를 History API와 window.location의 뒤를 이어 그 부족한 점을 풀고, SPA의 필요에 맞춘 기능으로 소개합니다.
Navigation API에서는 navigation 객체의 navigate 이벤트 하나가 링크 클릭, 폼 제출, 뒤로 가기와 앞으로 가기, 코드로 시작한 이동처럼 모든 종류의 탐색에서 발생합니다. 리스너는 event.canIntercept로 가로챌 수 있는 탐색인지, event.hashChange나 event.downloadRequest로 해시 이동이나 다운로드인지를 확인한 뒤, event.intercept({ handler })에 화면을 그리는 함수를 넘깁니다. 그러면 브라우저가 주소와 기록을 바꾸고, 라우터는 탐색의 종류마다 따로 처리할 필요 없이 한곳에서 화면만 그리면 됩니다. 또 navigation.entries()로 지금 탭에서 같은 출처의 기록 항목을 배열로 읽을 수 있어, 개수만 알려 주는 history.length보다 많은 정보를 얻습니다.
MDN의 Navigation API 설명에 따르면 이 API는 2026년 1월부터 주요 브라우저의 최신 버전에서 함께 쓸 수 있게 되었습니다. 지원된 지 얼마 되지 않았으므로, 지원 범위에 오래된 브라우저가 있다면 'navigation' in window로 확인해 이 글의 History API 방식으로 대신하게 합니다.
주소를 직접 입력하거나 새로고침할 때
클라이언트 라우터는 페이지를 이미 불러온 상태에서만 동작합니다. 사용자가 /about을 주소 표시줄에 직접 입력하거나 그 주소에서 새로고침하면, 브라우저는 서버에 /about 문서를 요청합니다. 그래서 클라이언트 라우터보다 먼저 서버가 무엇을 응답하는지 확인해야 합니다.
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는 서로 다른 관찰 대상이다 |
위 그림은 직접 접근한 요청이 서버와 클라이언트를 거치는 과정입니다. 서버는 먼저 요청이 이미지나 스크립트 같은 정적 파일(asset)이나 API 요청인지 확인합니다. 그렇다면 그 파일이나 API 처리기가 응답하고, 없는 파일이면 HTTP 404를 돌려줍니다. 화면을 보여 줘야 하는 문서 경로라면 앱의 뼈대가 되는 HTML(app shell)과 자바스크립트를 응답합니다.
앱이 실행되면 클라이언트 라우터가 원래 요청한 경로를 읽어 맞는 화면이 있는지 확인합니다. 맞는 화면이 있으면 그리고 제목과 포커스를 맞추며, 없으면 클라이언트가 그리는 “찾을 수 없음” 화면을 보여 줍니다. 이 화면을 그린다고 해서 서버가 처음 돌려준 HTTP 상태 코드가 404가 되는 것은 아니며, 둘은 서로 별개입니다.
그림 아래의 설명처럼 SPA를 위한 대체 응답(fallback)은 모든 404를 index.html로 바꾸는 규칙이 아닙니다. 정적 파일과 API는 먼저 각자의 규칙으로 응답하고, 문서 경로만 클라이언트 라우터에 넘겨야 직접 접근한 주소가 올바르게 복원됩니다. 이 설정은 서버나 호스팅 서비스에서 하는 것이며 자바스크립트가 대신해 주지 않습니다.
정리
- SPA는 페이지를 새로 불러오지 않고 주소와 화면을 함께 바꾸며, 이를 위해 History API로 세션 기록을 다룹니다.
pushState()는 기록에 새 항목을 추가하고,replaceState()는 지금 항목을 바꾸며, 둘 다 서버에 요청하지 않고popstate도 발생시키지 않습니다.back(),forward(),go()는 이동을 요청할 뿐이며, 이동이 끝나면popstate가 발생하고event.state로 저장한 상태를 읽습니다.- 새 링크는
pushState()뒤에 직접 화면을 그리고, 기록 이동은popstate에서 화면만 복원하며 기록을 다시 쓰지 않습니다. - 라우터는 화면 내용과 함께 문서 제목과 포커스도 맞추고, 외부 링크나 보조 키 클릭은 가로채지 않아야 합니다.
- 주소를 직접 입력하거나 새로고침하면 서버가 먼저 응답하므로, 정적 파일과 API를 뺀 문서 경로에만 앱의 HTML을 돌려주도록 서버를 설정해야 합니다.
연습 문제
1. 추가와 교체
학습 페이지의 첫 기록이 홈 하나라고 가정합니다. 확장 라우터에서 소개로 pushState(), 제품으로 pushState()한 뒤, 지금의 제품 기록을 replaceState({ page: '/products' }, '', '/products?sort=price')로 바꿨습니다. 각 호출 뒤에는 renderRoute()도 실행합니다. 이어서 뒤로 한 번, 앞으로 한 번 이동하고 각 popstate 처리가 끝났습니다. 기록의 개수, 두 목적지의 URL과 화면 제목을 예상해 보세요. 마지막 URL에서 새로고침할 때 서버는 무엇을 해야 할까요?
해설 보기
기록은 홈, 소개, 제품?sort=price의 세 개입니다. replaceState()는 지금 항목을 바꿀 뿐 네 번째 항목을 만들지 않습니다. 뒤로 이동한 목적지는 /about이고 제목은 소개이며, 앞으로 이동한 목적지는 /products?sort=price이고 제목은 제품입니다. 이 라우터는 pathname으로 화면을 고르므로 쿼리 문자열이 붙어도 제품 화면을 그립니다.
새로고침하면 브라우저가 서버에 /products?sort=price 문서를 요청하므로, 서버가 이 문서 경로에 앱의 HTML을 돌려줘야 합니다. 정적 파일이나 API의 오류까지 같은 HTML로 바꿀 필요는 없으며, 클라이언트의 “찾을 수 없음” 화면과 HTTP 404 상태도 구별해야 합니다.
2. back() 바로 다음의 주소
기록 이동 예제처럼 지금 주소가 /contact이고 그 앞에 /about이 있습니다. history.back(); 바로 다음 줄의 console.log(location.pathname)은 무엇을 출력할까요? 이동이 끝난 주소를 확인하려면 어떻게 해야 할까요?
해설 보기
/contact가 출력됩니다. history.back()은 이동을 요청만 하고 바로 반환하므로, 다음 줄이 실행될 때는 아직 이동이 끝나지 않았습니다.
이동이 끝나면 popstate가 발생하므로, window.addEventListener('popstate', () => console.log(location.pathname));처럼 이벤트 안에서 읽으면 /about이 출력됩니다. 기록 이동의 결과는 호출 직후가 아니라 popstate에서 확인해야 합니다.
3. popstate에서 pushState를 부르면
홈, 소개, 제품 순서로 이동한 뒤 뒤로 가기를 눌러 소개로 돌아왔습니다. 그런데 잘못 만든 라우터가 popstate 처리 안에서 history.pushState({}, '', location.pathname)을 호출했습니다. 기록은 어떻게 될까요? 이제 앞으로 가기를 누르면 제품으로 갈 수 있을까요?
해설 보기
기록은 홈, 소개, 소개가 되고 제품 항목은 사라집니다. pushState()는 지금 항목 뒤에 있던 앞으로 가기 기록을 모두 지운 다음 새 항목을 추가하기 때문입니다.
그래서 앞으로 가기를 눌러도 제품으로 갈 수 없고, 뒤로 가기는 같은 소개 화면을 한 번 더 보여 줍니다. 복원할 때는 기록을 쓰지 말고 화면만 그려야 하는 이유입니다.
pushState()와 replaceState()의 규칙은 HTML 표준의 History 인터페이스에서, 사용 예는 MDN의 History API 문서에서 확인할 수 있습니다.
다음 글 Canvas API 기초에서는 <canvas> 요소에 자바스크립트로 도형과 글자, 이미지를 그리고 애니메이션을 만드는 방법을 살펴봅니다.