ryong.logryong.log
2026년 2월 27일·읽는 데 약 2분

숫자 피커에 물리 법칙을 입히다 — React에서 관성 스크롤 구현기

관련 글

BottomSheet Drag Close Handler 적용 가이드

2026. 2. 27.

OpenClaw를 활용한 hotfix 대응구축팀 설계 및 문서 관리 체계

2026. 3. 6.

OpenRun Bot — RAG 기반 챗봇 서버 구축기

2026. 2. 16.

문제: 작동은 하지만 손맛이 없는 숫자 피커

초기 NumberDial은 드래그로 값을 바꾸는 기능은 정상 동작했지만, 손을 떼는 순간 즉시 멈추는 동작 때문에 iOS 피커 특유의 자연스러운 감각이 부족했습니다.

  • 터치 종료 즉시 정지: 관성(inertia) 부재
  • 값 변경이 임계점(예: 64px)에서만 발생: 계단식 전환
  • 불투명도/강조가 단계식 하드코딩: 연속 피드백 부족
  • 빠른 스와이프 시 state 의존 콜백으로 stale closure 위험
typescript
Copied
1const handleEnd = () => {2  touchStartY.current = null3  mouseStartY.current = null4  currentPositionY.current = 0 // 즉시 종료5}

해결 전략: 값이 아니라 위치(scrollY)를 모델링

핵심은 값을 직접 변경하지 않고, 먼저 픽셀 단위 위치를 물리적으로 업데이트한 뒤 그 위치에서 값을 파생하는 구조로 바꾸는 것입니다.

  1. 입력: 드래그 이동량을 scrollY(px)로 반영
  2. 시뮬레이션: 속도와 마찰로 관성 감속
  3. 종료: 임계 조건에서 가장 가까운 항목으로 스냅
  4. 출력: scrollY 기반으로 현재 값/시각 효과 계산
typescript
Copied
1const currentValue = base + Math.round(scrollY / ITEM_HEIGHT)2const offsetInItem = scrollY % ITEM_HEIGHT

핵심 구현 1: 손을 떼는 순간의 속도 추정

전체 드래그 평균 속도가 아니라, 최근 100ms 샘플만으로 종료 시점의 체감 속도를 계산합니다. 이렇게 해야 플리킹 감각이 자연스럽습니다.

typescript
Copied
1const now = performance.now()2velocitySamplesRef.current = [3  ...velocitySamplesRef.current.filter((s) => now - s.time <= 100),4  { time: now, y: clientY },5]67const calcVelocity = () => {8  const samples = velocitySamplesRef.current9  if (samples.length < 2) return 010  const first = samples[0]11  const last = samples[samples.length - 1]12  const dt = last.time - first.time13  if (dt <= 0) return 014  return clamp((last.y - first.y) / dt, -MAX_VELOCITY, MAX_VELOCITY)15}

MAX_VELOCITY를 제한해 한 프레임에 여러 항목을 건너뛰는 현상을 막고, 과도한 튐을 방지했습니다.


핵심 구현 2: 관성 감속과 프레임 독립성

typescript
Copied
1const animate = (now: number) => {2  const dt = now - lastTime3  lastTime = now45  position += velocity * dt6  velocity *= 0.9578  rafIdRef.current = requestAnimationFrame(animate)9}
  • dt 기반 적분으로 60Hz/120Hz 환경에서 동일한 체감 유지
  • 마찰 계수(0.95)로 지수 감쇠를 적용해 기계적 감속을 회피
  • 속도와 위치를 ref로 관리해 고빈도 렌더링 비용 최소화

핵심 구현 3: 종료 조건과 스냅

속도만으로 종료를 판단하면 중간 지점에서 어색한 점프가 생깁니다. 그래서 속도와 경계 근접도를 함께 사용했습니다.

typescript
Copied
1const nearest = Math.round(position / ITEM_HEIGHT) * ITEM_HEIGHT2const dist = Math.abs(position - nearest)34const nearZero = Math.abs(velocity) <= 0.25const nearBoundary = dist <= 46const slowEnough = Math.abs(velocity) <= 0.0478if ((nearZero && nearBoundary) || slowEnough) {9  startSnap(nearest)10}
typescript
Copied
1const animateSnap = (current: number) => {2  if (Math.abs(current - target) < 0.5) return finish(target)3  const next = current + (target - current) * 0.24  requestAnimationFrame(() => animateSnap(next))5}

스냅도 즉시 점프가 아니라 지수 보간으로 처리해 마지막 안착 동작을 부드럽게 만들었습니다.


렌더링 품질: 계단식 대신 연속 피드백

scrollY의 소수점 구간을 활용해 항목별 opacity/scale/translate를 연속적으로 계산하면 시각적으로도 물리 엔진의 움직임이 그대로 전달됩니다.

  • 선택 항목 강조를 distance 기반 연속 함수로 계산
  • transition 의존을 줄이고, rAF 업데이트를 그대로 표현
  • 값 변경 순간뿐 아니라 이동 중간 프레임도 시각적으로 반응

React 구현에서 중요했던 포인트

  • 고빈도 값(scrollY, velocity)은 ref, 화면 표시값은 state로 분리
  • 비동기 루프(rAF/setTimeout)에서 최신 콜백 참조를 위해 ref 사용
  • touchmove 제어가 필요하면 passive: false로 등록
  • 언마운트 시 rAF/타이머를 반드시 정리
typescript
Copied
1const onChangeRef = useRef(onChange)2useLayoutEffect(() => {3  onChangeRef.current = onChange4}, [onChange])

재사용: useChainScroll로 추상화

물리 로직을 훅으로 분리하자 ChainPicker/TimePicker 등 다른 입력 UI에서도 동일한 감각을 쉽게 재사용할 수 있었습니다.

  • 컴포넌트는 렌더링 책임에 집중
  • 물리 상수/아이템 높이만 주입해서 도메인별 튜닝 가능
  • 동작 일관성 확보로 UI 완성도 개선

마무리

“

좋은 인터랙션은 거대한 엔진보다, 올바른 모델링(위치), 작은 물리 상수(마찰/스냅), 그리고 안정적인 React 패턴(ref/state 분리)에서 시작됩니다.

  • 숫자 피커에 물리 법칙을 입히다 — React에서 관성 스크롤 구현기
  • 문제: 작동은 하지만 손맛이 없는 숫자 피커
  • 해결 전략: 값이 아니라 위치(scrollY)를 모델링
  • 핵심 구현 1: 손을 떼는 순간의 속도 추정
  • 핵심 구현 2: 관성 감속과 프레임 독립성
  • 핵심 구현 3: 종료 조건과 스냅
  • 렌더링 품질: 계단식 대신 연속 피드백
  • React 구현에서 중요했던 포인트
  • 재사용: useChainScroll로 추상화
  • 마무리