JAN's History
헤드리스 컴포넌트란? React에서 UI와 로직을 분리하는 방법 본문
React에서 컴포넌트를 설계할 때 우리는 흔히 UI와 상태 로직 관리를 한 컴포넌트 안에 섞어서 작성합니다.
예를 들어 드롭다운 메뉴를 만든다고 하면 버튼 클릭, 메뉴 열고/닫기 상태, 선택된 항목 관리, 렌더링 되는 리스트 아이템과 스타일까지 모두 한 컴포넌트에서 처리하는 경우가 많죠. 이렇게 작성하면 초기에 빠르게 기능을 구현할 순 있지만, 컴포넌트가 커지고 다양한 UI 요구사항이 생기면 재사용성과 유지보수성이 떨어질 수 있습니다.
여기서 등장한 것이 바로 헤드리스(Headless) 컴포넌트 입니다. 헤드리스 컴포넌트는 UI를 직접 렌더링하지 않고, 상태와 동작(로직)만 제공하는 컴포넌트를 말하는데요!
즉, 어떻게 보여줄지는 사용자에게 맡기고 기능과 상태만 제공하는 구조라고 할 수 있습니다.
드롭다운 목록 구현하기
드롭다운 목록은 많은 곳에서 사용되는 일반적인 컴포넌트입니다. 완벽한 컴포넌트를 위해 처음부터 새로 만들려면 보기보다 더 많은 노력이 필요하죠.. 키보드 탐색, 접근성(예: 스크린 리더 호환성), 모바일 디바이스에서의 사용성 등을 고려해야 합니다. 사실 처음부터 이 작업을 하는 것은 권장하지 않습니다. 그 대신 잘 만들어진 라이브러리를 사용하는 것을 권장합니다.
기본적으로 사용자가 클릭할 요소(앞으로는 트리거 요소라고 부르겠습니다)와 목록 패널의 표시 및 숨기기 동작을 제어할 상태가 필요합니다. 처음에는 패널을 숨기고 트리거 요소가 클릭되면 목록 패널을 표시합니다.
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 상태가 업데이트됩니다.
컴포넌트를 더 명확하게 보기 위해 더 작고 관리하기 쉬운 조각으로 분해해 보겠습니다. 이 분해는 헤드리스 컴포넌트 패턴의 일부가 아니지만, 복잡한 UI 컴포넌트를 여러 조각으로 나누는 것은 아주 좋은 자세죠!
사용자 클릭을 처리하는 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>
);
};
업데이트된 이번 코드 구조에서는 드롭다운의 각 부분에 대해 특화된 컴포넌트를 생성하여 관련 사항을 분리함으로써 코드를 더욱 체계적이고, 쉽게 관리할 수 있도록 했습니다.

위 이미지에 표시된 것처럼 “Select an item…” 트리거 요소를 클릭하여 드롭다운을 열 수 있습니다.
목록에서 값을 선택하면 표시된 값이 업데이트되고 드롭다운 메뉴가 닫힙니다.
이 시점에서 리팩터링된 코드는 각 세그먼트가 간단하고 적응성이 뛰어나며 명확합니다. Trigger 컴포넌트를 수정하거나, 다른 컴포넌트를 도입하는 것은 비교적 간단합니다. 하지만 더 많은 기능을 도입하고 추가 상태를 관리할 때 현재 컴포넌트들이 그대로 유지될 수 있을까요?
자세한 내용은 키보드 탐색 기능이라는 중요한 개선 사항을 도입을 통해 알아봅시다.
키보드 탐색 구현
드롭다운 목록에 키보드 탐색 기능을 통합하면 마우스로 해야 하는 작업을 대체할 수 있어 사용자 경험이 향상됩니다. 이는 접근성을 위해 특히 중요하며 웹 페이지에서 원활한 탐색 환경을 제공합니다. 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 컴포넌트를 더욱 개선하고 기능을 추가할 수 있다는 것을 알려줍니다.
이 디자인의 장점은 로직과 프레젠테이션이 명확하게 분리되어 있다는 점입니다. 여기서 ‘로직’이란 선택 컴포넌트의 핵심 기능인 열기/닫기 상태, 선택된 항목, 강조 표시된 요소, 목록에서 선택할 때 화살표 아래로 누르는 등의 사용자 입력에 대한 반응 등을 의미합니다. 이러한 분리를 통해 컴포넌트는 특정 시각적 표현에 얽매이지 않고 핵심 동작을 유지하므로 “헤드리스 컴포넌트”라는 단어를 잘 보여줍니다.
헤드리스 컴포넌트의 장점
- UI와 로직 분리
- 컴포넌트 내부에서 상태와 행동을 관리하지만, 스타일이나 DOM 구조는 사용자가 결정합니다.
- 예를 들어 드롭다운의 토글, 선택 항목 관리, 접근성(ARIA) 상태는 헤드리스 컴포넌트가 제공하고, 버튼이나 리스트의 렌더링은 사용자 마음대로 할 수 있습니다.
- 재사용성 증가
- 동일한 로직을 다양한 UI로 재사용 가능.
- 예: 동일한 드롭다운 로직을 테이블 안에서도, 카드 안에서도 동일하게 사용 가능.
- 접근성 확보
- 로직을 담당하는 컴포넌트에서 ARIA 속성, 키보드 네비게이션 등을 제공하면, 사용자는 UI를 자유롭게 커스터마이징하면서도 접근성을 지킬 수 있습니다.
기본 헤드리스 vs 극단적인 헤드리스
제가 경험해본 바로, 헤드리스 컴포넌트도 수준에 따라 나눌 수 있었는데요.
기본 헤드리스
- 최소한의 DOM 래퍼는 존재.
- UI 렌더링을 위임하지만, 상태와 참조(ref), 접근성 속성을 제공합니다.
import React, { createContext, useContext, useState, useRef } from "react";
type DropdownContextType = {
isOpen: boolean;
toggleDropdown: () => void;
dropdownRef: React.RefObject<HTMLDivElement>;
};
const DropdownContext = createContext<DropdownContextType | null>(null);
export const useDropdownContext = () => {
const context = useContext(DropdownContext);
if (!context) throw new Error("Dropdown 안에서만 사용 가능");
return context;
};
export const HeadlessDropdown: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
const toggleDropdown = () => setIsOpen(prev => !prev);
return (
<DropdownContext.Provider value={{ isOpen, toggleDropdown, dropdownRef }}>
<div ref={dropdownRef}>{children}</div> {/* 최소한의 DOM wrapper */}
</DropdownContext.Provider>
);
};
// 자식 컴포넌트 사용 예시
HeadlessDropdown.Trigger = ({ children }) => {
const { toggleDropdown } = useDropdownContext();
return <button onClick={toggleDropdown}>{children}</button>;
};
극단적인 헤드리스
- DOM 요소조차 렌더링하지 않고, 상태와 이벤트 핸들러, ref만 제공합니다.
- 사용자가 모든 UI를 완전히 결정해야 함.
import React, { createContext, useContext, useState } from "react";
type ToggleContextType = { isOpen: boolean; toggle: () => void };
const ToggleContext = createContext<ToggleContextType | null>(null);
export const useToggle = () => {
const ctx = useContext(ToggleContext);
if (!ctx) throw new Error("Toggle 안에서만 사용 가능");
return ctx;
};
export const HeadlessToggle: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [isOpen, setIsOpen] = useState(false);
const toggle = () => setIsOpen(prev => !prev);
return <ToggleContext.Provider value={{ isOpen, toggle }}>{children}</ToggleContext.Provider>;
};
// 사용자는 버튼, div, span 등 자유롭게 렌더링 가능
const Example = () => (
<HeadlessToggle>
{() => {
const { isOpen, toggle } = useToggle();
return <button onClick={toggle}>{isOpen ? "닫기" : "열기"}</button>;
}}
</HeadlessToggle>
);
- 이 경우 HeadlessToggle 자체는 아무 DOM도 렌더링하지 않습니다.
- 모든 UI 구성과 접근성 처리, 스타일링을 사용자가 담당합니다.
- 극단적 헤드리스의 장점은 최대한 자유롭다는 것, 단점은 모든 UI를 직접 구현해야 한다는 것입니다.
헤드리스 컴포넌트를 사용할 때 고려할 점
- 상태/로직의 범위
- 어떤 상태를 헤드리스 컴포넌트에서 관리하고, 어떤 부분을 사용자가 직접 관리할지 명확히 해야 합니다.
- 접근성(ARIA) 처리
- Headless 컴포넌트는 접근성을 책임지는 경우가 많으므로, 사용자가 UI를 구현해도 키보드 네비게이션과 ARIA 속성이 작동하도록 해야 합니다.
- UI 라이브러리와의 조화
- Styled-components, Tailwind, MUI 같은 UI 라이브러리와 결합하면, 상태 로직은 Headless가, 스타일과 마크업은 라이브러리가 담당하는 구조로 효율적인 재사용이 가능합니다.
헤드리스 컴포넌트는 로직과 UI를 분리하여 재사용성과 커스터마이징을 극대화하는 패턴입니다.
- 기본 헤드리스: 최소한의 DOM 래퍼 제공 + 상태/로직 제공
- 극단적 헤드리스: DOM조차 제공하지 않고 상태/이벤트만 제공
React에서 복잡한 UI를 다양한 스타일로 재사용하고 싶다면, 헤드리스 컴포넌트는 좋은 방법이 될 수 있겠죠!
'React' 카테고리의 다른 글
| React 학습하기 - 탈출구 (3) | 2025.08.08 |
|---|---|
| React 학습하기 - State 관리하기 (3) | 2025.08.03 |
| React 학습하기 - 상호작용 더하기 (3) | 2025.07.30 |
| React 학습하기 - UI 표현하기 (2) | 2025.07.26 |
| React 학습하기 - 빠르게 시작하기 (1) | 2025.07.21 |