본문으로 건너뛰기
2026. 8. 2
© WONKOOK LEE

뒤로가기 한 번에 두 칸씩 가는 이유는 뭘까요?

뒤로가기 한 번에 히스토리 엔트리를 두 칸 건너뛰는 그림

앱 안에 웹뷰로 띄우는 신청 퍼널을 만들고 있었습니다. 네 단계짜리 폼이었고, 3단계에서 뒤로 가면 2단계가 나와야 했습니다. 그런데 1단계가 나왔습니다. 어떤 기기에서는 두 번을 눌러야 겨우 한 칸이 움직였고, 딥링크로 들어온 사용자는 뒤로가기를 누르는 순간 앱이 통째로 닫혔습니다.

처음에는 history.back()을 두 번 부르는 코드를 찾아다녔습니다. 없었습니다. 그다음에는 라우터를 의심했습니다. 아니었습니다. 범인은 제가 만든 스택이 아니라 제가 있는 줄도 몰랐던 두 번째 스택이었습니다.

뒤로가기는 대체 무엇을 되돌리는 걸까요?


이 글에서 다루는 내용

이 버그를 고치면서 알게 된 것은, 제가 locationhistory를 "URL을 읽고 쓰는 도구" 정도로만 알고 있었다는 사실입니다. 실제로 이 둘은 브라우저가 관리하는 세션 히스토리라는 자료구조를 서로 다른 각도에서 조작하는 API였고, 퍼널처럼 이동이 잦은 화면에서는 이 구조를 모르면 반드시 어딘가가 어긋납니다.

먼저 세션 히스토리가 실제로 어떻게 생겼는지부터 봅니다. 그다음 locationhistory.state가 각각 무엇을 책임지는지, popstate는 언제 오고 언제 오지 않는지를 정리합니다. 그러고 나서 제가 만난 일곱 가지 범인을 하나씩 짚고, 웹뷰에서 스택이 두 개가 되는 구조와 그 둘 사이의 계약을 어떻게 설계해야 하는지를 다룹니다. 마지막으로 히스토리를 눈으로 보는 디버깅 방법과, 이 문제를 위해 만들어진 Navigation API를 살펴봅니다.





1. 세션 히스토리는 스택이 아닙니다

우리는 습관적으로 "히스토리 스택"이라고 부르지만, 이 이름이 첫 번째 오해의 출발점입니다. 스택이라면 pushpop만 있어야 하는데, 세션 히스토리에는 커서가 있고 잘라내기가 있습니다.


엔트리의 리스트, 그리고 커서 하나

브라우저 탭 하나(정확히는 브라우징 컨텍스트 하나)는 세션 히스토리를 하나씩 가집니다. 이것은 엔트리(entry)의 순서 있는 리스트이고, 그 위에 "지금 여기"를 가리키는 커서가 하나 얹혀 있습니다.

엔트리 하나에는 이런 것들이 들어 있습니다.

  • URL — 주소창에 보이는 값
  • statepushState로 심어 둔 임의의 직렬화 가능한 값 (3절)
  • 스크롤 위치 — 뒤로 갔을 때 보던 자리로 돌아가는 이유
  • 문서(document)에 대한 참조 — 여러 엔트리가 같은 문서를 공유할 수 있습니다

마지막 항목이 중요합니다. /step1, /step2, /step3이 SPA에서 pushState로 만들어졌다면 엔트리는 세 개지만 문서는 하나입니다. 반대로 location.href로 이동했다면 엔트리 세 개에 문서도 세 개입니다. 이 차이가 4절의 popstate 규칙을 통째로 결정합니다.


back은 지우지 않고, push는 지웁니다

여기가 "스택이 아니다"의 핵심입니다.

  • history.back()커서를 왼쪽으로 한 칸 옮길 뿐 엔트리를 지우지 않습니다. 그래서 다시 앞으로 갈 수 있습니다.
  • 반면 커서가 리스트 중간에 있을 때 새 네비게이션이 일어나면, 커서 오른쪽의 엔트리가 전부 잘려나갑니다. pushState도 예외가 아닙니다.

뒤로 간 뒤 새 엔트리를 쌓으면 앞쪽 엔트리가 잘려나가는 과정

퍼널에서 이 규칙은 이런 모양으로 나타납니다. 사용자가 3단계에서 2단계로 돌아와 값을 고치고 다시 "다음"을 누르면, 원래 있던 3단계 엔트리는 사라지고 새 3단계 엔트리가 그 자리에 들어갑니다. 그 옛 엔트리의 state에 담아 둔 값도 함께 사라집니다. "뒤로 갔다가 다시 앞으로 오면 입력값이 날아간다"는 제보의 상당수가 여기서 나옵니다.


history.length로는 아무것도 알 수 없습니다

history.length는 언뜻 유용해 보이지만, 실제로는 거의 쓸 수 없는 값입니다.

history.length; // 7 — 그래서 나는 몇 번째에 있는가?

문제는 세 가지입니다.

첫째, 커서 위치를 알려주지 않습니다. 길이가 7이어도 내가 3번째인지 7번째인지 알 방법이 없습니다. back()을 부르면 무슨 일이 일어날지 예측할 수 없다는 뜻입니다.

둘째, 우리 사이트에 들어오기 전의 엔트리까지 포함합니다. 사용자가 검색 결과 페이지 여섯 곳을 거쳐 왔다면 길이는 이미 7에서 시작합니다. history.length === 1로 "직접 진입"을 판별하는 코드는 딥링크 진입에서만 맞고 그 외에는 전부 틀립니다.

셋째, 상한이 있습니다. 명세는 세션 히스토리 엔트리 수의 상한을 사용자 에이전트 재량으로 남겨 두었고, 실제로 브라우저마다 다릅니다. 상한에 닿으면 오래된 엔트리가 조용히 버려지므로 길이가 더 이상 늘지 않습니다.

정리하면 history.length관측용으로도 못 미덥고 제어용으로는 위험한 값입니다. 뒤에서 다룰 방법들은 전부 이 값을 쓰지 않는 방향으로 갑니다.


iframe도 같은 히스토리에 쌓입니다

의외로 많이 걸리는 함정입니다. <iframe>은 자기만의 브라우징 컨텍스트를 갖지만, 그 안의 네비게이션은 최상위 탭의 히스토리와 하나로 합쳐집니다. 명세는 이것을 조인트 세션 히스토리(joint session history)라고 부릅니다.

즉 결제 위젯이나 약관 뷰어를 iframe으로 붙여 두고 그 안에서 세 번 이동이 일어나면, 사용자의 뒤로가기 버튼은 우리 페이지가 아니라 iframe 안쪽을 세 번 되돌립니다. 화면은 그대로인데 뒤로가기만 먹통인 것처럼 보이는 전형적인 증상입니다.

<!-- ❌ src를 갈아끼우면 그때마다 엔트리가 쌓인다 -->
<iframe id="widget" src="/widget/step1"></iframe>
<script>
widget.src = "/widget/step2"; // 조인트 세션 히스토리에 한 칸 추가
</script>
// ✅ 같은 오리진이라면 iframe 안에서 replace로 이동시킨다
widget.contentWindow.location.replace("/widget/step2");

교차 출처 iframe이라 우리가 제어할 수 없다면, 그 위젯이 내부적으로 pushState를 쓰는지 문서를 확인하고, 필요하면 iframe 대신 별도 창이나 네이티브 화면으로 분리하는 편이 낫습니다.


Recap

세션 히스토리는 엔트리의 리스트와 커서 하나로 이루어져 있습니다. back()은 커서만 옮기고 엔트리를 지우지 않지만, 커서가 중간에 있을 때 새 네비게이션이 일어나면 앞쪽 엔트리가 전부 잘려나갑니다. history.length는 커서 위치를 알려주지 않고 진입 전 엔트리까지 포함하며 상한도 있어서, 제어의 근거로 삼기에 부적합합니다. iframe의 이동은 조인트 세션 히스토리를 통해 최상위 뒤로가기와 같은 줄에 쌓입니다.




2. location — 읽으면 값, 쓰면 명령

location은 겉으로는 평범한 객체처럼 보이지만, 읽을 때와 쓸 때의 성격이 완전히 다릅니다. 읽으면 현재 주소를 조각내어 돌려주는 값이고, 쓰면 그 즉시 네비게이션을 일으키는 명령입니다.


주소를 조각내는 방식

https://app.example.com:8443/funnel/step2?from=push&utm=abc#agree

이 주소를 location이 어떻게 나누는지 먼저 그림으로 보겠습니다.

URL을 protocol·hostname·port·pathname·search·hash로 조각내고 href·origin·host의 범위를 표시한 다이어그램

같은 내용을 표로 옮기면 이렇습니다.

속성자주 하는 실수
hrefhttps://app.example.com:8443/funnel/step2?from=push&utm=abc#agree
originhttps://app.example.com:8443포트가 붙습니다 (읽기 전용)
protocolhttps:콜론이 포함됩니다
hostapp.example.com:8443포트를 포함합니다
hostnameapp.example.comhost와 헷갈리기 쉽습니다
port8443기본 포트면 빈 문자열입니다
pathname/funnel/step2
search?from=push&utm=abc물음표가 포함됩니다
hash#agree샵이 포함되고, 없으면 빈 문자열입니다

protocol === "https"hash === "agree" 같은 비교가 조용히 실패하는 이유가 여기 있습니다. 문자열을 직접 자르는 대신 URLURLSearchParams를 쓰면 이런 실수를 피할 수 있습니다.

const url = new URL(location.href);

url.searchParams.get("from"); // "push" — 물음표도 인코딩도 신경 쓸 필요가 없다
url.searchParams.set("step", "3"); // 없으면 추가, 있으면 교체
url.hash = "terms"; // 샵을 붙이지 않아도 알아서 붙는다

url.toString(); // ".../funnel/step2?from=push&utm=abc&step=3#terms"

URL의 두 번째 인자는 기준(base) URL입니다. new URL("./step3", location.href)처럼 쓰면 상대 경로를 안전하게 절대 경로로 만들 수 있어서, 문자열을 이어 붙이다 슬래시가 두 개 되는 사고를 막을 수 있습니다.


대입은 곧 이동입니다

location의 속성은 대부분 쓰기 가능한 setter이고, 값을 넣는 순간 네비게이션이 시작됩니다.

location.href = "/funnel/step3";     // 이동한다
location.pathname = "/funnel/step3"; // 위와 같다
location.search = "?step=3"; // 위와 같다
location.hash = "agree"; // 이동하지만 문서는 유지된다 (아래 참고)

이것이 코드에서 위험한 이유는, location.pathname = ...이 문법적으로는 그냥 대입문이라 부작용이 있는 것처럼 보이지 않기 때문입니다. 로그를 찍으려고 location.search = qs를 잠깐 넣어 뒀다가 무한 리로드를 만드는 일이 실제로 벌어집니다.


assign · replace · reload — 엔트리를 두고 갈라지는 세 갈래

퍼널에서 가장 중요한 구분입니다.

location.assign("/login");  // 새 엔트리를 쌓는다. location.href = "/login"과 같다
location.replace("/login"); // 현재 엔트리를 덮어쓴다. 뒤로 가면 여기가 아니라 그 이전으로 간다
location.reload(); // 엔트리는 그대로, 문서만 다시 받는다

assign은 새 엔트리를 쌓고 replace는 현재 엔트리를 덮어쓰는 차이를 보여주는 다이어그램

replace는 "이 화면은 뒤로 갈 이유가 없다"는 선언입니다. 인증 리다이렉트, 잘못된 URL 정규화, 결제 완료 후 결과 화면처럼 되돌아가면 안 되거나 되돌아가는 것이 무의미한 이동은 전부 replace여야 합니다. 5절에서 볼 첫 번째 범인이 정확히 이것을 지키지 않아 생긴 문제였습니다.


hash만 바꾸면 문서는 그대로지만 엔트리는 쌓입니다

location.hash는 특이합니다. 문서를 다시 받지 않지만 히스토리 엔트리는 새로 쌓입니다.

location.hash = "agree"; // 문서 유지 + 엔트리 추가 + hashchange 이벤트

옛날 SPA 라우터가 해시를 썼던 이유가 이것이고, 요즘도 아코디언 열기나 앵커 이동에 무심코 해시를 쓰다가 히스토리가 부풀어 오르는 일이 있습니다. 같은 해시를 다시 넣으면 엔트리가 추가되지 않지만, 값이 다르면 매번 한 칸씩 늘어납니다.


어떤 호출이 무엇을 남기는가

지금까지 나온 것과 다음 절에서 나올 것을 한 표에 모았습니다. 이 표가 이 글의 절반입니다.

호출히스토리 엔트리문서이 호출로 발생하는 이벤트
location.href = url / assign(url)새로 쌓음 (앞쪽은 잘림)새로 로드언로드 후 새 문서의 load
location.replace(url)현재 엔트리를 덮어씀새로 로드위와 같음
location.reload()변화 없음다시 로드위와 같음
location.hash = "..."새로 쌓음그대로hashchange
history.pushState()새로 쌓음 (앞쪽은 잘림)그대로없음
history.replaceState()현재 엔트리를 덮어씀그대로없음
history.back() / go(-1)커서만 이동그대로 또는 복원popstate (문서가 바뀌면 pageshow도)

교차 출처에서는 읽을 수 없습니다

location은 동일 출처 정책(same-origin policy)의 적용을 받습니다. 다른 오리진의 iframe에 대해서는 iframe.contentWindow.location.href읽을 수 없고, 오직 쓰기만 가능합니다. 결제사 iframe이 지금 어느 단계에 있는지 URL로 알아내려는 시도가 번번이 막히는 이유입니다. 이런 상황에서 상태를 알아야 한다면 postMessage로 계약을 만드는 것이 정석입니다.


Recap

location은 읽으면 주소를 조각내어 돌려주는 값이고, 속성에 대입하면 그 즉시 네비게이션을 일으키는 명령입니다. protocol은 콜론을, search는 물음표를, hash는 샵을 포함하므로 직접 자르지 말고 URL·URLSearchParams를 쓰는 편이 안전합니다. assign은 엔트리를 쌓고 replace는 덮어쓰며, 이 구분이 퍼널의 히스토리 깊이를 결정합니다. 해시 변경은 문서를 유지한 채 엔트리만 늘리고, 교차 출처 location은 쓰기만 되고 읽기는 막힙니다.




3. history.state — URL 옆에 붙은 두 번째 값

history.state엔트리마다 하나씩 붙는 임의의 값입니다. URL이 "공개된 주소"라면 state는 "그 주소에 몰래 붙여 둔 메모"에 가깝습니다.


pushState의 두 번째 인자는 아무 일도 하지 않습니다

history.pushState(state, unused, url);
history.replaceState(state, unused, url);

두 번째 인자는 원래 히스토리 항목의 제목으로 쓰려던 것이었지만, 어느 브라우저도 의미 있게 구현하지 않아서 명세에서 이름이 아예 unused가 되었습니다. 빈 문자열을 넘기는 것이 관례입니다. 아직도 문서 제목을 넣는 코드를 종종 보는데, 그 값은 어디에도 쓰이지 않습니다.

세 번째 인자 url같은 오리진이어야 합니다. 다른 오리진을 넣으면 SecurityError가 납니다. 생략하면 현재 URL이 유지되므로, "URL은 그대로 두고 state만 갈아끼우기"가 가능합니다.


pushState는 네비게이션이 아닙니다

이것이 가장 자주 오해되는 지점입니다. pushState는 이름과 달리 아무 곳으로도 이동하지 않습니다.

  • 서버에 요청이 나가지 않습니다.
  • 문서가 바뀌지 않고 스크립트도 다시 실행되지 않습니다.
  • loadunload도 발생하지 않습니다.
  • popstate도 발생하지 않습니다.

일어나는 일은 딱 두 가지입니다. 세션 히스토리에 엔트리가 하나 추가되고, 주소창의 표시가 바뀝니다. 그래서 pushState 뒤에 화면을 바꾸는 것은 전적으로 우리 코드의 책임입니다. 라우터가 해주는 일이 정확히 이것입니다.

정보

호출 빈도에는 제한이 있습니다. Safari는 history.pushState()/replaceState()를 30초에 100회 넘게 부르면 SecurityError를 던지고, Firefox와 Chrome도 각각 "Too many calls to Location or History APIs" · "Throttling navigation to prevent the browser from hanging" 같은 형태로 제동을 겁니다. 스크롤 위치나 폼 입력값을 replaceState로 실시간 동기화하는 코드는 이 한도에 쉽게 닿으므로 스로틀링이 필요합니다.


state에 담을 수 있는 것

state는 구조화된 복제(structured clone) 알고리즘으로 직렬화됩니다. JSON.stringify보다는 관대해서 Date, Map, Set, ArrayBuffer, 순환 참조까지 담기지만, 다음은 담기지 않습니다.

history.pushState({ at: new Date(), tags: new Set(["a"]) }, ""); // ✅ 된다

history.pushState({ onDone: () => {} }, ""); // ❌ DataCloneError — 함수
history.pushState({ node: document.body }, ""); // ❌ DataCloneError — DOM 노드
history.pushState(new MyClass(), ""); // ⚠️ 필드는 남지만 프로토타입은 사라진다

마지막 줄이 특히 고약합니다. 클래스 인스턴스를 넣으면 예외 없이 통과하지만, 꺼낼 때는 메서드가 사라진 평범한 객체입니다. 나중에 state.isValid()를 부르는 순간 터집니다.

크기에도 제한이 있습니다. Firefox는 엔트리당 16 MiB로 못을 박아 두었고 다른 브라우저도 사실상의 한도가 있습니다. 다만 한도까지 쓸 일이 아닙니다. state는 뒤에서 볼 이유 때문에 디스크에 저장되므로, 큰 데이터를 넣으면 세션 복원이 그만큼 느려집니다.


새로고침에는 살아남고, 링크 공유에는 따라가지 않습니다

state의 성격을 한 문장으로 요약하면 이렇습니다. 엔트리에 붙어 있지 URL에 붙어 있지 않습니다.

history.replaceState({ draft: { name: "이원국" } }, "");

// F5로 새로고침 → history.state는 그대로 살아 있다
// 주소창의 URL을 복사해 다른 탭에 붙여넣기 → history.state는 null

새로고침·링크 공유·세션 복원 세 경우에 URL과 state가 각각 어떻게 되는지 비교한 다이어그램

새로고침해도 남는다는 사실은 처음 알면 조금 놀랍습니다. 새로고침은 새 엔트리를 만드는 것이 아니라 현재 엔트리의 문서를 다시 받는 것이기 때문에, 엔트리에 붙은 state는 그대로 유지됩니다. 반면 URL만 복사해 간 사람에게는 아무것도 따라가지 않습니다.

여기서 실무 원칙이 하나 나옵니다.

공유되어야 하는 상태는 URL에, 이 탭에서만 의미 있는 상태는 history.state에 둡니다.

그리고 state는 브라우저를 껐다 켜도 세션 복원과 함께 되살아날 수 있습니다. 즉 몇 주 전 스키마로 저장된 state가 오늘 배포한 코드에 흘러 들어올 수 있다는 뜻입니다. 그래서 state를 읽을 때는 버전을 확인하는 습관이 필요합니다.

const STATE_VERSION = 3;

function readFunnelState() {
const s = history.state;
// 옛 배포에서 저장된 state가 세션 복원으로 되살아날 수 있다
if (!s || s.v !== STATE_VERSION) return null;
return s.funnel;
}

function writeFunnelState(funnel) {
history.replaceState({ ...history.state, v: STATE_VERSION, funnel }, "");
}

{ ...history.state }로 시작하는 것도 중요합니다. 라우터들은 대부분 자기 키를 state 안에 심어 두는데, 통째로 덮어쓰면 라우터가 자기 엔트리를 잃어버립니다. Next.js의 App Router나 React Router 위에서 raw History API를 쓸 때 화면이 갑자기 멈추는 사고의 상당수가 여기서 옵니다.


어디에 무엇을 둘 것인가

퍼널의 값들을 어디에 둘지 정할 때 쓰는 비교표입니다. 네 저장소의 성격이 각각 다릅니다.

저장소새로고침뒤로가기로 돌아왔을 때링크 공유범위어울리는 값
URL (path·query)유지그 엔트리의 값따라감어디서나스텝, 필터, 검색어, 정렬
history.state유지그 엔트리의 값안 따라감이 탭의 이 엔트리스크롤 위치, 모달 여부, 스텝별 스냅샷
sessionStorage유지언제나 최신 값 하나안 따라감이 탭 전체퍼널 전체의 폼 초안
서버 draft유지최신 값(권한 있으면) 따라감기기 간이탈 후 재개, 기기 변경

2열과 3열의 차이가 실제로 큰 차이를 만듭니다. "3단계에서 뒤로 가면 2단계에 내가 그때 입력했던 값이 남아 있어야 한다"는 요구는 sessionStorage로는 자연스럽게 구현되지 않습니다. 최신 값 하나만 들고 있기 때문입니다. 반면 history.state는 엔트리마다 따로 붙으므로 뒤로 가면 그 시점의 스냅샷이 그대로 나옵니다.

거꾸로 "어느 단계에서 이탈했든 다시 들어오면 마지막 입력이 복원되어야 한다"는 요구라면 sessionStorage나 서버 draft가 맞습니다. 두 요구가 함께 오는 경우도 흔한데, 그때는 표시용 스냅샷은 state에, 진실의 원본은 별도 저장소에 두고 스텝 진입 시 병합하는 구조가 무난했습니다.


Recap

history.state는 URL이 아니라 히스토리 엔트리에 붙는 값이라, 새로고침에는 살아남지만 링크 공유에는 따라가지 않습니다. pushState는 이름과 달리 네비게이션을 일으키지 않으며 popstate도 발생시키지 않고, 두 번째 인자는 명세에서 unused가 되었습니다. state는 구조화된 복제로 직렬화되므로 함수·DOM 노드는 담기지 않고 클래스 인스턴스는 프로토타입을 잃습니다. 세션 복원으로 옛 스키마가 되살아날 수 있으니 버전 필드를 두고, 라우터가 심어 둔 키를 지우지 않도록 항상 기존 state를 펼쳐서 씁니다.




4. popstate — 언제 오고 언제 오지 않는가

popstate는 이름 때문에 "히스토리에서 뭔가 pop될 때 오는 이벤트"로 읽히지만, 실제 규칙은 조금 다릅니다.


규칙은 두 줄입니다

  1. 활성 히스토리 엔트리가 바뀔 때 발생합니다.
  2. 단, pushState/replaceState 호출로는 발생하지 않습니다.

두 번째 줄이 초심자를 가장 많이 괴롭힙니다. 라우팅을 직접 만들 때 "pushState를 부르면 popstate가 오겠지"라고 생각하고 화면 갱신을 popstate 핸들러에만 넣어 두면, 앞으로 갈 때는 아무 반응이 없고 뒤로 갈 때만 동작하는 라우터가 완성됩니다.

// 앞으로 갈 때: 우리가 직접 두 가지를 해야 한다
function goTo(url, state) {
history.pushState(state, "", url); // ① 엔트리를 쌓고
render(state); // ② 화면도 우리가 그린다
}

// 뒤로 갈 때: 브라우저가 알려준다
window.addEventListener("popstate", (e) => {
render(e.state); // e.state는 도착한 엔트리의 state다
});

같은 문서 안에서만 옵니다

세션 히스토리를 되짚어 갈 때, 도착지가 다른 문서라면 popstate가 아니라 문서 로드가 일어납니다. 우리 문서의 popstate 핸들러는 아예 실행될 기회가 없습니다.

다만 그 문서가 bfcache(back/forward cache)에서 복원되는 경우에는 이야기가 달라집니다. 문서가 메모리에 통째로 살아 있다가 되살아나므로, 새로 로드되는 대신 이런 순서로 이벤트가 옵니다.

pageshow (event.persisted === true)  →  popstate  →  hashchange

pushState·같은 문서 back·bfcache 복원 세 경우에 어떤 이벤트가 발생하는지 비교한 타임라인

그래서 "뒤로 왔을 때"를 정확히 잡으려면 popstate 하나로는 부족하고 pageshow를 함께 봐야 합니다.

window.addEventListener("pageshow", (e) => {
if (!e.persisted) return;
// bfcache에서 살아 돌아왔다. 화면은 떠날 때 그대로다
// 재고, 잔여 좌석, 만료 시간처럼 상해 있을 값들을 여기서 다시 가져온다
refreshVolatileData();
});

bfcache는 우리가 깨뜨릴 수 있습니다

bfcache에 들어가면 뒤로가기가 즉각적이고 스크롤과 입력값이 그대로 남습니다. 그런데 페이지가 몇 가지 조건을 어기면 브라우저가 캐시에 넣기를 포기합니다. 대표적인 것들입니다.

  • unload 이벤트 리스너를 등록한 경우 (가장 흔한 원인입니다)
  • Cache-Control: no-store 응답
  • 열려 있는 IndexedDB 트랜잭션이나 진행 중인 fetch가 남아 있는 경우

unload 대신 pagehide를, 이탈 확인이 필요하면 beforeunload를 조건부로 붙이는 것이 요즘 권장되는 방향입니다.

// ❌ bfcache가 무력화된다
window.addEventListener("unload", saveDraft);

// ✅ pagehide는 bfcache와 공존한다. event.persisted로 어느 쪽인지 알 수 있다
window.addEventListener("pagehide", (e) => {
saveDraft();
if (e.persisted) {
// 이 문서는 캐시로 들어간다. 타이머·폴링을 멈춰 둔다
}
});

퍼널에서 bfcache는 양날의 검입니다. 뒤로 갔을 때 입력값이 그대로 남는 것은 좋지만, 화면에 그려 둔 재고나 남은 시간이 떠날 때의 값 그대로 멈춰 있는 것은 대개 버그로 신고됩니다. pageshow에서 휘발성 데이터만 골라 갱신하는 처리가 필요합니다.


back()과 go()는 비동기입니다

의외로 잘 알려지지 않은 사실입니다. history.back()은 호출하는 즉시 이동하지 않고, 작업을 예약하고 곧바로 반환합니다.

history.back();
console.log(location.pathname); // 아직 이전 화면의 경로다. 바뀌지 않았다

그래서 "뒤로 간 다음에 무언가 하기"를 이런 식으로 쓰면 반드시 어긋납니다.

// ❌ 뒤로 가기 전에 실행된다
history.back();
trackEvent("funnel_back", { to: location.pathname });

// ✅ 이동이 끝난 뒤를 잡는다
window.addEventListener("popstate", function handler() {
window.removeEventListener("popstate", handler);
trackEvent("funnel_back", { to: location.pathname });
});
history.back();

더 나쁜 것은 연달아 부르는 경우입니다. history.back()을 두 번 부르면 두 칸 뒤로 가는 것이 보장되지 않습니다. 두 호출이 한 번의 순회로 합쳐지거나, 첫 이동이 반영되기 전에 두 번째가 계산되어 예상과 다른 지점에 도착할 수 있습니다. 여러 칸을 물러나야 한다면 go(-2)를 쓰거나, 애초에 여러 칸을 물러나야 하는 설계를 피해야 합니다.

그리고 뒤로 갈 곳이 없으면 back()은 조용히 아무것도 하지 않습니다. 예외도 반환값도 없습니다. 딥링크로 퍼널에 바로 들어온 사용자에게 "이전 단계" 버튼이 먹통이 되는 이유가 이것입니다. 웹뷰에서는 여기서 한 술 더 떠서 앱이 그냥 닫히기도 합니다. 6절에서 다룹니다.


scrollRestoration

브라우저는 뒤로 갈 때 스크롤 위치를 자동으로 복원합니다. 그런데 SPA에서는 복원 시점에 아직 데이터가 없어 화면이 비어 있는 경우가 많아, 엉뚱한 위치로 점프하는 것처럼 보입니다.

// 복원을 우리가 직접 하겠다고 선언한다
history.scrollRestoration = "manual";

이 값 역시 엔트리가 아니라 세션에 붙으므로 한 번만 설정하면 됩니다. 수동으로 바꿨다면 스크롤 위치를 history.state에 담아 두었다가 데이터 로딩이 끝난 뒤 직접 복원해 주어야 합니다.


Recap

popstate는 활성 엔트리가 바뀔 때 발생하지만 pushState/replaceState 호출로는 발생하지 않으므로, 앞으로 가는 이동의 화면 갱신은 우리가 직접 해야 합니다. 다른 문서로 넘어가는 뒤로가기는 문서 로드나 bfcache 복원으로 처리되며, 복원 시에는 pageshow(persisted: true) 다음에 popstate가 옵니다. unload 리스너는 bfcache를 무력화하므로 pagehide를 씁니다. back()go()는 비동기라 호출 직후의 location은 아직 옛 값이고, 갈 곳이 없으면 아무 일도 일어나지 않습니다.




5. 그래서 어디서 엔트리가 늘어났나

여기서부터가 실제로 제 스택을 꼬아 놓았던 것들입니다. 하나씩 증상과 원인, 고친 방법을 적었습니다.


범인 1 — 리다이렉트를 push로 했다

가장 흔하고 가장 조용한 범인입니다.

// ❌ 로그인으로 보내면서 엔트리를 하나 쌓는다
useEffect(() => {
if (!session) router.push("/login");
}, [session]);

/funnel/step1에 들어온 비로그인 사용자는 /login으로 밀려납니다. 그런데 /funnel/step1 엔트리가 그대로 남아 있으므로, 로그인 후 뒤로가기를 누르면 다시 /funnel/step1로 갔다가 가드에 걸려 또 /login으로 튕깁니다. 사용자 입장에서는 뒤로가기가 아예 먹지 않는 화면입니다.

// ✅ 되돌아갈 이유가 없는 이동은 현재 엔트리를 덮어쓴다
useEffect(() => {
if (!session) router.replace("/login");
}, [session]);

같은 이야기가 URL 정규화(/funnel/funnel/step1), 만료된 딥링크 처리, 실험군 분기에도 그대로 적용됩니다. 판단 기준은 하나입니다. "사용자가 뒤로가기를 눌렀을 때 이 화면으로 돌아오는 것이 말이 되는가?" 말이 안 되면 replace입니다.


범인 2 — 이펙트가 두 번 돌았다

React 18의 StrictMode는 개발 모드에서 이펙트를 두 번 실행합니다. 부작용이 있는 코드를 골라내라는 의도이고, pushState는 정확히 그 부작용입니다.

// ❌ 개발 모드에서 같은 URL 엔트리가 두 개 쌓인다
useEffect(() => {
history.pushState({ step }, "", `?step=${step}`);
}, [step]);

브라우저는 같은 URL이라도 중복 검사를 하지 않고 새 엔트리를 만듭니다. 그래서 뒤로가기를 눌렀는데 화면이 그대로인 것처럼 보입니다. 실제로는 한 칸 움직였는데 그 칸의 내용이 같을 뿐입니다. "뒤로가기를 두 번 눌러야 한 칸 간다"는 제보의 정체가 대개 이것입니다.

고치는 방법은 이동을 이펙트가 아니라 이벤트 핸들러로 옮기는 것입니다. 스텝 이동은 사용자의 클릭에서 비롯되는 일이지 렌더링의 결과가 아니기 때문입니다. 구조상 이펙트에 둘 수밖에 없다면 멱등성을 직접 챙깁니다.

useEffect(() => {
// 이미 이 스텝의 엔트리 위에 있다면 다시 쌓지 않는다
if (history.state?.step === step) return;
history.pushState({ ...history.state, step }, "", `?step=${step}`);
}, [step]);

history.state가 그 자리에서 멱등성 키 노릇을 해준다는 점이 편리합니다.


범인 3 — 입력마다 쿼리를 밀어 넣었다

"URL이 곧 상태"라는 원칙을 지나치게 밀어붙이면 이렇게 됩니다.

// ❌ 키 입력마다 엔트리가 하나씩
<input onChange={(e) => router.push(`?name=${e.target.value}`)} />

이름을 열 글자 치면 엔트리가 열 개 쌓입니다. 뒤로가기를 열 번 눌러야 이전 화면이 나옵니다. 게다가 3절에서 본 호출 빈도 제한에도 걸립니다.

값이 계속 바뀌는 것을 URL에 반영해야 한다면 push가 아니라 replace이고, 그마저도 디바운스가 필요합니다.

// ✅ 엔트리를 늘리지 않고 현재 엔트리의 URL만 갱신한다
const sync = useMemo(
() => debounce((name: string) => {
const url = new URL(location.href);
url.searchParams.set("name", name);
history.replaceState(history.state, "", url);
}, 300),
[],
);

사용자가 "뒤로 갈 만한 지점"이라고 느끼는 순간에만 push합니다. 검색어 확정, 탭 전환, 스텝 이동은 push이고, 타이핑 중간과 스크롤 위치는 replace입니다.


범인 4 — 모달을 히스토리로 열고 X로 닫았다

모바일에서 뒤로가기로 바텀시트를 닫는 UX는 좋은 패턴입니다. 문제는 닫는 경로가 두 개라는 데 있습니다.

// ❌ 열 때는 엔트리를 쌓는데, X 버튼은 상태만 바꾼다
const open = () => { history.pushState({ modal: true }, ""); setOpen(true); };
const close = () => setOpen(false); // 엔트리가 그대로 남는다

X 버튼으로 닫으면 화면에는 모달이 사라지지만 히스토리에는 유령 엔트리가 남습니다. 그다음 뒤로가기는 그 유령을 소비하느라 아무 일도 하지 않는 것처럼 보입니다.

닫는 경로를 하나로 모으는 것이 답입니다. X 버튼도 결국 history.back()을 부르게 하고, 실제 닫기는 popstate에서만 합니다.

function useHistoryModal(open: boolean, onClose: () => void) {
useEffect(() => {
if (!open) return;
history.pushState({ ...history.state, modal: true }, "");

const handlePop = () => onClose();
window.addEventListener("popstate", handlePop);

return () => {
window.removeEventListener("popstate", handlePop);
// 뒤로가기가 아닌 경로로 언마운트됐다면 우리가 쌓은 엔트리를 걷어낸다
if (history.state?.modal) history.back();
};
}, [open, onClose]);
}

// X 버튼은 상태를 직접 바꾸지 않는다
<button onClick={() => history.back()}>닫기</button>

popstate로 닫혔다면 정리 시점의 history.state에는 이미 modal 표시가 없으므로 back()이 중복 호출되지 않습니다. 다만 이 구조는 4절에서 본 back()의 비동기성 때문에 은근히 까다롭고, 모달을 여러 겹 쌓으면 더 까다로워집니다. 9절의 Navigation API가 겨냥하는 문제 중 하나가 정확히 이것입니다.


범인 5 — iframe 위젯이 부모 히스토리에 쌓았다

1절에서 본 조인트 세션 히스토리입니다. 결제 위젯을 iframe으로 붙였는데 그 안에서 카드사 인증 페이지를 두세 번 오갔다면, 사용자의 뒤로가기는 그 이동을 먼저 소비합니다. 우리 코드에는 아무 흔적이 없어서 원인을 찾기가 특히 어렵습니다.

같은 오리진이면 contentWindow.location.replace()로, 교차 출처면 별도 창이나 네이티브 화면으로 분리하는 쪽이 확실합니다.


범인 6 — 라우터와 raw History API를 섞어 썼다

React Router나 Next.js의 라우터는 history.state 안에 자기 키를 심어 두고 내부 인덱스를 관리합니다. 여기에 우리가 history.pushState({ step })를 그대로 부르면 그 키가 통째로 날아가고, 라우터는 자기가 어디에 있는지 모르게 됩니다.

// ❌ 라우터가 심어 둔 키를 지워버린다
history.pushState({ step: 2 }, "", "?step=2");

// ✅ 기존 state를 보존한 채 우리 필드만 얹는다
history.pushState({ ...history.state, step: 2 }, "", "?step=2");

원칙은 단순합니다. 라우터를 쓴다면 이동은 전부 라우터로 하고, raw History API는 state를 덧붙이는 용도로만 씁니다. 라우터가 state 옵션을 제공한다면(React Router의 navigate(to, { state }), Next.js의 history.pushState 연동 등) 그쪽을 먼저 확인하는 편이 좋습니다.


범인 7 — 네이티브 back과 웹 back이 둘 다 처리했다

그리고 제 버그의 진짜 범인이었습니다. 6절 전체를 여기에 씁니다.


한눈에 보기

증상범인고치는 법
뒤로가기를 눌러도 같은 화면으로 튕긴다리다이렉트를 push로 했다되돌아갈 이유가 없으면 replace
뒤로가기를 두 번 눌러야 한 칸 간다이펙트가 두 번 돌아 같은 엔트리를 쌓았다이동을 이벤트 핸들러로, 또는 state로 멱등하게
뒤로가기를 열 번 눌러야 나간다입력마다 push했다디바운스 + replaceState
뒤로가기가 한 번 먹통이다모달을 X로 닫아 유령 엔트리가 남았다닫는 경로를 history.back() 하나로
화면은 그대로인데 뒤로가기만 안 먹는다iframe이 조인트 히스토리에 쌓았다iframe 안에서 replace 또는 분리
라우터가 갑자기 멈춘다raw pushState가 라우터 키를 지웠다기존 state를 펼쳐서 보존
뒤로가기 한 번에 두 칸 간다네이티브와 웹이 둘 다 처리했다소유권을 한쪽으로 몰기 (6절)

Recap

엔트리가 부풀어 오르는 경로는 대부분 정해져 있습니다. 되돌아갈 이유가 없는 이동을 push로 한 리다이렉트, 두 번 실행되는 이펙트, 입력마다 밀어 넣은 쿼리, 닫는 경로가 둘인 모달, iframe의 조인트 히스토리, 라우터 state를 덮어쓴 raw 호출입니다. 각각의 해법은 다르지만 공통 원칙은 하나입니다. 한 번의 사용자 의도에 정확히 한 개의 엔트리를 대응시키는 것입니다.




6. 웹뷰 — 스택이 두 개인 세계

여기가 이 글을 쓰게 만든 지점입니다. 웹뷰는 브라우저처럼 생겼지만 브라우저가 아닙니다. 가장 큰 차이는 뒤로가기 버튼이 우리 것이 아니라는 점입니다.


뒤로가기의 주인이 다릅니다

브라우저에서 뒤로가기 버튼은 브라우저 크롬(chrome)의 일부이고, 그 동작은 세션 히스토리 하나로 결정됩니다. 웹뷰에서는 그렇지 않습니다.

  • Android — 시스템 back 제스처나 버튼은 OnBackPressedDispatcher를 거쳐 액티비티에 도달합니다. 앱이 아무 처리도 하지 않으면 화면이 그냥 닫힙니다. 웹 히스토리를 되짚고 싶으면 앱이 명시적으로 webView.canGoBack()을 확인하고 webView.goBack()을 불러야 합니다.
  • iOSWKWebView에는 뒤로가기 버튼이 없습니다. 네비게이션 바에 앱이 직접 만든 back 버튼이 있거나, allowsBackForwardNavigationGestures를 켜서 엣지 스와이프 제스처를 쓰거나, 둘 다입니다. 이 프로퍼티는 기본값이 꺼짐입니다.

즉 웹뷰 안의 퍼널에는 두 개의 스택이 있습니다. 네이티브 화면 스택(액티비티/뷰 컨트롤러)과 웹 세션 히스토리입니다. 그리고 사용자의 "뒤로" 한 번이 이 둘 중 어느 쪽을 움직일지는 아무도 정해 주지 않습니다. 우리가 정해야 합니다.


두 칸씩 가던 이유

제 코드에는 두 개의 처리가 각각 따로 들어가 있었습니다. 네이티브 쪽에는 "back이 눌리면 웹뷰를 뒤로 보낸다"는 상식적인 코드가 있었고, 웹 쪽에는 "네이티브가 back 이벤트를 알려주면 퍼널을 한 단계 되돌린다"는 브릿지 핸들러가 있었습니다. 둘 다 각자 맞는 코드였고, 함께 있어서 틀렸습니다.

네이티브 back 핸들러와 브릿지 핸들러가 같은 웹 세션 히스토리를 각각 한 칸씩 움직여 두 칸이 이동하는 다이어그램

증상이 기기마다 달랐던 이유도 설명이 됩니다. 두 호출이 거의 동시에 일어나고 back()은 비동기이므로, 브라우저가 두 순회를 하나로 합치는 순간에는 한 칸만 움직였습니다. 재현이 잘 안 되는 버그의 전형적인 모양이었습니다.


계약: 소유권을 한쪽으로 몰기

해법은 기술이 아니라 계약입니다. 뒤로가기의 소유자를 한쪽으로 정하고, 반대쪽은 절대 개입하지 않습니다. 두 가지 선택지가 있습니다.


(A) 웹이 소유한다 — 퍼널이 복잡할 때

네이티브는 "back이 눌렸다"는 사실만 전달하고, 실제 이동은 전부 웹이 결정합니다. 네이티브는 goBack()절대 부르지 않습니다.

// 웹: 뒤로가기를 소비했는지 여부를 boolean으로 답한다
declare global {
interface Window {
__handleNativeBack?: () => boolean;
}
}

window.__handleNativeBack = () => {
if (isModalOpen()) {
closeModal();
return true; // 웹이 소비했다
}
if (!history.state?.funnelRoot) {
history.back();
return true; // 웹이 소비했다
}
return false; // 더 물러설 곳이 없다 — 네이티브가 화면을 닫아라
};
// Android: goBack()을 부르지 않는다. 결과에 따라 화면만 닫는다
onBackPressedDispatcher.addCallback(this) {
// evaluateJavascript는 비동기이고 결과는 JSON 문자열로 돌아온다
webView.evaluateJavascript("window.__handleNativeBack?.() === true") { result ->
if (result != "true") finish()
}
}
// iOS: 스와이프 제스처는 끄고, back 버튼도 같은 계약을 거친다
webView.allowsBackForwardNavigationGestures = false

@objc private func backButtonTapped() {
webView.evaluateJavaScript("window.__handleNativeBack?.() === true") { result, _ in
if result as? Bool != true {
self.navigationController?.popViewController(animated: true)
}
}
}

이 방식의 장점은 모달·바텀시트·폼 검증 같은 웹 쪽 사정을 뒤로가기가 정확히 반영한다는 점입니다. 대신 JS 실행이 비동기라서 네이티브 back이 아주 미세하게 늦어지고, 웹이 죽으면(스크립트 오류, 네트워크 실패로 흰 화면) 뒤로가기가 통째로 먹통이 됩니다.

그래서 실무에서는 타임아웃 폴백을 함께 둡니다. 브릿지 호출에 200~300ms 정도의 시한을 걸고, 그 안에 응답이 없으면 네이티브가 화면을 닫도록 합니다. 흰 화면에 갇혀 앱을 강제 종료하게 만드는 것보다 훨씬 낫습니다.


(B) 네이티브가 소유한다 — 퍼널이 단순할 때

웹은 자체 뒤로가기 UI를 두지 않고 모든 것을 네이티브 back에 맡깁니다. 네이티브는 canGoBack()/goBack()만 씁니다.

onBackPressedDispatcher.addCallback(this) {
if (webView.canGoBack()) webView.goBack() else finish()
}

간단하고 견고합니다. 다만 canGoBack()웹뷰 자신의 히스토리만 봅니다. pushState로 만든 엔트리도 여기에 포함되므로 대부분 잘 동작하지만, 모달이 열려 있는지 같은 웹 쪽 문맥은 전혀 모릅니다. 뒤로가기로 모달을 닫아야 하는 화면이 하나라도 있으면 (A)로 가야 합니다.


어느 쪽이든 지켜야 하는 것

하지 말아야 할 것이유
네이티브 goBack()과 웹 history.back()을 함께 부르기두 칸씩 움직입니다
웹의 커스텀 back 버튼과 네이티브 back이 서로 다른 경로를 타기두 경로의 동작이 언젠가 갈라집니다
iOS 스와이프 제스처를 켜 둔 채 계약 (A)를 쓰기스와이프는 브릿지를 거치지 않고 곧장 히스토리를 순회합니다
history.length로 "뒤로 갈 곳이 있는지" 판단하기1절에서 본 이유로 틀립니다

세 번째 줄은 iOS에서 자주 놓치는 지점입니다. allowsBackForwardNavigationGestures = true인 상태의 엣지 스와이프는 우리 브릿지를 우회해 세션 히스토리를 직접 되짚습니다. (A) 계약을 택했다면 이 제스처는 꺼야 하고, 켜고 싶다면 스와이프와 back 버튼이 같은 결과를 내도록 웹 핸들러를 설계해야 합니다.


딥링크로 들어오면 뒤로 갈 곳이 없습니다

푸시 알림이나 카카오톡 링크로 /funnel/step2에 곧장 들어온 사용자를 생각해 봅시다. 세션 히스토리에는 엔트리가 하나뿐이므로 history.back()은 조용히 아무 일도 하지 않고, 계약 (B)라면 canGoBack()false라서 앱이 그대로 닫힙니다.

history.length로 판별하려는 유혹이 생기는 지점인데, 1절에서 본 이유로 그 값은 믿을 수 없습니다. 대신 우리가 표시를 심어 둡니다.

// 퍼널의 뿌리에서 한 번 실행 — 이 엔트리가 되돌아갈 수 있는 마지막 지점이다
export function markFunnelRoot() {
history.replaceState({ ...history.state, funnelRoot: true }, "", location.href);
}

// 뒤로가기 처리
export function handleBack(): boolean {
if (history.state?.funnelRoot) return false; // 네이티브에 넘긴다 (화면 종료)
history.back();
return true;
}

replaceState를 쓴다는 점이 중요합니다. 엔트리를 새로 쌓지 않고 지금 서 있는 자리에 표시만 남기는 것이므로, 정상 경로로 들어온 사용자의 히스토리에는 아무 영향이 없습니다.


웹뷰가 되살아날 때

앱이 백그라운드에 있다가 메모리 압박으로 정리되면, 돌아왔을 때 웹뷰가 새로 만들어집니다. 이때 세션 히스토리를 복원하지 않으면 사용자는 퍼널 3단계에서 1단계로 튕깁니다.

  • iOSWKWebView.interactionState(iOS 15+)를 직렬화해 두었다가 새 웹뷰에 넣어 주면 히스토리와 스크롤 위치까지 복원됩니다. 그 이전 버전에서는 URL 목록을 저장해 두고 pushState를 반복 호출한 뒤 history.go()로 위치를 맞추는 우회법이 쓰였습니다. Firefox for iOS의 구현 노트에 그 과정이 잘 정리되어 있습니다.
  • AndroidWebView.saveState(Bundle) / restoreState(Bundle)로 히스토리를 담아 두었다가 복원합니다. 다만 이 API는 페이지 데이터 전체를 저장하지는 않으므로, 폼 입력값 같은 것은 별도로 챙겨야 합니다.

여기에 더해 Android WebView는 bfcache가 기본으로 꺼져 있습니다. androidx.webkitWebSettingsCompat.setBackForwardCacheEnabled()로 켤 수 있고, setBackForwardCacheSettings()로 타임아웃과 캐시 항목 수를 조절할 수 있습니다. 웹뷰 퍼널에서 뒤로가기가 브라우저보다 유독 느리고 입력값이 날아간다면 이 설정부터 확인해 볼 가치가 있습니다.


Recap

웹뷰에는 네이티브 화면 스택과 웹 세션 히스토리라는 두 개의 스택이 있고, 사용자의 "뒤로" 한 번이 어느 쪽을 움직일지는 우리가 계약으로 정해야 합니다. 두 쪽이 모두 처리하면 한 번의 제스처가 두 칸을 이동합니다. 웹이 소유하는 계약은 모달 같은 웹 문맥을 정확히 반영하지만 비동기 브릿지와 타임아웃 폴백이 필요하고, 네이티브가 소유하는 계약은 단순하지만 웹 문맥을 모릅니다. 딥링크 진입은 history.length 대신 replaceState로 심어 둔 뿌리 표시로 판별하고, 웹뷰 재생성에 대비해 interactionStatesaveState로 히스토리를 복원합니다.




7. 퍼널의 히스토리 계약 설계하기

버그를 고치고 나서 다시 만들 때 세운 규칙들입니다. 특별한 것은 없고, 앞 절들의 결론을 뒤집어 놓은 것에 가깝습니다.


네 가지 원칙

원칙 1 — 한 번의 사용자 의도에 한 개의 엔트리. 클릭 한 번에 엔트리가 두 개 생기거나, 클릭 세 번에 엔트리가 하나만 생기면 뒤로가기는 이미 예측 불가능합니다.

원칙 2 — 되돌아갈 수 없는 지점 이후는 전부 replace. 결제 완료, 세션 만료, 인증 리다이렉트, URL 정규화가 여기 해당합니다. "이 화면에서 뒤로가기를 누른 사용자가 어디에 도착해야 하는가"를 먼저 답하고, 그 답이 "직전 화면이 아니다"라면 replace입니다.

원칙 3 — 뒤로 갈 위치를 계산하지 말고 선언한다. go(-2), go(-history.length + 1) 같은 코드가 등장했다면 이미 설계가 잘못된 신호입니다. 정확한 숫자를 알 수 없기 때문입니다. 목적지가 분명하다면 그 목적지로 replace하는 편이 낫고, 특정 엔트리로 정확히 돌아가야 한다면 9절의 traverseTo(key)가 있습니다.

원칙 4 — 퍼널의 깊이를 상수로 고정한다. 스텝이 넷이면 퍼널이 만드는 엔트리도 정확히 넷이어야 합니다. 모달, 검증 오류, 재시도, 툴팁이 엔트리를 만들기 시작하면 그 순간부터 뒤로가기는 사용자에게 복권이 됩니다.


스텝을 어디에 표현할 것인가

퍼널의 단계를 무엇으로 표현하느냐가 히스토리 설계의 절반을 결정합니다.

방식예시새로고침딥링크·공유뒤로가기비고
경로(path)/funnel/step2그 스텝으로 복원가능자연스럽게 스텝 이동중간 스텝 진입 시 앞 단계 데이터가 없다
쿼리(query)/funnel?step=2그 스텝으로 복원가능자연스럽게 스텝 이동라우트가 하나라 코드 분기가 단순하다
컴포넌트 상태useState1단계로 초기화불가능퍼널을 통째로 이탈웹뷰에서는 사실상 못 씁니다

세 번째 방식이 웹뷰에서 특히 나쁜 이유가 있습니다. 브라우저라면 "뒤로가기를 눌렀더니 퍼널 밖으로 나갔다" 정도지만, 웹뷰에서는 그 뒤로가기가 앱 화면을 닫습니다. 사용자는 3단계까지 채운 폼과 함께 앱 밖으로 튕겨 나갑니다.

저는 쿼리 방식을 기본으로 씁니다. 라우트가 하나라 레이아웃·진행 표시줄·이탈 방지를 한 곳에서 관리할 수 있고, 스텝이 늘어나도 라우팅 설정을 건드릴 일이 없기 때문입니다.


뿌리를 심고 그 위에 정확히 쌓기

앞 절들의 조각을 합치면 이런 모양이 됩니다.

진입점을 replaceState로 표시하고 전진할 때만 pushState로 한 칸씩 쌓는 퍼널 히스토리 구조

type Step = "intro" | "profile" | "verify" | "confirm";
const ORDER: Step[] = ["intro", "profile", "verify", "confirm"];

/** 퍼널 진입 시 한 번. 엔트리를 쌓지 않고 지금 자리에 뿌리 표시만 남긴다 */
export function enterFunnel(step: Step) {
const url = new URL(location.href);
url.searchParams.set("step", step);
history.replaceState(
{ ...history.state, v: 1, funnelRoot: true, step },
"",
url,
);
}

/** 다음 단계로. 사용자의 클릭에서만 호출한다 */
export function goNext(current: Step) {
const next = ORDER[ORDER.indexOf(current) + 1];
if (!next) return;

const url = new URL(location.href);
url.searchParams.set("step", next);
// 앞으로 갈 때만 엔트리를 쌓는다. 정확히 한 칸
history.pushState({ ...history.state, funnelRoot: false, step: next }, "", url);
}

/** 이전 단계로. 직접 그리지 않고 브라우저의 뒤로가기를 그대로 쓴다 */
export function goPrev(): boolean {
if (history.state?.funnelRoot) return false; // 여기가 뿌리다
history.back();
return true;
}

/** 완료 화면. 마지막 스텝 엔트리를 덮어쓴다 */
export function finishFunnel() {
history.replaceState({ v: 1, done: true }, "", "/funnel/done");
}

finishFunnelreplace인 것은 마지막 스텝 엔트리를 남기지 않기 위해서지만, 그 앞의 스텝들은 여전히 스택에 남아 있습니다. 그래서 완료 이후에는 스텝 화면 쪽에 "이미 완료된 신청이면 밖으로 내보낸다"는 가드가 함께 있어야 합니다. History API만으로는 퍼널 전체를 한 번에 걷어낼 방법이 없기 때문인데, 9절의 traverseTo(key)가 바로 이 빈틈을 메우는 API입니다.

여기서 눈여겨볼 것은 goPrev가 화면을 직접 그리지 않는다는 점입니다. history.back()을 부르면 popstate가 오고, 화면은 그 핸들러 한 곳에서만 갱신됩니다. 앞으로 가는 길과 뒤로 가는 길의 렌더링 경로를 하나로 합치는 것이 이 구조의 핵심입니다. 두 경로를 따로 만들면 언젠가 반드시 갈라집니다.

// 화면 갱신은 여기 한 곳
useEffect(() => {
const sync = () => setStep(history.state?.step ?? "intro");
window.addEventListener("popstate", sync);
return () => window.removeEventListener("popstate", sync);
}, []);

이탈 방지는 히스토리로 하지 않습니다

작성 중인 내용이 날아가는 것을 막고 싶을 때, popstate 안에서 다시 pushState를 불러 사용자를 붙잡아 두는 기법이 인터넷에 돌아다닙니다.

// ❌ 뒤로가기 가두기 — 쓰지 않는 편이 좋습니다
window.addEventListener("popstate", () => {
history.pushState(null, "", location.href);
showLeaveConfirm();
});

동작은 하지만 나쁜 방법입니다. 사용자의 명시적 의도를 무력화하고, 확인 창을 닫아도 히스토리는 이미 뒤틀려 있으며, 브라우저들이 이런 패턴을 점점 더 강하게 제한하고 있습니다. 무엇보다 웹뷰에서는 사용자가 앱을 강제 종료하는 것 말고는 빠져나갈 방법이 없어지는 상황이 만들어집니다.

의도에 맞는 도구를 씁니다.

  • 탭을 닫거나 다른 사이트로 나갈 때beforeunload. 다만 문구를 지정할 수 없고 사용자 상호작용이 있어야만 뜨며, 3절에서 본 대로 bfcache와도 상충합니다. 정말 필요한 화면에서만 등록하고 떠날 때 해제합니다.
  • 앱 내부 이동을 막을 때 — 라우터가 제공하는 blocker를 씁니다. React Router의 useBlocker가 대표적입니다.
  • 웹뷰의 네이티브 back — 6절의 브릿지 계약 안에서 처리합니다. __handleNativeBack이 확인 모달을 띄우고 true를 반환하면, 그 back은 소비된 것으로 끝납니다. 히스토리를 뒤틀지 않고도 같은 목적을 달성할 수 있습니다.

Recap

퍼널의 히스토리는 네 가지 원칙 위에 세웁니다. 사용자 의도 하나에 엔트리 하나, 되돌아갈 수 없는 이동은 replace, 뒤로 갈 위치는 계산하지 말고 선언, 퍼널의 깊이는 상수로 고정입니다. 스텝은 경로나 쿼리로 표현해 새로고침과 뒤로가기가 자연스럽게 동작하게 하고, 컴포넌트 상태로만 들고 있는 방식은 웹뷰에서 사용자를 앱 밖으로 내보냅니다. 진입점에 replaceState로 뿌리를 심고 전진할 때만 pushState하며, 렌더링 경로는 popstate 한 곳으로 모읍니다. 이탈 방지는 히스토리를 뒤트는 대신 beforeunload·라우터 blocker·브릿지 계약으로 해결합니다.




8. 스택을 눈으로 보기

이 버그가 오래 걸렸던 이유 중 하나는 히스토리 스택을 볼 방법이 없다고 생각했기 때문입니다. DevTools에는 세션 히스토리를 보여주는 패널이 없습니다. 하지만 볼 방법이 아주 없지는 않습니다.


누가 엔트리를 쌓았는지 기록하기

가장 먼저 넣는 코드입니다. pushStatereplaceState를 감싸서 호출 스택까지 남깁니다.

// 개발 환경에서만 실행한다
["pushState", "replaceState"].forEach((name) => {
const original = history[name].bind(history);
history[name] = (state, unused, url) => {
console.groupCollapsed(`history.${name}${url ?? location.href}`);
console.log("state:", state);
console.trace(); // 누가 불렀는지가 진짜 정보다
console.groupEnd();
return original(state, unused, url);
};
});

window.addEventListener("popstate", (e) => {
console.log("↩︎ popstate →", location.pathname + location.search, e.state);
});

window.addEventListener("hashchange", () => {
console.log("# hashchange →", location.hash);
});

window.addEventListener("pageshow", (e) => {
console.log("▶︎ pageshow · persisted =", e.persisted);
});

console.trace() 한 줄이 핵심입니다. 라우터·이펙트·브릿지 중 누가 엔트리를 쌓았는지가 여기서 바로 드러납니다. 제 경우 브릿지 핸들러와 네이티브 호출이 밀리초 단위로 붙어서 찍히는 것을 보고 원인을 알았습니다.


브라우저가 지원한다면 이게 훨씬 강력합니다. 히스토리 엔트리를 배열로 받을 수 있습니다.

function dumpHistory() {
if (!("navigation" in window)) return console.warn("Navigation API 미지원");

const current = navigation.currentEntry;
console.table(
navigation.entries().map((entry) => ({
"#": entry.index,
here: entry.key === current.key ? "◀︎" : "",
url: new URL(entry.url).pathname + new URL(entry.url).search,
state: JSON.stringify(entry.getState() ?? null),
})),
);
}

이 한 줄이면 "지금 스택에 뭐가 몇 개 쌓여 있고 나는 몇 번째에 있는가"가 표로 나옵니다. history.length로 추측하던 것과는 비교가 되지 않습니다. 저는 개발 빌드의 전역에 window.__dumpHistory로 붙여 두고 씁니다.


이 페이지에 어떻게 도착했는가

popstate가 왔는지 안 왔는지가 애매할 때, 도착 경로 자체를 물어볼 수 있습니다.

const nav = performance.getEntriesByType("navigation")[0];
nav.type; // "navigate" | "reload" | "back_forward" | "prerender"

"back_forward"가 나오면 뒤로/앞으로 순회로 도착했다는 뜻입니다. bfcache가 아니라 완전히 새로 로드된 경우에도 이 값이 "back_forward"이므로, pageshowpersisted와 함께 보면 네 가지 진입 경로를 모두 구분할 수 있습니다.


웹뷰 안을 들여다보기

웹뷰는 원격 디버깅을 켜야 보입니다. 그리고 기본값이 꺼짐이라 이것부터 막히는 경우가 많습니다.

  • Android — 앱 코드에서 WebView.setWebContentsDebuggingEnabled(true)를 호출한 디버그 빌드여야 합니다. 그러면 데스크톱 크롬의 chrome://inspect에 기기가 나타납니다.
  • iOS — 기기의 설정에서 Safari 웹 인스펙터를 켜고, iOS 16.4 이상이라면 앱이 WKWebView.isInspectable = true를 설정해야 합니다. 그러면 macOS Safari의 개발자 메뉴에 웹뷰가 나타납니다.

원격 디버깅이 붙으면 앞의 로거를 그대로 쓸 수 있습니다. 붙지 않는 환경(사내 배포 빌드, 릴리스 빌드)에서는 로그를 화면 위 오버레이에 그려 두거나 브릿지로 네이티브 로그에 흘려보내는 방법을 씁니다. 아름답지는 않지만, 재현 조건이 기기마다 다른 버그에서는 이쪽이 훨씬 빨랐습니다.


재현 체크리스트

같은 종류의 버그를 만났을 때 순서대로 확인하는 목록입니다.

  1. 로거를 붙이고 한 번의 사용자 동작이 엔트리를 몇 개 만드는지 센다.
  2. navigation.entries()로 스택 전체와 현재 인덱스를 본다.
  3. 리다이렉트·가드가 push인지 replace인지 확인한다.
  4. 이펙트 안에서 호출되는 이동이 있는지 찾는다. 있으면 두 번 도는지 확인한다.
  5. iframe이 있으면 그 안의 이동을 센다.
  6. 웹뷰라면 네이티브 back 핸들러를 열어 goBack() 호출이 있는지 본다.
  7. 딥링크로 곧장 진입한 경우를 따로 테스트한다.

특히 7번은 개발 중에 거의 테스트되지 않습니다. 항상 첫 화면부터 눌러 들어가기 때문입니다. 딥링크 진입은 별도의 시나리오로 QA 목록에 넣어야 합니다.


Recap

히스토리는 볼 수 없는 것이 아니라 보는 법이 덜 알려져 있을 뿐입니다. pushState/replaceState를 감싸 console.trace()를 남기면 누가 엔트리를 쌓았는지 바로 드러나고, Navigation API의 navigation.entries()는 스택 전체와 현재 위치를 표로 보여줍니다. performance.getEntriesByType("navigation")[0].type으로 도착 경로를, pageshowpersisted로 bfcache 여부를 구분합니다. 웹뷰는 원격 디버깅이 기본으로 꺼져 있으니 setWebContentsDebuggingEnabledisInspectable부터 확인합니다.




지금까지의 불편함을 늘어놓고 보면 공통점이 보입니다. History API에는 세 가지 근본적인 결함이 있습니다.

  1. 엔트리를 볼 수 없습니다. 길이만 알 수 있고 내용도 위치도 모릅니다.
  2. 네비게이션을 가로챌 수 없습니다. 링크 클릭을 SPA 라우팅으로 바꾸려면 <a>에 핸들러를 붙여 preventDefault하는 수밖에 없고, 그러면 브라우저의 뒤로가기·앵커 이동·폼 전송은 여전히 다른 길로 샙니다.
  3. 어디서 왔는지 모릅니다. popstate 하나로는 뒤로 왔는지 앞으로 왔는지, 사용자가 눌렀는지 코드가 불렀는지 구분할 수 없습니다.

Navigation API는 이 셋을 정면으로 겨냥해 만들어졌습니다. 그리고 2026년 1월, Chrome·Edge에 이어 Firefox 147과 Safari 26.2에 지원이 들어가면서 Baseline Newly Available이 되었습니다.


엔트리를 배열로 봅니다

navigation.entries();     // NavigationHistoryEntry[] — 스택 전체
navigation.currentEntry; // 지금 서 있는 엔트리
navigation.canGoBack; // 뒤로 갈 곳이 있는가 (드디어)
navigation.canGoForward;

const entry = navigation.currentEntry;
entry.index; // 스택 안에서의 위치
entry.key; // 이 "자리"의 식별자 — 같은 자리를 다시 방문해도 유지된다
entry.id; // 이 방문의 식별자 — 새로고침하면 바뀐다
entry.getState(); // history.state에 해당

history.length가 숫자 하나만 주는 것과 달리 navigation.entries()는 엔트리 배열과 현재 위치를 보여준다는 비교 그림

canGoBack 하나만으로도 6절에서 뿌리 표시를 심어 두던 수고가 거의 사라집니다. keyid의 구분도 유용합니다. key는 자리를, id는 방문을 가리킵니다. "이 자리로 정확히 돌아가고 싶다"면 key를 들고 있으면 됩니다.

// 퍼널 진입 시점의 key를 기억해 두었다가
const rootKey = navigation.currentEntry.key;

// 언제든 정확히 그 자리로 되돌아간다. go(-n)의 n을 계산할 필요가 없다
await navigation.traverseTo(rootKey).finished;

원칙 3에서 "뒤로 갈 위치를 계산하지 말고 선언하라"고 했던 것의 표준적인 구현이 이것입니다.


모든 네비게이션이 한 이벤트로 옵니다

navigate 이벤트는 링크 클릭, location 대입, 폼 전송, 뒤로/앞으로, pushState까지 거의 모든 이동을 한 곳으로 모읍니다.

navigation.addEventListener("navigate", (event) => {
if (!event.canIntercept || event.hashChange) return;

const url = new URL(event.destination.url);
if (!url.pathname.startsWith("/funnel/")) return;

event.intercept({
async handler() {
// 라우팅과 렌더링을 여기 한 곳에서 처리한다
await renderFunnel(url);
},
});
});

event에서 꺼낼 수 있는 정보가 popstate와는 차원이 다릅니다.

속성의미
navigationType"push" · "replace" · "reload" · "traverse"
destination목적지의 url·key·index·getState()
userInitiated사용자 제스처에서 비롯되었는가
canIntercept가로챌 수 있는 이동인가 (교차 출처 등은 불가)
hashChange해시만 바뀌는 이동인가
signal이동이 취소되면 abort되는 AbortSignal

navigationType"traverse"면 뒤로/앞으로 순회이고, destination.index를 현재 인덱스와 비교하면 어느 방향으로 몇 칸인지까지 알 수 있습니다. 5절과 6절에서 애를 먹던 것들이 대부분 여기서 그냥 풀립니다.

그리고 event.preventDefault()이동 자체를 취소할 수도 있습니다. 7절에서 "뒤로가기 가두기"를 안티패턴이라고 했는데, 이 API는 히스토리를 뒤틀지 않고 같은 목적을 달성하는 정식 경로를 제공합니다.


웹뷰에서 쓸 수 있을까

여기가 현실적인 제약입니다. 웹뷰의 엔진은 앱이 고르는 것이 아니라 OS에 묶여 있습니다.

  • Android WebView — Chromium 기반이고 Play 스토어를 통해 갱신되므로, 최신 기기에서는 대체로 쓸 수 있습니다. 다만 기업용 기기나 업데이트가 막힌 환경에서는 오래된 버전이 남아 있습니다.
  • iOS WKWebView — WebKit이고 iOS 버전에 묶입니다. Safari 26.2 기준이므로, 그보다 낮은 iOS를 지원해야 한다면 쓸 수 없습니다. 그리고 현재 Safari 구현에는 precommitHandler 같은 일부 기능이 빠져 있습니다.

그래서 당장은 기능 감지 후 점진적 향상이 현실적인 선택입니다. 다행히 이 API가 겨냥하는 문제들은 폴백을 짜기 어렵지 않습니다.

// 뒤로 갈 곳이 있는지 판단하기
export function canGoBack(): boolean {
if ("navigation" in window) return navigation.canGoBack;
return !history.state?.funnelRoot; // 6절의 뿌리 표시로 폴백
}

Recap

Navigation API는 History API의 세 가지 결함 — 엔트리를 볼 수 없고, 네비게이션을 가로챌 수 없고, 출처를 알 수 없다는 점 — 을 정면으로 해결하려고 만들어졌고 2026년 1월 Baseline Newly Available이 되었습니다. navigation.entries()로 스택을 배열로 보고, canGoBack으로 뒤로 갈 곳을 판단하며, traverseTo(key)로 계산 없이 특정 자리로 되돌아갑니다. navigate 이벤트는 거의 모든 이동을 한 곳에 모으고 navigationType·destination·userInitiated까지 알려줍니다. 다만 웹뷰의 엔진은 OS에 묶이므로 당분간은 기능 감지 후 폴백을 두는 방식이 안전합니다.




정리

처음의 질문으로 돌아가면, 뒤로가기가 되돌리는 것은 엔트리 하나입니다. 문제는 그 엔트리를 누가 언제 쌓았는지가 코드 곳곳에 흩어져 있다는 데 있었습니다.

알게 된 것요약
세션 히스토리스택이 아니라 커서를 가진 리스트. back은 지우지 않고 새 네비게이션이 앞쪽을 지운다
history.length커서 위치를 모르고 진입 전 엔트리를 포함하며 상한도 있다. 제어에 쓰면 안 된다
location읽으면 값, 대입하면 명령. assign은 쌓고 replace는 덮어쓴다
history.state엔트리에 붙는 값. 새로고침에는 살아남고 링크 공유에는 따라가지 않는다
pushState네비게이션이 아니다. popstate도 발생하지 않는다
popstate같은 문서 안에서 커서가 움직일 때만. bfcache 복원은 pageshow가 먼저
back()비동기이고, 갈 곳이 없으면 조용히 아무 일도 하지 않는다
웹뷰스택이 두 개다. 뒤로가기의 소유권을 계약으로 정하지 않으면 두 칸씩 간다
Navigation API엔트리를 배열로 보고, 이동을 가로채고, 자리를 key로 지목한다

이 목록을 다시 보면, 제가 겪은 버그의 절반은 "엔트리가 언제 생기는지 몰랐기 때문"이고 나머지 절반은 "뒤로가기의 주인이 누구인지 정하지 않았기 때문"이었습니다. 앞의 절반은 History API를 정확히 아는 것으로 해결되고, 뒤의 절반은 API 지식으로는 해결되지 않습니다. 팀이 계약을 정해야 합니다.

예측 가능한 소프트웨어는 예상한 대로 동작하는 소프트웨어입니다. 그리고 뒤로가기는 사용자가 하루에도 수십 번 누르는, 가장 예상대로 동작해야 하는 버튼입니다.

퍼널을 다시 만들면서 가장 크게 달라진 것은 코드가 아니라 리뷰였습니다. "이 이동은 push인가 replace인가"를 PR 설명에 한 줄 적기 시작했고, 그 뒤로는 같은 종류의 버그가 나오지 않았습니다.




References

명세

  1. HTML Standard — Navigation and session history
  2. HTML Standard — Session history entry
  3. WHATWG — The Navigation API

MDN

  1. MDN — History API
  2. MDN — History.pushState()
  3. MDN — Window: popstate event
  4. MDN — Location
  5. MDN — Navigation API
  6. MDN — Window: pageshow event

Navigation API · bfcache

  1. Navigation API — a better way to navigate, is now Baseline Newly Available (web.dev)
  2. Navigation API Reaches Baseline Newly Available as Replacement to the History API (InfoQ)
  3. Back/forward cache — web.dev

웹뷰

  1. Apple Developer — WKWebView.allowsBackForwardNavigationGestures
  2. Apple Developer — WKWebView.interactionState
  3. History Restoration in WKWebView — firefox-ios Wiki
  4. Android Developers — Simplify your WebView implementation with Jetpack Webkit
  5. Customize Android's back button navigation in a WebView — LogRocket

호출 빈도 제한

  1. history-throttled — pushState/replaceState 스로틀링 구현


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