2025-09-23 00:00
-
History API는 페이지 새로고침 없이 URL을 변경하고 브라우저 세션 기록을 조작하는 웹 기술.
-
단일 페이지 애플리케이션(SPA)에서 실제 페이지 이동과 같은 사용자 경험을 제공하기 위해 탄생.
-
pushState(),replaceState()로 URL과 상태를 제어하고popstate이벤트로 사용자의 뒤로/앞으로 가기 동작을 감지.
브라우저의 타임머신 History API 완벽 정복 핸드북
오늘날 우리가 사용하는 웹 애플리케이션은 더 이상 단순한 문서의 집합이 아니다. 페이스북의 뉴스피드를 스크롤하거나, 구글 맵에서 지도를 탐색할 때, 우리는 페이지 전체가 깜빡이며 새로고침되는 경험을 거의 하지 않는다. 콘텐츠는 부드럽게 변경되지만, 주소창의 URL은 현재 보고 있는 내용을 정확하게 반영한다. 덕분에 우리는 특정 상태를 친구에게 공유하거나 즐겨찾기에 추가할 수 있다.
이 모든 경험의 중심에는 History API가 있다. History API는 개발자가 브라우저의 세션 기록(session history)을 스크립트로 제어할 수 있게 해주는 강력한 도구다. 이 핸드북에서는 History API가 왜 만들어졌는지, 그 구조는 어떻게 이루어져 있으며, 어떻게 사용하여 현대적인 웹 애플리케이션을 구축할 수 있는지 심도 있게 탐구한다.
1. History API의 탄생 배경: 웹의 진화와 주소창의 숙제
History API의 필요성을 이해하려면, 웹이 어떻게 발전해왔는지 잠시 돌아볼 필요가 있다.
정적 페이지 시대: 1 URL = 1 HTML
초기 웹은 하이퍼링크로 연결된 HTML 문서들의 집합이었다. 사용자가 링크를 클릭하면, 브라우저는 해당 URL의 HTML 문서를 서버에 요청하고, 전체 페이지를 새로 렌더링했다. 이 모델은 매우 직관적이었다. 하나의 URL은 하나의 고유한 문서를 가리켰다. 뒤로 가기 버튼은 말 그대로 이전에 봤던 문서를 다시 불러오는 기능이었다.
AJAX의 등장과 동적 웹
2000년대 초반, AJAX (Asynchronous JavaScript and XML) 기술이 등장하며 웹은 새로운 국면을 맞이한다. AJAX를 통해 개발자들은 페이지 전체를 새로고침하지 않고도 서버와 통신하여 필요한 데이터만 비동기적으로 받아와 페이지의 일부를 동적으로 갱신할 수 있게 되었다. 이것이 바로 SPA (Single Page Application) 의 시작이었다. 사용자는 더 빠르고 부드러운, 마치 데스크톱 애플리케이션과 같은 경험을 할 수 있게 되었다.
하지만 이 혁신에는 치명적인 문제가 있었다. 콘텐츠는 계속 바뀌는데, 브라우저 주소창의 URL은 처음 접속했던 그대로였다. 이는 웹의 근본적인 원칙을 무너뜨렸다.
-
뒤로 가기/앞으로 가기 버튼의 오작동: 사용자가 뒤로 가기 버튼을 누르면 이전 애플리케이션 상태로 돌아가는 대신, 이 SPA에 진입하기 전의 페이지로 아예 이탈해버렸다.
-
새로고침의 문제: 페이지를 새로고침하면 동적으로 변경되었던 모든 내용이 사라지고 초기 상태로 돌아갔다.
-
링크 공유 및 즐겨찾기 불가: 내가 보고 있는 이 특정 콘텐츠를 다른 사람에게 URL로 공유할 방법이 없었다.
임시방편 hash(#): 앵커의 재발견
개발자들은 이 문제를 해결하기 위해 URL의 일부인 해시(hash, #) 를 사용하기 시작했다. URL의 해시 부분은 서버로 전송되지 않으며, location.hash 속성을 통해 자바스크립트로 읽고 쓸 수 있었다. hashchange 이벤트를 감지하여 해시 값이 변경될 때마다 페이지의 콘텐츠를 동적으로 바꾸는 ‘해시뱅 라우팅(#!)’ 기법이 유행했다.
JavaScript
// 해시 값 변경을 감지
window.addEventListener('hashchange', function() {
const hash = location.hash; // #/profile/123
// 해시 값에 따라 적절한 콘텐츠를 렌더링
renderContent(hash);
});
이 방법은 어느 정도 문제를 해결했지만, 여전히 한계가 명확했다.
-
비정상적인 URL:
http://example.com/#/users/123처럼 URL이 다소 지저분해 보였다. -
SEO 문제: 검색 엔진 크롤러는 전통적으로 해시 이후의 값을 페이지의 일부로 인식하지 않아 SEO(검색 엔진 최적화)에 불리했다.
-
제한적인 기능: 해시는 단순히 문자열일 뿐, 상태 정보를 구조적으로 담기 어려웠다.
History API의 등장: 진정한 SPA 시대를 열다
이러한 문제들을 근본적으로 해결하기 위해 HTML5 명세의 일부로 History API가 등장했다. History API는 개발자에게 페이지를 실제로 이동시키지 않으면서 브라우저의 URL을 변경하고, 세션 기록 스택에 상태(state)를 추가하거나 수정할 권한을 부여했다. 이제 개발자들은 http://example.com/users/123 와 같이 깔끔하고 의미 있는 URL을 사용하면서도 SPA의 부드러운 경험을 제공할 수 있게 되었다. 이것이 바로 현대적인 웹 프레임워크의 ‘라우터’가 동작하는 핵심 원리다.
2. History API 핵심 구조 파헤치기: 무엇으로 이루어져 있나
History API는 window 객체에 속한 history 객체를 통해 접근할 수 있다. 터미널에서 window.history 또는 간단히 history를 입력하면 그 구조를 직접 확인할 수 있다.
window.history 객체
History API의 모든 기능은 이 객체에 담겨있다. 비유하자면, 브라우저라는 타임머신의 조종석과 같다.
주요 속성 (Properties)
| 속성 | 설명 |
|---|---|
history.length | 현재 브라우징 세션 기록 스택에 쌓인 항목의 개수를 반환한다. (읽기 전용) |
history.state | pushState(), replaceState()를 통해 설정된 현재 history 항목의 상태(state) 객체를 반환한다. (읽기 전용) 만약 설정된 상태가 없다면 null을 반환. |
history.scrollRestoration | 페이지 이동 시 스크롤 위치 복원 동작을 제어한다. auto (기본값) 또는 manual 값을 가질 수 있다. |
주요 메서드 (Methods)
| 메서드 | 설명 |
|---|---|
history.back() | 세션 기록의 바로 이전 페이지로 이동한다. 브라우저의 ‘뒤로 가기’ 버튼과 동일. |
history.forward() | 세션 기록의 바로 다음 페이지로 이동한다. 브라우저의 ‘앞으로 가기’ 버튼과 동일. |
history.go(delta) | 현재 페이지를 기준으로 상대적인 위치로 이동한다. go(-1)은 back()과 같고, go(1)은 forward()와 같다. go(0)은 현재 페이지를 새로고침한다. |
history.pushState() | 세션 기록 스택에 새로운 상태를 추가한다. |
history.replaceState() | 세션 기록 스택의 현재 상태를 새로운 상태로 교체한다. |
back(), forward(), go()는 전통적인 페이지 이동 방식과 유사하게 동작하지만, pushState()와 replaceState()는 History API의 핵심이자 SPA를 구현하는 마법의 열쇠다.
3. History API 사용법: SPA를 만드는 마법
이제 가장 중요한 두 메서드와 하나의 이벤트를 통해 어떻게 SPA에서 페이지 전환 효과를 만들어내는지 알아보자.
history.pushState(state, unused, url): 새로운 기록 추가하기
이 메서드는 브라우저의 세션 기록 스택에 새로운 항목을 ‘밀어 넣는다(push)‘. 페이지를 실제로 새로고침하지 않으면서 주소창의 URL을 바꾸고, 새로운 상태 객체를 이 URL과 연결한다.
JavaScript
history.pushState(state, unused, url);
-
state: 새로운 history 항목과 연관시킬 자바스크립트 객체. 사용자가 뒤로가기 버튼을 눌러 이 상태로 돌아왔을 때,popstate이벤트의event.state속성을 통해 이 객체를 다시 꺼내 쓸 수 있다. UI를 복원하는 데 필요한 최소한의 데이터를 담는 ‘보물 상자’와 같다. 이 객체는 직렬화 가능해야 하며, 브라우저마다 크기 제한(보통 2MB ~ 16MB)이 있다. -
unused: 역사적인 이유로 남아있는 파라미터. 과거 Firefox 브라우저에서 페이지 제목(title)을 설정하기 위해 사용되었으나 현재는 대부분의 브라우저에서 무시된다. 호환성을 위해 빈 문자열('')이나null을 전달하는 것이 관례다. -
url(선택 사항): 새로운 history 항목의 URL. 절대 경로나 상대 경로를 지정할 수 있다. 만약 생략하면 현재 URL이 그대로 유지된다. 중요한 제약 조건으로, 현재 URL과 동일한 출처(origin)를 가져야 한다. 다른 도메인으로의 URL 변경은 보안상의 이유로 불가능하다.
예시 시나리오: 블로그 목록에서 상세 페이지로 이동
사용자가 게시물 목록(example.com/posts)에서 ID가 123인 게시물을 클릭했다고 가정해보자.
JavaScript
// 게시물 링크 클릭 이벤트 핸들러
function onPostClick(postId) {
// 1. AJAX로 게시물 상세 데이터 가져오기
fetch(`/api/posts/${postId}`)
.then(res => res.json())
.then(postData => {
// 2. 받아온 데이터로 화면 내용 업데이트
renderPostDetail(postData);
// 3. History API로 URL과 상태 변경
const state = { postId: postId, type: 'post' };
const newUrl = `/posts/${postId}`;
history.pushState(state, '', newUrl);
});
}
// 사용자가 ID 123 게시물 클릭
onPostClick(123);
이 코드가 실행되면, 페이지는 새로고침되지 않고 내용만 상세 보기로 바뀐다. 동시에 주소창의 URL은 example.com/posts/123으로 변경되며, 세션 기록 스택에는 { postId: 123, type: 'post' } 라는 state 객체와 함께 새로운 항목이 추가된다.
history.replaceState(state, unused, url): 현재 기록 덮어쓰기
replaceState()는 pushState()와 매우 유사하지만, 새로운 기록을 추가하는 대신 현재 기록을 덮어쓴다. 즉, 세션 기록의 length가 늘어나지 않는다.
언제 사용할까? 사용자의 뒤로가기 경험에 혼란을 줄 수 있는 불필요한 기록을 남기고 싶지 않을 때 유용하다.
-
정렬이나 필터 변경: 쇼핑몰에서 상품 목록의 정렬 기준을 ‘가격순’에서 ‘인기순’으로 변경했을 때. 이 사소한 변경 하나하나가 기록에 남는다면 사용자가 뒤로가기를 눌렀을 때 불편할 것이다. 이럴 때
replaceState()를 사용하여 URL의 쿼리 파라미터(?sort=popular)만 변경하고 기록은 덮어쓰는 것이 좋다. -
초기 상태 설정: 페이지에 처음 진입했을 때, 특정 조건에 따라 초기 상태와 URL을 미세하게 조정해야 할 경우
replaceState()를 사용해 첫 기록을 수정할 수 있다.
JavaScript
// 필터 버튼 클릭 이벤트 핸들러
function onFilterChange(newFilter) {
// 1. 새 필터로 콘텐츠 다시 렌더링
renderContentWithFilter(newFilter);
// 2. 새 필터 값이 포함된 URL로 현재 기록을 '교체'
const state = { filter: newFilter };
const newUrl = `?filter=${newFilter}`;
history.replaceState(state, '', newUrl); // pushState가 아님!
}
popstate 이벤트: 사용자의 시간 여행 감지하기
pushState와 replaceState가 타임머신을 미래로 보내는 버튼이라면, popstate 이벤트는 사용자가 과거(뒤로 가기)나 미래(앞으로 가기)로 이동했을 때 울리는 알람이다.
이 이벤트는 다음과 같은 경우 window 객체에서 발생한다.
-
사용자가 브라우저의 뒤로 가기 또는 앞으로 가기 버튼을 클릭했을 때.
-
자바스크립트로
history.back(),history.forward(),history.go()메서드가 호출되었을 때.
매우 중요한 점은 history.pushState()나 history.replaceState()를 호출할 때는 popstate 이벤트가 발생하지 않는다는 것이다. 이 이벤트는 오직 브라우저의 탐색 동작에 의해서만 트리거된다.
popstate 이벤트 리스너는 이벤트 객체를 인자로 받으며, 이 객체의 state 속성에 주목해야 한다.
JavaScript
window.addEventListener('popstate', function(event) {
// event.state에는 pushState나 replaceState로 저장했던 state 객체가 들어있다.
// 만약 state가 없는 history 항목(예: 페이지 첫 진입)이면 null이 될 수 있다.
console.log('Location changed to:', document.location);
console.log('State object:', event.state);
if (event.state) {
// state 객체에 담아둔 정보를 바탕으로 UI를 복원한다.
// 예를 들어, postId를 이용해 해당 게시물 데이터를 다시 렌더링
if (event.state.type === 'post') {
renderPostDetailById(event.state.postId);
} else if (event.state.type === 'list') {
renderPostList();
}
} else {
// state가 없는 경우 (예: 초기 페이지) 처리
renderInitialPage();
}
});
event.state는 우리가 pushState의 state 인자로 넣어주었던 바로 그 ‘보물 상자’다. 이 객체를 이용하여 해당 URL에 맞는 화면을 다시 그려주는 로직을 구현하면, 사용자는 완벽한 SPA 탐색 경험을 하게 된다.
4. 심화 탐구 및 실전 팁
History API의 기본을 익혔다면, 이제 실전에서 마주할 수 있는 몇 가지 고급 주제와 팁을 살펴보자.
state 객체 활용 전략
state 객체는 SPA의 상태 복원을 위한 핵심이다. 무엇을 담아야 할까?
-
최소한의 필수 데이터: UI를 복원하는 데 필요한 최소한의 식별자를 담는 것이 좋다. 예를 들어, 사용자 프로필 페이지라면 사용자 ID(
{ userId: 123 })만 담고,popstate이벤트 발생 시 이 ID로 다시 API를 호출하여 전체 데이터를 받아오는 것이 일반적이다. -
전체 데이터를 담는 경우: 애플리케이션의 상태가 복잡하지 않고 데이터 크기가 작다면, API를 다시 호출하는 비용을 줄이기 위해
state에 UI 렌더링에 필요한 전체 데이터를 담을 수도 있다. 하지만 데이터가 변경되었을 때state객체와 실제 데이터 간의 동기화 문제에 신경 써야 한다. -
주의점:
state객체는 브라우저에 의해 직렬화되어 저장된다. 너무 큰 데이터를 저장하면 성능에 영향을 미치고 브라우저별 저장 용량 제한에 걸릴 수 있으니 주의해야 한다.
서버 사이드 렌더링(SSR)과 History API
최신 웹 애플리케이션은 사용자 경험과 SEO를 모두 잡기 위해 서버 사이드 렌더링(SSR)과 클라이언트 사이드 렌더링(CSR)을 함께 사용하는 경우가 많다.
-
첫 페이지 로드: 사용자가
example.com/posts/123URL로 직접 접속하거나 새로고침하면, 서버는 이 경로를 해석하여 완전한 HTML 페이지를 렌더링해서 보내준다(SSR). 이는 빠른 초기 로딩 속도와 SEO에 유리하다. -
이후 페이지 탐색: 페이지가 로드된 후, 사용자가 다른 링크를 클릭하면 더 이상 서버에 전체 페이지를 요청하지 않는다. 대신, History API(
pushState)를 사용하여 URL만 변경하고, AJAX로 필요한 데이터만 받아와 클라이언트 측에서 페이지를 렌더링한다(CSR).
이러한 하이브리드 방식의 핵심은, 서버와 클라이언트 모두 동일한 URL 경로를 이해하고 그에 맞는 화면을 그려줄 수 있어야 한다는 점이다. 서버 측에서는 어떤 URL로 요청이 들어와도 적절한 초기 페이지를 반환하도록 ‘catch-all’ 라우팅 설정이 필요하다.
scrollRestoration 속성 마스터하기
기본적으로 브라우저는 페이지를 이동할 때 이전 페이지의 스크롤 위치를 기억했다가 다시 돌아왔을 때 복원해준다(auto). 하지만 SPA에서는 이러한 자동 복원 기능이 오히려 방해가 될 때가 있다.
history.scrollRestoration = 'manual'로 설정하면, 브라우저의 자동 스크롤 복원 기능을 끄고 개발자가 직접 제어할 수 있다.
JavaScript
// 스크롤 위치를 직접 제어하고 싶을 때
if ('scrollRestoration' in history) {
history.scrollRestoration = 'manual';
}
// pushState를 호출하기 전에 현재 스크롤 위치를 state에 저장
const currentState = { ... };
currentState.scrollTop = window.scrollY;
history.pushState(currentState, '', newUrl);
// popstate 이벤트에서 저장된 스크롤 위치로 복원
window.addEventListener('popstate', function(event) {
if (event.state && event.state.scrollTop) {
window.scrollTo(0, event.state.scrollTop);
}
});
크로스 브라우저 호환성 및 폴리필(Polyfill)
History API는 IE 10 이상을 포함한 모든 현대 브라우저에서 잘 지원된다. 따라서 대부분의 경우 별도의 호환성 처리가 필요 없다. 하지만 만약 IE 9 이하의 구형 브라우저를 지원해야 하는 매우 드문 상황이라면, 해시뱅 라우팅으로 대체 동작을 구현하는 history.js와 같은 폴리필 라이브러리 사용을 고려할 수 있다.
5. 결론: History API, 현대 웹 개발의 필수 교양
History API는 단순히 브라우저의 기록을 조작하는 작은 기능이 아니다. 이 API의 등장은 정적인 문서들의 집합이었던 웹을 동적인 애플리케이션 플랫폼으로 진화시키는 데 결정적인 역할을 했다.
-
사용자 경험 향상: 페이지 깜빡임 없는 부드러운 화면 전환을 가능하게 했다.
-
웹의 기본 원칙 복원: 동적인 SPA 환경에서도 URL이 애플리케이션의 상태를 정확히 표현하게 하여, 뒤로 가기, 새로고침, 즐겨찾기, 링크 공유와 같은 웹의 핵심 기능을 정상적으로 작동시켰다.
-
현대 프레임워크의 기반: 우리가 사용하는 React Router, Vue Router, Angular Router와 같은 모든 클라이언트 사이드 라우팅 라이브러리들은 내부적으로 History API를 추상화하여 편리한 API를 제공한다.
History API의 동작 원리를 깊이 이해하는 것은 단순히 기술 하나를 더 아는 것을 넘어, 현대 웹 애플리케이션이 어떻게 사용자에게 매끄러운 경험을 선사하는지 그 근본을 이해하는 것이다. 이 핸드북이 여러분의 웹 개발 여정에 든든한 나침반이 되기를 바란다.