Wheel Picker 스크롤 차단 해결 방법

Yong Lee·2025년 12월 23일

📋 문제 상황

Wheel picker 컴포넌트에서 마우스 휠을 사용할 때, wheel picker 자체와 상위 요소(페이지) 두 개의 스크롤이 모두 움직이는 문제가 발생했습니다.

상위 요소: min-h-screen bg-background flex flex-col overflow-y-auto
  └─ 하위 요소: relative overflow-hidden rounded-lg bg-background touch-none overscroll-none
      └─ 문제: 두 스크롤이 동시에 움직임 ❌

🔍 문제의 원인

React의 onWheel prop의 한계

React의 onWheel prop은 내부적으로 브라우저가 passive listener로 등록할 수 있습니다.

// ❌ 작동하지 않았던 코드
<div onWheel={handleIntegerWheel}>
  {/* React의 onWheel은 passive listener로 등록될 수 있음 */}
</div>

const handleIntegerWheel = async (e: React.WheelEvent) => {
  e.preventDefault()  // ❌ passive listener에서는 무시됨!
  e.stopPropagation()
  // ...
}

Passive Listener의 특징

// 브라우저가 자동으로 passive로 등록할 수 있음
element.addEventListener('wheel', handler)
// 또는
element.addEventListener('wheel', handler, { passive: true })

// 특징:
// - preventDefault() 호출해도 무시됨 ❌
// - 성능 최적화 (스크롤이 블로킹되지 않음)
// - 스크롤 이벤트는 기본적으로 passive로 처리됨

왜 passive로 처리하나요?

  • 브라우저는 성능 최적화를 위해 스크롤 이벤트를 passive로 처리합니다
  • 스크롤이 블로킹되지 않아 사용자 경험이 부드럽습니다
  • 하지만 우리는 스크롤을 차단해야 하므로 non-passive가 필요합니다

✅ 해결 방법

핵심: { passive: false } 옵션으로 직접 이벤트 등록

React의 onWheel prop 대신, useEffect에서 addEventListener를 사용하여 non-passive listener로 직접 등록합니다.

// ✅ 해결된 코드
const handleIntegerWheel = useCallback(async (e: React.WheelEvent | { deltaY: number }) => {
  const delta = e.deltaY > 0 ? 1 : -1
  const currentInteger = getIntegerPart(value)
  const newInteger = Math.max(integerMin, Math.min(integerMax, currentInteger + delta))
  
  if (newInteger !== currentInteger) {
    // ... 애니메이션 및 상태 업데이트
  }
}, [value, integerMin, integerMax, getIntegerPart, getDecimalPart, getIntegerY, integerControls, onChange])

// Non-passive wheel event listeners를 직접 등록
useEffect(() => {
  const integerContainer = integerContainerRef.current
  const decimalContainer = decimalContainerRef.current

  if (!integerContainer || !decimalContainer) return

  // Native wheel handler
  const handleIntegerWheelNative = (e: WheelEvent) => {
    e.preventDefault()      // ✅ 이제 확실히 작동!
    e.stopPropagation()     // ✅ 이벤트 버블링 차단
    handleIntegerWheel({ deltaY: e.deltaY } as React.WheelEvent)
  }

  const handleDecimalWheelNative = (e: WheelEvent) => {
    e.preventDefault()
    e.stopPropagation()
    handleDecimalWheel({ deltaY: e.deltaY } as React.WheelEvent)
  }

  // 핵심: { passive: false } 옵션
  integerContainer.addEventListener('wheel', handleIntegerWheelNative, { passive: false })
  decimalContainer.addEventListener('wheel', handleDecimalWheelNative, { passive: false })

  return () => {
    integerContainer.removeEventListener('wheel', handleIntegerWheelNative)
    decimalContainer.removeEventListener('wheel', handleDecimalWheelNative)
  }
}, [handleIntegerWheel, handleDecimalWheel])

Non-Passive Listener의 특징

// 명시적으로 non-passive로 등록
element.addEventListener('wheel', handler, { passive: false })

// 특징:
// - preventDefault()가 정상 작동 ✅
// - 스크롤을 차단할 수 있음
// - 약간의 성능 오버헤드 (하지만 이 경우 필요함)

🎯 useCallback의 역할

useCallback은 스크롤 차단과 직접적인 관련은 없지만, useEffect의 의존성 배열을 안정화하기 위해 필요합니다.

useCallback 없이 사용하면 (문제 발생)

// ❌ 문제가 있는 코드
const handleIntegerWheel = async (e) => { 
  // ... 로직
}

useEffect(() => {
  // handleIntegerWheel이 의존성 배열에 있으면
  // 컴포넌트가 리렌더링될 때마다 새로운 함수가 생성되어
  // useEffect가 계속 실행됨 → 이벤트 리스너가 계속 추가/제거됨
  integerContainer.addEventListener('wheel', handler, { passive: false })
}, [handleIntegerWheel])  // handleIntegerWheel이 매번 새로 생성됨!

문제점:

  • 컴포넌트가 리렌더링될 때마다 handleIntegerWheel이 새로운 함수로 생성됨
  • useEffect가 계속 실행되어 이벤트 리스너가 반복적으로 추가/제거됨
  • 메모리 누수 및 성능 저하 가능성

useCallback을 사용하면 (해결)

// ✅ 올바른 코드
const handleIntegerWheel = useCallback(async (e) => {
  // ...
}, [value, integerMin, integerMax, ...])  // 의존성이 변경될 때만 새로 생성

useEffect(() => {
  // handleIntegerWheel이 의존성 배열에 있어도
  // 의존성이 실제로 변경될 때만 useEffect가 실행됨
  integerContainer.addEventListener('wheel', handler, { passive: false })
}, [handleIntegerWheel])  // 안정적인 참조

장점:

  • 의존성이 실제로 변경될 때만 함수가 새로 생성됨
  • useEffect가 불필요하게 재실행되지 않음
  • 이벤트 리스너가 안정적으로 관리됨

🔄 전체 이벤트 흐름

1. 사용자가 wheel picker 위에서 마우스 휠을 돌림
   ↓
2. 브라우저가 wheel 이벤트 발생
   ↓
3. 우리가 등록한 non-passive listener가 먼저 실행
   ↓
4. e.preventDefault() 호출 → 상위 스크롤 차단 ✅
   ↓
5. e.stopPropagation() 호출 → 이벤트 버블링 차단 ✅
   ↓
6. handleIntegerWheel 실행 → wheel picker 값 변경
   ↓
7. 상위 요소로 이벤트가 전파되지 않음 → 상위 스크롤 안 움직임 ✅

📝 추가 적용 사항

CSS overscroll 설정

상위 요소에도 overscroll-contain 클래스를 추가하여 추가 보호:

// features/onboarding/components/onboarding-flow.tsx
<div className="min-h-screen bg-background flex flex-col overflow-y-auto overscroll-contain">

터치 이벤트 처리

모바일 환경을 위한 터치 이벤트도 처리:

const handleTouchMove = useCallback((e: React.TouchEvent) => {
  // 상위 요소의 스크롤을 완전히 차단
  e.preventDefault()
  e.stopPropagation()
}, [])

📊 비교: Passive vs Non-Passive

특징Passive ListenerNon-Passive Listener
preventDefault()❌ 무시됨✅ 정상 작동
성능⚡ 빠름 (블로킹 없음)🐌 약간 느림 (블로킹 가능)
스크롤 차단❌ 불가능✅ 가능
기본 동작브라우저가 자동 선택명시적으로 지정 필요
사용 사례일반적인 스크롤 이벤트스크롤을 차단해야 하는 경우

🎓 핵심 요약

  1. 해결의 핵심: { passive: false } 옵션으로 non-passive 이벤트 등록
  2. useCallback의 역할: useEffect 의존성 배열 안정화 (부수효과)
  3. 결과: preventDefault()가 정상 작동하여 상위 스크롤 차단 성공

🔗 관련 파일

  • components/ui/decimal-wheel-picker.tsx - Wheel picker 컴포넌트
  • features/onboarding/components/onboarding-flow.tsx - 상위 레이아웃
  • app/globals.css - 전역 CSS 설정

📚 참고 자료

profile
오늘은 어떤 새로운 것이 나를 즐겁게 할까?

0개의 댓글