react-ranger 뜯어보기(feat. Headless UI)

기록일기📫·2023년 11월 18일
0

Range Slider는 사용자가 고정 옵션 집합에서 값 또는 범위를 선택할 수 있는 UI입니다. 볼륨이나 화면의 밝기, 필터에서 많이 사용됩니다.

이번 포스팅에서는 tanstack팀에서 제공하는 react-ranger라는 라이브러리를 소개할 예정인데요. 이 라이브러리는 Range Slider를 Headless UI의 형태로 제공합니다.

Range Slider를 실제로 사용해보고, 이후 내부 코드를 살펴보며 Headless 형태의 라이브러리가 사용자에게 어떤 장점을 제공하는지, 그리고 이러한 사용성을 제공하기 위해 interface는 어떻게 설계 되었는지 살펴보겠습니다.

Headless UI

본격적으로 살펴보기 전에, 우선 Headless UI의 정의에 대해 알아보겠습니다. Headless UI란 무엇일까요?

Headless UI란 컴포넌트의 별도 UI를 제공하지 않고, '동작'에 대한 기능만 제공해주는 라이브러리를 말합니다. 쉽게 말하자면 스타일 없이 기능만 제공해주는 라이브러리라고도 할 수 있습니다. 대표적으로 Radix-UI, Headless-UI, tanstack/table등이 있습니다.

프론트엔드 개발을 하다 보면 기능 요구사항만큼 중요한 것이 디자인 요구사항인데요. 같은 기능을 제공 하더라도 UX/UI에 따라 사용자가 느끼는 제품의 퀄리티가 많이 달라지기 때문에, 디자인 구현에 쏟는 시간이 적지 않은 것 같습니다.

개발을 하다 보면 일정, 편의, 안정성 등 다양한 이유로 라이브러리를 적용하는 경우가 많습니다. 이 때 스타일이 포함되어 제공되는 라이브러리를 사용하는 경우에는 css 오버라이드를 통해 스타일을 재정의하여 디자인 요구사항을 맞추게 됩니다.

문제는 이 작업이 생각보다 번거롭고 쉽지 않다는 점인데요. 가끔은 이미 정의된 레이아웃을 뒤엎고 원하는 UI에 완전히 맞추는 것이 불가능할 때도 있습니다.

이런 느낌이랄까요? 😞

Headless 컴포넌트 패턴은 이러한 문제에 대해 로직(동작)과 UI(표현)를 분리하는 것으로 해결책을 제시합니다. 라이브러리는 로직에 대한 기능만 제공하고, UI는 사용자가 알아서 정의하는 것이죠.

이는 곧 두가지 장점으로 이어집니다. 첫번째로는 관심사의 분리입니다. Headless UI는 로직과 UI를 명확하게 분리합니다. 이로 인해 코드베이스가 더 관리하기 쉽고 이해하기 쉬워지며 기능 추가 또한 용이해집니다.

두번째로는 유연성입니다. Headless UI는 표현(UI)에 구애받지 않기 때문에 디자인 유연성을 높일 수 있습니다. 기본 로직에 영향을 받지 않고 사용자가 원하는대로 UI를 정의할 수 있습니다.

그럼 실제로 라이브러리 예시 코드를 보면서 headless 컨셉에 대해 조금 더 자세히 살펴보도록 하겠습니다.

React-Ranger 뜯어보기

본격적으로 시작하기 전 Range Slider의 UI 구성 요소에 대해 먼저 살펴보겠습니다. Slider는 Track, Handle, Segment, Tick 네가지 영역으로 구분됩니다.

각 구성 요소가 의미하는 바는 아래와 같습니다.

  • Track: 전체 bar 영역을 의미합니다.
  • Handle: range를 조절하는 버튼을 의미합니다.
  • Segment: Handle(버튼)들에 의해 나눠지는 영역으로, 위 이미지에서 빨간색 박스로 감싸진 영역입니다.
  • Tick: 하단 눈금(0~100)을 나타내는 영역입니다.

React-Ranger 적용 코드

이제 예제 코드를 살펴볼텐데요. 코드는 크게 hook을 적용하는 부분과 hook에서 받은 rangerInstance를 이용해서 렌더하는 부분으로 나눌 수 있습니다.

useRanger

먼저 useRanger hook입니다.

react-ranger는 useRanger hook 내부에서 모든 로직을 관리합니다. 주요 로직들은 hook에서 반환하는 rangerInstance안에 대부분 추상화되어 있습니다. 필요한 상태 및 함수는 인스턴스를 통해 최소한으로 노출합니다.

사용자는 rangerInstance를 통해 range 내부의 상태와 상호작용이 가능합니다.

hook을 호출할 때 option을 전달해야 주어야 하는데요. 넘겨주는 option 각 요소의 의미는 다음과 같습니다.

  • getRangerElement: track에 해당하는 html element 입니다. 내부에서 handle과 segment의 위치를 구하는데에 사용됩니다.
  • values: 현재 range slider의 handle의 위치입니다. state로 선언해서 넘겨주어야 합니다.
  • min, max: track에서 handle이 가질 수 있는 최소, 최대값 입니다.
  • stepSize: 한 번의 drag당 handle의 값이 변화하는 크기입니다.
  • onChange: handle 값의 변화마다 트리거되는 이벤트 핸들러입니다

렌더부

다음은 useRanger hook에서 반환한 rangeInstance를 이용해서 range slider UI를 렌더하는 부분입니다.

설명을 위해 최소한의 코드만 남겨두었으며, 전체 코드는 여기서 확인 가능합니다.

rangeInstance는 UI 요소들(tick, segment, handle)에 대한 정보를 담은 getTicks, getSteps, handles와 같은 함수를 제공하는데요. 위 코드를 보면 해당 함수들을 이용하여 UI를 그려내는것을 볼 수 있습니다.

getTicks (라인4~8)

Track 하단에 노출되는 Tick(눈금)을 그리기 위해 사용되는 함수입니다. getTicks 함수는 tick의 value, percentage 값을 담은 배열을 반환합니다. tick은 option으로 전달한 step size로 자동 생성되며, percentage 또한 min, max와 step size 값을 통해 자동으로 계산됩니다.

getSteps (라인 9~11)

Track 내부에서 handle들로 인해 나눠지는 영역인 Segment들을 그리기 위한 함수입니다. getSteps 함수는 각 segment의 시작점(left)과 너비(width)의 값을 담은 배열을 반환합니다. 각 segment는 handle의 값을 참조하여 자동으로 계산됩니다.

handles (라인 12~34)

Track 위 영역을 조절하는 버튼인 handle을 그리는 함수입니다. 실질적으로 핵심 기능을 수행하는 친구입니다.

앞 선 두 함수와 다르게 내려주는 인자가 좀 많은데요. 유저가 handle을 drag 했을때, 이를 감지할 수 있어야 하기 때문에 필요한 handler(keyDown, mouseDown, touchStart)를 함께 내려주어야 하기 때문입니다.

이를 받아서 handle로 렌더하려는 element에 심어주면 됩니다. (라인 26~30)

handles 함수는 이러한 eventHandler 외에도 handle의 value, 그리고 현재 해당 handle이 drag되고 있는지를 판단할 수 있는 isActive등의 필드를 내려줍니다.

이렇게 코드를 작성하면 아래와 같은 슬라이더가 멋지게 그려집니다.

Headless UI!

간단하게 라이브러리를 이용해보았습니다. 이제 앞서 정의한 Headless UI 정의를 다시한번 살펴보겠습니다.

Headless UI란 컴포넌트의 별도 UI를 제공하지 않고, '동작'에 대한 기능만 제공해주는 라이브러리를 말합니다.

의미가 조금 더 와닿으셨나요? 앞서 살펴본 예시에서 react-range는 UI 요소인 tick, handle, segment에 대해 그릴 수 있는 정보만 제공할 뿐, 렌더(UI) 로직에는 일절 관여하지 않습니다.

반면에 tick의 값이나 유저의 drag 액션에 의해 변화하는 handle 및 segment 의 값을 처리하는 로직은 rangeInstance 내부에서 알아서 처리해줍니다.

만약 새로운 기능이 추가되어야 하는 경우, rangeInstance 내부에 로직을 작성하고, hook을 통해 노출해주기만 하면 됩니다.

또한 사용자는 이러한 내부 값들이 어떻게 관리되는지 알 필요 없이 정보를 받아서 UI만 그려낼 수 있습니다.

MUI-sliderreact-suite-slider와 같이 디자인 컴포넌트 형식으로 제공해주는 컴포넌트와 비교하면 작성해야할 코드가 조금 더 많긴 하지만, UI와 비즈니스 로직의 의존성을 분리하여 유연하게 대응이 가능한 점이 Headless UI의 장점입니다.

React Ranger 라이브러리의 내부 구조

지금까지 라이브러리를 사용하는 시점에서의 코드를 살펴보았습니다. 이제 라이브러리 내부 코드를 살펴보겠습니다.

React Ranger는 크게 두 패키지로 나눌 수 있는데요. 핵심 기능을 처리하는 ranger 패키지와 이것을 리액트 프로젝트에서 사용할 수 있도록 adapter의 역할을 하는 react-ranger 패키지가 존재합니다.

useRanger

먼저 react-ranger 패키지 내부에 있는 useRanger hook을 살펴보겠습니다.

앞서 언급했듯이 코어 로직은 ranger 패키지에 담겨있기 때문에, useRanger는 Ranger Class와 react를 연결하는 단순 adapter의 역할만을 담당합니다.

아래는 useRanger hook의 코드입니다.

hook의 전체 코드를 가지고 온 건데요. 굉장히 짧습니다. 앞서 말했듯 hook은 value의 변화에 따라 컴포넌트를 다시 그리는 역할만을 담당하며, 이를 위해 useReducer hook을 사용합니다.

모든 액션에 대해 새로운 객체를 반환함으로써 force update가 가능한 reducer를 선언해줍니다. (라인 12)

이후 유저가 전달한 옵션과 합쳐 22번째 라인에서 Ranger Instance를 만들어주면, onChange시마다 rerender가 되는 rangeInstance가 완성됩니다.(라인 13~22)

react-ranger도 tantack의 다른 라이브러리들처럼 코어 로직을 바닐라js로 이관하며 다른 라이브러리도 지원하기 위한 준비를 하고 있습니다. 레거시 프로젝트는 여기서 확인하실 수 있습니다.

Ranger Class

이제 코어 로직이 담겨있는 ranger class를 살펴보겠습니다.

constructor

우선, 생성자입니다. 앞서 useRanger hook에서 class를 생성해서 state에 담았었는데요. 생성자 내부에서 사용자가 전달한 option으로 rangerInstance를 생성합니다.

이 때 사용자가 전달한 min, max, stepSize, values, onChange, onDrag등을 내부 변수로 관리됩니다.

getTicks

tick을 그리기위해 사용했던 getTicks 함수입니다.

getTicks 함수는 생각보다 간단합니다. step을 참조해서 tick을 배열의 형태로 생성해주는 역할을 합니다. (라인 9~16)

예를들어, min = 0, max = 100, step = 10의 값을 넘기면 [0,10,20,...100]까지 tick을 만들어서 내려줍니다.

반환값에서 사용되는 getPercentageForValue 함수도 살펴볼까요?

이 함수는 현재 value가 전체 길이에서 얼만큼의 비율에 위치해야하는지를 백분위로 내려줍니다.
계산식은 (시작부터 현재위치까지의 길이 / 전체 길이) * 100이네요.

이 백분위 값은 tick의 상대적 위치를 잡는데에 사용됩니다.

getSteps

두번째로 살펴볼 함수는 Segment의 정보를 반환해주는 getSteps입니다.

이 함수는 현재 segment의 left와 width값이 담긴 객체의 배열을 반환합니다. Segment는 결국 handle의 위치에 따라 결정되기 때문에, handle의 값에 의존적인 값인데요.

만약 handle의 값이 [v1,v2,v3]이라고 가정해보면, segment는 총 4개로 나눠지며 구간은 s1: 0~v1, s2: v1~v2, s3: v3~v4, s4: v4~max가 됩니다.

이를 구하기 위해 handle의 위치 변수값인 values에 max를 추가한 배열([...values, options.map])을 돌며 각각의 left와 width를 계산해서 반환합니다.

handle

마지막은 handle 함수입니다. handle 함수는 range slider 위의 handle(range button)들을 렌더하기 위해 사용됩니다.

handle은 값이 변할때마다 track을 리렌더시켜야합니다. 이 때 상태의 변화를 감지하고, 새로운 상태(위치)를 계산하기 위해서는 handle element에 적절한 핸들러가 부착되어 있어야합니다.

react-ranger는 데스크탑 환경과 모바일 환경, 키보드를 지원하기 때문에 drag시 handle의 위치를 추적할 수 있도록 onKeyDown, onMouseDown, onTouchStart 세개의 handler를 제공합니다.

values 배열을 돌며 현재 handle의 value와, 미리 정의된 핸들러들을 배열의 형태로 반환해줍니다.
그럼 마지막으로 handlePress의 코드를 살펴보겠습니다.

handlePress는 onMouseDown 이벤트 핸들러로 사용되므로, 마우스가 내려간 시점에 실행됩니다.

마우스가 내려갔을때 mousemove 이벤트 핸들러를 달아주면 마우스가 움직이는 동안 handle 값을 추적할 수 있게 되는데요. 이 역할은 handleDrag 함수가 담당합니다(라인 24~25).

handleDrag 함수 내부에는 handle의 위치와 가장 가까운 step으로 값을 업데이트 해주는 로직이 들어가 있습니다.

이렇게 값을 계속 추적하다가 드래그가 종료되는 시점, 즉 mouseup event가 발생했을때 등록한 모든 이벤트를 해지하고 최종 값으로 업데이트를 해줍니다.(라인 21)

이를 통해 handle이 range slider 내부에서 좌우로 움직일때마다 계속 value를 갱신하며 가지고 있을 수 있게 됩니다.

정리하며

Headless로 라이브러리를 제공하기 위해서는 설계 단계부터 다양한 측면을 고려해야 합니다. 기능은 추상화해서 제공하면서도 디자인 요소는 자유롭게 커스터마이징 가능하도록 설계되어야 하기 때문입니다.

그래서 기능과 UI가 많이 얽혀 있는 Ranger Slider의 경우 headless로 제공하기 어려울 것으로 생각했는데 UI를 작은 요소(segment, tick, handle등)들로 분리하고, UI의 관리 로직은 rangerInstance를 통해 추상화하여 제공하는 방식으로 해결한 점이 인상깊었습니다.

그리고 Headless 라이브러리들도 레이아웃 구조상 강제되는 스타일 코드들은 내부에 포함하는 경우를 많이 보았는데, 이를 라이브러리 내부에 추가하지 않고 가이드 문서를 통해 제공한 방식도 흥미로웠습니다. 막연하게 디자인 라이브러리는 import해서 바로 쓰면 사용할 수 있어야 한다는 생각을 가지고 있었던 것 같기도 합니다.

최근에 컴포넌트를 만들때 인터페이스 설계에 대해 고민이 깊어지고 있었는데 큰 인사이트를 얻어 기쁩니다.

0개의 댓글