HTML의 <select><option>처럼, 여러 컴포넌트가 하나의 기능을 이루며 상태를 암묵적으로 공유하는 패턴은 React에서 어떻게 구현할까요?

개념 정의

Compound Component 패턴은 여러 관련 컴포넌트가 암묵적으로 상태를 공유 하며, 함께 사용될 때 하나의 완성된 기능을 제공하는 설계 패턴입니다. HTML의 <select>+<option>, <table>+<tr>+<td> 같은 관계를 React 컴포넌트로 구현한 것입니다.

왜 필요한가

하나의 거대한 컴포넌트에 모든 옵션을 props로 전달하는 방식은 유연성이 떨어집니다.

JSX
// ❌ Props 폭발 — 옵션이 많아질수록 복잡
<Accordion
  items={[
    { title: '섹션 1', content: <Content1 />, disabled: false, icon: 'star' },
    { title: '섹션 2', content: <Content2 />, disabled: true, icon: null },
  ]}
  multiple={false}
  defaultOpen={0}
  onChange={handleChange}
  headerClassName="custom-header"
  contentClassName="custom-content"
/>

// ✅ Compound Component — 선언적이고 유연
<Accordion defaultOpen={0}>
  <Accordion.Item>
    <Accordion.Trigger icon="star">섹션 1</Accordion.Trigger>
    <Accordion.Content><Content1 /></Accordion.Content>
  </Accordion.Item>
  <Accordion.Item disabled>
    <Accordion.Trigger>섹션 2</Accordion.Trigger>
    <Accordion.Content><Content2 /></Accordion.Content>
  </Accordion.Item>
</Accordion>

구현 방법 1: Context 기반 (권장)

Select 컴포넌트 구현

JSX
import { createContext, useContext, useState, useCallback } from 'react';

// 1. Context 생성
const SelectContext = createContext(null);

// 2. 부모 컴포넌트 — 상태 관리
function Select({ value, onChange, children }) {
  const [isOpen, setIsOpen] = useState(false);

  const toggle = useCallback(() => setIsOpen(prev => !prev), []);
  const close = useCallback(() => setIsOpen(false), []);

  const contextValue = {
    value,
    onChange,
    isOpen,
    toggle,
    close,
  };

  return (
    <SelectContext.Provider value={contextValue}>
      <div className="select-container">
        {children}
      </div>
    </SelectContext.Provider>
  );
}

// 3. 자식 컴포넌트들
function SelectTrigger({ children, placeholder = '선택하세요' }) {
  const { value, isOpen, toggle } = useContext(SelectContext);

  return (
    <button
      className={`select-trigger ${isOpen ? 'open' : ''}`}
      onClick={toggle}
      aria-expanded={isOpen}
    >
      {value || placeholder}
      <span className="select-arrow">{isOpen ? '▲' : '▼'}</span>
    </button>
  );
}

function SelectOptions({ children }) {
  const { isOpen } = useContext(SelectContext);

  if (!isOpen) return null;

  return (
    <ul className="select-options" role="listbox">
      {children}
    </ul>
  );
}

function SelectOption({ value, children }) {
  const { value: selectedValue, onChange, close } = useContext(SelectContext);
  const isSelected = value === selectedValue;

  const handleClick = () => {
    onChange(value);
    close();
  };

  return (
    <li
      className={`select-option ${isSelected ? 'selected' : ''}`}
      onClick={handleClick}
      role="option"
      aria-selected={isSelected}
    >
      {children}
      {isSelected && <span></span>}
    </li>
  );
}

// 4. 하위 컴포넌트를 부모에 연결
Select.Trigger = SelectTrigger;
Select.Options = SelectOptions;
Select.Option = SelectOption;

export default Select;

사용

JSX
function CountrySelector() {
  const [country, setCountry] = useState('');

  return (
    <Select value={country} onChange={setCountry}>
      <Select.Trigger placeholder="국가를 선택하세요" />
      <Select.Options>
        <Select.Option value="kr">대한민국</Select.Option>
        <Select.Option value="us">미국</Select.Option>
        <Select.Option value="jp">일본</Select.Option>
      </Select.Options>
    </Select>
  );
}

구현 방법 2: React.Children API

Context 없이 직접 자식을 순회하며 props를 주입하는 방식입니다.

JSX
function Tabs({ activeIndex, onChange, children }) {
  return (
    <div className="tabs">
      <div className="tab-list" role="tablist">
        {React.Children.map(children, (child, index) => {
          if (child.type === TabPanel) {
            return React.cloneElement(child, {
              isActive: index === activeIndex,
              onSelect: () => onChange(index),
              index,
            });
          }
          return child;
        })}
      </div>
    </div>
  );
}

React.Children의 한계

JSX
// ❌ 래퍼가 있으면 동작하지 않음
<Tabs activeIndex={0} onChange={setIndex}>
  <div className="wrapper">
    <TabPanel title="탭1">내용1</TabPanel>  {/* 접근 불가 */}
  </div>
</Tabs>

// Context 방식은 이런 제약이 없음

이런 이유로 Context 기반 구현이 더 유연하고 권장 됩니다.

실전: Accordion 컴포넌트

JSX
const AccordionContext = createContext(null);
const AccordionItemContext = createContext(null);

function Accordion({ children, multiple = false, defaultOpen = [] }) {
  const [openItems, setOpenItems] = useState(
    Array.isArray(defaultOpen) ? defaultOpen : [defaultOpen]
  );

  const toggle = useCallback((index) => {
    setOpenItems(prev => {
      if (prev.includes(index)) {
        return prev.filter(i => i !== index);
      }
      return multiple ? [...prev, index] : [index];
    });
  }, [multiple]);

  const isOpen = useCallback((index) => openItems.includes(index), [openItems]);

  return (
    <AccordionContext.Provider value={{ toggle, isOpen }}>
      <div className="accordion">{children}</div>
    </AccordionContext.Provider>
  );
}

function AccordionItem({ children, index, disabled = false }) {
  return (
    <AccordionItemContext.Provider value={{ index, disabled }}>
      <div className={`accordion-item ${disabled ? 'disabled' : ''}`}>
        {children}
      </div>
    </AccordionItemContext.Provider>
  );
}

function AccordionTrigger({ children, icon }) {
  const { toggle, isOpen } = useContext(AccordionContext);
  const { index, disabled } = useContext(AccordionItemContext);
  const open = isOpen(index);

  return (
    <button
      className={`accordion-trigger ${open ? 'open' : ''}`}
      onClick={() => !disabled && toggle(index)}
      disabled={disabled}
      aria-expanded={open}
    >
      {icon && <span className="trigger-icon">{icon}</span>}
      {children}
      <span className="chevron">{open ? '−' : '+'}</span>
    </button>
  );
}

function AccordionContent({ children }) {
  const { isOpen } = useContext(AccordionContext);
  const { index } = useContext(AccordionItemContext);

  if (!isOpen(index)) return null;

  return (
    <div className="accordion-content" role="region">
      {children}
    </div>
  );
}

Accordion.Item = AccordionItem;
Accordion.Trigger = AccordionTrigger;
Accordion.Content = AccordionContent;

사용

JSX
<Accordion multiple defaultOpen={[0]}>
  <Accordion.Item index={0}>
    <Accordion.Trigger>React란?</Accordion.Trigger>
    <Accordion.Content>
      <p>사용자 인터페이스를 구축하기 위한 JavaScript 라이브러리입니다.</p>
    </Accordion.Content>
  </Accordion.Item>

  <Accordion.Item index={1}>
    <Accordion.Trigger icon="⚡">성능 최적화</Accordion.Trigger>
    <Accordion.Content>
      <p>React.memo, useMemo, useCallback 등을 활용합니다.</p>
    </Accordion.Content>
  </Accordion.Item>

  <Accordion.Item index={2} disabled>
    <Accordion.Trigger>준비 중</Accordion.Trigger>
    <Accordion.Content>
      <p>아직 작성되지 않은 섹션입니다.</p>
    </Accordion.Content>
  </Accordion.Item>
</Accordion>

Headless UI

Compound Component의 진화형으로, 로직과 접근성만 제공 하고 스타일은 사용자에게 위임합니다.

JSX
// Headless UI 라이브러리 예시 (Radix UI, Headless UI)
import * as Select from '@radix-ui/react-select';

function MySelect() {
  return (
    <Select.Root>
      <Select.Trigger className="my-trigger">
        <Select.Value placeholder="선택" />
        <Select.Icon />
      </Select.Trigger>

      <Select.Content className="my-dropdown">
        <Select.Item value="apple" className="my-option">
          <Select.ItemText>사과</Select.ItemText>
        </Select.Item>
        <Select.Item value="banana" className="my-option">
          <Select.ItemText>바나나</Select.ItemText>
        </Select.Item>
      </Select.Content>
    </Select.Root>
  );
}

Headless UI의 장점입니다.

  • 접근성(WAI-ARIA) 패턴이 내장되어 있음
  • 키보드 네비게이션, 포커스 관리가 자동
  • 스타일을 완전히 커스터마이징 가능
  • 디자인 시스템과 쉽게 통합

주의할 점

React.Children API의 한계

React.Children.map을 사용한 구현은 하위 컴포넌트가 ** 직접적인 자식 **일 때만 동작합니다. <div>로 감싸거나 Fragment를 사용하면 깨집니다. Context 기반 구현이 훨씬 유연하고 권장됩니다.

Compound Component를 과도하게 적용

단순한 버튼이나 입력 필드에 Compound Component 패턴을 적용하면 오히려 복잡해집니다. ** 여러 하위 컴포넌트가 상태를 공유해야 하는 위젯 **(Select, Accordion, Tabs)에만 사용해야 합니다.

정리

항목설명
핵심여러 컴포넌트가 암묵적으로 상태를 공유하는 패턴
구현 방식Context 기반이 React.Children보다 유연하고 권장
API 특성사용자에게 선언적이고 유연한 인터페이스 제공
Headless UI로직과 접근성만 제공, 스타일은 위임하는 진화형
적합한 대상Select, Accordion, Tabs, Menu 등 상태 공유 위젯

이 패턴을 알면 "props를 더 추가해야 하나?"가 아니라 "하위 컴포넌트로 분리하여 합성하자"는 사고가 자연스럽게 됩니다.

댓글 로딩 중...