- Published on
Safari 스크롤 끊김 원인 - passive listener 제대로 쓰기
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
모바일 Safari에서 스크롤이 뚝뚝 끊기거나, 스크롤 중 터치 반응이 늦고 페이지가 잠깐 멈춘 듯한 느낌을 받은 적이 있다면, 원인 후보 중 하나가 passive listener 입니다.
특히 touchstart, touchmove, wheel 같은 입력 이벤트는 브라우저가 스크롤을 시작하기 전에 “이 핸들러가 preventDefault()를 호출해서 스크롤을 막을 수도 있는지”를 판단해야 합니다. 이 판단 때문에 메인 스레드가 이벤트 핸들러 실행을 기다리면, 스크롤 시작이 지연되고 체감상 끊김(jank)으로 이어집니다.
이 글에서는 Safari에서 왜 이 현상이 두드러질 수 있는지, passive: true가 무엇을 바꾸는지, 그리고 “무작정 passive로 바꾸면 안 되는” 케이스까지 포함해 실전 적용법을 정리합니다.
관련해서 CSS 측면에서의 원인과 해결(특히 iOS 17)도 함께 보면 좋습니다: Safari iOS 17 스크롤 끊김, CSS contain으로 해결하기
Safari에서 스크롤이 끊기는 메커니즘
스크롤 입력 이벤트는 기본적으로 스크롤과 경쟁한다
대표적으로 다음 이벤트들은 스크롤과 직접 연관됩니다.
touchstart/touchmove(모바일)wheel(데스크톱 트랙패드, 마우스 휠)
브라우저는 스크롤을 매끄럽게 시작하려면 입력을 받자마자 스크롤을 진행해야 합니다. 그런데 이벤트 리스너가 등록되어 있으면, 브라우저는 다음을 확인해야 합니다.
- 이 핸들러가
event.preventDefault()를 호출할 가능성이 있는가? - 호출한다면 기본 스크롤 동작을 막아야 하는가?
이 “가능성 확인” 과정에서 브라우저가 메인 스레드의 JS 실행을 기다리게 되면, 스크롤 시작이 늦어지고 프레임 드랍이 발생합니다.
passive listener는 “난 스크롤 막지 않을게”라는 힌트
passive: true는 브라우저에게 다음을 약속합니다.
- 이 리스너는
preventDefault()를 호출하지 않는다
따라서 브라우저는 이벤트 핸들러 실행을 기다리지 않고 스크롤을 진행할 수 있고, 스크롤이 훨씬 부드러워집니다.
passive 옵션의 핵심 규칙
1) passive: true면 preventDefault()가 무시된다
예를 들어 아래 코드는 의도대로 동작하지 않습니다.
window.addEventListener(
'touchmove',
(e) => {
e.preventDefault(); // passive라서 무시될 수 있음
},
{ passive: true }
);
Safari/Chrome 모두에서 콘솔 경고가 뜨거나, 실제로 스크롤이 계속 진행됩니다.
즉, “스크롤을 막아야 하는 UI”에서는 passive: true를 쓰면 안 됩니다.
2) 스크롤을 막는 게 목적이라면 touch-action부터 고려
과거에는 touchmove에서 preventDefault()로 스크롤을 막는 패턴이 흔했지만, 현대 브라우저에서는 CSS touch-action이 더 안정적이고 성능 친화적입니다.
다만 iOS Safari는 touch-action 지원이 늦게/제한적으로 들어왔고, 여전히 케이스별 주의가 필요합니다. 그래서 “iOS Safari에서만 스크롤을 막기 위해 preventDefault()가 필요”한 레거시 코드가 남아 있는 경우가 많습니다.
어떤 이벤트에 passive를 적용해야 하나
기본 전략: 스크롤 관련 이벤트는 우선 passive를 검토
touchstart,touchmove,wheel은 대부분passive: true가 이득입니다.- 특히 “스크롤을 막지 않는 단순 로깅/분석/상태 업데이트” 용도라면 거의 무조건 passive가 맞습니다.
예시: 스크롤 위치 추적
window.addEventListener(
'scroll',
() => {
// scroll 이벤트 자체는 passive 옵션 대상이 아니지만,
// 여기서는 작업을 가볍게 유지하는 게 핵심
},
{ passive: true }
);
주의: 스펙/브라우저에 따라 scroll은 passive의 의미가 제한적일 수 있습니다. 실질적으로 병목이 되는 건 대개 touchmove/wheel 쪽입니다.
문제를 많이 만드는 패턴: 전역 touchmove + 무거운 작업
window.addEventListener('touchmove', (e) => {
// 무거운 계산, DOM 측정, 동기 setState 등이 섞이면
// 스크롤 중 프레임 드랍이 체감됨
doSomethingExpensive();
});
이 코드는 브라우저가 스크롤을 시작하기 전에 핸들러 실행을 기다릴 수 있어, Safari에서 특히 끊김이 두드러집니다.
개선 1: passive 적용
window.addEventListener(
'touchmove',
(e) => {
doSomethingLight();
},
{ passive: true }
);
개선 2: 이벤트에서 무거운 일을 하지 않고 스케줄링
let scheduled = false;
window.addEventListener(
'touchmove',
() => {
if (scheduled) return;
scheduled = true;
requestAnimationFrame(() => {
scheduled = false;
// 프레임 타이밍에 맞춰 최소 작업만 수행
doSomethingLight();
});
},
{ passive: true }
);
핵심은 “입력 이벤트 핸들러는 가능한 한 빨리 끝내고, DOM 측정/레이아웃 유발 작업을 피한다”입니다.
스크롤을 막아야 하는 UI에서의 안전한 패턴
예: 바텀시트/모달을 열었을 때 배경 스크롤을 막고 싶다.
이때 흔히 touchmove에서 preventDefault()를 쓰는데, 이것은 passive와 충돌합니다. 해결은 보통 다음 중 하나입니다.
1) 특정 영역에만 non-passive 적용
“배경 스크롤만 막고, 내부 스크롤 컨테이너는 허용” 같은 요구가 있다면, 이벤트 타겟을 좁히는 것이 중요합니다.
const backdrop = document.querySelector('.backdrop');
backdrop.addEventListener(
'touchmove',
(e) => {
e.preventDefault();
},
{ passive: false }
);
포인트는 전역 window에 non-passive를 걸지 않는 것입니다. 전역 non-passive는 스크롤 성능에 직접 타격을 줍니다.
2) “기본은 passive, 필요한 순간만 non-passive” 토글
모달이 열릴 때만 non-passive 리스너를 붙이고, 닫힐 때 제거합니다.
function preventScroll(e) {
e.preventDefault();
}
function lockScroll() {
window.addEventListener('touchmove', preventScroll, { passive: false });
}
function unlockScroll() {
window.removeEventListener('touchmove', preventScroll);
}
이 패턴은 “항상 non-passive인 전역 리스너”를 피할 수 있어 효과적입니다.
React/Next.js에서 놓치기 쉬운 지점
React의 onWheel, onTouchMove 같은 합성 이벤트는 내부적으로 이벤트 위임을 사용합니다. 이때 브라우저 네이티브 addEventListener의 옵션(passive)을 세밀하게 제어하기 어렵거나, 프레임워크 버전/환경에 따라 기대와 달라질 수 있습니다.
성능 이슈가 Safari에서만 재현되고, 스크롤/터치 계열 이벤트가 관련되어 보인다면 다음 전략이 실전적으로 유효합니다.
- 정말 필요한 곳에만 네이티브
addEventListener를 직접 사용 - 컴포넌트 마운트 시 등록, 언마운트 시 해제
- 핸들러는 가볍게, 상태 업데이트는
requestAnimationFrame으로 합치기
예시: React에서 passive wheel 등록
import { useEffect } from 'react';
export function usePassiveWheel(ref, onWheel) {
useEffect(() => {
const el = ref.current;
if (!el) return;
const handler = (e) => onWheel(e);
el.addEventListener('wheel', handler, { passive: true });
return () => {
el.removeEventListener('wheel', handler);
};
}, [ref, onWheel]);
}
React 렌더링이 과도하게 일어나 스크롤 중 끊김을 악화시키는 경우도 많습니다. 스크롤과 직접 관련이 없어 보이더라도 “입력 이벤트가 상태 업데이트를 촉발해 렌더 폭주로 이어지는지”를 함께 점검해보세요: React 렌더링 폭주? 리렌더 원인 추적 실전 가이드
디버깅 체크리스트
1) Safari 개발자 도구에서 경고 확인
iOS Safari는 macOS Safari의 Web Inspector로 원격 디버깅합니다. 여기서 다음 힌트를 자주 볼 수 있습니다.
- 스크롤 블로킹 가능성이 있는 이벤트 리스너 경고
preventDefault()가 passive에서 무시되었다는 경고
2) 전역 이벤트 리스너부터 의심
다음 패턴이 있으면 우선 후보입니다.
window또는document에 등록된touchmove/wheel- 옵션 객체 없이 등록된 리스너(기본이 non-passive로 취급될 수 있음)
검색 키워드 예시
addEventListener('touchmove'addEventListener('wheel'
3) 핸들러 안의 “레이아웃 유발” 작업 제거
스크롤 중에 아래 작업이 있으면 프레임 드랍이 쉽게 발생합니다.
getBoundingClientRect()를 매 이벤트마다 호출offsetHeight,scrollTop읽기와 DOM 쓰기 혼합- 동기적으로 큰 배열/JSON 처리
가능하면 측정은 캐싱하고, DOM 업데이트는 requestAnimationFrame 단위로 합치는 전략을 씁니다.
결론: passive는 만능이 아니라 “의도 선언”이다
Safari 스크롤 끊김을 줄이는 데 passive listener는 매우 강력한 처방이지만, 핵심은 다음 두 가지입니다.
- 스크롤을 막지 않는 이벤트라면
passive: true로 브라우저에 확실히 알려라 - 스크롤을 막아야 한다면 전역 non-passive를 피하고, 범위를 좁히거나 토글로 관리하라
여기에 더해 CSS 레이아웃/페인팅 비용까지 함께 줄이면 체감 개선 폭이 커집니다. 특히 iOS 17 Safari에서의 케이스는 CSS 최적화가 결정타가 되는 경우가 많으니, 필요하다면 위의 CSS contain 글도 함께 확인해보세요.