브라우저 새로고침 팝업은 왜 커스텀할 수 없는가
편집 중인 문서가 있을 때 새로고침을 막는 커스텀 모달을 만들려다 막혔다. beforeunload 다이얼로그를 페이지가 제어할 수 없는 이유를 HTML 표준과 Firefox·Safari·Chrome의 결정 기록에서 찾아간 기록.
편집 중인 문서에서 사용자가 브라우저를 새로고침하면 "정말 새로고침하시겠습니까?"를 묻는 팝업을 띄워야 했다. 우리 디자인 시스템에 맞는 모달을 쓰려 했지만 브라우저의 네이티브 다이얼로그는 대체할 수도, 꾸밀 수도 없었다. 메시지와 버튼, 모양을 바꾸지 못하고 우리 모달로 대신할 수도 없다.
처음에는 필자가 API를 못 찾은 줄 알았다. 그러나 표준 문서와 브라우저 벤더의 결정 기록을 따라가 보니 버그나 미구현이 아니었다. 웹 플랫폼 전체가 보안을 위해 의도적으로 막아둔 동작이었다. 페이지는 이 다이얼로그를 띄울지만 정할 수 있다. 왜 이런 제약이 생겼는지 살펴보자.
새로고침을 누르면 브라우저 안에서 무슨 일이 일어나는가
새로고침은 브라우저 입장에서 "현재 문서를 내리고(unload) 같은 URL로 다시 navigate 한다"는 뜻이다. HTML Living Standard의 navigation 알고리즘은 문서를 내리기 전에 "check if unloading is canceled" 절차를 거친다.1 여기에 beforeunload 이벤트와 확인 다이얼로그가 들어 있다. 스펙의 흐름을 간추리면 다음과 같다.
BeforeUnloadEvent를cancelable = true로 생성해서window에 dispatch 한다.- 페이지 스크립트가
event.preventDefault()를 호출했거나returnValue가 빈 문자열이 아니면 "페이지가 떠나는 것을 막고 싶어 한다"고 판단한다. - 단, 문서의 global object가 sticky activation(한 번이라도 사용자가 그 페이지와 상호작용한 적이 있음)인 경우에만 다음 단계로 간다.
- 브라우저가 "사용자에게 문서를 내려도 되는지 확인을 요청하고, 사용자의 응답을 기다리는 동안 pause 한다". 이 pause 동안 페이지의 JavaScript는 실행되지 않는다.
- 사용자가 "나가기"를 고르면 unload가 진행된다. "취소"를 고르면 navigation 전체가 취소된다.
그림에서 페이지 코드가 관여하는 부분은 주황색 칸 하나뿐이다. 페이지가 그 칸에서 이동을 막지 않거나 사용자 상호작용 이력이 없으면 다이얼로그 없이 바로 unload로 빠진다. 두 조건을 모두 통과해 파란 칸에 도달한 뒤에는 브라우저가 절차를 맡고 페이지 JS는 멈춘다.
스펙 원문의 핵심 문장은 이렇다.
"Ask the user to confirm that they wish to unload the document, and pause while waiting for the user's response."
"The message shown to the user is not customizable, but instead determined by the user agent. In particular, the actual value of the returnValue attribute is ignored."
표준부터 "메시지는 커스텀할 수 없고 브라우저가 정한다"고 명시한다. returnValue에 넣은 문자열은 비어 있지 않다는 사실만 쓰이고 내용은 버려진다.2
커스텀을 막는 세 개의 층
표준 층: 2016년에 스펙이 "문자열을 무시하라"로 바뀌었다
원래 HTML 스펙에는 "다이얼로그에 returnValue 문자열을 포함할 수 있다(may include ... optionally truncated)"는 문구가 있었다. 2016년 3월 Chromium 엔지니어 Avi Drissman이 WHATWG에 "페이지가 지정한 onbeforeunload 메시지를 쓰지 않도록 스펙이 요구해야 하지 않는가"라는 이슈를 열었다.3 당시 이미 Firefox와 Safari는 문자열을 표시하지 않았고 Chrome도 곧 제거할 예정이었다. 스펙 에디터 Domenic Denicola는 "문자열을 아예 처리하지 않도록 스펙을 바꾸는 데 동의한다"고 답했다. PR #1001 "Ignore beforeunload event return value"는 2016년 4월에 머지되었다.
이때부터 커스텀 메시지는 비표준 동작이 되었다. 당시 유일하게 메시지를 보여주던 Edge(EdgeHTML)도 이후 Chromium 기반으로 전환되면서 사라졌다.4
브라우저 층: 모든 벤더가 각자 먼저 제거했다
스펙 변경은 브라우저들이 먼저 내린 결정을 추인한 쪽에 가깝다.
| 브라우저 | 커스텀 메시지 제거 | 근거 기록 |
|---|---|---|
| Firefox | Firefox 4 (2011) | Bugzilla 588292 "Remove site-supplied text for beforeunload and onunload dialogs".5 보안 이슈로 분류. MDN 호환표에는 44로 적혀 있어 자료 간 표기 차이가 있다.6 |
| Safari | Safari 9.1 (2016) | CVE-2009-2197에 대한 보안 수정으로 처리. |
| Chrome / Opera | Chrome 51 / Opera 38 (2016) | Intent "Do not use web page provided strings for onbeforeunload".7 Chrome 51 Deprecations 공식 블로그.8 |
| Edge | Chromium 전환 후 Chrome과 동일 | EdgeHTML 시절엔 표시했으나 Chromium 기반 Edge 79+부터 Chrome과 동일. |
Chrome 51 공식 공지의 문장은 이렇다.
"A window's onbeforeunload property no longer supports a custom string. ... Unfortunately, it is often used to scam users. ... Chrome will still show a dialog to prevent users from losing data, but its contents will be set by the browser instead of the web page."
보안 층: "권한 있어 보이는 UI에 페이지 텍스트를 넣으면 안 된다"
세 벤더와 스펙이 든 이유는 같다. 이 다이얼로그는 주소창이나 탭 같은 브라우저 크롬의 신뢰 영역에서 띄우는 UI처럼 보인다. 페이지가 여기에 임의의 문장을 넣으면 사용자는 브라우저의 말과 사이트의 말을 구분하기 어렵다. 실제로 다음과 같은 악용이 흔했다.
- 기술 지원 사기(tech support scam): "당신의 PC가 감염되었습니다. 이 번호로 전화하세요"를 다이얼로그에 띄워 사용자를 가둔다.
- 이탈 방지: 어떤 버튼을 눌러도 다시 다이얼로그가 떠서 사이트를 떠날 수 없게 만든다.
- 브라우저 경고 위장: "Chrome 보안 경고"처럼 꾸민 문구로 클릭을 유도한다.
Chromium 쪽은 WHATWG 이슈에서 이를 명확히 "we consider using page-supplied text in a privileged-appearing dialog as a security fix"라고 표현했다.3 Chromium의 JavaScript 다이얼로그 정책 문서도 이 다이얼로그가 "commonly (and unfortunately)" 사용자에게 해를 끼치는 데 쓰인다고 적는다.9
페이지가 메시지를 바꾸는 기능 자체가 공격 표면이었다. 벤더들은 저장하지 않은 데이터에 대한 경고는 유지하되 공격 표면을 없애려고 다이얼로그의 내용은 브라우저가 정하도록 했다.
그렇다면 "커스텀 팝업으로 대체"는 왜 불가능한가
메시지를 바꾸지 못한다면 네이티브 다이얼로그를 막고 우리 모달을 띄우면 되지 않을까? 이 방법도 알고리즘 구조상 불가능하다.
- 동기적 pause: 스펙상 브라우저는 사용자 응답을 기다리며 멈춘다. 이 시점에 페이지의 JS는 돌지 않으므로 우리 모달을 렌더링하거나 클릭을 받을 수 없다.
- 비동기 취소 수단이 없다:
beforeunload핸들러가 리턴하는 순간 결정이 끝난다. Promise를 리턴하거나 나중에 "사실 취소할래"라고 알릴 API가 없다. 핸들러 안에서 커스텀 모달을 띄워도 unload는 그대로 진행된다. - 새로고침 자체를 가로채는 API가 없다:
keydown에서 F5나 Ctrl+R을preventDefault()하는 방법은 키보드로 시작한 새로고침만 막는다. 툴바 새로고침 버튼, 주소창 Enter, 컨텍스트 메뉴, 탭 닫기에는 전혀 작동하지 않는다. 브라우저 UI는 페이지 JS의 도달 범위 밖이다. 이 한계가 쓸모 있는 상황은 뒤에서 다시 살펴본다. - Chrome 60부터 sticky activation 필요: 사용자가 페이지와 한 번도 상호작용하지 않았으면 다이얼로그 자체가 표시되지 않는다. 콘솔에는 "Blocked attempt to show a 'beforeunload' confirmation panel for a frame that never had a user gesture since its load." 경고가 찍힌다. 이것도 "상호작용이 없었다면 잃을 데이터도 없다"는 논리의 남용 방지책이다.10
페이지가 정할 수 있는 것은 다이얼로그를 띄울지 말지뿐이다. 형태와 문구, 타이밍, 버튼은 브라우저가 소유한다.
실제로 눈으로 확인해 보자
최소 예제를 만들어 녹화했다. textarea에 입력하면 리스너를 등록하고 "저장"을 누르면 해제하는 페이지다. returnValue에는 일부러 한국어 문장을 넣어 두었다.
<textarea id="doc" placeholder="여기에 입력하세요"></textarea>
<button id="save">저장</button> <span id="status">저장됨</span>
<pre id="log"></pre>
<script>
const doc = document.getElementById('doc');
const status = document.getElementById('status');
const log = (m) => { document.getElementById('log').textContent += `[${new Date().toLocaleTimeString()}] ${m}\n`; };
const beforeUnloadHandler = (event) => {
event.preventDefault(); // 표준: 다이얼로그 요청
event.returnValue = '이 문구는 절대 표시되지 않는다'; // 레거시 호환. 내용은 브라우저가 무시
log('beforeunload 발생 → 브라우저가 다이얼로그를 띄움 (텍스트는 브라우저가 결정)');
};
doc.addEventListener('input', () => {
window.addEventListener('beforeunload', beforeUnloadHandler); // 중복 등록은 무시됨
status.textContent = '저장 안 됨';
});
document.getElementById('save').addEventListener('click', () => {
window.removeEventListener('beforeunload', beforeUnloadHandler);
status.textContent = '저장됨';
log('저장 → 리스너 해제. 이제 새로고침해도 다이얼로그 없음');
});
</script>
녹화에서는 세 가지를 확인할 수 있다.
returnValue에 넣은 "이 문구는 절대 표시되지 않는다"는 다이얼로그 어디에도 나오지 않는다. 다이얼로그 문구("변경사항이 저장되지 않을 수 있습니다")와 버튼("취소", "새로고침")은 전부 Chrome이 정했다.- 취소를 누른 뒤에야 페이지 로그에 "beforeunload 발생"이 찍힌다. 핸들러는 다이얼로그가 뜨기 전에 이미 실행을 마쳤고 다이얼로그가 떠 있는 동안 페이지 JS는 멈춰 있었다는 뜻이다. 이 시점에 커스텀 모달을 띄울 틈은 없다.
- "저장"으로 리스너를 해제하면 같은 ⌘R에 다이얼로그가 뜨지 않는다. 페이지가 쥔 제어권이 "띄울지 말지" 하나뿐임을 보여 준다.
"브라우저가 멈춘다"는 말의 실체
앞 절에서 동기적 pause 때문에 커스텀 모달을 끼워 넣을 틈이 없다고 했다. 이 문장은 스펙 한 줄과 Chromium 코드 몇 군데를 같이 읽어야 실감이 난다. 렌더링 관점에서 무엇이 멈추는지, 그 멈춤이 코드 어디에서 일어나는지 따라가 보자.
스펙이 말하는 pause
HTML 표준의 이벤트 루프 절에는 pause라는 알고리즘이 따로 정의되어 있다.11 beforeunload 확인, alert(), confirm(), prompt()가 모두 이것을 호출한다. 단계는 넷이다.
- Let global be the current global object.
- Let timeBeforePause be the current high resolution time given global.
- If necessary, update the rendering or user interface of any Document or navigable to reflect the current state.
- Wait until the condition goal is met. While a user agent has a paused task, the corresponding event loop must not run further tasks, and any script in the currently running task must block. User agents should remain responsive to user input while paused, however, albeit in a reduced capacity since the event loop will not be doing anything.
렌더링 파이프라인 관점에서 읽으면 이렇다. 3단계에서 브라우저는 마지막으로 한 번 스타일·레이아웃·페인트를 갱신할 수 있다. 그 뒤 4단계부터는 이벤트 루프가 태스크를 꺼내지 않는다. 타이머 콜백, requestAnimationFrame, 클릭 이벤트 핸들러, fetch 응답 처리, 그리고 렌더링 갱신 자체가 전부 이벤트 루프의 태스크이므로 하나도 돌지 않는다. beforeunload 핸들러 안에서 모달 DOM을 붙여 두었다면 3단계 덕에 화면에 그려질 수는 있다. 하지만 그 모달의 버튼을 눌러도 핸들러가 실행되지 않는다. 사용자는 브라우저 다이얼로그에만 응답할 수 있다.
스펙은 이 설계를 자랑스러워하지 않는다. 바로 아래 주석에 "Pausing is highly detrimental to the user experience"라고 적고, 구현체들이 대안을 실험하길 권한다. 그럼에도 남아 있는 이유는 기존 콘텐츠와의 호환성이다.
Chromium에서 그 pause가 일어나는 자리
Chromium은 렌더러 프로세스(페이지의 JS와 DOM이 사는 곳)와 브라우저 프로세스(탭·주소창·다이얼로그 같은 UI를 그리는 곳)로 나뉜다. beforeunload 다이얼로그는 이 두 프로세스를 한 번 왕복한다. 호출 순서대로 따라가면 "멈춤"이 어디서 생기는지 보인다.
1. 렌더러: 이벤트를 쏘고 결과를 판정한다. third_party/blink/renderer/core/dom/document.cc의 Document::DispatchBeforeUnloadEvent가 시작점이다.12 이벤트를 dispatch한 뒤 returnValue가 비어 있지 않거나 preventDefault()가 불렸는지만 본다. 문자열 내용은 보지 않는다.
bool cancelled_by_script = !before_unload_event.returnValue().empty() ||
before_unload_event.defaultPrevented();
// ...
if (!GetFrame()->HasStickyUserActivation() || force_to_proceed) {
String message =
"Blocked attempt to show a 'beforeunload' confirmation panel for a "
"frame that never had a user gesture since its load. ...";
Intervention::GenerateReport(GetFrame(), "BeforeUnloadNoGesture", message);
return true;
}
// ...
did_allow_navigation =
chrome_client->OpenBeforeUnloadConfirmPanel(text, GetFrame(), is_reload);마지막 줄이 핵심이다. OpenBeforeUnloadConfirmPanel은 bool을 동기적으로 반환하는 일반 C++ 함수 호출이다. 사용자가 다이얼로그에서 버튼을 누를 때까지 이 함수는 리턴하지 않고, 따라서 렌더러 메인 스레드는 이 줄에 멈춰 있다. 앞에서 본 "sticky activation이 없으면 다이얼로그 없이 통과"와 콘솔 경고 문구도 같은 함수 안에 있다.
2. 렌더러: 페이지를 일시정지 상태로 바꾼다. chrome_client.cc의 OpenBeforeUnloadConfirmPanel은 모든 JS 다이얼로그가 공유하는 템플릿을 거친다.13
template <typename Delegate>
static bool OpenJavaScriptDialog(LocalFrame* frame,
const String& message,
const Delegate& delegate) {
DOMWindowPerformance::performance(*frame->DomWindow())->WillShowModalDialog();
// Suspend pages in case the client method runs a new event loop that would
// otherwise cause the load to continue while we're in the middle of
// executing JavaScript.
ScopedPagePauser pauser;
probe::WillRunJavaScriptDialog(frame);
bool result = delegate();
probe::DidRunJavaScriptDialog(frame);
return result;
}
bool ChromeClient::OpenBeforeUnloadConfirmPanel(const String& message,
LocalFrame* frame,
bool is_reload) {
return OpenJavaScriptDialog(frame, message, [this, frame, is_reload]() {
return OpenBeforeUnloadConfirmPanelDelegate(frame, is_reload);
});
}ScopedPagePauser가 스펙의 pause를 구현하는 객체다.14 생성자에서 Page::SetPaused(true)를 불러 모든 프레임의 lifecycle state를 kPaused로 바꾸고, 메인 스레드 스케줄러의 PauseScheduler()를 호출한다. 이 메서드의 헤더 주석은 스펙 링크를 그대로 달고 있다. "Pausing means that all javascript callbacks should not fire. https://html.spec.whatwg.org/#pause".[^scheduler] 소멸자가 돌 때, 즉 다이얼로그가 닫혀 함수가 리턴할 때 모든 것이 풀린다.
람다가 캡처하는 값도 눈여겨볼 만하다. frame과 is_reload뿐이다. message는 이 함수까지는 전달되지만 델리게이트로 넘어가지 않는다. 우리가 returnValue에 넣은 문자열은 렌더러를 떠나기도 전에 여기서 버려진다.
3. 렌더러 → 브라우저: 동기 IPC로 넘어가 블록된다. chrome_client_impl.cc의 델리게이트는 브라우저 프로세스에 메시지를 보낸다.15
bool ChromeClientImpl::OpenBeforeUnloadConfirmPanelDelegate(LocalFrame* frame,
bool is_reload) {
// ...
bool success = false;
// Synchronous mojo call.
frame->GetLocalFrameHostRemote().RunBeforeUnloadConfirm(is_reload, &success);
return success;
}이 메시지의 정의는 third_party/blink/public/mojom/frame/frame.mojom에 있다.16
[Sync]
RunBeforeUnloadConfirm(bool is_reload) => (bool success);[Sync] 속성이 붙은 mojo 호출은 응답이 올 때까지 호출한 스레드를 막는다. Mojo 문서는 이 동안 "다른 메시지, 즉 비동기 메시지와 이 호출에 대응하지 않는 동기 응답은 모두 블록한다"고 설명한다.17 렌더러 메인 스레드가 여기서 서 있으니 이벤트 루프가 돌 수 없고, 스펙의 "must not run further tasks"가 그대로 성립한다. 인자도 is_reload 하나뿐이다. 페이지가 지정한 문자열은 프로세스 경계를 넘을 통로조차 없다.
4. 브라우저: 다이얼로그를 그리고 문구를 고른다. 메시지를 받은 content/browser/renderer_host/render_frame_host_impl.cc의 RunBeforeUnloadConfirm은 먼저 GetProcess()->SetBlocked(true)로 렌더러 프로세스를 블록 상태로 표시한 뒤 다이얼로그 매니저에 넘긴다.18 실제 문구를 고르는 곳은 components/javascript_dialogs/app_modal_dialog_manager.cc다.19 주석이 스펙을 인용하고 있다.
// Build the dialog message. We explicitly do _not_ allow the webpage to
// specify the contents of this dialog, as per the current spec
//
// https://html.spec.whatwg.org/#unloading-documents, step 8:
//
// "The message shown to the user is not customizable, but instead
// determined by the user agent. In particular, the actual value of the
// returnValue attribute is ignored."
//
// This message used to be customizable, but it was frequently abused by
// scam websites so the specification was changed.
std::u16string title = l10n_util::GetStringUTF16(
is_reload ? IDS_BEFORERELOAD_MESSAGEBOX_TITLE
: IDS_BEFOREUNLOAD_MESSAGEBOX_TITLE);
const std::u16string message =
l10n_util::GetStringUTF16(IDS_BEFOREUNLOAD_MESSAGEBOX_MESSAGE);이 리소스 ID의 원문은 components/javascript_dialogs_strings.grdp에 있다.20 IDS_BEFORERELOAD_MESSAGEBOX_TITLE이 "Reload site?", IDS_BEFOREUNLOAD_MESSAGEBOX_MESSAGE가 "Changes you made may not be saved."다. 앞의 녹화에서 본 "사이트를 새로고침하시겠습니까?"와 "변경사항이 저장되지 않을 수 있습니다"는 이 두 문자열의 한국어 번역이다. 다이얼로그는 브라우저 프로세스의 UI 스레드가 그리므로 렌더러가 멈춰 있어도 정상적으로 표시되고 입력을 받는다.
렌더링 관점에서 정리하면
사용자가 다이얼로그를 보고 있는 동안 페이지에는 이런 일이 벌어지고 있다.
- 렌더러 메인 스레드는
OpenBeforeUnloadConfirmPanel호출 안, 더 정확히는 동기 mojo 호출 안에 서 있다. 이벤트 루프는 다음 태스크를 꺼내지 않는다. - 페이지는
kPaused상태라 타이머·rAF·이벤트 핸들러 같은 JS 콜백이 예약되어도 실행되지 않는다. - 스타일·레이아웃·페인트 갱신도 메인 스레드 태스크이므로 멈춘다. 화면에는 pause 직전에 커밋된 마지막 프레임이 그대로 남는다.
- 다이얼로그는 브라우저 프로세스가 별도로 그린다. 문구와 버튼은 브라우저의 문자열 리소스에서 온다.
- 사용자가 버튼을 누르면
success가 돌아오고, 동기 호출이 풀리면서ScopedPagePauser소멸자가 페이지를 깨운다. 이 시점에야 우리 코드의 다음 줄이 실행된다.
그러니 "핸들러 안에서 모달을 띄우고 비동기로 기다린다"는 설계는 코드 한 줄도 돌아갈 자리가 없다. 다이얼로그가 떠 있는 시간 동안 우리 JS는 글자 그대로 한 줄도 실행되지 않는다.
그래도 커스텀 모달이 필요하다면 어디서 가로챌 수 있나
브라우저 레벨 이동을 건드릴 수 없다면 질문을 바꿔야 한다. "새로고침 팝업을 어떻게 바꾸나"가 아니라 "우리 코드가 가로챌 수 있는 이동은 어디까지인가"를 물어야 한다. 라우터 안에서 시작하는 이동과 키보드에서 시작하는 새로고침은 서로 다른 지점에서 붙잡을 수 있다.
앱 내부 이동은 react-router의 useBlocker로
SPA에서 사용자가 페이지를 떠나는 경우는 대부분 브라우저 이동이 아니라 라우터 이동이다. 사이드바에서 다른 문서를 누르거나 뒤로 가거나 링크를 타는 동작은 history.pushState 위에서 돌아가는 클라이언트 네비게이션이다. 이 영역은 우리 코드 안에 있으며 react-router는 이를 처리하는 useBlocker를 제공한다.
import { useBlocker } from "react-router";
function Editor({ dirty }: { dirty: boolean }) {
// dirty 일 때만 라우터 이동을 멈춘다
const blocker = useBlocker(
({ currentLocation, nextLocation }) =>
dirty && currentLocation.pathname !== nextLocation.pathname,
);
return (
<>
{/* ...편집 UI... */}
{blocker.state === "blocked" && (
<ConfirmModal
title="저장하지 않은 변경 사항이 있다"
onConfirm={() => blocker.proceed()} // 이동 계속
onCancel={() => blocker.reset()} // 현재 페이지에 남는다
/>
)}
</>
);
}useBlocker는 조건이 참일 때 라우터 이동을 blocked 상태로 붙잡고 우리가 proceed()나 reset()을 부를 때까지 기다린다. beforeunload처럼 동기적으로 답할 필요가 없다. 모달을 렌더링해 사용자의 선택을 기다리거나 "저장하고 이동" 같은 비동기 작업을 마친 뒤 proceed()를 호출할 수도 있다. 문구와 버튼, 디자인도 모두 직접 정한다.
공식 문서는 적용 범위를 분명히 밝힌다. "This does not handle hard-reloads or cross-origin navigations."21 새로고침, 탭 닫기, 주소창 입력은 useBlocker의 영역이 아니다. 실제 앱에서는 라우터 이동에 useBlocker 커스텀 모달을 쓰고 브라우저 이동에 beforeunload 네이티브 다이얼로그를 쓰는 식으로 두 겹을 구성한다.
키보드 이벤트를 래핑해 입력 단계에서 가로채기
키보드에서 시작한 새로고침은 beforeunload보다 앞서 페이지의 keydown에 도착한다. 이때 preventDefault()를 부르면 브라우저가 해당 키를 새로고침 명령으로 해석하지 않는다.
function isReloadKey(e: KeyboardEvent) {
const mod = e.metaKey || e.ctrlKey;
return e.key === "F5" || (mod && e.key.toLowerCase() === "r");
}
window.addEventListener("keydown", (e) => {
if (!isReloadKey(e) || !hasUnsavedChanges()) return;
e.preventDefault(); // 브라우저가 새로고침을 시작하기 전에 끊는다
openConfirmModal({
onConfirm: () => window.location.reload(), // 우리 모달에서 "확인"을 누르면 그때 새로고침
});
});아직 navigation이 시작되지 않은 시점이라 beforeunload의 동기 pause 제약을 받지 않는다. 커스텀 모달에서 사용자의 선택을 기다린 다음 확인을 받으면 location.reload()를 직접 호출하면 된다. 리스너가 등록된 beforeunload는 이 reload에서도 다시 발화한다. 네이티브 다이얼로그가 이중으로 뜨지 않게 확인 후에는 리스너를 잠시 떼거나 플래그로 건너뛰어야 한다.
한계는 분명하다. 툴바의 새로고침 버튼, 컨텍스트 메뉴의 "새로고침", 주소창에서 Enter, 탭 닫기는 키보드 이벤트를 거치지 않는다. 일반 브라우저에서 이 방법만 쓰면 마우스로 새로고침하는 순간 네이티브 다이얼로그가 뜨고, 리스너마저 없다면 그대로 페이지를 떠난다.
필자의 환경은 웹뷰라서 키보드가 전부였다
필자가 다루던 제품은 일반 브라우저에서 도는 웹이 아니라 Windows 패키지 안에 들어가는 웹뷰였다. 브라우저 크롬이 없어서 주소창과 툴바의 새로고침 버튼, 탭도 없다. 사용자가 새로고침을 일으킬 수 있는 입력은 F5와 Ctrl+R뿐이다.
이 환경에서는 키보드 이벤트를 래핑해 모든 새로고침 경로를 입력 단계에서 가로챌 수 있다. 필자는 useBlocker로 앱 내부 이동을 처리하고 keydown 래핑으로 새로고침에 커스텀 모달을 붙였다. beforeunload는 혹시 모를 경로를 위한 마지막 안전망으로 남겼다.
일반 브라우저에는 같은 결론을 적용할 수 없다. 마우스로 누른 새로고침은 여전히 커스텀 모달을 거치지 않는다. 다만 "브라우저 UI는 페이지의 도달 범위 밖"이라는 원칙은 브라우저 UI가 없는 환경에서 페이지가 모든 입력의 출발점이라는 뜻이기도 하다. 제약의 출처를 확인했기 때문에 필자의 환경에는 그 제약이 적용되지 않는다고 판단할 수 있었다.
그래서 실무에서는 어떻게 해야 하나
MDN과 Chrome 개발자 문서가 함께 권하는 패턴은 이렇다.6
const beforeUnloadHandler = (event) => {
event.preventDefault(); // 표준. Chrome 119+, Firefox, Safari
event.returnValue = true; // 레거시 호환 (Chrome/Edge < 119)
};
// 저장 안 된 변경이 생겼을 때만 등록
function onPageHasUnsavedChanges() {
window.addEventListener("beforeunload", beforeUnloadHandler);
}
// 저장 완료되면 바로 해제
function onAllChangesSaved() {
window.removeEventListener("beforeunload", beforeUnloadHandler);
}- 리스너는 조건부로 등록하고 저장되면 해제한다. 무조건 등록하면 bfcache(뒤로/앞으로 캐시) 적격성에 영향을 주거나 불필요한 다이얼로그로 사용자를 괴롭힌다. Chrome 문서는 "절대 무조건 추가하지 말라(never add unconditionally)"고 명시한다.22
- 커스텀 UI는 앱 내부 네비게이션에서 처리한다. SPA 라우터의 route 변경, 문서 전환, 탭 전환처럼 우리 코드가 트리거하는 이동은
useBlocker같은 라우터 API로 모달을 띄우고 비동기로 취소할 수 있다. 브라우저 레벨 이동(새로고침, 탭 닫기, 주소 입력)만 네이티브 다이얼로그에 맡긴다. - 키보드 새로고침은
keydown에서 먼저 받을 수 있다. F5와 Ctrl+R을preventDefault()하면 navigation이 시작되기 전에 커스텀 모달을 띄울 수 있다. 다만 마우스로 누르는 새로고침은 못 막는다. 브라우저 크롬이 없는 웹뷰 같은 환경이 아니라면beforeunload와 함께 써야 한다. - 상태 저장은
beforeunload에 의존하지 않는다. 특히 모바일에서는 이벤트가 안 올 수 있다. 자동 저장 시점은visibilitychange(hidden)나pagehide를 쓴다.23 - 메시지 텍스트를 넣는 코드는 의미가 없다.
returnValue = "변경 사항이 저장되지 않았습니다"처럼 써도 사용자에게 보이지 않는다. 오해를 막으려면true나 빈 문자열이 아닌 값을 쓰고 주석으로 이유를 남기는 편이 낫다.
마치며
브라우저 새로고침 다이얼로그를 커스텀할 수 없는 것은 단순한 기술적 한계가 아니다. 2011년(Firefox)부터 2016년(Safari, Chrome, 그리고 WHATWG 스펙)에 걸쳐 웹 플랫폼 전체가 내린 보안 결정이다. 브라우저가 말하는 것처럼 보이는 UI에 페이지가 글을 쓸 수 없어야 한다는 원칙이 그 배경에 있다.
일반 브라우저에서 페이지가 정할 수 있는 것은 다이얼로그를 띄울 시점과 앱 내부 이동의 커스텀 UX까지다. 반면 필자의 웹뷰처럼 브라우저 UI가 없는 환경에서는 키보드 입력 단계에서 새로고침까지 가로챌 수 있다. 우회 방법부터 찾기보다 제약이 어느 층에서 생겼는지 확인해야 구현 가능한 경계도 정확히 그을 수 있다.
Footnotes
-
WHATWG HTML Living Standard — checking if unloading is canceled, steps to fire beforeunload, prompt to unload. ↩
-
MDN — BeforeUnloadEvent.
returnValue의 값은 다이얼로그 표시 여부에만 쓰이고 문자열 자체는 표시되지 않는다. ↩ -
whatwg/html #952 — Should the spec require the page-specified onbeforeunload message not be used? PR #1001 "Ignore beforeunload event return value" 로 2016년 4월 머지. ↩ ↩2
-
Chrome Platform Status — Remove custom messages in onbeforeunload dialogs ↩
-
Mozilla Bugzilla 588292 — Remove site-supplied text for beforeunload and onunload dialogs ↩
-
blink-dev — Intent to Implement and Ship: Do not use web page provided strings for onbeforeunload ↩
-
Chrome Developers — API Deprecations and Removals in Chrome 51 ↩
-
blink-dev — Intent to Implement and Ship: Require user gesture for beforeunload dialogs ↩
-
WHATWG HTML Living Standard — Event loops: pause. 네 단계 알고리즘과 "highly detrimental" 주석의 출처. ↩
-
Chromium — third_party/blink/renderer/core/dom/document.cc,
Document::DispatchBeforeUnloadEvent. 2026-11-03 기준 main 브랜치. ↩ -
Chromium — third_party/blink/renderer/core/page/chrome_client.cc,
OpenJavaScriptDialog템플릿과ChromeClient::OpenBeforeUnloadConfirmPanel. ↩ -
Chromium — third_party/blink/renderer/core/page/scoped_page_pauser.cc.
Page::SetPaused가 각LocalFrame::OnPageLifecycleStateUpdated를 거쳐FrameLifecycleState::kPaused를 설정한다. ↩ -
Chromium — third_party/blink/renderer/core/page/chrome_client_impl.cc,
ChromeClientImpl::OpenBeforeUnloadConfirmPanelDelegate. ↩ -
Chromium — third_party/blink/public/mojom/frame/frame.mojom,
LocalFrameHost.RunBeforeUnloadConfirm. ↩ -
Mojo C++ Bindings — Synchronous Calls. "Re-entrancy" 절에 호출 스레드가 응답을 기다리는 동안 다른 메시지를 블록한다고 적혀 있다. ↩
-
Chromium — content/browser/renderer_host/render_frame_host_impl.cc,
RenderFrameHostImpl::RunBeforeUnloadConfirm. ↩ -
Chromium — components/javascript_dialogs/app_modal_dialog_manager.cc,
AppModalDialogManager::RunBeforeUnloadDialogWithOptions. ↩ -
Chromium — components/javascript_dialogs_strings.grdp.
IDS_BEFOREUNLOAD_MESSAGEBOX_MESSAGE,IDS_BEFORERELOAD_MESSAGEBOX_TITLE의 영어 원문. ↩ -
web.dev — Back/forward cache. 무조건 등록한
beforeunload리스너는 bfcache 적격성을 떨어뜨린다. ↩