본문으로 건너뛰기
2026. 8. 1

브라우저는 뒤로가기를 어떻게 기억할까요?

연속된 히스토리 엔트리 가운데 현재 위치를 가리키는 그림

브라우저에서 뒤로가기를 누르면 이전 "화면"이 나온다고 말합니다. 그런데 SPA에서는 새 문서를 받지 않고도 여러 화면을 오갑니다. 모달 하나가 뒤로가기에 닫히기도 하고 검색어를 몇 글자 입력했을 뿐인데 뒤로가기를 여러 번 눌러야 할 때도 있습니다.

익숙한 기능인데 설명하려고 하면 단어부터 막힙니다. 브라우저는 화면을 저장하는 것인지, URL을 쌓는 것인지, JavaScript의 상태까지 기억하는 것인지 선뜻 답하기 어렵습니다. 여기에 history, 라우터, 브라우저 캐시가 한꺼번에 등장하면 머릿속 모델은 금방 엉킵니다.

브라우저는 무엇을 기록하고, SPA는 그 기록으로 어떻게 이전 화면을 복원할까요?


이 글에서 다루는 내용

이 글은 브라우저가 관리하는 세션 히스토리(session history)의 모델에서 시작합니다. 엔트리와 문서를 구분한 뒤 추가, 교체, 순회, 새로고침이 그 목록을 어떻게 바꾸는지 살펴봅니다. 이어서 SPA 라우터가 같은 모델 위에서 URL과 UI를 맞추는 과정을 정리합니다.

웹뷰에 들어가면 이 웹 히스토리 바깥에 네이티브 화면 스택이 하나 더 생깁니다. 그 환경에서 누가 뒤로가기를 처리해야 하는지는 2편에서 실제 퍼널 버그를 통해 다룹니다.




브라우저는 화면이 아니라 엔트리를 기억합니다

브라우저 히스토리를 흔히 "스택"이라고 부릅니다. 뒤로가기가 가장 최근 항목부터 되돌아간다는 점에서는 쓸 만한 비유입니다. 다만 실제 동작을 설명할 때는 순서가 있는 엔트리 목록과 현재 위치로 이해하는 편이 정확합니다.

뒤로가기는 현재 위치를 왼쪽으로 옮깁니다. 엔트리를 삭제하지 않으므로 다시 앞으로 갈 수 있습니다. 과거 엔트리로 돌아간 상태에서 새 탐색을 시작하면 현재 위치 오른쪽의 forward 엔트리가 제거되고 새 갈래가 생깁니다.

엔트리와 Document는 같은 것이 아닙니다

세션 히스토리 엔트리(session history entry)는 특정 시점의 탐색 상태를 나타냅니다. 엔트리에는 URL만 들어 있지 않습니다.

구성브라우저가 기억하는 것
URL해당 엔트리의 주소
문서 상태활성화하거나 필요하면 다시 만들 Document에 관한 정보
History API statepushState()replaceState()로 저장한 직렬화된 값
복원 정보스크롤 위치나 폼 값처럼 사용자 에이전트가 보존한 상태

엔트리와 Document는 일대일이 아닙니다. /step/1에서 새 HTML 문서를 받고 /step/2/step/3pushState()로 만든 SPA라면 엔트리는 세 개지만 같은 Document를 공유할 수 있습니다. 반대로 일반적인 문서 탐색에서는 새 엔트리가 새 Document를 가리킵니다.

이 구분 때문에 "페이지가 바뀌었다"는 말은 모호합니다. URL 엔트리만 바뀌었는지, 활성 Document가 바뀌었는지, 애플리케이션이 같은 문서 안에서 컴포넌트만 바꿨는지 나누어 봐야 합니다.

same-document와 cross-document

명세는 탐색 결과를 크게 같은 문서 탐색(same-document navigation)과 다른 문서 탐색(cross-document navigation)으로 구분합니다.

  • 링크를 눌러 새 HTML 문서를 받으면 보통 cross-document navigation입니다.
  • pushState()replaceState(), 프래그먼트 이동은 같은 Document를 유지한 채 엔트리를 바꿀 수 있습니다.

같은 문서를 유지한다고 히스토리에 남지 않는 것은 아닙니다. 엔트리는 여러 개일 수 있고 각각 다른 URL과 state를 가질 수 있습니다. SPA가 브라우저의 뒤로가기와 결합할 수 있는 자리가 바로 여기입니다.

iframe도 전체 순회에 참여합니다

iframe은 자체 탐색 영역을 갖지만 사용자는 탭 전체에서 하나의 뒤로가기 인터페이스를 사용합니다. 브라우저는 자식 탐색 영역의 이동도 최상위 탐색 영역이 순회할 수 있는 하나의 순서로 맞춥니다.

결제 위젯이나 인증 iframe이 내부에서 여러 번 이동했다면 사용자의 뒤로가기가 부모 화면보다 iframe의 이동을 먼저 소비할 수 있습니다. 교차 출처 iframe의 내부 상태를 부모가 직접 읽을 수 없으므로 이런 위젯은 postMessage나 제공된 API를 포함해 별도의 탐색 계약이 필요합니다.

Recap

세션 히스토리는 엔트리의 순서와 현재 위치를 관리합니다. 같은 Document를 공유하는 엔트리가 여러 개일 수 있고, iframe의 탐색도 사용자가 누르는 하나의 뒤로가기 순서에 참여합니다. URL이 바뀌었다는 사실만으로 문서가 새로 만들어졌다고 단정할 수는 없습니다.




히스토리는 추가·교체·순회됩니다

세션 히스토리에 일어나는 동작을 API 이름부터 외우면 location, history, 라우터 메서드가 서로 다른 체계처럼 보입니다. 목록에 어떤 변화가 생기는지로 분류하면 네 가지입니다.

동작목록과 현재 위치대표 API
추가현재 위치 다음에 엔트리를 만들고 그곳으로 이동링크 탐색, location.assign(), pushState()
교체현재 위치의 엔트리를 새 값으로 바꿈location.replace(), replaceState()
순회기존 엔트리 사이에서 현재 위치를 옮김back(), forward(), go()
새로고침현재 엔트리를 유지하며 리소스와 문서를 다시 준비location.reload()

추가와 교체는 다시 문서를 받는 방식과 같은 문서를 유지하는 방식으로 나뉩니다.

호출새 엔트리Document호출 순간 popstate
location.assign(url)추가보통 생성없음
location.replace(url)현재 엔트리 교체보통 생성없음
history.pushState(state, "", url)추가유지없음
history.replaceState(state, "", url)현재 엔트리 교체유지없음
history.back()기존 엔트리로 순회목적지에 따라 다름같은 문서 엔트리로 가면 발생

push와 replace는 사용자 관점으로 고릅니다

pushreplace의 차이는 자료구조만의 문제가 아닙니다. 사용자가 나중에 뒤로가기를 눌렀을 때 현재 상태로 돌아올 이유가 있는가를 결정하는 제품 규칙입니다.

상품 목록에서 상세 화면으로 이동했다면 돌아올 이유가 있으므로 새 엔트리를 추가합니다. 인증 리다이렉트, URL 정규화, 만료된 링크 처리처럼 중간 상태를 다시 보여줄 이유가 없다면 현재 엔트리를 교체합니다.

검색 입력을 URL과 동기화할 때 키 하나마다 push하면 열 글자를 지우기 위해 뒤로가기를 열 번 눌러야 합니다. 타이핑 중간값은 replace, 검색을 확정한 순간은 push처럼 사용자가 기억하는 이동 단위에 맞춰야 합니다.

pushState는 화면을 그려주지 않습니다

pushState()replaceState()는 URL과 엔트리 state를 바꾸지만 애플리케이션 화면을 렌더하지 않습니다. 호출 순간에는 popstate도 발생하지 않습니다. 따라서 전진할 때는 애플리케이션이 직접 화면을 바꾸고, 뒤로·앞으로 순회할 때는 popstate에서 도착한 엔트리를 읽어 복원합니다.

function navigate(url, state) {
history.pushState(state, "", url)
render(url, state) // pushState는 화면을 바꾸지 않는다
}

window.addEventListener("popstate", (event) => {
render(location.href, event.state) // 도착한 엔트리를 복원한다
})

history.back()도 이동이 끝날 때까지 기다리는 함수가 아닙니다. 한 단계 순회를 요청하고 반환합니다. 같은 문서 순회의 결과는 popstate에서 관찰하고 다른 문서가 활성화되는 경우에는 도착 문서의 수명주기까지 함께 봐야 합니다.

history.length는 경계를 알려주지 않습니다

history.length는 현재 문서가 속한 세션 히스토리의 길이를 제공합니다. 노출되지 않는 것은 현재 인덱스와 우리 애플리케이션이 시작된 위치입니다.

사용자가 검색 결과에서 직접 /checkout/address로 들어왔다고 가정해 보겠습니다. history.length > 1이어도 이전 엔트리는 다른 사이트일 수 있습니다. 이 값만 보고 "체크아웃 안에서 뒤로 갈 수 있다"고 판단하면 사용자를 서비스 밖으로 내보낼 수 있습니다.

퍼널이나 모달의 경계는 애플리케이션이 URL이나 엔트리 state에 직접 표시해야 합니다. go(-history.length + 1)처럼 길이로 뿌리를 계산하는 코드는 브라우저 히스토리와 제품 히스토리를 같은 것으로 취급합니다.

뒤로 돌아온 문서는 다시 만들어지지 않을 수도 있습니다

다른 문서로 이동했다고 이전 문서가 곧바로 폐기되는 것은 아닙니다. 브라우저가 이전 문서를 뒤로가기 캐시(back-forward cache, bfcache)에 보관했다면 뒤로 왔을 때 JavaScript 힙과 DOM을 포함한 문서가 정지 전 상태로 복원될 수 있습니다.

이때 새 문서를 처음 여는 초기화 경로와 bfcache에서 돌아오는 복원 경로를 구분해야 합니다. pageshow 이벤트의 persistedtrue이면 캐시된 문서가 복원된 경우입니다.

window.addEventListener("pageshow", (event) => {
if (event.persisted) {
reconnectRealtimeChannel()
refreshExpiredData()
}
})

popstate는 활성 엔트리가 바뀌었다는 신호이고 pageshow는 문서가 표시되는 수명주기 신호입니다. SPA의 같은 문서 순회만 본다면 popstate가 중심이지만 다른 문서를 오갔다가 돌아오는 문제까지 추적하려면 둘을 함께 기록해야 합니다.

Recap

추가와 교체는 새 문서를 만드는 방식과 같은 문서를 유지하는 방식으로 다시 나뉩니다. pushState()는 화면을 그리거나 popstate를 발생시키지 않고 history.length는 애플리케이션의 시작점을 알려주지 않습니다. 다른 문서에서 돌아올 때는 새로 생성된 문서인지 bfcache에서 복원된 문서인지도 구분해야 합니다.




SPA는 엔트리를 화면으로 해석합니다

SPA 라우터가 브라우저와 별개의 히스토리를 만드는 것은 아닙니다. 라우터는 같은 Document 안에서 브라우저의 URL과 엔트리를 애플리케이션 화면으로 투영합니다.

이 관계가 제대로 동작하려면 현재 URL + 현재 엔트리 state → 현재 UI가 직접 진입, 새로고침, 뒤로가기에서 모두 성립해야 합니다.

URL에는 공유하고 복구할 상태를 둡니다

URL은 새 탭과 새로고침에서도 남고 다른 사람에게 전달할 수 있습니다. 상품 ID, 검색 조건, 탭처럼 공유하거나 다시 열 수 있어야 하는 상태는 URL이 출발점입니다.

history.state는 현재 브라우저 세션의 특정 엔트리에 붙는 보조 상태입니다. 모달이 열린 출발 좌표, 스크롤 복원을 위한 작은 식별자처럼 URL에 넣을 필요가 없고 같은 세션에서만 의미 있는 값을 둘 수 있습니다.

state는 JSON 문자열로 저장되는 것이 아니라 구조화된 직렬화(structured serialization)를 거칩니다. Date, Map, Set, 순환 참조를 담을 수 있지만 함수와 DOM 노드는 DataCloneError로 거절됩니다. 사용자 정의 클래스 인스턴스의 프로토타입과 메서드도 그대로 보존되지 않습니다. 브라우저가 직렬화된 state의 크기를 제한할 수도 있으므로 서버에서 다시 받을 수 있는 큰 데이터 전체보다 복원에 필요한 작은 식별자와 UI 단서만 남기는 편이 안전합니다.

컴포넌트의 로컬 state는 엔트리에 기록되지 않습니다. 뒤로가기로 도착했을 때 필요한 값이 useState 안에만 있었다면 라우터는 그 화면을 재구성할 근거가 없습니다.

한 엔트리는 사용자가 기억하는 한 지점입니다

SPA에서 모든 상태 변경을 히스토리에 남길 필요는 없습니다. 판단 기준은 뒤로가기를 눌렀을 때 사용자가 그 상태를 다시 보리라고 기대하는가입니다.

UI 변화보통의 선택이유
목록 → 상세push명확한 탐색 지점
퍼널의 다음 단계push이전 단계로 돌아갈 수 있음
인증 후 목적지 복귀replace인증 중간 화면으로 돌아갈 이유가 없음
검색어 입력 중replace 또는 로컬 state매 키 입력은 탐색 지점이 아님
공유 가능한 상세 모달제품 계약에 따라 push뒤로가기로 닫는 행동을 제공할 수 있음
툴팁·검증 오류엔트리 없음일시적인 표현 상태

모달을 열며 엔트리를 추가했다면 X 버튼으로 닫을 때도 그 엔트리를 소비해야 합니다. 화면만 닫고 엔트리를 남기면 다음 뒤로가기는 보이지 않는 모달 엔트리를 순회하느라 먹통처럼 보입니다.

function openModal() {
const url = new URL(location.href)
url.searchParams.set("modal", "share")
history.pushState({ ...history.state, __modal: true }, "", url)
renderModal(true)
}

function closeModal() {
if (history.state?.__modal === true) {
history.back() // 모달을 열며 만든 엔트리를 소비한다
return
}

renderModal(false)
}

window.addEventListener("popstate", () => {
renderModal(history.state?.__modal === true)
})

엔트리가 예상보다 늘어나는 경로를 찾습니다

히스토리가 두 칸씩 움직이거나 뒤로가기를 여러 번 눌러야 한다면 현재 길이보다 엔트리를 만든 호출자를 찾아야 합니다.

경로확인할 것
클릭 핸들러와 이펙트같은 이동을 양쪽에서 호출하는지
React StrictMode 개발 환경정리되지 않은 이펙트가 재실행되며 이동을 반복하는지
검색어·필터 입력매 입력마다 push하는지
리다이렉트·인증 가드다시 방문할 필요 없는 중간 화면을 push하는지
모달·바텀시트닫을 때 화면만 숨기고 엔트리를 남기는지
라우터와 raw History API같은 이동을 각자 기록하거나 라우터 state를 덮어쓰는지

검색어 입력처럼 중간 상태를 되돌아갈 이유가 없다면 현재 URL을 보존해 일부 파라미터만 바꾸고 replaceState()를 사용합니다.

function syncSearchQuery(name) {
const url = new URL(location.href)
url.searchParams.set("name", name)
history.replaceState(history.state, "", url)
}

라우터가 관리하는 state를 보존합니다

라우터는 history.state에 자체 인덱스나 식별자를 넣을 수 있습니다. 애플리케이션 코드가 raw History API로 { step }만 저장하면 라우터의 값이 통째로 사라질 수 있습니다.

가능하면 라우터가 제공하는 pushreplace를 사용합니다. raw API를 함께 써야 한다면 현재 state가 객체인지 확인하고 기존 값을 보존한 별도 키에 애플리케이션 상태를 넣습니다.

const current =
history.state && typeof history.state === "object" ? history.state : {}

history.replaceState(
{
...current,
__checkout: { version: 1, root: true, step: "address" },
},
"",
location.href,
)

이 코드에서 root는 브라우저 히스토리의 첫 엔트리를 뜻하지 않습니다. 우리 제품 흐름이 시작된 엔트리라는 애플리케이션의 표시입니다. 브라우저가 모르는 경계를 애플리케이션이 명시한 것입니다.

Recap

SPA 라우터는 현재 URL과 엔트리 state를 읽어 화면을 복원합니다. 공유하거나 새로 열 수 있어야 하는 값은 URL에 두고 state에는 같은 세션에서만 필요한 작은 단서를 둡니다. 사용자가 되돌아갈 지점에서만 push하고 모달처럼 엔트리를 추가해 연 UI는 닫을 때 그 엔트리까지 소비해야 합니다.




History API는 오래 널리 쓰였지만 현재 인덱스와 엔트리 목록을 직접 노출하지 않습니다. 탐색을 시작하는 메서드와 popstate를 조합해야 하고 전진과 순회의 완료를 같은 형태로 다루기도 어렵습니다.

Navigation API는 현재 탐색 영역에서 스크립트에 노출할 수 있는 엔트리와 현재 위치를 더 명시적으로 제공합니다.

navigation.entries()
navigation.currentEntry
navigation.canGoBack
navigation.canGoForward

navigation.addEventListener("navigate", (event) => {
// 같은 문서 탐색을 가로채 라우터 렌더링과 연결할 수 있다
})

NavigationHistoryEntry에는 key, id, index, getState()가 있습니다. key는 엔트리 목록의 자리를 식별하고 replace 탐색 뒤에도 같은 자리에 재사용됩니다. id는 그 자리에 놓인 특정 엔트리 자체를 식별하므로 replace 뒤에는 달라집니다. traverseTo()id가 아니라 key를 받는 이유도 특정 엔트리 객체보다 목록의 그 자리로 순회하기 위해서입니다.

그렇다고 Navigation API가 제품의 경계를 알아주는 것은 아닙니다. canGoBack은 현재 API가 노출하는 범위에서 이전 엔트리가 있다는 뜻입니다. 그 엔트리가 결제 퍼널의 이전 단계인지, 사용자가 돌아가도 되는 화면인지는 여전히 애플리케이션이 정합니다.

Navigation API는 2026년 1월부터 최신 주요 브라우저 범위에서 Baseline Newly Available로 분류됩니다. 오래된 브라우저와 운영체제 웹뷰는 이 범위 밖에 남을 수 있습니다. "navigation" in window처럼 기능을 감지해 점진적으로 적용하고 History API에서도 같은 URL·복원 계약이 성립하게 둡니다.

if ("navigation" in window) {
// Navigation API 경로
} else {
// History API 경로
}

Recap

Navigation API는 같은 출처에서 노출할 수 있는 엔트리 목록과 현재 인덱스를 보여주고 특정 key로 순회할 수 있게 합니다. keyid는 각각 목록의 자리와 특정 엔트리를 구분합니다. API가 더 많은 정보를 주더라도 제품 흐름의 시작점과 오래된 웹뷰 지원 여부는 애플리케이션이 결정해야 합니다.




히스토리 계약을 세 문장으로 남기기

브라우저의 모델을 애플리케이션 규칙으로 바꾸면 세 문장이 남습니다.

  1. 사용자가 뒤로 돌아갈 만한 지점 하나에 엔트리 하나를 대응시킵니다. 같은 클릭에서 엔트리가 두 개 생기거나 타이핑 한 번마다 엔트리가 늘어나면 이 대응이 깨진 것입니다.
  2. 현재 엔트리로 현재 화면을 복원할 수 있어야 합니다. URL과 작은 state를 읽었을 때 직접 진입, 새로고침, 뒤로가기가 같은 화면으로 모여야 합니다.
  3. 한 번의 탐색 의도는 한 명의 소유자가 한 번만 처리합니다. 브라우저 안에서는 라우터와 raw API가, 웹뷰에서는 웹과 네이티브가 같은 순회를 반복하지 않아야 합니다.

세 번째 규칙은 브라우저 탭 안에서는 잘 드러나지 않습니다. 웹뷰로 들어가면 하드웨어 back, 네이티브 헤더, 엣지 제스처, JavaScript가 같은 사용자 의도를 받을 수 있습니다. 이 규칙이 실제로 깨지면 한 번 눌렀는데 두 칸씩 이동합니다.

그 사례와 소유권 계약은 뒤로가기 한 번에 두 칸씩 가는 이유는 뭘까요?에서 이어서 다룹니다.

Recap

히스토리 계약은 엔트리와 사용자가 기억하는 이동 지점을 대응시키는 데서 시작합니다. URL과 작은 state만으로 현재 화면을 복원할 수 있어야 하고 한 번의 이동 의도는 한 소유자가 한 번만 처리해야 합니다. 웹뷰에서는 마지막 규칙이 웹과 네이티브의 back 경계를 정하는 기준이 됩니다.




정리

알게 된 것요약
세션 히스토리의 형태엔트리 목록과 현재 위치로 이해하며 여러 엔트리가 같은 Document를 공유할 수 있음
pushreplace사용자가 나중에 되돌아갈 지점인지에 따라 추가와 교체를 선택
history.state구조화된 직렬화가 가능한 작은 복원 단서를 엔트리에 저장
popstatepageshow엔트리 순회와 문서 표시·bfcache 복원을 서로 다른 신호로 관찰
SPA 라우터현재 URL과 state를 화면으로 해석하고 직접 진입·새로고침·순회에서 같은 결과를 복원
Navigation API엔트리의 key, id, index를 노출하지만 제품 경계까지 정해주지는 않음
웹뷰로 넘어갈 때한 번의 back 입력을 웹과 네이티브가 중복 처리하지 않도록 소유권을 정함



References

웹 명세

  1. HTML Standard: Navigation and session history
  2. HTML Standard: History API
  3. HTML Standard: Navigation API
  4. HTML Standard: Structured data

API 가이드

  1. MDN: History API
  2. MDN: Working with the History API
  3. MDN: popstate event
  4. MDN: pageshow event
  5. MDN: Structured clone algorithm
  6. MDN: Navigation API
  7. MDN: NavigationHistoryEntry
  8. web.dev: January 2026 Baseline monthly digest

프레임워크

  1. React: StrictMode


좋은 사람들과 재미있는 일을 하며 열정적이고 즐겁게 살고 싶은 개발자