ryong.logryong.log
물리법칙을 통한 ChainPicker 컴포넌트 UX 개선기 배너
2026년 2월 27일·읽는 데 약 7분

물리법칙을 통한 ChainPicker 컴포넌트 UX 개선기

관련 글

OpenClaw로 hotfix 대응팀을 만들어봤습니다

2026. 3. 6.

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

2026. 2. 16.

PSPDFKit에서 pdfjs-dist로: 유료 PDF 라이브러리 교체 삽질기

2026. 3. 31.

Before
After

기존 방식: 거리 기반 스크롤

기존 오픈런의 피커 컴포넌트(NumberDial + useNumberDial)는 매우 단순한 방식으로 스크롤을 처리하고 있었습니다. 손가락(또는 마우스)이 움직인 거리를 누적하고, 그 누적값이 64px을 넘으면 한 칸 이동한 뒤 다시 0으로 초기화하는 구조였습니다.

// 기존 useNumberDial.ts (삭제됨) const diff = startY - clientY currentPositionY.current += diff

const newValue = Math.round(currentPositionY.current / 64) if (newValue !== 0) { let nextValue = value + newValue // 경계 처리 (순환) if (nextValue < min) nextValue = max - (min - nextValue - 1) else if (nextValue > max) nextValue = min + (nextValue - max - 1)

plain text
Copied
1setValue(nextValue)2onChange(nextValue)3currentPositionY.current = 0  // 위치 초기화

}

동작 자체는 문제가 없었지만, 두 가지 근본적인 한계가 있었습니다.

문제 1: 물리 법칙과의 불일치 — 관성의 부재

현실 세계에서 물체를 밀면 손을 떼도 관성에 의해 계속 움직입니다. iOS의 UIPickerView를 떠올려 보면, 빠르게 튕겼을 때 피커가 쭉 미끄러지다가 서서히 멈추는 것을 볼 수 있습니다. 하지만 기존 방식에서는 손가락을 떼는 순간 모든 것이 즉시 멈췄습니다. 어떤 속도로 스와이프하든, 손가락이 화면에서 떨어지면 그 자리에서 정지합니다. 마찰도, 감속도, 관성도 없었습니다.

문제 2: 스크롤 강도의 제한

이 방식은 스크롤 강도가 피커 영역의 물리적 크기에 제약을 받습니다. 각 항목의 기본 높이가 64px이기 때문에, 한 번의 드래그로 2칸 이상 이동하려면 128px 이상을 손가락으로 직접 끌어야 합니다. 오픈런의 러닝 횟수 피커는 0부터 7+까지 8개 항목인데, 끝에서 끝으로 가려면 한 칸씩 여러 번 스크롤해야 했습니다. 페이스 피커의 경우 분(0~59)과 초(0~59)를 각각 선택해야 하므로 이 불편함이 더 크게 느껴졌습니다.

두 문제의 근본 원인은 같습니다. 관성이 없다는 것. 그래서 스크롤에 물리 법칙을 도입하기로 했습니다.


핵심 아이디어: "거리"가 아니라 "속도"를 보자

실제 물리 세계에서 물체를 밀면, 얼마나 멀리 밀었는지가 아니라 놓는 순간 얼마나 빠른지가 물체가 굴러가는 거리를 결정합니다. 천천히 길게 끌어도, 빠르게 짧게 튕겨도, 결국 중요한 것은 "놓는 순간의 속도"입니다.

이 원리를 스크롤에 그대로 적용했습니다. 전체 흐름은 세 단계로 나뉩니다:

[1단계: 속도 측정] → [2단계: 관성 감속] → [3단계: 스냅] (드래그하는 동안) (손가락을 뗀 후) (가장 가까운 항목에 정착)


1단계: 속도 측정 — 놓는 순간의 속도를 포착하기

모멘텀 스크롤의 출발점은 "손가락을 떼는 순간, 사용자의 손가락이 얼마나 빠르게 움직이고 있었는가"를 정확히 아는 것입니다. 이를 위해 드래그하는 동안 매 터치/마우스 이벤트마다 { 시간, y좌표 } 쌍을 샘플로 기록합니다.

interface VelocitySample { time: number y: number }

샘플 수집 전략: 시간 기반 윈도우

샘플을 모두 저장하는 것이 아니라, 최근 100ms 이내의 샘플만 유지합니다.

const MAX_HOLDING_VELOCITY_DURATION = 100 // ms

const now = performance.now() velocitySamplesRef.current = [ ...velocitySamplesRef.current.filter((s) => now - s.time <= 100), { time: now, y: clientY }, ]

여기서 "왜 샘플 개수가 아니라 시간으로 제한하는가?"라는 의문이 들 수 있습니다. 터치 이벤트의 호출 빈도는 기기마다 다릅니다. 고성능 기기는 1ms마다 이벤트를 발생시킬 수도 있고, 저사양 기기는 16ms마다 한 번일 수도 있습니다. "최근 5개 샘플"처럼 개수로 제한하면, 같은 스와이프 동작이라도 기기에 따라 측정되는 시간 범위가 크게 달라집니다. 시간 기반 윈도우로 고정하면 어떤 기기에서든 "놓기 직전 0.1초의 움직임"이라는 일관된 구간을 측정할 수 있습니다.

또한 100ms라는 값 자체도 중요합니다. 사용자가 스크롤 도중 방향을 바꾸거나 속도를 변화시킬 수 있는데, 너무 긴 구간(예: 500ms)을 보면 과거의 움직임이 섞여 "놓는 순간의 의도"가 희석됩니다. 100ms는 손가락을 떼기 직전의 의도만 정확히 포착할 수 있는 구간입니다.

속도 계산

손가락을 떼는 순간, 남아 있는 샘플의 첫 번째와 마지막으로 속도를 계산합니다. 단순한 거리 / 시간입니다.

const calculateVelocity = (): number => { const samples = velocitySamplesRef.current if (samples.length < 2) return 0

plain text
Copied
1const first = samples[0]2const last = samples[samples.length - 1]3const dt = last.time - first.time4if (dt === 0) return 056const v = (last.y - first.y) / dt  // px/ms 단위78// 과도한 속도 방지를 위한 클램핑9return Math.max(-physics.MAX_VELOCITY, Math.min(physics.MAX_VELOCITY, v))

}

예를 들어 100ms 동안 80px를 움직였다면 속도는 0.8 px/ms가 됩니다. 빠르게 튕기면 이 값이 크고, 천천히 놓으면 작습니다. MAX_VELOCITY로 상한을 두어 비현실적으로 빠른 스크롤이 발생하지 않도록 합니다.

그리고 이 속도값이 MIN_VELOCITY(0.01) 미만이면 사실상 "정지 상태에서 놓은 것"이므로 관성 없이 바로 스냅 단계로 넘어갑니다.

const handleEnd = useCallback(() => { isDraggingRef.current = false const velocity = calculateVelocity()

plain text
Copied
1if (Math.abs(velocity) < physics.MIN_VELOCITY) {2  snapToClosestItem(scrollYRef.current)  // 관성 없이 바로 스냅3  return4}56applyMomentum(velocity)  // 관성 감속 시작

}, [/* deps */])


2단계: 관성 감속 — 마찰로 서서히 멈추기

손가락을 떼면 측정된 속도를 초기값으로 하여 requestAnimationFrame 기반의 애니메이션 루프가 시작됩니다. 매 프레임마다 두 가지 일을 합니다:

  1. 현재 속도만큼 위치를 이동시킵니다.
  2. 속도에 마찰 계수를 곱해 줄입니다.

const FRICTION = 0.95

const animate = (now: number) => { const dt = now - lastTime lastTime = now

plain text
Copied
1pos += velocity * dt        // ① 현재 속도만큼 위치 이동2velocity *= FRICTION         // ② 매 프레임 속도를 5% 감소34updateScrollY(pos)56// 충분히 느려졌는지 확인7const nearestTarget = Math.round(pos / itemHeight) * itemHeight8const dist = Math.abs(pos - nearestTarget)9const slowEnough = Math.abs(velocity) <= physics.SNAP_VELOCITY_THRESHOLD10const nearZero = Math.abs(velocity) <= physics.NEAR_ZERO_VELOCITY11const nearBoundary = dist <= physics.BOUNDARY_PX1213if ((nearZero && nearBoundary) || slowEnough) {14  snapToClosestItem(pos)       // → 3단계로 전환15} else {16  requestAnimationFrame(animate) // 계속 감속17}

}

마찰 계수 0.95의 의미

velocity *= 0.95는 매 프레임마다 속도가 5%씩 줄어든다는 뜻입니다. 단순해 보이지만 복리처럼 누적되어 자연스러운 감속 곡선을 만들어냅니다.

초기 속도가 0.5 px/ms라고 가정하면:

┌────────┬──────────────┬────────────────────────┐ │ 프레임 │ 속도 (px/ms) │ 비고 │ ├────────┼──────────────┼────────────────────────┤ │ 0 │ 0.500 │ 시작 │ ├────────┼──────────────┼────────────────────────┤ │ 5 │ 0.387 │ │ ├────────┼──────────────┼────────────────────────┤ │ 10 │ 0.299 │ 절반 가까이 줄었습니다 │ ├────────┼──────────────┼────────────────────────┤ │ 20 │ 0.179 │ │ ├────────┼──────────────┼────────────────────────┤ │ 40 │ 0.064 │ 거의 멈춤 직전 │ ├────────┼──────────────┼────────────────────────┤ │ 60 │ 0.024 │ 스냅 임계값 근처 │ └────────┴──────────────┴────────────────────────┘

빙판 위에서 퍽 밀어놓은 물체가 서서히 멈추는 것과 같은 느낌입니다. 빠르게 튕기면 초기 속도가 크니까 오래 미끄러지고, 천천히 놓으면 몇 프레임 만에 멈춥니다. 이 마찰 계수는 여러 값을 시험한 끝에 0.95로 결정했습니다. 0.9이면 너무 빨리 멈추고, 0.98이면 끝없이 미끄러지는 느낌이 납니다.

경계 처리

오픈런의 피커 중에는 순환하는 것(러닝 횟수: 0→7+→0)과 순환하지 않는 것이 있습니다. 순환하지 않는 피커(wrap: false)에서는 경계에 도달하면 즉시 속도를 0으로 만들어 벽에 부딪히는 느낌을 줍니다.

if (!wrap) { const maxPos = (totalItems - 1) * itemHeight if (pos < 0) { pos = 0 velocity = 0 } else if (pos > maxPos) { pos = maxPos velocity = 0 } }

스냅 진입 조건

감속 중 스냅 단계로 전환하는 시점의 판단도 중요합니다. 단순히 "속도가 0에 가까울 때"만으로는 부족합니다. 속도가 충분히 줄었더라도 현재 위치가 항목 경계에서 멀면 어색한 점프가 발생할 수 있기 때문입니다.

그래서 두 가지 조건을 조합합니다:

  • 속도가 매우 느리고(NEAR_ZERO_VELOCITY) 항목 경계 근처(BOUNDARY_PX)일 때 → 스냅
  • 속도가 스냅 임계값(SNAP_VELOCITY_THRESHOLD) 이하일 때 → 무조건 스냅

이렇게 하면 항목 위를 천천히 지나가는 경우에도 자연스럽게 가장 가까운 항목에 안착합니다.


3단계: 스냅 — 가장 가까운 항목에 정확히 맞추기

감속이 끝나면 현재 위치가 항목과 항목 사이 어중간한 곳에 있을 수 있습니다. 예를 들어 항목 간격이 64px인데 현재 위치가 150px이라면, 2번째 항목(128px)과 3번째 항목(192px) 사이에 걸쳐 있는 셈입니다.

이때 가장 가까운 항목 위치로 부드럽게 이동시킵니다.

const SNAP_FORCE = 0.2

const animateSnap = (current: number) => { if (Math.abs(current - targetPosition) < 0.5) { // 도착 — 정확한 위치로 고정하고 onChange 호출 updateScrollY(normalizeScrollY(targetPosition)) onChangeRef.current(finalIndex) } else { // 남은 거리의 20%씩 이동 const next = current + (targetPosition - current) * SNAP_FORCE requestAnimationFrame(() => animateSnap(next)) } }

보간 비율 0.2의 선정 근거

(목표 - 현재) * 0.2는 매 프레임마다 남은 거리의 20%만 이동한다는 뜻입니다. 처음에는 남은 거리가 크니까 이동량도 크고, 가까워질수록 이동량이 줄어듭니다. 이른바 ease-out 효과가 별도의 easing 라이브러리 없이 곱셈 한 번으로 만들어집니다.

이 비율도 여러 값을 비교해서 결정했습니다:

┌──────┬──────────────────────┬────────────────────────┐ │ 비율 │ 60fps 기준 소요 시간 │ 체감 │ ├──────┼──────────────────────┼────────────────────────┤ │ 0.1 │ ~0.53초 │ 느리고 답답합니다 │ ├──────┼──────────────────────┼────────────────────────┤ │ 0.2 │ ~0.25초 │ 자연스럽고 쾌적합니다 │ ├──────┼──────────────────────┼────────────────────────┤ │ 0.33 │ ~0.15초 │ 너무 빠르고 딱딱합니다 │ └──────┴──────────────────────┴────────────────────────┘

0.2일 때 약 15프레임(0.25초)에 완료되는데, 사용자가 "부드럽게 착지했다"고 느끼기에 적절한 시간입니다.


물리 상수의 스케일링 — 크기가 다른 피커에서 동일한 조작감 유지

오픈런에는 크기가 다른 두 종류의 피커가 있습니다:

  • ChainPicker: 러닝 횟수, 페이스 선택에 사용. 항목 높이 64px, 7개 항목이 한 화면에 보입니다.
  • TimePicker: 시간 선택에 사용. 항목 높이 40px, 3개 항목이 보입니다.

같은 마찰 계수, 같은 최대 속도를 두 피커에 그대로 적용하면, 작은 피커에서는 같은 속도로도 상대적으로 더 많은 칸을 지나가게 됩니다. 이를 해결하기 위해 기준 높이(64px) 대비 실제 항목 높이의 비율로 물리 상수를 스케일링합니다.

const BASE_ITEM_HEIGHT = 64 const scale = itemHeight / BASE_ITEM_HEIGHT // TimePicker: 40/64 = 0.625

const physics = { FRICTION: 0.95, // 마찰은 비율이므로 스케일링 불필요 SNAP_FORCE: 0.2, // 스냅도 비율이므로 동일 MAX_VELOCITY: 1.62 * scale, // 최대 속도를 비례 축소 NEAR_ZERO_VELOCITY: 0.2 * scale, SNAP_VELOCITY_THRESHOLD: 0.04 * scale, BOUNDARY_PX: 4 * scale, }

TimePicker는 scale = 0.625이므로 최대 속도와 각종 임계값이 약 62.5%로 줄어듭니다. 마찰 계수(FRICTION)와 스냅 비율(SNAP_FORCE)은 비율 값이기 때문에 스케일링하지 않습니다. 이렇게 하면 항목 크기가 다른 피커에서도 "같은 세기로 스와이프했을 때 비슷한 칸수를 이동하는" 일관된 경험을 제공할 수 있습니다.


렌더링: 서브픽셀 오프셋으로 매끄러운 연속 스크롤

기존 NumberDial은 선택된 값이 바뀔 때 항목이 한 칸씩 "뚝뚝" 점프했습니다. 중간 상태가 없었습니다. 새 방식에서는 scrollY를 정수가 아닌 실수(float)로 관리하면서, 소수점 이하의 위치까지 렌더링에 반영합니다.

const centerFloat = scrollY / itemHeight // 예: 2.3 const centerIndex = Math.round(centerFloat) // 예: 2 (가장 가까운 항목) const subPixelOffset = (centerFloat - centerIndex) * itemHeight // 예: 0.3 × 64 = 19.2px

subPixelOffset은 현재 중심이 정확한 항목 위치에서 얼마나 벗어나 있는지를 픽셀 단위로 나타냅니다. 이 값이 0px에서 63px 사이를 연속적으로 변화하면서, 각 항목의 translateY에 실시간으로 반영됩니다.

{Array.from({ length: 7 }, (_, i) => i - 3).map((slot) => { const yPos = slot * ITEM_HEIGHT - subPixelOffset + CENTER_Y

plain text
Copied
1return (2  <div3    style={{ transform: `translateY(${yPos}px)` }}4  >5    {/* 항목 내용 */}6  </div>7)

})}

거리 기반 불투명도

여기에 중앙에서의 거리에 따른 불투명도를 적용하여, 선택된 항목은 선명하게, 멀어질수록 자연스럽게 사라지는 효과를 줍니다.

const dist = Math.abs(slot - subPixelOffset / ITEM_HEIGHT) const opacity = Math.min(1, Math.pow(0.2, dist))

┌─────────────────────┬──────────┐ │ 중앙으로부터의 거리 │ 불투명도 │ ├─────────────────────┼──────────┤ │ 0 (선택됨) │ 1.0 │ ├─────────────────────┼──────────┤ │ 1칸 │ 0.2 │ ├─────────────────────┼──────────┤ │ 2칸 │ 0.04 │ ├─────────────────────┼──────────┤ │ 3칸 │ 0.008 │ └─────────────────────┴──────────┘

기존 NumberDial에서는 distance === 0이면 text-primary, distance === 1이면 rgba(74,92,239,0.18), 그 외에는 rgba(74,92,239,0.04)로 3단계로 끊어져 있었습니다. 새 방식에서는 불투명도가 지수 함수로 연속적으로 변하기 때문에, 스크롤 도중에도 항목들이 부드럽게 나타나고 사라집니다.


구조: useChainScroll 훅 하나로 모든 피커 통일

위의 모든 물리 로직(속도 측정, 관성 감속, 스냅, 경계 처리, 스케일링)은 useChainScroll이라는 커스텀 훅 하나에 캡슐화되어 있습니다.

useChainScroll (물리 엔진 훅) ├── ChainPicker (러닝 횟수 · 페이스 선택 — 64px 항목, 7개 표시) └── TimePicker > Picker (시간 선택 — 40px 항목, 3개 표시)

각 피커는 totalItems, itemHeight, wrap 여부, initialIndex, onChange 콜백만 넘기면 됩니다.

// ChainPicker에서의 사용 const { scrollY, centerIndex, subPixelOffset, containerRef, handlers } = useChainScroll({ totalItems, itemHeight: 64, wrap: true, initialIndex: value - min, onChange: (index) => onChange(min + index), })

// TimePicker에서의 사용 const { centerIndex, subPixelOffset, containerRef, handlers } = useChainScroll({ totalItems: options.length, itemHeight: 40, wrap: true, initialIndex: options.indexOf(initialValue), onChange: (index) => onChange(options[index]), })

기존에는 NumberDial + useNumberDial이 페이스/횟수 피커 전용이었고, TimePicker 내부에 거의 동일한 스크롤 로직이 별도로 중복 구현되어 있었습니다. 이제 훅 하나로 통일되면서 두 곳의 스크롤 경험이 완전히 동일해졌고, 컴포넌트 사용 코드도 훨씬 간결해졌습니다.

예를 들어 러닝 횟수 선택 컴포넌트(Frequency)의 변화를 보면:

// Before: 10개의 핸들러를 일일이 넘겨야 했습니다 <NumberDial value={value} min={0} max={7} digits={1} handleTouchStart={handleTouchStart} handleTouchMove={handleTouchMove} handleTouchEnd={handleTouchEnd} handleMouseDown={handleMouseDown} handleMouseMove={handleMouseMove} handleMouseUp={handleMouseUp} handleWheel={handleWheel} />

// After: 값과 범위만 넘기면 됩니다 <ChainPicker value={frequency} min={0} max={7} onChange={(val) => setFrequency(val as WeekCount)} wrap={true} renderItem={(val) => (val === 7 ? '7 +' : String(val))} />


향후 개선 방향: Spring 물리 도입

현재 스냅 단계의 보간은 "남은 거리의 일정 비율만큼 이동"하는 지수 감소 방식입니다. 충분히 자연스럽지만, 엄밀히 말하면 실제 물리 법칙과는 차이가 있습니다.

다음 단계로는 Spring(스프링) 물리를 도입하는 것을 고려하고 있습니다. 스프링 물리를 적용하면:

  • 거리가 멀수록 더 강한 힘으로 당겨져 빠르게 가까워집니다
  • 목표 지점에 가까워질수록 속도가 자연스럽게 줄어듭니다
  • *댐핑(damping)**을 적용하면 목표를 살짝 지나쳤다가 돌아오는 미세한 오버슈팅 효과도 표현할 수 있습니다

이런 디테일이 더해지면 기계적인 느낌이 아닌, 마치 실제 물체를 만지는 듯한 촉감에 한 발짝 더 가까워질 수 있을 것입니다.

  • 물리법칙을 통한 ChainPicker 컴포넌트 UX 개선기