뒤로가기 한 번에 두 칸씩 가는 이유는 뭘까요?
앱 안에 웹뷰로 띄우는 신청 퍼널을 만들고 있었습니다. 네 단계짜리 폼이었고 3단계에서 뒤로 가면 2단계가 나와야 했습니다. 그런데 1단계가 나왔습니다. 어떤 기기에서는 두 번을 눌러야 겨우 한 칸이 움직였고, 딥링크로 들어온 사용자는 뒤로가기를 누르는 순간 앱 화면이 닫혔습니다.
처음에는
history.back()을 두 번 부르는 코드를 찾아다녔습니다. 그런 코드는 없었습니다. 대신 같은 사용자 입력을 처리하는 두 개의 back 실행 경로가 있었습니다. 네이티브는webView.goBack()을 불렀고 웹 브릿지는 다시history.back()을 불렀습니다.입력은 한 번이었지만 같은 웹 세션 히스토리에는 두 번의 순회가 예약됐습니다.
뒤로가기는 누가 처리해야 할까요?
이 글에서 다루는 내용
이 글은 브라우저 세션 히스토리의 일반 원리를 다시 설명하지 않습니다. 엔트리와 Document, push와 replace, 순회가 낯설다면 1편을 먼저 읽는 편이 자연스럽습니다.
여기서는 웹뷰가 추가하는 네이티브 화면 스택과 back 입력 경로에 집중합니다. 한 번의 입력이 왜 두 번의 웹 히스토리 순회로 이어졌는지 추적하고 웹과 네이티브 중 한쪽에 소유권을 두는 계약, 퍼널의 경계를 표시하는 방법, 수명주기 복원, 로깅과 재현 시나리오를 차례로 정리합니다.
- 웹뷰에는 두 탐색 체계가 있습니다
- 두 스택이 아니라 두 실행 경로가 문제였습니다
- 뒤로가기의 소유자를 한쪽으로 정합니다
- 퍼널의 히스토리 경계를 표시합니다
- 웹뷰가 다시 만들어질 때 탐색 상태를 복원합니다
- 입력부터 순회까지 함께 기록합니다
- References
웹뷰에는 두 탐색 체계가 있습니다
웹뷰 화면에는 서로 다른 두 상태 기계가 공존합니다.
| 영역 | 담고 있는 것 | 뒤로가기 동작의 예 |
|---|---|---|
| 네이티브 화면 스택 | 액티비티, 프래그먼트, 뷰 컨트롤러 | finish(), popViewController() |
| 웹 세션 히스토리 | 문서 탐색과 SPA가 만든 엔트리 | webView.goBack(), history.back() |
둘이 함께 있는 것은 정상입니다. 앱의 상품 화면에서 웹뷰 신청 화면을 열고 그 안에서 /step/1, /step/2, /step/3으로 이동했다면 네이티브 화면 스택의 웹뷰 안에 별도의 웹 세션 히스토리가 놓입니다.
웹뷰에서 back이 들어오면 먼저 질문해야 할 것은 "스택이 몇 개인가"가 아닙니다. 이번 입력이 웹 히스토리를 순회해야 하는지, 네이티브 웹뷰 화면을 닫아야 하는지입니다.
웹의 이전 엔트리가 있다면 /step/2로 순회할 수 있습니다. 웹 퍼널의 뿌리에 도착했다면 네이티브가 웹뷰 화면을 닫는 편이 자연스럽습니다. 모달이나 바텀시트가 열려 있다면 URL 이동 전에 그 UI가 입력을 소비할 수도 있습니다.
back 입력도 하나가 아닙니다
같은 사용자 의도는 여러 진입점에서 발생합니다.
- Android 시스템 back 또는 예측형 back 제스처
- iOS의 네비게이션 버튼과 엣지 스와이프
- 앱이 그린 상단 뒤로가기 버튼
- 웹 페이지 안의 뒤로가기 버튼
- 네이티브가 JavaScript로 전달한 브릿지 이벤트
이 입력들이 같은 핸들러로 모이지 않으면 각각은 정상인 코드가 한꺼번에 실행됩니다. 제 경우가 그랬습니다.
두 스택이 아니라 두 실행 경로가 문제였습니다
문제가 있던 구현에서는 네이티브 back 핸들러가 두 가지 일을 했습니다.
webView.goBack()으로 웹뷰의 이전 엔트리를 요청했습니다.- 같은 입력을 웹 브릿지에도 전달했습니다.
웹 브릿지 핸들러는 이 이벤트를 받아 history.back()을 호출했습니다.
중요한 구분이 하나 있습니다. webView.goBack()은 네이티브 화면 스택을 pop하는 API가 아닙니다. 웹뷰가 관리하는 back-forward 목록에서 이전 항목으로 이동합니다. history.back()도 웹 세션 히스토리를 한 단계 순회합니다. 호출 위치는 네이티브와 JavaScript로 달랐지만 목적지는 같았습니다.
위 도식은 관찰된 한 가지 순서를 시간축으로 펼친 것입니다. 두 호출 모두 순회를 요청하고 즉시 최종 화면을 돌려주는 동기 함수가 아닙니다. 짧은 간격으로 요청이 겹치면 두 번의 순회가 처리될 수 있고 탐색이 진행 중일 때 다음 요청이 제한되면 한 번은 먹히지 않은 것처럼 보일 수 있습니다. 기기와 타이밍에 따라 "두 칸 이동"과 "두 번 눌러야 이동"이 섞여 나타난 이유입니다.
두 개의 스택은 조정해야 할 배경이었고, 두 개의 실행 경로가 버그의 직접 원인이었습니다.
중복 호출을 막는 플래그를 양쪽에 덧붙이면 증상을 줄일 수는 있습니다. 하지만 누가 주인인지 정하지 않은 채 두 핸들러를 계속 유지하면 제스처나 타임아웃 같은 새 경로가 들어올 때 다시 같은 문제가 생깁니다. 수정 단위는 조건문보다 소유권 계약이어야 했습니다.
뒤로가기의 소유자를 한쪽으로 정합니다
계약은 두 가지 중 하나를 고릅니다.
| 계약 | 네이티브의 역할 | 웹의 역할 | 어울리는 경우 |
|---|---|---|---|
| 웹 소유 | 입력을 전달하고 웹이 소비하지 않으면 화면을 닫음 | 모달, 라우트, 퍼널 경계를 판단 | 웹 UI 문맥이 복잡함 |
| 네이티브 소유 | canGoBack()을 보고 웹뷰 순회 또는 화면 종료 | 같은 입력에서 history.back()을 호출하지 않음 | 웹 내부 back 규칙이 단순함 |
두 계약 모두 가능합니다. 섞는 것만 피하면 됩니다.
웹이 소유하는 계약
퍼널 안에 뒤로가기로 닫아야 하는 모달과 단계별 검증이 있다면 웹이 판단하기 쉽습니다. 네이티브는 입력을 전달하고 JavaScript가 false를 반환했을 때만 웹뷰 화면을 닫습니다. 이 경로에서 네이티브는 webView.goBack()을 호출하지 않습니다.
declare global {
interface Window {
__handleNativeBack?: () => boolean
}
}
let traversalPending = false
let traversalTimer: number | undefined
function finishTraversal() {
traversalPending = false
window.clearTimeout(traversalTimer)
}
function readFunnelBoundary(): { version: 1; root: boolean } | null {
const entry = history.state?.__funnel
if (!entry || entry.version !== 1 || typeof entry.root !== "boolean") {
return null
}
return entry
}
window.addEventListener("popstate", finishTraversal)
window.__handleNativeBack = () => {
if (traversalPending) return true
if (isModalOpen()) {
closeModal()
return true
}
const entry = readFunnelBoundary()
if (entry?.root === false) {
traversalPending = true
traversalTimer = window.setTimeout(() => {
finishTraversal()
console.warn("history traversal timed out")
}, 1000)
history.back()
return true
}
return false // 뿌리이거나 유효한 퍼널 state가 없으므로 네이티브 화면을 닫는다
}
Android 쪽은 JavaScript 평가 결과만 처리합니다.
private var backPending = false
onBackPressedDispatcher.addCallback(this) {
if (backPending) return@addCallback
backPending = true
webView.evaluateJavascript("window.__handleNativeBack?.() === true") { result ->
backPending = false
if (result != "true") finish()
}
}
iOS에서도 웹 소유 계약을 선택했다면 네이티브 버튼은 JavaScript의 소비 여부만 확인합니다. back-forward 제스처는 같은 브릿지를 거치지 않으므로 이 계약에서는 꺼야 합니다.
webView.allowsBackForwardNavigationGestures = false
private var backPending = false
@objc private func backButtonTapped() {
guard !backPending else { return }
backPending = true
webView.evaluateJavaScript("window.__handleNativeBack?.() === true") {
[weak self] result, _ in
guard let self else { return }
self.backPending = false
if (result as? Bool) != true {
self.navigationController?.popViewController(animated: true)
}
}
}
네이티브의 backPending은 JS 평가가 겹치는 일을 막습니다. 웹의 traversalPending은 첫 평가가 끝난 뒤 popstate가 오기 전에 다음 입력이 들어오는 경우를 막습니다. 유효한 비-root 엔트리는 pushState()로 만든 이전 퍼널 엔트리가 있다는 불변식을 가집니다. state가 없거나 버전이 다르면 웹이 순회를 시작했다고 응답하지 않습니다. history.back()은 이전 엔트리가 없을 때 아무 일도 하지 않으므로 state가 없는 경우까지 웹이 소비하면 화면이 1초 동안 멈춘 뒤 잠금만 풀릴 수 있습니다.
브릿지는 응답이 영원히 오지 않는 상황도 처리해야 합니다. 제품에서 정상 응답 시간을 측정해 타임아웃을 정하고 타임아웃과 늦게 도착한 응답이 화면을 두 번 닫지 않도록 한 번만 완료되는 가드를 둡니다. 폴백이 발생하면 입력 ID와 함께 로그로 남깁니다.
iOS에서 웹뷰의 back-forward 제스처를 허용하면 엣지 스와이프가 브릿지 판단을 거치지 않고 웹 히스토리를 순회할 수 있습니다. 웹 소유 계약을 선택했다면 제스처를 끄거나 제스처도 같은 결과를 보장하는 별도 정책이 필요합니다.
네이티브가 소유하는 계약
웹 내부에 back으로 먼저 닫아야 하는 UI가 없고 웹뷰의 이전 항목만 보면 되는 화면이라면 네이티브가 직접 소유할 수 있습니다.
onBackPressedDispatcher.addCallback(this) {
if (webView.canGoBack()) {
webView.goBack()
} else {
finish()
}
}
이 계약에서 웹은 네이티브 back 이벤트를 받아 history.back()을 호출하지 않습니다. 웹 페이지의 뒤로가기 버튼이 브릿지로 네이티브 back을 요청하도록 구현돼 있다면 웹에서 직접 history.back()까지 호출하거나 시스템 back 핸들러를 다시 통과하지 않게 해야 합니다.
canGoBack()은 웹뷰 목록에 이전 항목이 있는지 알려줄 뿐 그 항목이 퍼널 안에 있는지는 모릅니다. 외부 페이지를 거쳐 들어오거나 웹 앱 하나가 여러 제품 흐름을 공유한다면 네이티브 소유 방식에도 웹이 제공하는 경계 신호가 필요합니다.
Recap
웹이 소유하면 네이티브는 입력만 전달하고 웹이 소비하지 않았을 때 화면을 닫습니다. 네이티브가 소유하면 웹은 같은 입력에서 다시 순회하지 않습니다. 어떤 계약을 선택하든 유효하지 않은 경계 state, 중복 입력, 브릿지 타임아웃, iOS 제스처가 같은 완료 경로로 모이게 해야 합니다.
퍼널의 히스토리 경계를 표시합니다
직접 진입한 퍼널의 중간 단계에서는 웹 안에 이전 단계 엔트리가 없습니다. history.length나 canGoBack()만 보면 사용자가 우리 퍼널에 들어오기 전 엔트리까지 포함될 수 있습니다.
브라우저와 웹뷰는 "퍼널"이라는 제품 개념을 모릅니다. 진입 엔트리에 애플리케이션의 뿌리 표시를 남겨야 합니다.
퍼널에서 사용한 규칙은 다음과 같습니다.
| 사용자 동작 | 히스토리 동작 | 이유 |
|---|---|---|
| 퍼널 진입 | 현재 엔트리에 replaceState | 진입 자체를 중복으로 쌓지 않고 뿌리를 표시 |
| 다음 단계 | pushState | 이전 단계로 돌아갈 수 있음 |
| 이전 단계 | history.back() | 직접 화면을 그리지 않고 기존 엔트리로 순회 |
| 완료 | 현재 엔트리를 완료 상태로 replaceState | 마지막 입력 단계 재진입을 줄임 |
| 뿌리에서 back | 웹이 false 반환 | 네이티브 웹뷰 화면을 닫음 |
type Step = "intro" | "profile" | "verify" | "confirm"
type FunnelView =
| { kind: "step"; step: Step }
| { kind: "done" }
type FunnelEntry = {
version: 1
root: boolean
view: FunnelView
}
type Renderer = {
renderStep: (step: Step) => void
renderDone: () => void
}
const KEY = "__funnel"
function baseState(): Record<string, unknown> {
return history.state && typeof history.state === "object"
? history.state
: {}
}
function writeEntry(entry: FunnelEntry) {
return { ...baseState(), [KEY]: entry }
}
function readEntry(): FunnelEntry | null {
const entry = history.state?.[KEY] as FunnelEntry | undefined
if (!entry || entry.version !== 1) return null
return entry
}
function urlWithStep(step: Step) {
const url = new URL(location.href)
url.searchParams.set("step", step)
return url
}
function renderFromEntry(renderer: Renderer) {
const entry = readEntry()
if (!entry) return
if (entry.view.kind === "done") renderer.renderDone()
else renderer.renderStep(entry.view.step)
}
export function enterFunnel(step: Step, renderer: Renderer) {
history.replaceState(
writeEntry({
version: 1,
root: true,
view: { kind: "step", step },
}),
"",
urlWithStep(step),
)
renderFromEntry(renderer)
}
export function goNext(step: Step, renderer: Renderer) {
history.pushState(
writeEntry({
version: 1,
root: false,
view: { kind: "step", step },
}),
"",
urlWithStep(step),
)
renderFromEntry(renderer) // pushState는 popstate를 발생시키지 않는다
}
export function finishFunnel(renderer: Renderer) {
const url = new URL(location.href)
url.pathname = "/funnel/done"
url.searchParams.delete("step")
history.replaceState(
writeEntry({
version: 1,
root: false,
view: { kind: "done" },
}),
"",
url,
)
renderFromEntry(renderer)
}
export function handleBack(): boolean {
const entry = readEntry()
if (!entry || entry.root) return false
history.back()
return true
}
export function listenForTraversal(renderer: Renderer) {
const sync = () => renderFromEntry(renderer)
window.addEventListener("popstate", sync)
sync()
return () => window.removeEventListener("popstate", sync)
}
진입 시 replaceState()를 사용하므로 정상 경로로 첫 단계에 들어왔든 딥링크로 verify 단계에 들어왔든 현재 엔트리가 그 세션의 퍼널 뿌리가 됩니다. history.length로 몇 칸을 되돌아갈지 계산하지 않습니다. URL은 new URL(location.href)에서 시작해 step만 바꾸므로 applicationId나 유입 파라미터도 보존합니다.
readEntry()는 버전을 확인합니다. 이전 배포가 남긴 state나 다른 코드가 같은 키를 잘못 사용한 경우 웹이 순회했다고 응답하지 않고 네이티브 폴백으로 보냅니다. renderFromEntry()는 전진 직후와 popstate 이후에 같은 방식으로 단계와 완료 화면을 복원합니다.
완료 화면에서 마지막 단계 엔트리를 교체해도 그보다 오래된 퍼널 엔트리는 남습니다. History API에는 임의의 과거 구간을 지우는 기능이 없습니다. 완료된 신청의 과거 단계 재진입은 서버 상태와 라우팅 가드로 막습니다.
퍼널을 벗어나지 못하게 하려고 popstate마다 다시 pushState()를 호출하면 forward와 back의 의미가 무너집니다. 앱 내부 이동은 라우터 blocker, 웹뷰의 네이티브 back은 브릿지 계약에서 처리합니다. 문서 자체를 떠날 때 저장되지 않은 입력을 경고해야 한다면 beforeunload를 필요한 동안만 등록합니다. 모바일에서는 이벤트가 보장되지 않고 Firefox에서는 리스너가 bfcache 사용을 막을 수 있으므로 자동 저장이나 일반적인 라우트 차단 수단으로 쓰지는 않습니다.
웹뷰가 다시 만들어질 때 탐색 상태를 복원합니다
앱이 백그라운드에서 정리되거나 화면 구성이 바뀌면 웹뷰 객체가 새로 만들어질 수 있습니다. 퍼널의 원본 입력 데이터와 웹뷰의 탐색 목록은 수명이 다르므로 따로 보존합니다.
Android에서는 WebView.saveState(Bundle)와 restoreState(Bundle)로 탐색 히스토리를 복원할 수 있습니다.
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
webView.saveState(outState)
}
override fun onRestoreInstanceState(savedInstanceState: Bundle) {
super.onRestoreInstanceState(savedInstanceState)
webView.restoreState(savedInstanceState)
}
saved instance state를 전달할 때 사용하는 Binder 트랜잭션 버퍼는 약 1MB이며, 프로세스에서 진행 중인 트랜잭션들이 공유합니다. 긴 히스토리를 그대로 넣으면 TransactionTooLargeException이 날 수 있습니다. 지원 여부를 확인한 뒤 WebViewCompat.saveState()를 사용하면 최대 크기와 forward 엔트리 포함 여부를 지정할 수 있습니다.
iOS 15 이상에서는 기존 WKWebView의 interactionState를 새 인스턴스에 적용해 상호작용과 탐색 상태를 이어갈 수 있습니다.
if #available(iOS 15.0, *) {
let savedInteractionState = oldWebView.interactionState
let newWebView = WKWebView(frame: .zero, configuration: configuration)
newWebView.interactionState = savedInteractionState
}
이 API들이 폼의 도메인 데이터까지 복구해주는 것은 아닙니다. 신청서 원본은 애플리케이션 저장소나 서버에서 복구하고 웹뷰 상태는 사용자가 보던 탐색 위치를 이어주는 데 사용합니다.
입력부터 순회까지 함께 기록합니다
이 버그를 찾는 동안 history.length를 출력하는 것은 별 도움이 되지 않았습니다. 필요한 정보는 엔트리 개수보다 누가 이동을 요청했는가였습니다.
개발 환경에서는 pushState()와 replaceState()의 호출자를 기록합니다.
["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", (event) => {
console.log("popstate →", location.pathname + location.search, event.state)
})
window.addEventListener("pageshow", (event) => {
console.log("pageshow → persisted =", event.persisted)
})
웹뷰에서는 웹 로그만으로 부족합니다. 사용자 입력마다 ID를 하나 발급하고 다음 시점을 네이티브와 웹 양쪽에 같은 ID로 남기면 실행 경로가 보입니다.
back-input id=42 source=android-system
bridge-dispatch id=42
web-consumed id=42 reason=history-traversal
popstate id=42 step=profile
같은 ID에서 goBack과 history.back이 함께 보이면 소유권이 겹친 것입니다. 입력 ID가 둘인데 순회가 하나라면 중복 입력 가드가 소비했는지 확인할 수 있습니다.
재현 시나리오
| 시나리오 | 기대 결과 |
|---|---|
| 정상 진입 후 3단계에서 back | 2단계로 한 칸 이동 |
| 중간 단계 딥링크에서 back | 이전 퍼널 단계로 가지 않고 웹뷰 종료 |
| back을 빠르게 두 번 입력 | 한 순회가 진행 중일 때 두 번째 입력이 중복 순회를 만들지 않음 |
| 웹 모달이 열린 상태에서 back | 모달만 닫히고 URL 엔트리는 계약대로 소비됨 |
| 브릿지가 응답하지 않음 | 타임아웃 후 한 번만 네이티브 폴백 수행 |
| iOS 엣지 제스처 | 버튼 back과 동일한 경계 및 화면 결과 |
| 완료 화면에서 back/forward | 완료된 신청의 입력 단계가 다시 활성화되지 않음 |
Android 디버그 빌드에서는 원격 디버깅을 명시적으로 허용한 뒤 데스크톱 Chrome의 chrome://inspect에서 웹뷰를 선택합니다.
if (BuildConfig.DEBUG) {
WebView.setWebContentsDebuggingEnabled(true)
}
iOS 16.4 이상에서는 앱이 각 WKWebView를 검사 가능 상태로 설정해야 Safari Web Inspector에 나타납니다.
if #available(iOS 16.4, *) {
webView.isInspectable = true
}
릴리스 기기에서만 재현된다면 웹 로그를 브릿지로 네이티브 로거에 보내는 방식도 쓸 만합니다. 예쁘지는 않지만 back이 두 칸 가는 것보다는 낫습니다.
정리
| 알게 된 것 | 요약 |
|---|---|
| 두 탐색 체계 | 네이티브 화면 스택과 웹 세션 히스토리는 서로 다른 상태 기계 |
| 두 칸 이동의 원인 | webView.goBack()과 history.back()이 같은 입력에서 웹 히스토리를 두 번 순회 |
| 소유권 계약 | 웹 또는 네이티브 한쪽만 같은 back 입력을 소비 |
| 퍼널 경계 | 진입 엔트리에 root와 version을 표시하고 유효하지 않은 state는 네이티브로 폴백 |
| 화면 복원 | URL 파라미터를 보존하고 step과 done을 하나의 state 모델로 렌더링 |
| 웹뷰 재생성 | 탐색 상태와 폼 원본 데이터를 나누어 복원 |
| 디버깅 | 입력 ID와 pushState, replaceState, popstate, pageshow를 한 시간축에 기록 |
퍼널을 다시 만들면서 PR 설명에 "이 이동은 push인가 replace인가", "이 back은 누가 소유하는가"를 적기 시작했습니다. 코드보다 리뷰 질문이 먼저 달라졌습니다.
References
브라우저 히스토리
- 브라우저는 뒤로가기를 어떻게 기억할까요?
- HTML Standard: Navigation and session history
- MDN: History API
- MDN: history.back()
- MDN: pageshow event
- MDN: beforeunload event
Android WebView
- Android Developers: WebView API reference
- Android Developers: Manage WebView objects
- Android Developers: Manage WebView state efficiently
- Android Developers: WebViewCompat.saveState
- Android Developers: OnBackPressedDispatcher
WKWebView
- Apple Developer: WKWebView.goBack
- Apple Developer: WKWebView.allowsBackForwardNavigationGestures
- Apple Developer: WKWebView.interactionState
- Apple Developer: Enabling inspection of web content
프레임워크
