청약 진단 페이지엔 보유 자산을 입력하는 progress bar 가 있어요. 드래그로 금액을 조절하는 동안엔 body 가 같이 스크롤되면 안 되는데, iOS Safari 에서 자꾸 페이지가 흔들리는 문제가 있었어요. 두 가지 방식 사이를 한 번 왕복하고 결국 처음 방식으로 돌아온 짧은 여정을 정리해봤어요.
1. 진단하기
문제 자체는 단순했어요. 사용자가 progress bar 의 thumb 을 길게 잡고 위아래로 드래그하면, thumb 의 touchmove 이벤트가 progress bar 안에서 처리되는 동시에, document 의 기본 스크롤 동작도 함께 발화돼서 페이지 전체가 위아래로 함께 움직였어요. 사용자 입장에서는 “막대를 움직이려고 했을 뿐인데 화면도 같이 출렁인다” 는 인상이 됐어요.
자연스러운 후보가 두 개 있었어요. 하나는 touchmove 이벤트를 직접 잡아서 preventDefault() 로 기본 스크롤을 막는 방식. 다른 하나는 body-scroll-lock 같은 기성 라이브러리를 써서 body 자체를 락하는 방식. 둘 다 “드래그 중에만” 동작해야 하니까, useEffect 의 isDragging 플래그에 묶어서 켰다 껐다 하면 돼요.
처음 도입할 땐 이벤트 직접 막기 방식으로 갔는데, 이후 다른 모바일 환경에서 마이너 이슈가 보고되며 body-scroll-lock 으로 갈아탔다가, 거기서 또 다른 부작용이 보이면서 결국 다시 직접 막기 방식으로 돌아왔어요. 두 방식의 trade-off 를 직접 부딪히며 배운 셈이에요.
2. 재현하기
두 방식의 동작을 격리해서 비교해봤어요. 먼저 직접 막기:
useEffect(() => {
if (!isDragging) return
const preventScroll = (e: TouchEvent) => {
e.preventDefault()
}
document.addEventListener('touchmove', preventScroll, { passive: false })
return () => {
document.removeEventListener('touchmove', preventScroll)
}
}, [isDragging])
핵심은 { passive: false } 옵션이에요. iOS Safari 는 성능 최적화를 위해 touchmove 리스너를 기본적으로 passive 로 다뤄요. passive 리스너에서는 preventDefault() 가 무시되거든요. { passive: false } 를 명시해야 정말로 기본 스크롤을 막을 수 있어요.
다음으로 body-scroll-lock:
useEffect(() => {
if (isDragging) {
disableBodyScroll(document.body)
} else {
enableBodyScroll(document.body)
}
return () => enableBodyScroll(document.body)
}, [isDragging])
훨씬 깔끔해 보여요. 그런데 실제로 써보면 몇 가지 문제가 있었어요. 라이브러리가 body 의 스타일(overflow, position 등)을 직접 만지면서, drag 가 끝나고 unlock 될 때 스크롤 위치가 살짝 점프하거나, allowTouchMove 옵션을 정밀하게 설정하지 않으면 내부 스크롤 영역이 같이 막히는 등 케이스별 부작용이 누적됐어요. 또 라이브러리가 적극적으로 유지보수되는 상태가 아니라 새 iOS 버전에서 미묘한 차이를 다루기 어려웠어요.
반면 직접 막기 방식은 코드는 좀 더 길지만 동작이 투명해요. “드래그 중엔 touchmove 의 default 만 막는다” 가 끝이라, 어디서 무엇이 일어나는지 한눈에 보여요.
3. 수정하기
최종적으로 progress bar 의 useEffect 를 직접 막기 방식으로 되돌렸어요.
useEffect(() => {
if (!isDragging) return
const preventScroll = (e: globalThis.TouchEvent) => {
e.preventDefault()
}
document.addEventListener('touchmove', preventScroll, { passive: false })
return () => {
document.removeEventListener('touchmove', preventScroll)
}
}, [isDragging])
부수적으로 useThrottledValue 를 함께 도입해서, 드래그 중 progress bar 위에 떠 있는 금액 표시(NumberFlow 기반의 AnimatedNumber)가 매 프레임마다 재계산되는 걸 100ms 간격으로 throttle 했어요. 스크롤은 막혔지만 number flip 애니메이션이 너무 빈번하면 jank 가 보이거든요. 이건 직접적으론 스크롤 락과 관련이 없지만, 같은 PR 에서 함께 다뤘던 UX 개선 항목이었어요.
4. 재발 방지하기
이번 사건을 거치면서 모바일 스크롤 제어에 대해 몇 가지 원칙이 생겼어요.
첫째, addEventListener('touchmove', ..., { passive: false }) 의 의미를 정확히 알고 쓴다. passive 옵션을 빼먹은 채로 preventDefault() 를 부르는 코드는 콘솔에 경고만 띄우고 동작하지 않아요. iOS 환경의 성능 최적화 정책 때문이에요.
둘째, 기성 라이브러리의 추상화 비용을 가볍게 보지 않는다. body-scroll-lock 같은 라이브러리는 “한 줄로 끝낸다” 는 매력이 있지만, 실제로는 body 스타일을 만지고 여러 환경 대응 코드를 가지고 있어서 동작이 불투명해요. 우리 케이스처럼 제어가 필요한 영역이 명확하고 단순할 땐 직접 막는 쪽이 디버깅하기도 수정하기도 더 쉬워요.
셋째, 드래그 / 스크롤 / 줌 같은 터치 인터랙션은 시뮬레이터가 아니라 실기기에서 검증한다. 시뮬레이터의 터치 모델은 실제 iOS Safari 의 touchmove 이벤트 시퀀스와 미묘하게 달라서, 같은 코드도 실기기에서 다르게 동작하는 경우가 많아요.
라이브러리에 의존하느냐 직접 다루느냐는 정답이 있는 질문이 아니에요. 다만 동작이 단순하고 환경마다 미묘한 차이를 컨트롤해야 한다면, 추상화가 가려놓은 면적이 작은 쪽 — 즉 직접 다루는 쪽이 결국 더 빠르게 안정화된다는 걸 배웠어요.