본문 바로가기
빌드노트 검색

빌드노트

preventDefault를 호출했는데 휠 스크롤이 멈추지 않는다

wheel 핸들러의 preventDefault 호출만으로 취소 성공을 판단할 수 없다. 명시적 passive true/false와 cancelable 조건의 실제 브라우저 비교로 defaultPrevented와 dispatch 반환을 읽고, 취소가 필요한 등록만 조정한다. 합성 이벤트와 실제 스크롤 검증의 한계도 구분한다.

wheel 핸들러가 실행되고 preventDefault()까지 호출되는데 스크롤이 계속된다면, 그 리스너를 등록한 passive 옵션부터 확인한다. cancelable이 true여도 passive 리스너 안에서는 기본 동작을 취소할 수 없다. 호출했다는 로그와 취소에 성공했다는 상태는 다르다.

기존 onWheel(e) 핸들러에서 취소를 시도하는 바로 그 줄을 다음처럼 바꾸면, 같은 이벤트의 호출 전후 상태를 확인할 수 있다. 원래 취소 조건이 있었다면 그 조건 안에 둔다.

const before = e.defaultPrevented;
e.preventDefault();
console.log({
  type: e.type, cancelable: e.cancelable,
  before, after: e.defaultPrevented
});

cancelable: true이고 before: false → after: true라면 그 호출이 이벤트를 취소했다. false → false라면 취소되지 않았다. 이때 등록 코드의 passive 설정을 조사한다. cancelable: false라면 리스너 옵션을 바꾸어도 그 이벤트를 취소할 수 없다. preventDefault()의 반환값은 두 경우 모두 undefined이므로 성공 여부로 쓰지 않는다. MDN의 preventDefault 설명 (새 탭에서 열림)

before부터 true였다면 앞선 리스너가 이미 취소했을 수 있다. 이번 별도 실험에서도 일반 리스너가 먼저 취소한 이벤트를 passive 리스너가 받자 true → true가 나왔다. 호출 후 값만 읽고 passive 리스너가 취소에 성공했다고 판단하면 틀린다. 이벤트 객체에는 표준 passive 조회 프로퍼티가 없으므로 실제 addEventListener() 호출과 그 호출을 감싼 코드까지 찾아야 한다.

취소가 필요한 등록에서 false를 명시한다

passive: true는 브라우저가 리스너의 취소 판단을 기다리지 않고 기본 동작을 시작할 수 있게 하는 설정이다. 스크롤 위치를 관찰하는 용도라면 이를 유지할 수 있다. 반면 특정 영역에서 휠의 기본 동작을 직접 처리하려고 취소하는 경로라면, 해당 리스너의 등록을 passive: false로 바꾼다. MDN의 passive 리스너 설명 (새 탭에서 열림)

아래 surface는 휠을 처리할 기존 DOM 요소이고 onWheel은 그 요소에 등록하던 기존 함수다. 원래 등록 코드에서 다른 옵션은 유지하고 passive를 수정한 뒤 페이지를 다시 로드한다. 다음은 다른 옵션이 없는 등록의 예다.

surface.addEventListener("wheel", onWheel, { passive: false });

실행 중인 페이지에 같은 줄을 한 번 더 입력해 옵션을 갱신하려 해서는 안 된다. 같은 대상·이벤트 종류·함수 참조·capture로 이미 등록된 리스너는 중복 추가되지 않으며, 추가 호출이 기존 passive 값을 바꾸지도 않는다. DOM 표준의 리스너 추가 절차 (새 탭에서 열림)

옵션 생략도 확인한다. 현재 MDN과 DOM 표준은 window, document, 문서의 루트 요소와 body에 등록하는 wheel, mousewheel, touchstart, touchmove의 기본 passive 값을 true로 정의한다. 모든 요소와 이벤트가 같은 기본값을 쓰는 것은 아니다. 취소가 필요한 등록에서는 생략에 기대지 않고 명시한다. 세 번째 인자에 불리언 false만 넣는 형태는 capture: false를 뜻하므로 passive: false 지정과 다르다. MDN의 등록 옵션 (새 탭에서 열림)

같은 취소 가능 조건에서 결과를 비교한다

등록 옵션의 차이만 확인하려면 새 빈 탭의 개발자 도구 Console에서 다음 코드를 실행한다. 서버나 라이브러리는 필요 없다. 각 호출은 새 대상과 새 이벤트를 만들어 앞선 취소 상태가 다음 비교에 섞이지 않게 한다.

function compare(passive, cancelable = true) {
  const target = new EventTarget();
  target.addEventListener("wheel", e => e.preventDefault(), { passive });
  const event = new WheelEvent("wheel", { cancelable });
  const dispatchResult = target.dispatchEvent(event);
  return { passive, cancelable,
    defaultPrevented: event.defaultPrevented, dispatchResult };
}
console.log(JSON.stringify(compare(true)));
console.log(JSON.stringify(compare(false)));
console.log(JSON.stringify(compare(false, false)));

Windows 11의 headless Chromium 153.0.8010.36에서 이 코드를 실행한 출력은 다음과 같았다. 실제 서비스의 장애 기록이 아니라 이 글을 위해 격리 문서에서 실행한 비교다.

{"passive":true,"cancelable":true,"defaultPrevented":false,"dispatchResult":true}
{"passive":false,"cancelable":true,"defaultPrevented":true,"dispatchResult":false}
{"passive":false,"cancelable":false,"defaultPrevented":false,"dispatchResult":true}

첫 두 줄은 이벤트가 모두 취소 가능한데 리스너의 권한만 달라진 경우다. 세 번째 줄은 passive: false로 바꾸는 것만으로는 충분하지 않다는 대조다. cancelable: false를 passive: true와 조합한 추가 조건에서도 취소되지 않았다.

dispatchResult: true는 이벤트가 취소되지 않았다는 뜻이다. 스크롤을 실제로 수행했다는 뜻이 아니다. 반대로 false도 전달 실패가 아니다. DOM 표준의 전달 절차 (새 탭에서 열림)는 취소 상태에 따라 이 값을 반환한다. 별도 비교에서는 취소에 성공한 뒤에도 같은 대상의 다음 리스너가 한 번 실행됐다. preventDefault()는 이벤트 전달을 중단하는 호출이 아니다.

합성 이벤트의 취소와 실제 스크롤을 나눠 확인한다

위 예제의 이벤트는 코드가 만든 합성 이벤트이며 isTrusted는 false다. dispatchEvent()는 해당 리스너들을 동기적으로 실행하므로 반환 뒤 취소 상태를 읽을 수 있다. MDN의 dispatchEvent 설명 (새 탭에서 열림)

스크롤 가능한 별도 요소에도 deltaY: 240인 합성 WheelEvent를 전달했지만 passive: true/false 두 조건 모두 scrollTop은 0이었다. 취소 상태 비교는 가능했어도 이 입력이 실제 휠 스크롤을 일으킨 것은 아니다. 자동화 도구의 휠 입력도 따로 시도했으나 두 조건 모두 wheel 이벤트가 도착하지 않았다. 따라서 이 실험에서 실제 사용자 스크롤의 차단 성공은 확인하지 못했다.

자기 화면에서는 수정 후 실제 휠 입력으로 호출 전후 값과 스크롤 위치를 함께 확인한다. 입력 중 cancelable이 달라지면 이벤트별로 기록한다. MDN은 브라우저에 따라 연속된 휠 이벤트 중 첫 이벤트만 취소 가능할 수 있다고 설명한다. passive: false는 모든 입력을 취소 가능하게 만드는 설정이 아니다. MDN의 wheel 취소 조건 (새 탭에서 열림)

리스너가 scroll을 받고 있다면 그 이벤트에서 preventDefault()로 되돌리려 하지 않는다. scroll은 취소할 수 없고, 휠 동작의 취소는 wheel 경로에서 판단해야 한다. 또한 휠 경로를 고쳤어도 키보드·스크롤바·자바스크립트에 의한 이동까지 막는 것은 아니다. defaultPrevented가 true인데도 이동한다면 어떤 요소가 어떤 입력이나 코드로 움직이는지 따로 추적한다. 이번 비교는 프레임워크의 이벤트 처리, 모바일 터치, 다른 브라우저 엔진과 스크롤 성능까지 검증하지는 않았다.

← 목록으로 돌아가기