속도 기반 모멘텀 스크롤 직접 구현하기

뮤돔면·2025년 8월 17일
post-thumbnail

디자인 시스템의 속도 기반 모멘텀 스크롤을 직접 구현하여 UX를 개선한 사례를 참고차 공유합니다. 사용자 경험에 집착하시는 우리 FE 개발자분들에게 조금이나마 도움이 되기를 바랍니다.


ChainPicker 컴포넌트

째깍악어 FE팀에서 개발한 @dotss/ui에는 ChainPicker 라는 독특한 컴포넌트가 있습니다. 사용자가 직접 마우스 클릭이나 터치를 통해 체인을 돌리는 동작에서 착안한 이 컴포넌트는 사실 웹 서비스에서는 쉽게 볼 수 없는 컴포넌트이기도 합니다. 모바일 개발자분들은 iOS의 UIPickerView를 떠올려 보면 쉽게 디자인을 짐작하실 수 있으실 겁니다.

째깍악어 부모님 앱에서 사용하는 ChainPicker 컴포넌트
ChainPicker 사용사례

해당 컴포넌트를 구현할 당시에는 빠른 구현을 위해 스냅(최종 항목을 결정) 단계에서만 선형적인 보간 기법을 활용했었습니다. 스냅 기능에만 적용된 거리 기반의 모멘텀 스크롤인 이 방식은, 비즈니스 요구사항을 충족시키는 데에는 충분했고, 또 제한된 개발 기간 내에서는 가장 최적의 타협점일 수 밖에 없었습니다. 관성 적용을 위해 외부 애니메이션 라이브러리를 끌고 들어오는 상황은 피하고 싶었지만 너무 어색한 UX를 제공하고 싶지는 않고 싶다는 욕심에, 찝찝하지만 단순히 사용자가 스크롤한 거리를 기반으로 관성을 적용하는 단순한 형태의 방식을 택한 것입니다.

디자인 팀에서 사용성 검토를 마친 후에는 ChainPicker를 웹(앱)에서 사용해왔습니다. 하나의 화면 혹은 모달 내에서 단 건의 항목을 선택해야 하는 경우에 주로 이 ChainPicker가 선택되었습니다. 특히 위의 사례처럼 날짜나 시간처럼 항목의 값들이 연속적인 경우 이 컴포넌트는 아주 매력적인 선택지였습니다.


투박한 스크롤의 한계

태생적으로 한계가 있던 스크롤을 섣부르게 적용한 탓일까요, 배포 후 몇가지 사용성 이슈들이 눈에 강하게 들어왔습니다.

(구)ChainPicker 컴포넌트의 동작
과거의 Chainpicker

1. 실제 물리 법칙과 차이가 있었습니다.

  • 스냅 과정에서 거리 기반의 선형적 보간을 사용하기는 하지만 스크롤 과정에는 마찰력이 작용하지 않아 부자연스러운 느낌이 들었습니다.

2. 긴(강한) 스크롤링이 불가능했습니다.

  • 스크롤의 강도가 ChainPicker 영역의 크기에 큰 영향을 받았습니다.ChainPicker의 크기가 작은 경우에는 위치 변화량이 작을 수 밖에 없었고, 사용자가 한번에 스크롤으로 ChainPicker를 돌릴 수 있는 한계가 분명했습니다.
  • ChainPicker의 내부 요소를 커스텀하지 않은 경우 각 요소의 최소 높이값이 54px인데, 한번의 스크롤로 2칸 이상을 돌리기 어려웠습니다. 따라서 요소가 많은 경우 원하는 값을 선택하기 위해 스크롤을 여러번 수행해야할 수 밖에 없었습니다.

이러한 지점들은 분명히 사용자 경험을 저해했습니다. 더구나 해당 컴포넌트가 웹뿐만 아니라 웹앱 환경, 즉 모바일 환경에서도 사용되었기에 사용자가 체감하는 완성도는 더 낮을 수밖에 없었습니다. 비교 대상이 네이티브단의 UI 컴포넌트일 것이기 때문입니다.

결국 돌아보면 이러한 이슈들은 '관성의 부재'에서 기인한 것들이었습니다. 그래서 저는 '속도' 기반의 모멘텀 스크롤을 추가하기로 결정하고 작업에 착수했습니다. 외부의 애니메이션 라이브러리 사용은 최소화하고 싶었기에, 직접 모멘텀 스크롤을 구현하기로 결정했습니다. 물리 법칙에 대해서는 아무것도 모르는 상태였지만, (1) 스크롤링의 강도(=최초 속도)를 포착하고, (2) 감속시킨다의 기본적인 공식만 구현하면 될 것 같았습니다.


속도를 포착하다

사용자의 스크롤링 강도를 포착하는 것에서부터 모멘텀 스크롤이 시작되었습니다. 즉, 체인이 돌아가기 시작하는 시점의 최초 속도를 포착하는 것이 관건이었습니다. 속도는 결국 이동 거리를 시간으로 나눈 것이기에, 특정 지점에서의 속도 관련 지표들을 지속적으로 추적하는 것이 필요해보였습니다. 스크롤이 종료되는 현재 시점의 값들(시간, y좌표)을 안다 해도, 과거 시점의 값들에 접근할 수 있어야 정확한 속도를 계산할 수 있었기 때문입니다.

그래서 VelocitySample이라는 인터페이스를 하나 추가하고, 해당 객체의 배열을 컴포넌트 내에서 useState로 관리하는 방식을 채택했습니다.

// 이하 코드는 실제 코드를 축약해 재구성한 코드입니다.

export interface VelocitySample {
  time: number;
  y: number;
}

// ChainPicker.tsx 중
const [velocitySamples, setVelocitySamples] = useState<Array<VelocitySample>>([]);

해당 속도 샘플들은 체인피커에서 mousemove 혹은 touchmove 이벤트가 호출될 때 수집했습니다. 이때 가장 고민되었던 지점은 몇 개의 속도 샘플들을 관리할 것인가였습니다.

const MAX_HOLDING_VELOCITY_DURATION = 100

// 이벤트 핸들러 내부
const now = performance.now(); // 더 정밀한 계산을 위해 performance.now를 사용했습니다.
setVelocitySamples((prev) =>
      [...prev, { time: now, y: clientY }].filter((sample) => now - sample.time <= MAX_HOLDING_VELOCITY_DURATION)
);

상기 코드에서 확인하실 수 있는 것처럼 샘플의 구체적인 개수가 아닌, 샘플을 들고 있는 최대 시간을 고정하는 것으로 결정했습니다. 처음에는 '몇 개의 샘플을 가지고 있어야 할까'에 꽂혀 아래와 같은 고민을 했습니다. (아래는 제 사고 과정을 글로 표현하기 어려워 적나라하게 서술해본 것입니다...)

너무 많은 샘플을 들고 있으면 메모리 이슈 & 너무 오래된 샘플이 스크롤 속도에 영향을 줄 수 있다. 너무 적은 샘플들을 가지고 있는 경우에도 몇 개 튀는 샘플들(outlier)이 속도에 큰 영향을 줄 수 있다. 모두 사용자가 의도한 강도(속도)를 적절하게 포착하지 못하는 것이다... 사용자의 의도...? 사용자의 의도는 샘플의 개수가 아니라 스크롤한 시간이 더 잘 포착하는 것 아닌가? 샘플 개수와 스크롤 강도는 사실 관련이 없잖아! 스크롤한 시간 동안 얼마나 이동했는지가 핵심이니까...!

결국 사용자의 스크롤 강도의 핵심은 속도 샘플의 개수가 아닌 특정 시간동안의 샘플에 있는 것 같았습니다. 좀더 생각해보면 샘플의 개수는 이벤트 핸들러의 호출 횟수의 영향을 받을 수 밖에 없습니다. 기기마다 주사율도, 동작 환경도 다를 수 있기에 이벤트 핸들러의 호출 횟수는 현실적으로 기기마다 차이가 있을 수 밖에 없습니다. 때문에 좀더 견고하고 정확하게 속도값을 계산하려면 시간값이 고정되어야 한다는 생각이었습니다.

  const calculateVelocity = (samples: VelocitySample[]): number => {
    const firstSample = samples[0];
    const lastSample = samples[samples.length - 1];
    const time = lastSample.time - firstSample.time;
    const distance = lastSample.y - firstSample.y;
    const velocity = distance / time;

    return Math.max(
      -MAX_VELOCITY,
      Math.min(MAX_VELOCITY, velocity)
    );
  };

다시 속도값 포착으로 돌아오겠습니다. 속도값은 위와 같은 연산을 통해 계산됩니다. velocitySamples의 첫번째 샘플과 마지막 샘플을 활용해 속도값을 계산하는 것입니다. 첫번째와 마지막 샘플을 사용하므로써 단위 시간이 0.1초 근사값으로 고정될 수 있습니다. 이때 계산된 속도값은 MAX_VELOCITY의 범위를 넘어가지 않도록 설정했습니다. 처음에는 이 제한을 두지 않았는데, 실제로 구현해 확인해보니 속도가 지나치게 빠른 경우 체인이 돌아가지 않는 것처럼 보이기도 했습니다. 실제로는 매우 빠르게 돌아간 것인데도 말이죠! 그래서 체인이 돌아갈 수 있는 최대 속도 제한을 둠으로써 좀더 자연스럽게 보일 수 있도록 했습니다.


감속하다

최초 속도를 포착했으니, 이제는 이 속도를 자연스럽게 감속해야할 차례입니다. 현실 세계에서 체인이 돌아가다가 멈추는 과정으로 떠올려보면, 처음에는 빠르게 돌다가 마찰력에 의해 점점 느려지고 정지하게 됩니다. 이 물리 법칙을 웹에서 구현하는 것이 핵심이었습니다.

// 이벤트 핸들러 발췌 
const handleEnd = (e: MouseEvent | TouchEvent) => {
  {...}
  const initialVelocity = calculateVelocity(velocitySamples);
  applyMomentumScroll(initialVelocity);
}

최초 속도를 계산했습니다. 이 최초 속도는 applyMomentumScroll 함수에 전달되어 점점 감소되어야 합니다. 이 감속 과정에는 마찰 계수를 사용했습니다. 매 프레임마다 현재 속도에 마찰 계수를 곱해주는 것으로 점진적인 감속을 구현했습니다(실제 물리 법칙에서 마찰력은 이렇게 단순하게 동작하지는 않는다고 합니다!).

const FRICTION = 0.95; // 시행착오를 거쳐 0.95라는 값에 정착했습니다

velocity *= FRICTION;

이렇게 계산된 속도가 특정 임계점보다 작아지면 최종 항목을 결정하는 스냅 과정으로 진입하도록 했습니다. 이는 사용자가 의도한 최종 선택값이 모호해지는 것을 방지하기 위함이었습니다. 아무리 자연스럽더라도 결국은 명확한 값 선택이라는 목적을 달성해야 했으니까요!

const SNAP_VELOCITY_THRESHOLD = 0.006 // 0.1px/frame의 속도입니다!

if (Math.abs(velocity) > SNAP_VELOCITY_THRESHOLD) {
  requestAnimationFrame(animate);
} else {
  snapToClosestItem(currentPosition);
}

이 스냅 과정은 기존의 거리 기반 보간 기법을 그대로 사용했습니다. 프레임별 이동 강도(비율)도 이전과 동일하게 0.2로 설정했습니다. 일반적인 60fps의 주사율의 기기, ChainPicker의 각 항목의 기본 높이값(27px)을 가정하고 최적의 값(가장 자연스러운 값)을 도출한 결과입니다. 간단하게 절차를 재구성해보자면 아래와 같습니다.

(1) 스크롤을 놓는 마지막 위치에서 가장 가까운 항목과의 최대 거리는 54/2, 즉 27px
(2) 최대 거리인 27px을 매 프레임마다 줄여나가야 한다.
(3) 0.1, 0.2, 0.33의 세 값을 비교해보자.

(결과)
0.1인 경우: 32프레임만에 종료, 60fps 기준 0.53초만에 멈춰버린다!
0.2인 경우: 15프레임만에 종료, 60fps 기준 0.25초만에 멈춰버린다!
0.33인 경우: 9프레임만에 종료, 60fps 기준 0.15초만에 멈춰버린다!
=> 0.2가 가장 자연스럽게 느껴질 수 있다.

결과적으로 이전과는 확연히 다른 사용자 경험을 제공할 수 있게 되었습니다. 이제 사용자는 한 번의 강한 스와이프로도 여러 항목을 한번에 넘길 수 있고, 물리 법칙에 따른 자연스러운 감속으로 인해 훨씬 더 직관적인 조작감을 느낄 수 있게 되었습니다! 실제 아래 동작을 보세요 🌈

(신)ChainPicker 컴포넌트의 동작
새로운 Chainpicker

가운데 선택된 항목의 바로 상/하의 항목을 클릭(터치)하면 곧바로 해당 항목으로 스크롤될 수 있도록 추가적으로 구현해두었습니다.
항상 스크롤링해야 해서 불편하다는 사용자 피드백이 몇 건 인입되었거든요.
일반 클릭(터치)와 스크롤은 시간과 속도를 통해 결정됩니다(임계값을 넘어가면 스크롤로 처리돼요).


추후 더 개선할 지점

커멘트가 장황해서 미안해요...
너무너무 긴 커멘트

최근 p5.js 관련 책을 읽으면서 GPT 선생님의 도움을 받아 물리 엔진을 구현해보는 연습을 하고 있습니다(링크. 그중 Spring이라는 개념을 공부했는데, 마지막 스냅 과정에 이 Spring 법칙을 적용하면 더 현실감 있어질 것 같다는 생각입니다.

현재는 단순하게 남은 거리를 선형적으로 감소시키고 있는데, 이는 물리 법칙과는 분명히 어긋나는 지점이기는 합니다. 실제 물리 법칙과 유사해지려면 최종 요소와 거리가 멀수록 더 빠르게 가까워지고, 점점 거리가 줄어들면 속도도 같이 줄어들어야 합니다. 댐핑도 적용되어 살짝 최종 요소를 지나쳤다가 복귀하는 등의 동작도 추가되어야 조금더 현실적인 체인 동작에 가까워질 것입니다. 이러한 미묘한 지점들이 UX 개선과도 맞물려 있는 지점이겠죠.

조금더 공부해서 이런 것들도 같이 개선해보고 싶다는 마음입니다. 점점 깊어질수록 수학과 물리학에 가까워지는 듯한 느낌이 듭니다.

profile
🚚​🚛​ 블로그를 이전했습니다 > https://ksr20612.github.io/blog/

0개의 댓글