빌드노트
클릭 핸들러에서 await 뒤에 막았는데 링크가 이동한다
비동기 click 핸들러에서 뒤늦게 preventDefault를 호출하면 취소 값이 true여도 링크는 이미 이동할 수 있다. 실제 URL과 이벤트 상태를 비교하고, 먼저 취소한 뒤 허용 결과에 따라 이동하는 코드와 즉시 완료된 Promise의 함정을 설명한다.
링크의 click 핸들러에서 비동기 확인 결과가 거부인데도 페이지가 이동한다면, 첫 await와 preventDefault()의 위치를 확인한다. 브라우저의 이벤트 전달은 async 핸들러가 반환한 Promise의 완료를 기다려 주지 않는다. 타이머나 통신 결과를 기다리는 동안 기본 이동이 진행될 수 있다. 확인이 끝날 때까지 머물러야 하는 링크라면 먼저 기본 동작을 취소하고, 허용 결과를 받은 뒤 코드로 이동한다.
여기서는 네이티브 DOM 리스너가 처리하는 현재 탭의 일반 링크 클릭을 다룬다. 자기 코드에서는 거부 분기에 preventDefault()가 있다는 사실보다, 그 분기에 도착하기 전에 비동기 대기가 있는지를 먼저 본다. 개발자 도구에서 이동 전후 로그를 보존하고 실제 주소도 함께 확인한다.
취소 값은 true인데 주소는 이미 바뀌었다
이 차이를 확인하려면 다른 클릭 리스너가 없는 개발용 페이지에 <a id="next" href="#arrived">이동</a>를 두고 아래 코드를 한 번 실행한다. canLeave()는 50ms 타이머 뒤 false를 반환하도록 만든 비교용 함수다. 기존 서비스의 확인 함수를 이 코드로 교체하라는 뜻은 아니다. 링크를 마우스로 클릭하면, 취소 전후 값과 그때의 주소를 볼 수 있다.
const link = document.querySelector("#next");
const canLeave = () => new Promise(resolve =>
setTimeout(() => resolve(false), 50));
link.addEventListener("click", async event => {
const allowed = await canLeave();
console.log("before", event.cancelable, event.defaultPrevented,
event.eventPhase, location.hash);
if (!allowed) event.preventDefault();
console.log("after", event.cancelable, event.defaultPrevented,
event.eventPhase, location.hash);
}, { passive: false });
Windows 11, headless Chromium 147.0.7727.15에서 새 loopback 페이지를 열어 이 코드를 실행했다. 브라우저 입력으로 발생한 클릭의 isTrusted는 true였고 출력은 다음과 같았다.
before true false 0 #arrived
after true true 0 #arrived
cancelable은 계속 true였고, 호출 뒤 defaultPrevented도 true가 됐다. 하지만 주소는 호출 전부터 이미 #arrived였다. eventPhase: 0도 이때 클릭 이벤트의 전달이 끝났음을 보여 준다. 나중에 읽은 취소 값만으로 링크 이동을 막았다고 판단할 수 없는 사례다.
DOM 표준의 preventDefault 정의 (새 탭에서 열림)는 취소 가능한 이벤트에서 passive 리스너 실행 중이 아니면 취소 플래그를 설정하도록 한다. 뒤늦은 호출이 플래그를 바꾸는 것과 이미 진행된 이동을 되돌리는 것은 별개다. 이번 실험은 passive: false를 명시했으므로 passive 제한 때문에 취소 값이 바뀌지 않는 문제와도 구분된다.
별도 관찰에서는 이 클릭이 document까지 버블링할 때 defaultPrevented: false였고, 주소 변경 뒤 타이머 결과가 도착했다. DOM 표준의 이벤트 전달 절차 (새 탭에서 열림)는 리스너 호출 이후 취소 플래그를 보고 활성화 동작을 실행한다. 이 절차와 관찰을 함께 보면, 거부 결과를 받기 전에 링크의 기본 동작을 진행한 것이 원인이다. MDN의 await 설명 (새 탭에서 열림)도 대기 중인 async 함수의 실행은 멈추지만 그 함수를 부른 쪽은 계속 실행한다고 설명한다.
먼저 취소하고 허용일 때 이동한다
위 예제의 리스너를 다음 형태로 바꾼다. 기존 리스너가 남은 페이지에 추가로 붙이지 말고, 등록 코드를 수정한 뒤 새로 로드한다. link는 앞에서 선택한 링크이고 canLeave()는 허용이면 true, 거부면 false로 완료되는 함수다. 예제에서는 resolve(false)를 유지해 거부를 확인한 뒤 resolve(true)로 바꾸어 허용을 비교할 수 있다.
link.addEventListener("click", async event => {
event.preventDefault();
const destination = event.currentTarget.href;
try {
const allowed = await canLeave();
if (allowed) location.assign(destination);
} catch (error) {
console.error("이동 확인 실패", error);
}
}, { passive: false });
거부나 예외에서는 이동 코드를 실행하지 않는다. 허용이면 저장해 둔 주소로 이동한다. 취소했던 브라우저 기본 동작을 나중에 다시 켜는 방식이 아니므로, 허용 분기에서도 defaultPrevented는 true로 남는다.
주소를 await 전에 읽는 것도 필요하다. 위 타이머 비교에서 대기 뒤 event.currentTarget은 null이었다. 이벤트 전달이 끝나면 현재 리스너 대상을 비우도록 한 표준 절차와 일치한다. 대기 뒤 event.currentTarget.href를 읽는 코드를 그대로 두면 취소 시점을 고친 다음에는 다른 오류가 날 수 있다.
같은 문서 안의 #arrived만 목적지로 삼아 기본 이동, 동기 취소, 비동기 허용·거부를 비교했다. 공통 주소는 http://127.0.0.1:18041/lab.html이었으며 조건별 쿼리만 달랐다.
- 아무것도 취소하지 않은 클릭은
?mode=normal#arrived로 이동했다. 최종 취소 값은false였다. - 즉시 취소한 클릭은
?mode=sync-cancel에 남았다. 최종 취소 값은true였다. - 결과를 기다린 뒤 거부하며 취소한 클릭은
?mode=late-deny#arrived로 이동했다. 최종 취소 값은true였어도 늦었다. - 먼저 취소한 뒤 거부한 클릭은
?mode=early-deny에 남았다. 허용한 클릭은 결과 뒤location.assign()을 실행해?mode=early-allow#arrived로 이동했다. 두 조건의 최종 취소 값은 모두true였다.
결과를 기다린 뒤 허용한 대조 조건도 기본 이동으로 #arrived에 도착했다. 수정 코드 자체도 별도로 실행해 거부·허용·Promise 거절을 비교했다. 거부와 예외에서는 주소가 유지됐고, 허용에서만 바뀌었다. 자기 화면에서도 허용 결과만 확인하지 말고, 거부할 때 실제 주소가 유지되는지를 확인해야 한다.
즉시 완료된 Promise로만 확인하면 놓친다
await Promise.resolve()도 함수의 나머지 실행을 미루지만, 위 타이머와 똑같은 결과를 내지는 않았다. 같은 브라우저에서 실제 입력으로 클릭하자 await 뒤 코드가 이벤트 전달 도중에 재개됐고 취소도 이동을 막았다. 반면 스크립트에서 link.click()을 호출한 비교에서는 기본 이동이 먼저 진행됐다. 두 입력의 isTrusted는 각각 true, false였다.
HTML 표준의 스크립트 실행 정리 절차 (새 탭에서 열림)는 실행 스택이 비면 마이크로태스크 체크포인트를 수행하도록 한다. 위 차이는 이 실행 경계와 맞지만, 입력 방식 하나만으로 모든 순서를 일반화할 수는 없다. 판단 기준은 await라는 문자가 있었는지가 아니라 브라우저가 기본 동작을 진행할 때 취소돼 있었는가다. 비동기 결과가 필요한 코드에서는 첫 대기 전에 취소하는 형태로 이 순서 의존성을 없앤다.
return false로 바꾸는 것도 대안이 아니다. HTML의 이벤트 핸들러 처리 규칙 (새 탭에서 열림)은 일반 onclick 핸들러가 직접 반환한 불리언 false를 취소로 처리한다. async 함수의 반환값은 Promise이므로 그 안에서 나중에 반환할 false와 다르다. 이 규칙을 addEventListener() 콜백의 반환값에 적용해서도 안 된다. 이 반환값 비교는 문서에서 확인했으며 이번 브라우저 실험에는 포함하지 않았다.
직접 확인한 범위는 Chromium 한 버전, 프레임워크 없는 DOM 리스너, 단일 클릭과 같은 문서의 fragment 이동이다. 타이머는 대기 상황을 만들기 위한 값이며 정확한 완료 시간을 보장하지 않는다. 페이지를 교체하는 이동에서 이전 핸들러가 끝까지 실행되는지, Firefox·Safari·모바일·연속 클릭은 확인하지 않았다. 위 수정 코드는 현재 탭으로 직접 이동하므로 새 탭 열기, 다운로드, 수정 키를 누른 클릭의 동작까지 대신하지 않는다. 그런 링크에 적용하려면 어떤 클릭을 이 경로에서 처리할지 먼저 정해야 한다.