Home
All Posts
🎨

Headless UI를 적용해 라이브러리 만들기

Created
2024/06/18
Tags
Library
Category
Project
2 more properties
캘린더 라이브러리를 만들면서 ‘Headless UI’ 라는 개념을 처음 접했는데요. 이번 포스팅에서는 Headless UI가 무엇이며 왜 필요한지, 그리고 실제로 코드에 어떻게 녹여냈는지 경험을 풀어봅니다.

Headless UI 개념 톺아보기

Headless UI 란

Headless UI는 UI 라이브러리의 한 형태다. 다만 컴포넌트의 스타일링과 레이아웃을 전혀 포함하지 않거나 최소한으로 포함하고, 순수하게 기능적인 부분만을 제공하는 라이브러리를 말한다. 따라서 Headless UI는 개발자가 스타일링과 디자인에 대한 완전한 제어권을 가지면서 복잡한 UI 로직을 쉽게 구현할 수 있도록 돕는다.
날짜 선택기 캘린더 라이브러리를 예로 들어보겠다. 라이브러리는 메인인 날짜 선택 기능을 제공하되, 캘린더 컴포넌트를 만들 때 필요한 스타일링 그리고 추가적인 세부 기능 구현은 사용자에게 위임하는 것이다.

커스텀 키보드에 비유해보자

기본적인 기능은 동일하게 유지되지만, 사용자가 원하는 대로 커스터마이즈 할 수 있다는 점에서 커스텀 키보드와 Headless UI는 결이 닿아있다.
출처: unsplash @Bernin Uben
키보드 본연의 기능부터 보자면, 사용자가 키보드의 특정 키를 눌러 컴퓨터에 명령을 전달하면 키보드가 이를 처리한다.
비슷하게 Headless UI 컴포넌트도 Prop이라는 것을 통해 외부와 소통하며, Prop을 통해 컴포넌트가 외부로부터 입력을 받아들이고 이에 따라 동작한다. 사용자가 키보드의 키를 통해 컴퓨터의 동작을 제어하듯, Headless UI 컴포넌트도 Prop을 통해 컴포넌트의 동작을 제어할 수 있다.
또한 커스텀 키보드는 키 입력, 매크로, 백라이트 제어 등 다양한 기능을 가지고 있을 뿐만 아니라 사용자가 직접 키캡이나 사용 환경을 변경할 수 있다는 것이 매력이다. 커스텀을 통해 사용자는 자신에게 가장 적합한 키보드를 만들 수 있다.
이와 비슷하게 사용자는 Prop을 통해 스타일링을 하거나, 자신이 선호하는 스타일링 도구를 사용해 UI 컴포넌트를 커스터마이즈할 수 있다.
오, 이제 조금 Headless UI 개념과 가까워진 것 같다.

Head가 없다는게 무슨 뜻이지

그나저나.. 왜 굳이 스타일링이 없는 UI 라이브러리를 Headless UI로 표현할까 라는 의문이 있을 수 있다. 이럴 땐 Head를 머리 스타일, Head를 제외한 부분을 Body라고 생각해 보자.
 Head: 쉽게 바꿀 수 있는 머리 스타일 → UI의 스타일링과 레이아웃  Body: 어떠한 행동을 할 수 있는 팔과 다리 → UI의 핵심 기능  Headless UI: 머리 없이 팔과 다리만을 제공 → Headless UI 라이브러리는 핵심 기능만을 제공해 사용자가 직접 스타일링 가능

외부 UI의 불편함을 떠올려보자

Headless 개념을 잘 이해했다면 외부 UI 컴포넌트를 사용할 때를 떠올려 보자. 종종 그들이 자체적으로 만든 디자인으로 구현되어 있어 사용자들이 상황이나 취향에 따라 커스텀하기 어렵다.
만약 장례 서비스 사이트에 귀엽고 알록달록한 캐릭터 모달과 스피너를 그대로 쓴다면 생각만해도 끔찍하다.
개인적으로도 프로젝트를 하면서 일일이 모달이나 로딩 스피너 등을 갖다 썼는데 기존 웹사이트 디자인에 녹아드는 것을 찾기 어렵거나, 적용한다 해도 브랜드 톤앤매너나 상황에 맞게 수정하기 어려웠다. 그래서 결국은 외부 라이브러리를 사용하지 않고 시간을 들여서라도 직접 만들었던 경험이 있다.
이렇듯 각자 독자적인 스타일을 가지고 있는 여러 외부 컴포넌트들이 스타일 재정의 없이 하나의 서비스에 모여 사용되는 순간, 서비스의 UI/UX 완성도가 떨어지기 마련이다.

Headless UI의 효용과 장점

관심사를 분리할 수 있다

UI를 꾸미는 코드와, 이 코드를 어떻게 언제 보여줄지 결정하는 로직이 분리되면 좋다. 사용자와의 다양한 인터렉션에 대응해야 한다면 코드의 변경 가능성이 높기 때문이다. 이에 비해 데이터가 오고가는 로직은 상대적으로 변경 가능성이 낮다.
위에서 언급한듯이 사용자와의 인터렉션에 대응해 여러 기능을 추가하고 변경하다 보면 즉, 코드를 이리저리 수정하다보면 점점 스파게티가 될 가능성이 농후하다. 따라서 변경 가능성이 높은 코드와 그렇지 않은 코드를 분리하는 것이 좋다.
출처: martinfowler.com @Juntao QIU
이에 Headless UI는 로직과 스타일링을 분리하여 관심사의 분리를 가능하게 한다. 덕분에 각 부분이 더 명확해지고 독립적으로 동작하므로 개선 또한 각 부분별로 하면 된다.

자유롭게 커스텀 할 수 있다

Headless UI 덕분에 사용자는 원하는 상황과 톤앤매너에 맞게 커스텀 할 수 있다. 예를 들어 캘린더 라이브러리라면 캘린더의 색상을 브랜드 키컬러에 맞게 수정하거나, 영어권 유저를 위해 영문 버전의 캘린더로 바꿀 수 있게 할 수도 있겠다.
출처: wojtekmaj/react-calendar
이렇듯 Headless UI는 사용자에게 자유로운 커스터마이징을 제공한다. 사용자의 서비스나 상황에 맞게 UI를 수정할 수 있다. 스타일 뿐만 아니라 기본 제공 로직에 사용자가 원하는 로직을 추가하거나 변경할 수 있기도 하다.

유지보수에 용이하다

스타일링 코드가 컴포넌트와 함께 추상화되어 있으면 스타일을 바꾸기 위해 여러 가지 설정을 추가로 제공해야 한다. 즉, 그만큼 많은 Props를 제공해야 한다.
여기서 Props는 컴포넌트와 외부 간의 소통 방법을 의미하며, 이 소통 방법은 매우 중요하게 관리해야 한다. 이 Props 중 하나라도 역할이 바뀌거나 수정되면 기존의 코드가 갑자기 동작하지 않는 상황(Breaking Change)이 발생할 수 있기 때문이다. 라이브러리나 기본 모듈에서의 Breaking Change는 실제 운영 중인 코드의 변경을 의미하기에, 변경이 잦을수록 버그가 생길 가능성이 높아진다.
출처: Medium @Nathan Curtis
예를 들어, 캘린더 컴포넌트에 color 라는 Prop이 있다치자. 이 Prop은 버튼의 색상을 외부에서 지정할 수 있는 소통 방법이다. 만약 color 의 역할이 바뀌거나 이름이 바뀐다면 이 버튼을 사용하는 모든 코드에서 오류가 발생한다. 이것이 Breaking Change다.
따라서 많은 Props가 있을수록 이 모든 Props를 모두 관리하고 변경사항에 대응하는 것이 점점 어려워진다. 예를 들어, 1개의 버튼이 10개의 Props를 가지는데 이 중 하나라도 바뀐다면 영향을 받는 코드가 많아진다. (생각만해도 어지럽다)
이런 상황에서는 Headless UI를 통해 각 부분을 별도로 관리함으로써 코드 관리를 더 쉽게 할 수 있다.

재사용이 용이하다

앞의 유지보수에 용이하다는 점과 맞닿아 있는 점이다. 프론트엔드 개발자로서 개발을 하다보면 같은 기능이지만 다른 디자인을 가지는 컴포넌트를 종종 새롭게 만들어야 할 때가 있다. 이때, Headless UI를 활용하면 한번 구현된 기능 로직을 다양한 UI에서도 재사용할 수 있어 이러한 반복적인 작업을 하지 않아도 된다.
예시: 노션 캘린더
예를 들어 DatePicker 라이브러리의 UI를 그대로 사용해 RangeDatePicker 라이브러리를 만들 수도 있다. 각 모듈이 독립적으로 운영되므로 디버깅을 할 때 버그의 원인을 추적하는 과정이 단순해지는 것은 물론, 기능을 추가할 때도 용이하다.
이는 곧 여러 컴포넌트를 만들 때 코드 중복을 줄일 수 있다는 의미이기도 하다.

Headless UI를 위한 Optional Props

위에서 언급한듯이 Headless의 핵심은 UI 로직과 프레젠테이션(스타일링과 레이아웃)을 분리하는 것이다. 따라서 사용자가 각자의 필요에 맞게 컴포넌트를 커스터마이즈할 수 있어야 한다. 여기서 등장하는 Optional Props는 Headless UI의 유연성과 커스터마이즈 가능성을 높여주는 역할을 한다.
예를 들어, DatePicker의 경우 languagecolor 등을 Optional Props로 제공할 수 있다.
뿐만 아니라 개발자가 직접 기능을 확장할 수도 있다. 예를 들어, onDateSelect, onDateChange와 같은 콜백 함수를 Optional Props로 제공하면 사용자가 원하는 시점에 커스텀 로직을 추가할 수 있다.
// onDateChange라는 사용자가 날짜를 변경할 때 호출되는 콜백 함수 추가 const DatePicker = ({ onDateChange, ...props }) => { // ... };
JavaScript
복사
위에서 Headless의 장점으로 재사용성에 대해 말한 것처럼, Optional Props를 통해 컴포넌트를 더욱 재사용 가능하게 만들 수 있다. 다양한 사용 사례에 대응할 수 있도록 기본 동작을 오버라이드하거나 추가할 수 있는 옵션을 제공하면 동일한 컴포넌트를 여러 곳에서 재사용할 수 있다.

Headless를 구현하는 방법

중복되는 로직이 있는지 파악하기

Headless UI 구현을 위해 가장 먼저 할 것은 중복되는 로직이 있는가를 파악하는 것이다. 코드 중복을 줄이기 위해 컴포넌트들에서 중복되는 부분을 추출하면 Headless의 핵심 원리를 이해하고 적용할 수 있는 길이 보인다.
1. 보다 일반적으로 쓰이는 부분을 파악하기 컴포넌트에서 공통으로 사용되는 로직이나 상태 관리 부분을 식별한다. 2. 이를 재사용 가능한 부분으로 추출하기 중복되는 로직을 별도의 hook이나 utils 함수로 분리하여 재사용 가능하도록 한다.

상태를 추상화해서 모듈화하기

또한 UI가 가지고 있는 상태나 로직을 추상화하고, 이것을 모듈화 한다. 이는 위에서 말한 재사용 가능한 부분으로 추출한다는 것과 의미가 맞닿아 있다. 스타일은 걷어내고 이 UI가 가진 상태는 무엇인지, 그리고 이를 관리하기 위해 적절한 로직은 무엇인지 고민하는 것이다.
1. 상태를 추상화 하기
각 UI 컴포넌트가 필요로 하는 상태를 정의하고 이를 추상화한다. 예를 들어, DatePicker라면 선택된 날짜와 달력의 현재 월 같은 상태가 있을 수 있다.
2. 모듈화 하기
추상화된 상태와 로직을 별도의 hook이나 모듈로 분리한다. 예를 들어, useCalendar 훅을 만들어 달력의 상태와 로직을 관리할 수 있다.
3. 최소한의 API를 제공하기
상태를 제어할 수 있는 최소한의 API를 제공한다. 이는 사용자가 필요한 부분만 접근할 수 있도록 하여 인터페이스를 단순하게 유지한다. 여러 개의 API를 제공할 필요는 없다.

간단한 예시 코드로 이해하기

// DatePicker에 필요한 상태와 로직을 관리하는 Custom Hook function useCalendar(initialDate) { const [selectedDate, setSelectedDate] = useState(initialDate); const [currentMonth, setCurrentMonth] = useState(new Date().getMonth()); // 날짜가 변경될 때 상태를 업데이트하는 함수 const handleDateChange = (date) => { setSelectedDate(date); }; // 월이 변경될 때 상태를 업데이트하는 함수 const handleMonthChange = (month) => { setCurrentMonth(month); }; // 상태와 함수를 객체 형태로 반환해 훅을 사용하는 컴포넌트에서 쉽게 접근하고 사용 가능 return { selectedDate, currentMonth, handleDateChange, handleMonthChange, }; }
JavaScript
복사
useCalendar는 DatePicker 컴포넌트에 필요한 상태와 로직을 캡슐화한 Custom Hook이다. 이 훅은 useState를 사용하여 선택된 날짜(selectedDate)와 현재 월(currentMonth)을 관리한다.
// Custom Hook을 사용해 상태와 로직을 관리하는 컴포넌트 const DatePicker = ({ initialDate }) => { const { selectedDate, currentMonth, handleDateChange, handleMonthChange } = useCalendar(initialDate); // Calendar, DateDisplay, DateInput 컴포넌트를 렌더링하며 // 각각의 컴포넌트에 필요한 props를 전달 return ( <div> <Calendar month={currentMonth} onMonthChange={handleMonthChange} /> <DateDisplay date={selectedDate} /> <DateInput onDateChange={handleDateChange} /> </div> ); };
JavaScript
복사
useCalendar Custom Hook은 상태 관리와 로직을 컴포넌트로부터 분리하여 코드의 가독성과 유지보수성을 향상시킬 수 있다. 이렇게 하면 다양한 컴포넌트에서 동일한 로직을 손쉽게 재사용할 수 있다.

Headless UI를 코드에 적용하기

세 가지 목표 달성을 위한 두 번의 업데이트

Headless 개념을 캘린더 라이브러리에 녹이기 위해 아래 세 가지 목표를 정했다.
1. 사용자가 필요한 Prop만 선택적으로 사용할 수 있도록 한다.
2. 사용자가 캘린더의 스타일과 부가기능을 원하는대로 설정할 수 있도록 한다.
3. 핵심 로직과 UI를 분리하여, 사용자는 세부 로직은 신경쓰지 않고 UI 제어권만을 가지도록 한다.
그리고 Headless UI 개념에 다가가기 위해 지금까지는 두 번의 업데이트를 거쳤다.

1차 업데이트 - 스타일 커스텀을 돕는 optional props

우선 사용자가 쓸 디자인부터 Headless UI를 지향하고자 했다. 이에 특정 색상이나 디자인을 강제하지 않고, 캘린더의 핵심 로직 (날짜 선택, 이동 등)만 제공하여 사용자가 원하는 대로 UI를 구현할 수 있게 했다. 이를 위해 Optional Props를 적극적으로 사용했다.
DatePicker에서 지원하는 Prop들
DatePicker 캘린더 라이브러리에서는 아래와 같은 다양한 Props를 제공해 사용자가 입맛대로 다양하게 커스텀할 수 있다.
color와 language를 변경한 모습

2차 업데이트 - 비즈니스 로직과 이벤트 핸들러를 훅으로 분리

기존의 Calendar 컴포넌트는 상태 관리 로직과 UI 렌더링이 합쳐져 있어 코드 자체가 너무 복잡하고 중복된 코드가 많았다. 때문에 기능 추가나 리팩토링에 손을 대는 것 자체가 어려웠다.
그래서 기존 Calendar 컴포넌트 내에서 직접 상태를 관리하던 부분을 useCalendar 훅으로 분리하여 상태 관리와 이벤트 처리를 캡슐화했다.
// src > hooks > useCalendar.ts import { useState } from 'react'; const useCalendar = (initialDate: Date) => { const [dateInput, setDateInput] = useState(''); const [displayDate, setDisplayDate] = useState(''); const [isInputValid, setIsInputValid] = useState(true); const [currentDate, setCurrentDate] = useState(initialDate); const handleDateChange = (event: React.ChangeEvent<HTMLInputElement>) => { setDateInput(event.target.value); }; const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => { // ... 유효성 검사 로직 }; return { dateInput, displayDate, isInputValid, currentDate, handleDateChange, handleKeyDown, setDisplayDate, setDateInput, setIsInputValid, setCurrentDate, }; }; export default useCalendar;
TypeScript
복사
이제 Calendar 컴포넌트는 UI 렌더링과 훅을 통한 상태 관리만을 담당하게 되어 역할이 명확해졌다. 상태 관리 로직이 굳이 Calendar 컴포넌트에 포함되지 않고, 따로 훅으로 캡슐화된 것이다.
Calendar 컴포넌트의 기존 코드 → 변경된 코드
만약 DatePicker가 아닌 RangeDatePicker 라는 다른 라이브러리를 만든다면 캡슐화된 저 훅을 재사용하기만 하면 된다. (물론 직접 구현해 봐야 알지만, 세부 로직은 또 다를 수 있기에 완벽한 재사용은 어려울 수도 있다)
사실 말이 어렵지, 간단하게 표현하면 ‘관심사 분리’를 한 것이다.
Custom Hook을 통한 관심사 분리로 크게 세 가지 문제를 개선했다.

기존에 있던 문제

1. 각 상태와 상태를 변경하는 함수들이 메인 컴포넌트에 집중되어 컴포넌트의 역할이 명확하지 않았다.
2. 여러 상태 변경 함수와 이벤트 핸들러가 컴포넌트 내부에 중복되어 작성되었다.
3. 상태 관리와 비즈니스 로직이 컴포넌트에 직접 포함되어 해당 로직을 독립적으로 유지보수하거나 테스트하기 어려웠다.

문제 해결로 인한 효과

1. 메인 컴포넌트는 이제 UI 렌더링과 훅을 통한 상태 관리만을 담당하게 되어 역할이 명확해졌다.
2. 상태 관리와 비즈니스 로직을 훅으로 분리해 코드가 모듈화되어 재사용이 용이해졌다.
3. 훅으로 분리된 상태 관리와 비즈니스 로직을 독립적으로 테스트할 수 있어 코드의 신뢰성이 높아졌다.

마무리

신경썼던 두 가지

어디서 들어보기만 했던 Headless UI 개념을 직접 라이브러리를 만들며 구현하니 더 와닿았다. 이번 라이브러리를 만들면서 크게 두 가지 측면에서 Headless UI를 녹여내고자 했다.
첫 번째로는 이 라이브러리를 사용할 개발자, 즉 사용자들이 기능적 로직은 신경쓰지 않고 각자 입맛에 자유롭게 커스텀하도록 하고 싶었다. 비록 아직 엄청나게 다양한 Prop을 제공하는 것은 아니기에 그들의 니즈를 100% 만족시키기 어렵지만, 앞으로 더 다양한 커스텀을 가능하게 업데이트할 예정이다.
두 번째로는 이 라이브러리를 개발하는 나, 혹은 오픈소스로 함께 작업하게 될 수도 있는 개발자 동료분들을 위해 코드 구조 자체를 Headless로 염두했다. 단순히 사용자를 위해 보여주는 Headless 뿐만 아니라, 사용자의 다양한 요구를 반영할 수 있도록 유지보수하기 좋은 구조를 짜려고 했다.
예를 들어, 개발 초기에는 컨트롤러 역할을 하는 캘린더 컴포넌트에 온갖 상태 관리와 핸들러들이 덕지덕지 붙어있었지만, 커스텀 훅으로 분리한 덕분에 지저분한 코드들을 걷어내고 코드를 한눈에 훑을 수 있게 되었다. 사실 이는 비단 라이브러리를 만들 때 뿐만 아니라 프로덕션 코드에서도 적용할 수 있으니 참고하면 좋겠다.

함께 자라기

라이브러리를 업데이트 해나가면서 중간에 나와 같은 고민을 하시는 분의 링크드인 글을 보았다. Headless로 DatePicker 컴포넌트를 설계하며 들었던 고민을 공론화하신 거였다. 덕분에 다른 분들은 어떻게 해결하셨는지, 나는 어떻게 해결할지 더 입체적으로 고민할 수 있었다.
이번 업데이트에서는 DatePicker 자체의 Headless를 고민하고 적용했지만, 다음 업데이트에서는 RangeDatePicker 라이브러리 개발을 염두해 더 재사용 가능한 로직을 고민해 볼 것 같다.
마지막으로, 아직 부족한 점이 많은 라이브러리지만 누군가 이슈 하나라도 달아준다면 정말 감사히 여길 것 같다. 사용자의 요구를 기반으로 계속 업데이트 해나가며 나도 더 많이 공부하고 발전하고 싶다.
캘린더 컴포넌트는 거의 모든 서비스에 필요하죠. 언젠가 캘린더가 필요할 때 이 라이브러리를 사용해 보시고, 개선이 필요한 점이 있다면 이슈를 올려주세요! 혹은 저와 함께 Contributor로서 참여하고 싶으신 분이 있다면 더더욱 환영입니다!
react-simple-datepicker-calendar
ella-yschoi

참고 자료