
저희 오픈런 서비스에는 평균 페이스를 선택하는 페이지에 숫자를 고르는 NumberPicker 컴포넌트가 있습니다. 위아래로 드래그하면 숫자가 바뀌고, 손을 떼면 멈추는, iOS에서 흔히 볼 수 있는 스크롤형 숫자 선택기입니다. 이슈도 한 번도 발생하지 않은 기능적으로는 문제가 없는 컴포넌트였습니다.
하지만 써보면 iOS 기본 피커와의 차이가 바로 느껴졌습니다. iOS 피커는 손을 떼도 관성에 의해 미끄러지듯 움직이다가 천천히 멈추는데, 저희 피커는 손을 떼는 순간 딱 멈춰버렸습니다. 마치 기름칠 안 된 다이얼을 돌리는 느낌이었습니다.
원인을 파고 들어가 보니 구조 자체에 문제가 있었습니다.
터치가 끝나면 현재 드래그 위치를 0으로 초기화하고 끝입니다. 손을 떼면 "아직 남은 움직임"이란 개념 자체가 없으니, 관성 없이 그 자리에서 뚝 끊기는 느낌이 날 수밖에 없었습니다. 거기에 값 변경도 64px(항목 하나의 높이)을 넘겨야 한 칸 이동하는 계단식이라 드래그하는 중간에는 아무 시각적 변화가 없었습니다. 불투명도도 선택된 항목, 한 칸 떨어진 항목, 두 칸 떨어진 항목 — 이렇게 세 단계로만 하드코딩되어 있어서 전환이 뚝뚝 끊겼습니다.
아쉬운 점을 정리하자면 다음과 같습니다.
기존 코드의 근본 문제는 드래그한 거리로 숫자 값을 직접 바꾸려 했다는 겁니다. 드래그 누적 거리가 64px을 넘으면 숫자를 1 올리고 거리를 0으로 초기화하는 식이었습니다. 숫자는 정수(1, 2, 3…)니까 중간 상태가 없고, 그래서 부드러운 전환도, 관성도 끼워 넣을 곳이 없었습니다.
비유하자면, 기존 구조는 손가락을 64px 움직여야 "턱" 하고 한 칸 넘어가는 리모컨 버튼 같은 것이었습니다. 1과 2 사이에 1.5라는 상태가 없으니 중간 과정이 보이지 않는 거죠.
발상을 바꿨습니다. 숫자를 직접 건드리지 않고, 먼저 화면 위치(scrollY, 픽셀 단위)를 부드럽게 움직인 다음, 그 위치가 어디인지 보고 "지금 몇 번째 숫자인지"를 계산하는 구조로 뒤집는 겁니다. 리모컨 버튼이 아니라, 물리적으로 굴러가는 바퀴를 만드는 것입니다.
입력 → scrollY(px) 반영 → 속도·마찰로 관성 감속 → 가장 가까운 항목에 스냅 → scrollY에서 값·시각 효과 파생
scrollY를 항목 높이로 나누면 "지금 몇 번째 항목 근처에 있는지"가 소수점으로 나옵니다. 예를 들어 scrollY가 96px이고 항목 높이가 64px이면 96 / 64 = 1.5, 즉 1번과 2번 항목의 정확히 중간입니다. 이 소수점 부분(subPixelOffset)을 활용하면 드래그 중간에도 화면이 매끄럽게 따라오고, 불투명도나 위치 같은 시각 효과도 연속적으로 계산할 수 있게 됩니다. 이렇게 하니까 계단식 전환이 알아서 사라졌습니다. 값을 직접 조작하던 구조에서 위치를 모델링하는 구조로 바꾼 것뿐인데, 여러 문제가 한꺼번에 풀린 셈입니다.
관성을 구현하려면, 손을 떼는 시점에 "손가락이 얼마나 빠르게 움직이고 있었는지"를 알아야 합니다. 처음엔 드래그 시작부터 끝까지의 전체 평균 속도를 사용했는데, 이러면 천천히 움직이다가 마지막에 탁 튕기는 동작을 잡아내지 못합니다.
이 부분에서 시간을 많이 투자했던 기억이 있습니다. 분명히 빠르게 튕겼는데 피커는 느긋하게 굴러가고, 천천히 놓았는데 생각보다 많이 굴러가기도 하고. 예를 들어, 500ms 동안 천천히 올리다가 마지막 100ms에 확 튕기고 손을 떼면, 전체 평균은 "보통 속도"가 됩니다. 하지만 사용자가 기대하는 건 마지막 튕김에 맞는 빠른 관성일 것입니다. 사용자의 의도와 계산 결과가 계속 어긋나는 게 느껴지는데 원인을 바로 못 짚어서 한참 콘솔에 속도값을 찍어보다가, 결국 "전체 평균이 문제"라는 걸 깨달았습니다.
드래그하는 동안 손가락 위치를 시간과 함께 계속 기록하되, 최근 100ms 안의 기록만 남기고 나머지는 버리는 방식으로 바꿨습니다.
손을 떼는 시점에 이 기록들로 속도를 계산하면, 마지막 손가락 움직임의 방향과 세기가 그대로 반영됩니다. 추가로 MAX_VELOCITY(최대 속도 제한)를 걸어서, 아무리 빠르게 튕겨도 한 프레임에 여러 항목을 건너뛰어 버리는 현상을 막았습니다.
속도를 얻었으면, 손을 뗀 후에도 그 속도로 화면 위치(scrollY)를 계속 밀어주면 됩니다. 다만 영원히 굴러가면 안 되니, 매 프레임마다 속도에 마찰 계수를 곱해서 점점 느려지도록 했습니다.
매 프레임 속도에 0.95를 곱하면 100 → 95 → 90.25 → 85.7 → … 처럼 처음엔 빠르게 줄고, 나중엔 천천히 줄어듭니다. 실제로 공을 굴렸을 때의 감속 곡선과 비슷한 형태라 자연스럽게 느껴집니다. 만약 매 프레임 일정하게 5씩 빼는 방식이었다면, 멈추기 직전에 갑자기 뚝 끊기는 느낌이 났을 겁니다.
여기서 dt는 이전 프레임과 현재 프레임 사이의 시간 간격입니다. 초당 60프레임(60Hz)인 화면은 한 프레임이 약 16ms이고, 초당 120프레임(120Hz)인 화면은 약 8ms입니다. 속도에 이 시간 간격을 곱해주면, 화면 주사율이 달라도 같은 시간 동안 같은 거리를 이동하게 됩니다. 프레임 독립적(frame-independent)이라고 부르는 방식인데, 이걸 안 하면 120Hz 디바이스에서 피커가 두 배 빠르게 굴러가는 황당한 상황이 벌어집니다.
관성으로 굴러가던 피커가 어느 시점에서 멈추고, 가장 가까운 숫자 항목에 딱 맞춰 정렬되어야 합니다. 이 "딱 맞추는 동작"을 스냅이라고 합니다.
처음엔 단순히 속도가 충분히 작아지면 스냅하도록 했습니다. 그런데 두 항목의 중간 지점에서 갑자기 가까운 쪽으로 순간이동하듯 튀는 경우가 생겼습니다. 속도가 거의 0인데 1번 항목과 2번 항목의 정확히 중간에 멈춰버리면, 가까운 쪽으로 "턱" 점프해버리는 거죠. 관성까지 열심히 구현해놓고 마지막 안착이 이러면 전부 헛수고처럼 느껴졌습니다.
그래서 속도와 항목 경계까지의 거리, 이 두 가지를 같이 보는 것으로 바꿨습니다.
"느리면서 항목 경계에 가까울 때" 스냅하면 관성으로 자연스럽게 항목 근처까지 온 뒤에 안착하게 됩니다. 그리고 혹시 중간 지점에서 너무 오래 떠다니는 경우를 대비해, "아주 느릴 때"는 위치에 관계없이 스냅하는 안전장치도 넣었습니다.
스냅 자체도 즉시 점프가 아니라 지수 보간으로 처리해서 마지막 안착까지 부드럽게 만들었습니다.
매 프레임 목표까지 남은 거리의 20%만큼만 이동합니다. 남은 거리가 100px이면 20px 이동하고, 그다음은 80px에서 16px, 그다음은 64px에서 12.8px… 이런 식으로 처음엔 크게 움직이고 목표에 가까워질수록 미세하게 접근합니다. 자석에 끌리듯 착 달라붙는 느낌은 결국 이 20%짜리 보간에서 나옵니다.
화면 위치(scrollY)가 소수점을 가지니까, 각 숫자 항목이 현재 선택 위치로부터 얼마나 떨어져 있는지도 소수점으로 나옵니다. 이 거리를 불투명도 계산에 그대로 넣으면 드래그 중간 프레임에서도 항목들의 진하기가 실시간으로 변합니다.
기존에는 선택된 항목 = 100% 진하기, 한 칸 떨어진 항목 = 18%, 두 칸 떨어진 항목 = 4%로 세 단계뿐이었습니다. 스크롤하다가 경계를 넘는 순간 갑자기 18%에서 100%로 확 바뀌니 전환이 뚝뚝 끊겼습니다. 연속 함수로 바꾸니 거리가 0.3칸이면 0.3에 맞는 진하기, 0.7칸이면 0.7에 맞는 진하기가 적용됩니다. 물리 엔진이 매 프레임 scrollY를 바꾸고, 렌더링이 그 scrollY를 받아서 불투명도를 계산하니까, 움직임과 시각 피드백 사이에 끊김이 없어졌습니다.
물리 시뮬레이션을 React 안에서 돌리면서 몇 가지 함정이 있었습니다. 솔직히 물리 로직 자체보다 React와의 궁합 맞추기가 더 까다로웠습니다.
화면 위치(scrollY)와 속도(velocity)는 매 프레임 업데이트되는 값입니다. React에서 setState를 호출하면 컴포넌트 전체가 다시 그려지는데(리렌더), 1초에 60~120번 호출하면 당연히 버벅입니다. 그래서 물리 계산에 쓰이는 값은 ref로 관리하고(ref는 값이 바뀌어도 리렌더를 유발하지 않음), 실제로 화면에 그려야 하는 값만 state로 올리는 구조로 분리했습니다.
숫자가 바뀔 때 부모 컴포넌트에 알려주는 onChange 콜백이 있는데, 이 콜백이 관성 애니메이션 도중에 호출됩니다. 문제는 React에서 함수가 렌더링할 때마다 새로 만들어진다는 점입니다. 애니메이션이 시작될 때 잡아둔 onChange는 300ms 뒤에는 이미 옛날 버전일 수 있고, 옛날 버전의 함수는 옛날 상태를 참조합니다. 이걸 stale closure(오래된 클로저)라고 합니다. ref에 항상 최신 콜백을 넣어두면, 애니메이션 중 언제 꺼내 쓰든 최신 버전이 보장됩니다.
모바일 브라우저에서는 손가락으로 화면을 쓸면 기본적으로 페이지 전체가 스크롤됩니다. 피커를 드래그하는데 페이지까지 같이 움직이면 안 되니까 preventDefault()로 기본 스크롤을 막아야 합니다. 그런데 React의 onTouchMove는 기본적으로 "이 이벤트는 스크롤을 막지 않을 거야"라고 브라우저에 선언(passive: true)하기 때문에 preventDefault()가 무시됩니다. useEffect 안에서 addEventListener를 직접 호출하면서 passive: false를 명시해야 브라우저에게 "스크롤 막을 거야"라고 알려줄 수 있습니다.
정리. 컴포넌트가 화면에서 사라졌는데(언마운트) 관성 애니메이션 루프는 계속 돌고 있으면, 이미 없어진 DOM 요소를 업데이트하려다 에러가 발생합니다. useLayoutEffect의 cleanup 함수에서 반드시 cancelAnimationFrame을 호출해서 컴포넌트가 사라질 때 애니메이션도 함께 정리해야 합니다.
물리 로직이 특정 컴포넌트 안에 들어있으면 다른 피커에서 재사용할 수 없습니다. 물리 상수와 항목 높이(itemHeight)만 외부에서 주입받는 useChainScroll이라는 커스텀 훅으로 분리했더니, 기존에 NumberDial을 쓰던 ChainPicker뿐 아니라 시간 선택에 쓰이는 TimePicker에서도 동일한 물리 감각을 공유할 수 있게 됐습니다.
컴포넌트는 "어떻게 그릴지"에만 집중하고, "어떻게 움직일지"는 훅이 전담하는 구조입니다. 항목 높이나 마찰 계수를 바꾸면 같은 훅으로 다른 느낌의 피커도 쉽게 만들 수 있습니다.
잘 만들어져있는 컴포넌트를 주로 가져다 쓸 생각만 했지, 직접 구현을 0부터 1까지 마무리 해 본 적은 처음이었습니다. 완벽에 가까운 UX를 제공하기 위해서는 생각보다 간단하지 않은 요소들이 여전히 존재한다는 것을 깨달을 수 있는 계기가 되었던 것 같습니다.
돌이켜보면 숫자를 직접 바꾸는 대신 화면 위치를 모델링하는 구조 전환, 마찰 0.95라는 숫자 하나, ref와 state를 역할에 맞게 나누는 React 패턴. 이 세 가지가 "작동은 하는데 어색한 피커"와 "손에 감기는 피커"의 차이를 만들었습니다. 비록 지금은 ChainPicker와 TimePicker 두 곳에서만 쓰고 있지만, 이 훅의 물리 상수를 조절하면 슬라이더나 캐러셀 같은 다른 드래그 인터랙션에도 적용할 수 있을 거라고 생각합니다. 다만 스냅 로직의 임계값(0.2, 0.04, 4px)은 아직 손으로 튜닝한 매직 넘버라서, 항목 크기가 달라지면 다시 만져야 할 부분입니다.