유연한 컴포넌트(Headless component)

신태일·2024년 10월 17일
post-thumbnail

원문: https://martinfowler.com/articles/headless-component.html

리액트 UI 컨트롤이 더 정교해짐에 따라 복잡한 로직이 시각적 표현과 얽히게 될 수 있습니다. 이로 인해 컴포넌트의 동작을 추론하기 어렵고, 테스트하기도 어려워지며, 다른 모양이 필요한 유사한 컴포넌트를 구축해야 할 수도 있습니다. 헤드리스 컴포넌트는 모든 비시각적인 로직과 상태 관리를 추출하여 컴포넌트의 두뇌를 UI에서 분리합니다.

리액트는 UI 컴포넌트와 UI의 상태 관리에 대한 사고 방식을 혁신적으로 변화시켰다. 그러나 새로운 기능 요청이나 개선이 있을 때마다, 보기엔 단순해 보이는 컴포넌트도 서로 얽힌 상태와 UI 로직의 복잡체로 빠르게 발전할 수 있다.

간단한 드롭다운 목록을 구축한다고 생각해보자. 처음에는 열기 / 닫기 상태를 관리하고 모양을 디자인하는 증 간단해 보인다.
하지만 애플리케이션이 성장하고 발전함에 따라 이 드롭다운에 대한 요구 사항도 발전한다.

  • 키보드 네비게이션: 사용자는 마우스 상호작용에만 제한되어서는 안된다. 화살표 키를 사용하여 옵션을 탐색하거나 Enter 키를 사용하여 선택하거나, Escape 키를 사용하여 드롭다운을 닫을 수 있어야 한다. 이를 위해서는 추가적인 이벤트 리스너와 상태 관리가 필요하다.
  • 비동기 데이터 고려사항: 애플리케이션이 확장됨에 따라 드롭다운 옵션이 더 이상 하드코딩되지 않을 수 있다. 대신 API에서 가져올 수도 있다. 이 경우 드롭다운 내에서 로딩, 오류 및 비어 있는 상태를 관리해야 할 필요성이 생긴다.
  • UI 변형 및 테마: 애플리케이션의 각 부분마다 드롭다운에 대해 다른 스타일이나 테마가 필요할 수 있다. 컴포넌트 내에서 이러한 변형을 관리하면 프로퍼티와 합성이 폭발적으로 늘어날 수 있다.
  • 기능 확장: 시간이 지남에 따라 다중 선택, 필터링 옵션 또는 다른 폼 컨트롤과의 통합과 같은 추가 기능이 필요할 수 있다. 이미 복잡한 컴포넌트에 이러한 기능을 추가하는 것은 어려울 수 있다.

이러한 각 고려 사항은 드롭다운 컴포넌트에 복잡성을 더한다. 상태, 로직 및 UI 표현이 섞이면 유지 관리가 어렵고 재사용하기 어렵다. 서로 얽혀 있을수록 의도하지 않은 부작용 없이 변경하기가 더 어렵다.


헤드리스 컴포넌트 패턴 소개


이러한 문제를 정면으로 마주한 상황에서, 헤드리스 컴포넌트 패턴은 해결책을 제시한다. 헤드리스 컴포넌트 패턴은 계산과 UI 표현을 분리하여, 개발자가 다재다능하고 유지 관리가 가능하며 재사용 가능한 컴포넌트를 구축할 수 있도록 지원한다.

헤드리스 컴포넌트는 리액트 디자인 패턴으로 일반적으로 리액트 훅으로 구현되며, 컴포넌트가 특정 UI(사용자 인터페이스)를 규정하지 않고, 로직과 상태 관리만을 전적으로 책임지는 컴포넌트이다. 이는 작업의 ‘두뇌’ 를 제공하지만 ‘겉모습’ 은 구현하는 개발자에게 맡긴다. 본질적으로 특정 시각적 표현을 강요하지 않고 기능성을 제공한다.

헤드리스 컴포넌트를 시각화해보자면, 한쪽에서는 JSX 뷰와 상호작용하고 다른 한쪽에서는 필요에 따라 기본 데이터 모델과 통신하는 가느다란 레이어로 나타낼 수 있다. 이 패턴은 시각적 표현에서 로직을 분리하기 때문에, UI의 동작 또는 상태 관리 측면만을 원하는 개발자에게 특히 유용하다.

예를 들어, 헤드리스 드롭다운 컴포넌트를 생각해 보자.
이 컴포넌트는 열기 / 닫기 상태, 항목 선택, 키보드 탐색 등에 대한 상태 관리를 처리한다. 렌더링할 때가 되면 자체적으로 하드코딩된 드롭다운 UI를 렌더링하는 대신, 이 상태와 로직을 자식 함수나 컴포넌트에 제공하여 개발자가 시각적으로 어떻게 표시할지 결정할 수 있도록 한다.

아래에서는 복잡한 컴포넌트인 드롭다운 목록을 처음부터 다시 구성하여 실제 예제를 살펴볼것이다. 컴포넌트에 더 많은 기능을 추가하면서 발생하는 문제를 알아보자. 이를 통해 헤드리스 컴포넌트 패턴이 어떻게 이러한 문제를 해결하고, 서로 다른 관심사를 구분하며, 보다 다재다능한 컴포넌트를 제작하는 데 도움이 되는지 알아보자.


드롭다운 목록 구현하기


드롭다운 목록은 많은 곳에서 사용되는 일반적인 컴포넌트이다. 기본적인 사용 사례를 위한 네이티브 select 컴포넌트도 있지만, 각 옵션을 더 잘 제어할 수 있는 고급 버전은 더 나은 UX를 제공한다.

드롭다운 목록 컴포넌트

완벽한 구현을 위해 처음부터 새로 만들려면 보기보다 더 많은 노력이 필요하다. 키보드 탐색, 접근성(예: 스크린 리더 호환성), 모바일 디바이스에서의 사용성 등을 고려해야 한다.

마우스 클릭만 지원하는 간단한 데스크톱 버전부터 시작하여, 점차 기능을 추가하여 현실적인 드롭다운을 만들 것이다. 기본적으로 사용자가 클릭할 요소(트리거 요소라고 부르겠음)와 목록 패널의 표시 및 숨기기 동작을 제어할 상태가 필요하다. 처음에는 패널을 숨기고 트리거 요소가 클릭되면 목록 패널을 표시한다.

💡 하나의 드롭다운 컴포넌트를 해당 상태와 독립적으로 움직이는 각각의 컴포넌트들로 분해 후 결합합니다.

→ 드롭다운의 각 부분에 특화된 컴포넌트를 생성하여 관련 사항을 분리함으로써 코드를 더욱 체계적이고, 쉽게 관리할 수 있도록 함!

import { useState } from 'react';

interface Item {
  icon: string;
  text: string;
  description: string;
}

type DropdownProps = {
  items: Item[];
};

const Dropdown = ({ items }: DropdownProps) => {
  const [isOpen, setIsOpen] = useState(false);
  const [selectedItem, setSelectedItem] = useState<Item | null>(null);

  return (
    <div className="dropdown">
      <div className="trigger" tabIndex={0} onClick={() => setIsOpen(!isOpen)}>
        <span className="selection">
          {selectedItem ? selectedItem.text : 'Select an item...'}
        </span>
      </div>
      {isOpen && (
        <div className="dropdown-menu">
          {items.map((item, index) => (
            <div
              key={index}
              onClick={() => setSelectedItem(item)}
              className="item-container"
            >
              <img src={item.icon} alt={item.text} />
              <div className="details">
                <div>{item.text}</div>
                <small>{item.description}</small>
              </div>
            </div>
          ))}
        </div>
      )}
    </div>
  );
};

전형적인 단순 useState 훅을 사용하여 isOpen 및 selectedItem 상태를 관리하여 드롭다운의 동작을 제어한다. 트리거 요소를 클릭하면 드롭다운 메뉴가 토글되고, 항목을 선택하면 selectedItem 상태가 업데이트된다.

컴포넌트를 더 명확하게 보기 위해 더 작고 관리하기 쉬운 조각으로 분해해보자.

사용자 클릭을 처리하는 Trigger 컴포넌트를 추출하는 것부터 시작해보자.

const Trigger = ({
  label,
  onClick,
}: {
  label: string;
  onClick: () => void;
}) => {
  return (
    <div className="trigger" tabIndex={0} onClick={onClick}>
      <span className="selection">{label}</span>
    </div>
  );
};

Trigger 컴포넌트는 클릭 가능한 기본 UI 요소로, 표시할 label과 onClick 핸들러를 매개 변수로 받으며, 주변 컨텍스트에 구애받지 않는다. 마찬가지로 옵션 항목들의 목록을 렌더링하는 DropdownMenu 컴포넌트를 추출할 수 있다.

const DropdownMenu = ({
  items,
  onItemClick,
}: {
  items: Item[];
  onItemClick: (item: Item) => void;
}) => {
  return (
    <div className="dropdown-menu">
      {items.map((item, index) => (
        <div
          key={index}
          onClick={() => onItemClick(item)}
          className="item-container"
        >
          <img src={item.icon} alt={item.text} />
          <div className="details">
            <div>{item.text}</div>
            <small>{item.description}</small>
          </div>
        </div>
      ))}
    </div>
  );
};

DropdownMenu 컴포넌트는 각 항목에 아이콘과 설명이 포함된 항목들의 목록을 표시한다. 각 항목을 클릭하면 선택된 항목에 인수로 제공된 onItemClick 함수가 실행된다.

그런 다음 Dropdown 컴포넌트 내에서 Trigger와 DropdownMenu를 조합하고 필요한 상태를 제공한다. 이 접근 방식은 Trigger 및 DropdownMenu 컴포넌트가 상태에 구애받지 않고, 전달된 프로퍼티에만 반응하도록 보장한다.

const Dropdown = ({ items }: DropdownProps) => {
  const [isOpen, setIsOpen] = useState(false);
  const [selectedItem, setSelectedItem] = useState<Item | null>(null);

  return (
    <div className="dropdown">
      <Trigger
        label={selectedItem ? selectedItem.text : 'Select an item...'}
        onClick={() => setIsOpen(!isOpen)}
      />
      {isOpen && <DropdownMenu items={items} onItemClick={setSelectedItem} />}
    </div>
  );


이 시점에서 리팩토링된 코드는 각 세그먼트가 간단하고 적응성이 뛰어나며 명확하다. Trigger 컴포넌트를 수정하거나, 다른 컴포넌트를 도입하는 것은 비교적 간단하다. 하지만 더 많은 기능을 도입하고 추가 상태를 관리할 때 현재 컴포넌트들을 어떻게 가져가야할까 ?


키보드 탐색 구현


드롭다운 목록에 키보드 탐색 기능을 통합하면 마우스로 해야 하는 작업을 대체할 수 있어 UX가 향상된다. 이는 접근성을 위해 특히 중요하며 웹 페이지에서 원활한 탐색 환경을 제공한다. onKeyDown 이벤트 핸들러를 사용하여 이를 달성하는 방법을 살펴보자.

먼저, Dropdown 컴포넌트의 onKeyDown 이벤트에 handleKeyDown 함수를 연결해보자. 여기서는 switch문을 사용하여 특정 키가 눌렸는지 확인하고 그에 따라 동작을 수행한다. 예를 들어 ‘Enter’ 또는 ‘Space’키를 누르면 드롭다운이 토글된다. 마찬가지로 ‘ArrowDown’ 및 ‘ArrowUp’ 키를 사용하면 목록 항목을 탐색하고 필요한 경우 목록의 시작 또는 끝으로 돌아갈 수 있다.

const Dropdown = ({ items }: DropdownProps) => {
  // ... 이전 상태 변수 ...
  const [selectedIndex, setSelectedIndex] = useState<number>(-1);

  const handleKeyDown = (e: React.KeyboardEvent) => {
    switch (
      e.key
      // ... 케이스 구문 ...
      // ...  Enter, Space, ArrowDown and ArrowUp 키에 대한 핸들링 ...
    ) {
    }
  };

  return (
    <div className="dropdown" onKeyDown={handleKeyDown}>
      {/* ... JSX의 나머지 부분 ... */}
    </div>
  );
};

또한 selectedIndex 프로퍼티를 허용하도록 DropdownMenu 컴포넌트를 업데이트했다. 이 프로퍼티는 강조 표시된 CSS 스타일을 적용하고 현재 선택된 항목에 aria-selected 속성을 설정하는 데 사용되어 시각적 피드백과 접근성을 향상시킨다.

const DropdownMenu = ({
  items,
  selectedIndex,
  onItemClick,
}: {
  items: Item[];
  selectedIndex: number;
  onItemClick: (item: Item) => void;
}) => {
  return (
    <div className="dropdown-menu" role="listbox">
      {/* ... JSX의 나머지 부분 ... */}
    </div>
  );
};

이제 Dropdown 컴포넌트는 상태 관리 코드와 렌더링 로직이 모두 얽혀 있다. 여기에는 selectedItem, selectedIndex, setSelectedItem 등과 같은 모든 상태 관리 구조체와 함께 광범위한 스위치 케이스가 들어 있다.


커스텀 훅으로 헤드리스 컴포넌트 구현하기


이 문제를 해결하기 위해 useDropdown이라는 커스텀 훅을 통해 헤드리스 컴포넌트의 개념을 한번 적용시켜보자. 이 훅은 상태 및 키보드 이벤트 처리 로직을 효율적으로 마무리하여 필수 상태와 함수로 채워진 객체를 반환한다. Dropdown 컴포넌트에서 이를 비구조화함으로써 코드를 깔끔하고 지속 가능하게 유지할 수 있다.

비결은 바로 헤드리스 컴포넌트의 주인공인 useDropdown 훅에 있다. 이 다재다능한 유닛에는 드롭다운이 열려있는지 여부, 선택된 항목, 강조 표시된 항목, Enter 키에 대한 반응 등 드롭다운에 필요한 모든 것이 들어 있다.

다양한 시각적 프레젠테이션, 즉 JSX 요소와 결합할 수 있는 적응성이 장점이다.

const useDropdown = (items: Item[]) => {
  // ... 상태 변수 ...

  // 헬퍼 함수는 UI에 대한 일부 aria 속성을 반환할 수 있습니다.
  const getAriaAttributes = () => ({
    role: 'combobox',
    'aria-expanded': isOpen,
    'aria-activedescendant': selectedItem ? selectedItem.text : undefined,
  });

  const handleKeyDown = (e: React.KeyboardEvent) => {
    // ... switch 구문 ...
  };

  const toggleDropdown = () => setIsOpen(isOpen => !isOpen);

  return {
    isOpen,
    toggleDropdown,
    handleKeyDown,
    selectedItem,
    setSelectedItem,
    selectedIndex,
  };
};

이제 Dropdown 컴포넌트가 단순화되고 짧아졌으며 이해하기 쉬워졌다. useDropdown 훅을 활용하여 상태를 관리하고 키보드 상호 작용을 처리하여, 관심사를 명확하게 분리하고 코드를 더 쉽게 이해하고 관리할 수 있다.

const Dropdown = ({ items }: DropdownProps) => {
  const {
    isOpen,
    selectedItem,
    selectedIndex,
    toggleDropdown,
    handleKeyDown,
    setSelectedItem,
  } = useDropdown(items);

  return (
    <div className="dropdown" onKeyDown={handleKeyDown}>
      <Trigger
        onClick={toggleDropdown}
        label={selectedItem ? selectedItem.text : 'Select an item...'}
      />
      {isOpen && (
        <DropdownMenu
          items={items}
          onItemClick={setSelectedItem}
          selectedIndex={selectedIndex}
        />
      )}
    </div>
  );
};

이러한 수정을 통해 드롭다운 목록에 키보드 탐색 기능을 성공적으로 구현하여 접근성과 사용자 편의성을 높였다. 또한 이 예시는 훅을 활용하여 복잡한 상태와 로직을 구조적이고 모듈화된 방식으로 관리하는 방법을 보여줌으로써 UI 컴포넌트를 더욱 개선하고 기능을 추가할 수 있는 기반을 마련했다.

이 디자인의 장점은 로직과 프레젠테이션이 명확하게 분리되어 있다는 점이다. 여기서 ‘로직’이란 선택 컴포넌트의 핵심 기능인 열기 / 닫기 상태, 선택된 항목, 강조 표시된 요소, 목록에서 선택할 때 화살표 아래로 누르는 등의 사용자 입력에 대한 반응 등을 의미한다. 이러한 분리를 통해 컴포넌트는 특정 시각적 표현에 얽매이지 않고 핵심 동작을 유지하므로 “헤드리스 컴포넌트”라는 용어를 정당화할 수 있다.

헤드리스 컴포넌트 테스트


컴포넌트의 로직이 중앙 집중화되어 있어 다양한 시나리오에서 재사용할 수 있기 때문에 이 기능이 안정적으로 작동하는 것이 중요하다. 따라서 포괄적인 테스트는 필수이다. 다행인 점은 이러한 동작을 테스트하는 것이 간단하다는 것이다.

퍼블릭 메서드를 호출하고 해당 상태 변화를 관찰하여 상태 관리를 검증할 수 있다. 예를 들어 toggleDropdown 과 isOpen 상태 사이의 관계를 살펴볼 수 있다.

const items = [{ text: 'Apple' }, { text: 'Orange' }, { text: 'Banana' }];

it('드롭다운 열기/닫기 상태가 핸들링된다.', () => {
  const { result } = renderHook(() => useDropdown(items));

  expect(result.current.isOpen).toBe(false);

  act(() => {
    result.current.toggleDropdown();
  });

  expect(result.current.isOpen).toBe(true);

  act(() => {
    result.current.toggleDropdown();
  });

  expect(result.current.isOpen).toBe(false);
});

키보드 탐색 테스트는 주로 시각적 인터페이스가 없기 때문에 약간 더 복잡하다. 따라서 보다 통합적인 테스트 접근 방식이 필요하다. 한 가지 효과적인 방법은 동작을 인증하기 위해 가짜 테스트 컴포넌트를 만드는 것이다. 이러한 테스트는 헤드리스 컴포넌트 활용에 대한 지침 가이드를 제공하고 JSX를 사용하기 때문에 사용자 상호 작용에 대한 진정한 인사이트를 제공하는 두 가지 용도로 사용된다.

profile
노원거인

0개의 댓글