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
└─ 문제: 두 스크롤이 동시에 움직임 ❌
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로 등록할 수 있음
element.addEventListener('wheel', handler)
// 또는
element.addEventListener('wheel', handler, { passive: true })
// 특징:
// - preventDefault() 호출해도 무시됨 ❌
// - 성능 최적화 (스크롤이 블로킹되지 않음)
// - 스크롤 이벤트는 기본적으로 passive로 처리됨
왜 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로 등록
element.addEventListener('wheel', handler, { passive: false })
// 특징:
// - preventDefault()가 정상 작동 ✅
// - 스크롤을 차단할 수 있음
// - 약간의 성능 오버헤드 (하지만 이 경우 필요함)
useCallback은 스크롤 차단과 직접적인 관련은 없지만, useEffect의 의존성 배열을 안정화하기 위해 필요합니다.
// ❌ 문제가 있는 코드
const handleIntegerWheel = async (e) => {
// ... 로직
}
useEffect(() => {
// handleIntegerWheel이 의존성 배열에 있으면
// 컴포넌트가 리렌더링될 때마다 새로운 함수가 생성되어
// useEffect가 계속 실행됨 → 이벤트 리스너가 계속 추가/제거됨
integerContainer.addEventListener('wheel', handler, { passive: false })
}, [handleIntegerWheel]) // handleIntegerWheel이 매번 새로 생성됨!
문제점:
handleIntegerWheel이 새로운 함수로 생성됨useEffect가 계속 실행되어 이벤트 리스너가 반복적으로 추가/제거됨// ✅ 올바른 코드
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. 상위 요소로 이벤트가 전파되지 않음 → 상위 스크롤 안 움직임 ✅
상위 요소에도 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 Listener | Non-Passive Listener |
|---|---|---|
preventDefault() | ❌ 무시됨 | ✅ 정상 작동 |
| 성능 | ⚡ 빠름 (블로킹 없음) | 🐌 약간 느림 (블로킹 가능) |
| 스크롤 차단 | ❌ 불가능 | ✅ 가능 |
| 기본 동작 | 브라우저가 자동 선택 | 명시적으로 지정 필요 |
| 사용 사례 | 일반적인 스크롤 이벤트 | 스크롤을 차단해야 하는 경우 |
{ passive: false } 옵션으로 non-passive 이벤트 등록useEffect 의존성 배열 안정화 (부수효과)preventDefault()가 정상 작동하여 상위 스크롤 차단 성공components/ui/decimal-wheel-picker.tsx - Wheel picker 컴포넌트features/onboarding/components/onboarding-flow.tsx - 상위 레이아웃app/globals.css - 전역 CSS 설정