Compound Pattern

Select와 Option 컴포넌트를 본 적 있나요? 아니면 HTML의 <table>, <tr>, <td>처럼 함께 사용해야 의미가 있는 요소들을 떠올려보세요. React의 Compound Pattern은 바로 이런 방식으로 작동하는 컴포넌트들을 만드는 디자인 패턴입니다.

저는 처음 이 패턴을 접했을 때, “이게 정말 필요한가?”라는 의문이 들었습니다. 단순히 props를 전달하는 것만으로도 충분하지 않을까 생각했죠. 하지만 복잡한 UI 라이브러리를 만들면서 이 패턴의 진가를 깨달았습니다. 특히 Accordion, Tabs, Dropdown 같은 컴포넌트를 구현할 때 Compound Pattern은 유연성과 명확성을 동시에 제공했습니다.

왜 Compound Pattern이 필요한가?

실제 문제 상황

여러분이 재사용 가능한 Modal 컴포넌트를 만든다고 상상해보세요. 처음에는 이렇게 시작할 겁니다.

// 초기 버전 - props만 사용
<Modal
  title="제목"
  content="내용"
  footer="푸터"
  onClose={handleClose}
/>

하지만 곧 다양한 요구사항이 생깁니다.

  • “제목에 아이콘을 넣고 싶어요”
  • “푸터에 버튼을 3개 넣고 싶어요”
  • “제목과 닫기 버튼 사이에 검색창을 넣고 싶어요”
  • “본문에 탭을 넣고 싶어요”

이제 Modal은 이렇게 변합니다.

// 점점 복잡해지는 props
<Modal
  title="제목"
  titleIcon={<SearchIcon />}
  headerExtra={<SearchBar />}
  content="내용"
  footer="푸터"
  footerButtons={[button1, button2, button3]}
  footerAlign="right"
  showCloseButton={true}
  closeButtonPosition="top-right"
  onClose={handleClose}
/>

이런 식으로 계속 props를 추가하다 보면:

  • Props가 무한정 늘어남
  • 컴포넌트 내부 로직이 복잡해짐
  • 새로운 요구사항마다 코드 수정 필요
  • 타입 정의가 지옥이 됨

Compound Pattern의 해결책

Compound Pattern을 사용하면 이렇게 바뀝니다.

// 유연하고 명확한 구조
<Modal isOpen={isOpen} onClose={handleClose}>
  <Modal.Header>
    <SearchIcon />
    <Modal.Title>제목</Modal.Title>
    <SearchBar />
    <Modal.CloseButton />
  </Modal.Header>

  <Modal.Body>
    <Tabs>
      <Tabs.Tab>첫 번째 탭</Tabs.Tab>
      <Tabs.Tab>두 번째 탭</Tabs.Tab>
    </Tabs>
  </Modal.Body>

  <Modal.Footer align="right">
    <Button>취소</Button>
    <Button>저장</Button>
    <Button variant="primary">확인</Button>
  </Modal.Footer>
</Modal>

이제 다음이 가능합니다.

  • 무한한 유연성: 원하는 대로 조합 가능
  • 명확한 구조: 코드만 봐도 UI 구조가 보임
  • 확장 가능: 새로운 요구사항에 코드 수정 없이 대응
  • 재사용성: 각 서브 컴포넌트를 독립적으로 사용 가능

Compound Pattern의 핵심 개념

1. 암묵적 상태 공유 (Implicit State Sharing)

자식 컴포넌트들이 부모의 상태를 명시적인 props 전달 없이 공유합니다. 이는 React Context API를 통해 구현됩니다.

// 부모 컴포넌트에서 상태 관리
const Tabs = ({ children }) => {
  const [activeTab, setActiveTab] = useState(0);

  return (
    <TabsContext.Provider value={{ activeTab, setActiveTab }}>
      <div className="tabs">{children}</div>
    </TabsContext.Provider>
  );
};

// 자식 컴포넌트에서 props 없이 상태 접근
const TabPanel = ({ index, children }) => {
  const { activeTab } = useContext(TabsContext); // Context에서 직접 가져옴
  return activeTab === index ? <div>{children}</div> : null;
};

2. 유연한 조합 (Flexible Composition)

컴포넌트의 순서와 조합을 자유롭게 변경할 수 있습니다.

// 패턴 1: 기본 구조
<Accordion>
  <Accordion.Item>
    <Accordion.Header>제목</Accordion.Header>
    <Accordion.Panel>내용</Accordion.Panel>
  </Accordion.Item>
</Accordion>

// 패턴 2: 커스텀 아이콘 추가
<Accordion>
  <Accordion.Item>
    <Accordion.Header>
      <CustomIcon />
      <span>제목</span>
    </Accordion.Header>
    <Accordion.Panel>내용</Accordion.Panel>
  </Accordion.Item>
</Accordion>

// 패턴 3: 조건부 렌더링
<Accordion>
  <Accordion.Item>
    <Accordion.Header>제목</Accordion.Header>
    {showDetails && <Accordion.Panel>내용</Accordion.Panel>}
  </Accordion.Item>
</Accordion>

3. 명시적 API (Explicit API)

각 서브 컴포넌트의 이름이 그 역할을 명확히 표현합니다.

// 좋은 예 - 이름만 봐도 역할이 명확
<Card>
  <Card.Image />      // 이미지를 표시
  <Card.Title />      // 제목을 표시
  <Card.Description /> // 설명을 표시
  <Card.Actions />    // 액션 버튼들
</Card>

// 나쁜 예 - 역할이 불명확
<Card>
  <Card.Top />        // 무엇을 넣어야 하나?
  <Card.Content />    // 제목? 내용? 둘 다?
  <Card.Bottom />     // 어떤 요소가 들어가나?
</Card>

컴포넌트 관계 다이어그램

1. Context 기반 통신 구조

┌─────────────────────────────────────────┐
│         Parent Component                │
│  ┌───────────────────────────────────┐  │
│  │     Context Provider              │  │
│  │  (상태: isOpen, toggle, value)    │  │
│  └───────────────┬───────────────────┘  │
│                  │                       │
│         ┌────────┼────────┐             │
│         ↓        ↓         ↓             │
│   ┌─────────┐ ┌────────┐ ┌─────────┐   │
│   │ Child 1 │ │Child 2 │ │ Child 3 │   │
│   │(Header) │ │ (Body) │ │(Footer) │   │
│   └─────────┘ └────────┘ └─────────┘   │
│        ↑          ↑          ↑          │
│        └──────────┴──────────┘          │
│         Context Consumer                │
│      (상태를 읽고 업데이트)              │
└─────────────────────────────────────────┘

2. Accordion 컴포넌트 구조

Accordion (상태: openIndex)
│
├─ AccordionItem (index: 0)
│  ├─ AccordionHeader (toggle 함수 호출)
│  │  └─ "첫 번째 섹션" + Icon
│  │
│  └─ AccordionPanel (openIndex === 0 일 때 표시)
│     └─ "첫 번째 내용"
│
├─ AccordionItem (index: 1)
│  ├─ AccordionHeader (toggle 함수 호출)
│  │  └─ "두 번째 섹션" + Icon
│  │
│  └─ AccordionPanel (openIndex === 1 일 때 표시)
│     └─ "두 번째 내용"
│
└─ AccordionItem (index: 2)
   ├─ AccordionHeader (toggle 함수 호출)
   │  └─ "세 번째 섹션" + Icon
   │
   └─ AccordionPanel (openIndex === 2 일 때 표시)
      └─ "세 번째 내용"

3. 데이터 흐름 다이어그램

User Click
    ↓
AccordionHeader (index: 1)
    ↓
setOpenIndex(1) 호출
    ↓
Context 상태 업데이트
    ↓
모든 Consumer 리렌더링
    ↓
├─ AccordionItem (index: 0) → isOpen: false → 닫힘
├─ AccordionItem (index: 1) → isOpen: true  → 열림 ✓
└─ AccordionItem (index: 2) → isOpen: false → 닫힘

구현 방법

방법 1: Context API를 활용한 기본 구현

import { createContext, useContext, useState, ReactNode } from 'react';

// 1. Context 타입 정의
interface AccordionContextType {
  openIndex: number | null;
  setOpenIndex: (index: number | null) => void;
}

// 2. Context 생성
const AccordionContext = createContext<AccordionContextType | undefined>(undefined);

// 3. Custom Hook으로 Context 사용 편의성 증가
function useAccordion() {
  const context = useContext(AccordionContext);
  if (!context) {
    throw new Error('Accordion 컴포넌트 내부에서만 사용 가능합니다');
  }
  return context;
}

// 4. 부모 컴포넌트 - 상태 관리 및 Context 제공
interface AccordionProps {
  children: ReactNode;
  defaultIndex?: number | null;
  allowMultiple?: boolean;
}

const Accordion = ({ children, defaultIndex = null }: AccordionProps) => {
  const [openIndex, setOpenIndex] = useState<number | null>(defaultIndex);

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

// 5. 서브 컴포넌트들 - Context를 소비하여 상태 공유
interface AccordionItemProps {
  children: ReactNode;
  index: number;
}

const AccordionItem = ({ children, index }: AccordionItemProps) => {
  const { openIndex } = useAccordion();
  const isOpen = openIndex === index;

  return (
    <div className={`accordion-item ${isOpen ? 'open' : ''}`}>
      {children}
    </div>
  );
};

interface AccordionHeaderProps {
  children: ReactNode;
  index: number;
}

const AccordionHeader = ({ children, index }: AccordionHeaderProps) => {
  const { setOpenIndex, openIndex } = useAccordion();

  const handleClick = () => {
    // 같은 항목 클릭 시 닫기, 다른 항목 클릭 시 열기
    setOpenIndex(openIndex === index ? null : index);
  };

  return (
    <button
      className="accordion-header"
      onClick={handleClick}
      aria-expanded={openIndex === index}
    >
      {children}
      <span className="icon">{openIndex === index ? '' : ''}</span>
    </button>
  );
};

interface AccordionPanelProps {
  children: ReactNode;
  index: number;
}

const AccordionPanel = ({ children, index }: AccordionPanelProps) => {
  const { openIndex } = useAccordion();

  if (openIndex !== index) return null;

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

// 6. 서브 컴포넌트를 부모에 연결 (Compound Component 패턴의 핵심)
Accordion.Item = AccordionItem;
Accordion.Header = AccordionHeader;
Accordion.Panel = AccordionPanel;

export default Accordion;

방법 2: 고급 구현 - Multiple Open 지원

import { createContext, useContext, useState, ReactNode } from 'react';

interface AccordionContextType {
  openIndexes: number[];
  toggleIndex: (index: number) => void;
  allowMultiple: boolean;
}

const AccordionContext = createContext<AccordionContextType | undefined>(undefined);

function useAccordion() {
  const context = useContext(AccordionContext);
  if (!context) {
    throw new Error('Accordion 컴포넌트 내부에서만 사용 가능합니다');
  }
  return context;
}

interface AccordionProps {
  children: ReactNode;
  defaultIndexes?: number[];
  allowMultiple?: boolean; // 여러 패널을 동시에 열 수 있는지
}

const Accordion = ({
  children,
  defaultIndexes = [],
  allowMultiple = false
}: AccordionProps) => {
  const [openIndexes, setOpenIndexes] = useState<number[]>(defaultIndexes);

  const toggleIndex = (index: number) => {
    setOpenIndexes(prev => {
      if (allowMultiple) {
        // 다중 선택 모드: 배열에 추가/제거
        return prev.includes(index)
          ? prev.filter(i => i !== index)
          : [...prev, index];
      } else {
        // 단일 선택 모드: 하나만 유지
        return prev.includes(index) ? [] : [index];
      }
    });
  };

  return (
    <AccordionContext.Provider value={{ openIndexes, toggleIndex, allowMultiple }}>
      <div className="accordion" role="region">
        {children}
      </div>
    </AccordionContext.Provider>
  );
};

const AccordionItem = ({ children, index }: { children: ReactNode; index: number }) => {
  const { openIndexes } = useAccordion();
  const isOpen = openIndexes.includes(index);

  return (
    <div className={`accordion-item ${isOpen ? 'open' : ''}`}>
      {children}
    </div>
  );
};

const AccordionHeader = ({ children, index }: { children: ReactNode; index: number }) => {
  const { toggleIndex, openIndexes } = useAccordion();
  const isOpen = openIndexes.includes(index);

  return (
    <button
      className="accordion-header"
      onClick={() => toggleIndex(index)}
      aria-expanded={isOpen}
    >
      {children}
      <span className="icon">{isOpen ? '' : ''}</span>
    </button>
  );
};

const AccordionPanel = ({ children, index }: { children: ReactNode; index: number }) => {
  const { openIndexes } = useAccordion();
  const isOpen = openIndexes.includes(index);

  return (
    <div
      className={`accordion-panel ${isOpen ? 'visible' : 'hidden'}`}
      role="region"
      aria-hidden={!isOpen}
    >
      {children}
    </div>
  );
};

Accordion.Item = AccordionItem;
Accordion.Header = AccordionHeader;
Accordion.Panel = AccordionPanel;

export default Accordion;

사용 예시

// 기본 사용 (단일 선택)
<Accordion defaultIndexes={[0]}>
  <Accordion.Item index={0}>
    <Accordion.Header index={0}>
      첫 번째 섹션
    </Accordion.Header>
    <Accordion.Panel index={0}>
      첫 번째 내용입니다.
    </Accordion.Panel>
  </Accordion.Item>

  <Accordion.Item index={1}>
    <Accordion.Header index={1}>
      두 번째 섹션
    </Accordion.Header>
    <Accordion.Panel index={1}>
      두 번째 내용입니다.
    </Accordion.Panel>
  </Accordion.Item>
</Accordion>

// 다중 선택 모드
<Accordion allowMultiple defaultIndexes={[0, 2]}>
  <Accordion.Item index={0}>
    <Accordion.Header index={0}>FAQ 1</Accordion.Header>
    <Accordion.Panel index={0}>답변 1</Accordion.Panel>
  </Accordion.Item>

  <Accordion.Item index={1}>
    <Accordion.Header index={1}>FAQ 2</Accordion.Header>
    <Accordion.Panel index={1}>답변 2</Accordion.Panel>
  </Accordion.Item>

  <Accordion.Item index={2}>
    <Accordion.Header index={2}>FAQ 3</Accordion.Header>
    <Accordion.Panel index={2}>답변 3</Accordion.Panel>
  </Accordion.Item>
</Accordion>

실전 예제: 6가지 필수 컴포넌트

1. Select / Dropdown 컴포넌트

HTML의 <select><option>처럼 작동하는 커스텀 드롭다운입니다.

import { createContext, useContext, useState, ReactNode, useRef, useEffect } from 'react';

interface SelectContextType {
  value: string;
  isOpen: boolean;
  toggle: () => void;
  selectOption: (value: string) => void;
}

const SelectContext = createContext<SelectContextType | undefined>(undefined);

function useSelect() {
  const context = useContext(SelectContext);
  if (!context) {
    throw new Error('Select 컴포넌트 내부에서만 사용 가능합니다');
  }
  return context;
}

interface SelectProps {
  children: ReactNode;
  value?: string;
  onChange?: (value: string) => void;
  placeholder?: string;
}

const Select = ({ children, value: controlledValue, onChange, placeholder = '선택하세요' }: SelectProps) => {
  const [isOpen, setIsOpen] = useState(false);
  const [internalValue, setInternalValue] = useState('');
  const selectRef = useRef<HTMLDivElement>(null);

  const value = controlledValue ?? internalValue;

  const toggle = () => setIsOpen(prev => !prev);

  const selectOption = (newValue: string) => {
    setInternalValue(newValue);
    onChange?.(newValue);
    setIsOpen(false);
  };

  // 외부 클릭 감지
  useEffect(() => {
    const handleClickOutside = (event: MouseEvent) => {
      if (selectRef.current && !selectRef.current.contains(event.target as Node)) {
        setIsOpen(false);
      }
    };

    document.addEventListener('mousedown', handleClickOutside);
    return () => document.removeEventListener('mousedown', handleClickOutside);
  }, []);

  return (
    <SelectContext.Provider value={{ value, isOpen, toggle, selectOption }}>
      <div className="select-container" ref={selectRef}>
        {children}
      </div>
    </SelectContext.Provider>
  );
};

const SelectTrigger = ({ children }: { children?: ReactNode }) => {
  const { isOpen, toggle, value } = useSelect();

  return (
    <button
      className={`select-trigger ${isOpen ? 'open' : ''}`}
      onClick={toggle}
      aria-haspopup="listbox"
      aria-expanded={isOpen}
    >
      {children || value || '선택하세요'}
      <span className="arrow">{isOpen ? '' : ''}</span>
    </button>
  );
};

const SelectContent = ({ children }: { children: ReactNode }) => {
  const { isOpen } = useSelect();

  if (!isOpen) return null;

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

interface SelectOptionProps {
  value: string;
  children: ReactNode;
}

const SelectOption = ({ value, children }: SelectOptionProps) => {
  const { selectOption, value: selectedValue } = useSelect();
  const isSelected = selectedValue === value;

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

Select.Trigger = SelectTrigger;
Select.Content = SelectContent;
Select.Option = SelectOption;

// 사용 예시
function App() {
  const [fruit, setFruit] = useState('');

  return (
    <Select value={fruit} onChange={setFruit}>
      <Select.Trigger />
      <Select.Content>
        <Select.Option value="apple">사과 🍎</Select.Option>
        <Select.Option value="banana">바나나 🍌</Select.Option>
        <Select.Option value="orange">오렌지 🍊</Select.Option>
        <Select.Option value="grape">포도 🍇</Select.Option>
      </Select.Content>
    </Select>
  );
}

2. Tabs 컴포넌트

탭 네비게이션을 구현하는 컴포넌트입니다.

import { createContext, useContext, useState, ReactNode } from 'react';

interface TabsContextType {
  activeTab: string;
  setActiveTab: (tab: string) => void;
}

const TabsContext = createContext<TabsContextType | undefined>(undefined);

function useTabs() {
  const context = useContext(TabsContext);
  if (!context) {
    throw new Error('Tabs 컴포넌트 내부에서만 사용 가능합니다');
  }
  return context;
}

interface TabsProps {
  children: ReactNode;
  defaultTab?: string;
}

const Tabs = ({ children, defaultTab }: TabsProps) => {
  const [activeTab, setActiveTab] = useState(defaultTab || '');

  return (
    <TabsContext.Provider value={{ activeTab, setActiveTab }}>
      <div className="tabs">
        {children}
      </div>
    </TabsContext.Provider>
  );
};

const TabList = ({ children }: { children: ReactNode }) => {
  return (
    <div className="tab-list" role="tablist">
      {children}
    </div>
  );
};

interface TabProps {
  value: string;
  children: ReactNode;
  disabled?: boolean;
}

const Tab = ({ value, children, disabled = false }: TabProps) => {
  const { activeTab, setActiveTab } = useTabs();
  const isActive = activeTab === value;

  return (
    <button
      className={`tab ${isActive ? 'active' : ''} ${disabled ? 'disabled' : ''}`}
      onClick={() => !disabled && setActiveTab(value)}
      role="tab"
      aria-selected={isActive}
      aria-disabled={disabled}
      disabled={disabled}
    >
      {children}
    </button>
  );
};

interface TabPanelProps {
  value: string;
  children: ReactNode;
}

const TabPanel = ({ value, children }: TabPanelProps) => {
  const { activeTab } = useTabs();

  if (activeTab !== value) return null;

  return (
    <div className="tab-panel" role="tabpanel">
      {children}
    </div>
  );
};

Tabs.List = TabList;
Tabs.Tab = Tab;
Tabs.Panel = TabPanel;

// 사용 예시
function ProductDetailPage() {
  return (
    <Tabs defaultTab="description">
      <Tabs.List>
        <Tabs.Tab value="description">상품 설명</Tabs.Tab>
        <Tabs.Tab value="reviews">리뷰 (123)</Tabs.Tab>
        <Tabs.Tab value="qna">Q&A</Tabs.Tab>
        <Tabs.Tab value="shipping">배송 정보</Tabs.Tab>
      </Tabs.List>

      <Tabs.Panel value="description">
        <h3>상품 상세 설명</h3>
        <p>이 상품은...</p>
      </Tabs.Panel>

      <Tabs.Panel value="reviews">
        <h3>고객 리뷰</h3>
        <ReviewList />
      </Tabs.Panel>

      <Tabs.Panel value="qna">
        <h3>자주 묻는 질문</h3>
        <QnAList />
      </Tabs.Panel>

      <Tabs.Panel value="shipping">
        <h3>배송 및 반품 안내</h3>
        <ShippingInfo />
      </Tabs.Panel>
    </Tabs>
  );
}

3. Modal 컴포넌트

접근성을 고려한 모달 다이얼로그입니다.

import { createContext, useContext, useEffect, ReactNode, useCallback } from 'react';
import { createPortal } from 'react-dom';

interface ModalContextType {
  isOpen: boolean;
  onClose: () => void;
}

const ModalContext = createContext<ModalContextType | undefined>(undefined);

function useModal() {
  const context = useContext(ModalContext);
  if (!context) {
    throw new Error('Modal 컴포넌트 내부에서만 사용 가능합니다');
  }
  return context;
}

interface ModalProps {
  children: ReactNode;
  isOpen: boolean;
  onClose: () => void;
}

const Modal = ({ children, isOpen, onClose }: ModalProps) => {
  // ESC 키로 닫기
  useEffect(() => {
    const handleEscape = (e: KeyboardEvent) => {
      if (e.key === 'Escape') onClose();
    };

    if (isOpen) {
      document.addEventListener('keydown', handleEscape);
      // body 스크롤 방지
      document.body.style.overflow = 'hidden';
    }

    return () => {
      document.removeEventListener('keydown', handleEscape);
      document.body.style.overflow = 'unset';
    };
  }, [isOpen, onClose]);

  if (!isOpen) return null;

  return createPortal(
    <ModalContext.Provider value={{ isOpen, onClose }}>
      <div className="modal-overlay" onClick={onClose}>
        <div
          className="modal-content"
          onClick={e => e.stopPropagation()}
          role="dialog"
          aria-modal="true"
        >
          {children}
        </div>
      </div>
    </ModalContext.Provider>,
    document.body
  );
};

const ModalHeader = ({ children }: { children: ReactNode }) => {
  return (
    <div className="modal-header">
      {children}
    </div>
  );
};

const ModalTitle = ({ children }: { children: ReactNode }) => {
  return <h2 className="modal-title">{children}</h2>;
};

const ModalCloseButton = () => {
  const { onClose } = useModal();

  return (
    <button
      className="modal-close-button"
      onClick={onClose}
      aria-label="닫기"
    ></button>
  );
};

const ModalBody = ({ children }: { children: ReactNode }) => {
  return <div className="modal-body">{children}</div>;
};

const ModalFooter = ({ children }: { children: ReactNode }) => {
  return <div className="modal-footer">{children}</div>;
};

Modal.Header = ModalHeader;
Modal.Title = ModalTitle;
Modal.CloseButton = ModalCloseButton;
Modal.Body = ModalBody;
Modal.Footer = ModalFooter;

// 사용 예시
function DeleteConfirmModal() {
  const [isOpen, setIsOpen] = useState(false);

  const handleDelete = async () => {
    await deleteItem();
    setIsOpen(false);
  };

  return (
    <>
      <button onClick={() => setIsOpen(true)}>삭제</button>

      <Modal isOpen={isOpen} onClose={() => setIsOpen(false)}>
        <Modal.Header>
          <Modal.Title>정말 삭제하시겠습니까?</Modal.Title>
          <Modal.CloseButton />
        </Modal.Header>

        <Modal.Body>
          <p>이 작업은 되돌릴 수 없습니다.</p>
          <p>정말로 삭제하시겠습니까?</p>
        </Modal.Body>

        <Modal.Footer>
          <button onClick={() => setIsOpen(false)}>취소</button>
          <button onClick={handleDelete} className="danger">
            삭제
          </button>
        </Modal.Footer>
      </Modal>
    </>
  );
}

4. Card 컴포넌트

다양한 레이아웃을 지원하는 카드 컴포넌트입니다.

import { ReactNode } from 'react';

interface CardProps {
  children: ReactNode;
  variant?: 'default' | 'elevated' | 'outlined';
  onClick?: () => void;
}

const Card = ({ children, variant = 'default', onClick }: CardProps) => {
  return (
    <div
      className={`card card-${variant} ${onClick ? 'clickable' : ''}`}
      onClick={onClick}
    >
      {children}
    </div>
  );
};

const CardImage = ({ src, alt }: { src: string; alt: string }) => {
  return (
    <div className="card-image">
      <img src={src} alt={alt} />
    </div>
  );
};

const CardHeader = ({ children }: { children: ReactNode }) => {
  return <div className="card-header">{children}</div>;
};

const CardTitle = ({ children }: { children: ReactNode }) => {
  return <h3 className="card-title">{children}</h3>;
};

const CardSubtitle = ({ children }: { children: ReactNode }) => {
  return <p className="card-subtitle">{children}</p>;
};

const CardBody = ({ children }: { children: ReactNode }) => {
  return <div className="card-body">{children}</div>;
};

const CardDescription = ({ children }: { children: ReactNode }) => {
  return <p className="card-description">{children}</p>;
};

const CardFooter = ({ children }: { children: ReactNode }) => {
  return <div className="card-footer">{children}</div>;
};

const CardActions = ({ children }: { children: ReactNode }) => {
  return <div className="card-actions">{children}</div>;
};

Card.Image = CardImage;
Card.Header = CardHeader;
Card.Title = CardTitle;
Card.Subtitle = CardSubtitle;
Card.Body = CardBody;
Card.Description = CardDescription;
Card.Footer = CardFooter;
Card.Actions = CardActions;

// 사용 예시 1: 블로그 카드
function BlogCard({ post }) {
  return (
    <Card variant="elevated">
      <Card.Image src={post.thumbnail} alt={post.title} />
      <Card.Body>
        <Card.Title>{post.title}</Card.Title>
        <Card.Subtitle>{post.author} · {post.date}</Card.Subtitle>
        <Card.Description>{post.excerpt}</Card.Description>
      </Card.Body>
      <Card.Footer>
        <Card.Actions>
          <button>읽기</button>
          <button>저장</button>
        </Card.Actions>
      </Card.Footer>
    </Card>
  );
}

// 사용 예시 2: 상품 카드
function ProductCard({ product }) {
  return (
    <Card variant="outlined" onClick={() => navigate(`/products/${product.id}`)}>
      <Card.Image src={product.image} alt={product.name} />
      <Card.Body>
        <Card.Title>{product.name}</Card.Title>
        <Card.Description>{product.description}</Card.Description>
      </Card.Body>
      <Card.Footer>
        <div className="price">{product.price.toLocaleString()}</div>
        <button>장바구니</button>
      </Card.Footer>
    </Card>
  );
}

// 사용 예시 3: 프로필 카드
function ProfileCard({ user }) {
  return (
    <Card>
      <Card.Header>
        <img src={user.avatar} alt={user.name} className="avatar" />
        <Card.Title>{user.name}</Card.Title>
        <Card.Subtitle>@{user.username}</Card.Subtitle>
      </Card.Header>
      <Card.Body>
        <Card.Description>{user.bio}</Card.Description>
        <div className="stats">
          <span>팔로워 {user.followers}</span>
          <span>팔로잉 {user.following}</span>
        </div>
      </Card.Body>
      <Card.Footer>
        <button>팔로우</button>
        <button>메시지</button>
      </Card.Footer>
    </Card>
  );
}

5. Form 컴포넌트

유효성 검증을 포함한 폼 컴포넌트입니다.

import { createContext, useContext, useState, FormEvent, ReactNode } from 'react';

interface FormContextType {
  values: Record<string, any>;
  errors: Record<string, string>;
  touched: Record<string, boolean>;
  handleChange: (name: string, value: any) => void;
  handleBlur: (name: string) => void;
  setError: (name: string, error: string) => void;
}

const FormContext = createContext<FormContextType | undefined>(undefined);

function useForm() {
  const context = useContext(FormContext);
  if (!context) {
    throw new Error('Form 컴포넌트 내부에서만 사용 가능합니다');
  }
  return context;
}

interface FormProps {
  children: ReactNode;
  onSubmit: (values: Record<string, any>) => void;
  initialValues?: Record<string, any>;
}

const Form = ({ children, onSubmit, initialValues = {} }: FormProps) => {
  const [values, setValues] = useState(initialValues);
  const [errors, setErrors] = useState<Record<string, string>>({});
  const [touched, setTouched] = useState<Record<string, boolean>>({});

  const handleChange = (name: string, value: any) => {
    setValues(prev => ({ ...prev, [name]: value }));
    // 에러 초기화
    if (errors[name]) {
      setErrors(prev => {
        const newErrors = { ...prev };
        delete newErrors[name];
        return newErrors;
      });
    }
  };

  const handleBlur = (name: string) => {
    setTouched(prev => ({ ...prev, [name]: true }));
  };

  const setError = (name: string, error: string) => {
    setErrors(prev => ({ ...prev, [name]: error }));
  };

  const handleSubmit = (e: FormEvent) => {
    e.preventDefault();

    // 모든 필드를 touched로 표시
    const allTouched = Object.keys(values).reduce((acc, key) => {
      acc[key] = true;
      return acc;
    }, {} as Record<string, boolean>);
    setTouched(allTouched);

    // 에러가 없으면 submit
    if (Object.keys(errors).length === 0) {
      onSubmit(values);
    }
  };

  return (
    <FormContext.Provider value={{ values, errors, touched, handleChange, handleBlur, setError }}>
      <form onSubmit={handleSubmit} className="form">
        {children}
      </form>
    </FormContext.Provider>
  );
};

const FormField = ({ children }: { children: ReactNode }) => {
  return <div className="form-field">{children}</div>;
};

const FormLabel = ({ htmlFor, children }: { htmlFor: string; children: ReactNode }) => {
  return (
    <label htmlFor={htmlFor} className="form-label">
      {children}
    </label>
  );
};

interface FormInputProps {
  name: string;
  type?: string;
  placeholder?: string;
  required?: boolean;
  validate?: (value: any) => string | undefined;
}

const FormInput = ({ name, type = 'text', placeholder, required, validate }: FormInputProps) => {
  const { values, errors, touched, handleChange, handleBlur, setError } = useForm();

  const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const value = e.target.value;
    handleChange(name, value);

    // 실시간 유효성 검증
    if (validate) {
      const error = validate(value);
      if (error) {
        setError(name, error);
      }
    }
  };

  const showError = touched[name] && errors[name];

  return (
    <input
      id={name}
      name={name}
      type={type}
      value={values[name] || ''}
      onChange={handleInputChange}
      onBlur={() => handleBlur(name)}
      placeholder={placeholder}
      required={required}
      className={`form-input ${showError ? 'error' : ''}`}
      aria-invalid={!!showError}
      aria-describedby={showError ? `${name}-error` : undefined}
    />
  );
};

const FormTextarea = ({ name, placeholder, rows = 4 }: { name: string; placeholder?: string; rows?: number }) => {
  const { values, handleChange, handleBlur } = useForm();

  return (
    <textarea
      id={name}
      name={name}
      value={values[name] || ''}
      onChange={(e) => handleChange(name, e.target.value)}
      onBlur={() => handleBlur(name)}
      placeholder={placeholder}
      rows={rows}
      className="form-textarea"
    />
  );
};

const FormError = ({ name }: { name: string }) => {
  const { errors, touched } = useForm();

  if (!touched[name] || !errors[name]) return null;

  return (
    <span id={`${name}-error`} className="form-error" role="alert">
      {errors[name]}
    </span>
  );
};

const FormSubmit = ({ children }: { children: ReactNode }) => {
  return (
    <button type="submit" className="form-submit">
      {children}
    </button>
  );
};

Form.Field = FormField;
Form.Label = FormLabel;
Form.Input = FormInput;
Form.Textarea = FormTextarea;
Form.Error = FormError;
Form.Submit = FormSubmit;

// 사용 예시
function SignupForm() {
  const handleSubmit = (values: Record<string, any>) => {
    console.log('제출된 값:', values);
    // API 호출 등
  };

  const validateEmail = (email: string) => {
    if (!email) return '이메일을 입력하세요';
    if (!/\S+@\S+\.\S+/.test(email)) return '올바른 이메일 형식이 아닙니다';
  };

  const validatePassword = (password: string) => {
    if (!password) return '비밀번호를 입력하세요';
    if (password.length < 8) return '비밀번호는 최소 8자 이상이어야 합니다';
  };

  return (
    <Form onSubmit={handleSubmit} initialValues={{ name: '', email: '', password: '' }}>
      <Form.Field>
        <Form.Label htmlFor="name">이름</Form.Label>
        <Form.Input
          name="name"
          placeholder="홍길동"
          required
        />
        <Form.Error name="name" />
      </Form.Field>

      <Form.Field>
        <Form.Label htmlFor="email">이메일</Form.Label>
        <Form.Input
          name="email"
          type="email"
          placeholder="example@email.com"
          validate={validateEmail}
          required
        />
        <Form.Error name="email" />
      </Form.Field>

      <Form.Field>
        <Form.Label htmlFor="password">비밀번호</Form.Label>
        <Form.Input
          name="password"
          type="password"
          placeholder="8자 이상"
          validate={validatePassword}
          required
        />
        <Form.Error name="password" />
      </Form.Field>

      <Form.Field>
        <Form.Label htmlFor="bio">자기소개</Form.Label>
        <Form.Textarea name="bio" placeholder="간단히 소개해주세요" />
      </Form.Field>

      <Form.Submit>가입하기</Form.Submit>
    </Form>
  );
}

6. Menu / Dropdown Menu 컴포넌트

컨텍스트 메뉴나 드롭다운 메뉴를 구현하는 컴포넌트입니다.

import { createContext, useContext, useState, useRef, useEffect, ReactNode } from 'react';

interface MenuContextType {
  isOpen: boolean;
  toggle: () => void;
  close: () => void;
}

const MenuContext = createContext<MenuContextType | undefined>(undefined);

function useMenu() {
  const context = useContext(MenuContext);
  if (!context) {
    throw new Error('Menu 컴포넌트 내부에서만 사용 가능합니다');
  }
  return context;
}

interface MenuProps {
  children: ReactNode;
}

const Menu = ({ children }: MenuProps) => {
  const [isOpen, setIsOpen] = useState(false);
  const menuRef = useRef<HTMLDivElement>(null);

  const toggle = () => setIsOpen(prev => !prev);
  const close = () => setIsOpen(false);

  // 외부 클릭 감지
  useEffect(() => {
    const handleClickOutside = (event: MouseEvent) => {
      if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
        close();
      }
    };

    if (isOpen) {
      document.addEventListener('mousedown', handleClickOutside);
    }

    return () => {
      document.removeEventListener('mousedown', handleClickOutside);
    };
  }, [isOpen]);

  return (
    <MenuContext.Provider value={{ isOpen, toggle, close }}>
      <div className="menu" ref={menuRef}>
        {children}
      </div>
    </MenuContext.Provider>
  );
};

const MenuButton = ({ children }: { children: ReactNode }) => {
  const { toggle } = useMenu();

  return (
    <button
      className="menu-button"
      onClick={toggle}
      aria-haspopup="true"
    >
      {children}
    </button>
  );
};

const MenuList = ({ children }: { children: ReactNode }) => {
  const { isOpen } = useMenu();

  if (!isOpen) return null;

  return (
    <div className="menu-list" role="menu">
      {children}
    </div>
  );
};

interface MenuItemProps {
  children: ReactNode;
  onClick?: () => void;
  disabled?: boolean;
  danger?: boolean;
}

const MenuItem = ({ children, onClick, disabled = false, danger = false }: MenuItemProps) => {
  const { close } = useMenu();

  const handleClick = () => {
    if (!disabled) {
      onClick?.();
      close();
    }
  };

  return (
    <button
      className={`menu-item ${disabled ? 'disabled' : ''} ${danger ? 'danger' : ''}`}
      onClick={handleClick}
      disabled={disabled}
      role="menuitem"
    >
      {children}
    </button>
  );
};

const MenuDivider = () => {
  return <div className="menu-divider" role="separator" />;
};

const MenuGroup = ({ children, label }: { children: ReactNode; label?: string }) => {
  return (
    <div className="menu-group" role="group">
      {label && <div className="menu-group-label">{label}</div>}
      {children}
    </div>
  );
};

Menu.Button = MenuButton;
Menu.List = MenuList;
Menu.Item = MenuItem;
Menu.Divider = MenuDivider;
Menu.Group = MenuGroup;

// 사용 예시 1: 사용자 프로필 메뉴
function UserProfileMenu() {
  const navigate = useNavigate();
  const { logout } = useAuth();

  return (
    <Menu>
      <Menu.Button>
        <img src="/avatar.jpg" alt="프로필" />
      </Menu.Button>

      <Menu.List>
        <Menu.Item onClick={() => navigate('/profile')}>
          👤 내 프로필
        </Menu.Item>
        <Menu.Item onClick={() => navigate('/settings')}>
          ⚙️ 설정
        </Menu.Item>
        <Menu.Divider />
        <Menu.Item onClick={() => navigate('/help')}>
          ❓ 도움말
        </Menu.Item>
        <Menu.Divider />
        <Menu.Item onClick={logout} danger>
          🚪 로그아웃
        </Menu.Item>
      </Menu.List>
    </Menu>
  );
}

// 사용 예시 2: 컨텍스트 메뉴
function FileContextMenu({ file }) {
  const { duplicateFile, deleteFile, renameFile } = useFileActions();

  return (
    <Menu>
      <Menu.Button></Menu.Button>

      <Menu.List>
        <Menu.Group label="편집">
          <Menu.Item onClick={() => renameFile(file.id)}>
            ✏️ 이름 바꾸기
          </Menu.Item>
          <Menu.Item onClick={() => duplicateFile(file.id)}>
            📋 복제
          </Menu.Item>
        </Menu.Group>

        <Menu.Divider />

        <Menu.Group label="공유">
          <Menu.Item onClick={() => shareFile(file.id)}>
            🔗 링크 복사
          </Menu.Item>
          <Menu.Item onClick={() => downloadFile(file.id)}>
            📥 다운로드
          </Menu.Item>
        </Menu.Group>

        <Menu.Divider />

        <Menu.Item onClick={() => deleteFile(file.id)} danger>
          🗑️ 삭제
        </Menu.Item>
      </Menu.List>
    </Menu>
  );
}

함정과 주의사항

제가 Compound Pattern을 사용하면서 실제로 겪었던 문제들과 해결 방법을 공유합니다.

1. Context를 찾지 못하는 에러

문제: 서브 컴포넌트를 부모 컴포넌트 밖에서 사용하면 에러 발생

// ❌ 잘못된 사용
function App() {
  return (
    <>
      <Accordion />
      <Accordion.Item index={0}>  // 에러! Context를 찾을 수 없음
        <Accordion.Header index={0}>제목</Accordion.Header>
      </Accordion.Item>
    </>
  );
}

해결책: Custom Hook에서 명확한 에러 메시지 제공

function useAccordion() {
  const context = useContext(AccordionContext);
  if (!context) {
    throw new Error(
      'Accordion 서브 컴포넌트는 Accordion 컴포넌트 내부에서만 사용할 수 있습니다.\n' +
      '올바른 사용: <Accordion><Accordion.Item>...</Accordion.Item></Accordion>'
    );
  }
  return context;
}

2. Props Drilling vs Context 성능

문제: Context를 사용하면 Provider의 value가 바뀔 때마다 모든 Consumer가 리렌더링됩니다.

// ❌ 성능 문제: value 객체가 매번 새로 생성됨
const Accordion = ({ children }) => {
  const [openIndex, setOpenIndex] = useState(null);

  return (
    <AccordionContext.Provider value={{ openIndex, setOpenIndex }}>
      {children}
    </AccordionContext.Provider>
  );
};

해결책 1: useMemo로 value 메모이제이션

// ✅ 개선: value를 메모이제이션
const Accordion = ({ children }) => {
  const [openIndex, setOpenIndex] = useState(null);

  const contextValue = useMemo(
    () => ({ openIndex, setOpenIndex }),
    [openIndex]
  );

  return (
    <AccordionContext.Provider value={contextValue}>
      {children}
    </AccordionContext.Provider>
  );
};

해결책 2: Context 분리

// ✅ 더 나은 방법: 읽기와 쓰기 Context 분리
const AccordionStateContext = createContext();  // 읽기 전용
const AccordionDispatchContext = createContext(); // 쓰기 전용

const Accordion = ({ children }) => {
  const [openIndex, setOpenIndex] = useState(null);

  return (
    <AccordionStateContext.Provider value={openIndex}>
      <AccordionDispatchContext.Provider value={setOpenIndex}>
        {children}
      </AccordionDispatchContext.Provider>
    </AccordionStateContext.Provider>
  );
};

// 읽기만 필요한 컴포넌트
function AccordionPanel({ index, children }) {
  const openIndex = useContext(AccordionStateContext); // setOpenIndex 변경 시 리렌더링 안됨
  return openIndex === index ? <div>{children}</div> : null;
}

// 쓰기만 필요한 컴포넌트
function AccordionHeader({ index, children }) {
  const setOpenIndex = useContext(AccordionDispatchContext); // openIndex 변경 시 리렌더링 안됨
  return <button onClick={() => setOpenIndex(index)}>{children}</button>;
}

3. 타입스크립트 타입 정의의 어려움

문제: 서브 컴포넌트를 부모에 연결할 때 타입 정의가 복잡함

// ❌ 타입 에러 발생
const Accordion = ({ children }: { children: ReactNode }) => {
  // ...
};

Accordion.Item = AccordionItem; // 타입 에러!

해결책: 올바른 타입 정의

// ✅ 올바른 타입 정의
interface AccordionComponent extends React.FC<AccordionProps> {
  Item: React.FC<AccordionItemProps>;
  Header: React.FC<AccordionHeaderProps>;
  Panel: React.FC<AccordionPanelProps>;
}

const Accordion: AccordionComponent = ({ children }) => {
  // ...
} as AccordionComponent;

Accordion.Item = AccordionItem;
Accordion.Header = AccordionHeader;
Accordion.Panel = AccordionPanel;

4. 필수 컴포넌트 누락

문제: 사용자가 필수 서브 컴포넌트를 빠트릴 수 있음

// ❌ Header가 없으면 작동하지 않음
<Accordion>
  <Accordion.Item index={0}>
    <Accordion.Panel>내용만 있음</Accordion.Panel>
  </Accordion.Item>
</Accordion>

해결책 1: children 검증

const AccordionItem = ({ children, index }) => {
  // children 검증
  useEffect(() => {
    const childrenArray = React.Children.toArray(children);
    const hasHeader = childrenArray.some(
      child => React.isValidElement(child) && child.type === AccordionHeader
    );

    if (!hasHeader) {
      console.warn(`AccordionItem (index: ${index})에 AccordionHeader가 없습니다.`);
    }
  }, [children, index]);

  // ...
};

해결책 2: 명확한 문서화와 예시

/**
 * Accordion 컴포넌트
 *
 * @example
 * // 올바른 사용법
 * <Accordion>
 *   <Accordion.Item index={0}>
 *     <Accordion.Header index={0}>필수!</Accordion.Header>
 *     <Accordion.Panel>내용</Accordion.Panel>
 *   </Accordion.Item>
 * </Accordion>
 *
 * @requires AccordionItem 내부에는 반드시 AccordionHeader가 있어야 합니다.
 */

5. index prop의 불편함

문제: 모든 서브 컴포넌트에 index를 전달하는 것이 번거로움

// ❌ 반복적이고 에러 발생 가능
<Accordion>
  <Accordion.Item index={0}>
    <Accordion.Header index={0}>제목</Accordion.Header>  // index 중복
    <Accordion.Panel index={0}>내용</Accordion.Panel>
  </Accordion.Item>
</Accordion>

해결책: Context로 index 자동 전달

// ✅ 개선: Item에서 Context로 index 제공
const AccordionItemContext = createContext<number>(0);

const AccordionItem = ({ children, index }) => {
  return (
    <AccordionItemContext.Provider value={index}>
      <div className="accordion-item">
        {children}
      </div>
    </AccordionItemContext.Provider>
  );
};

const AccordionHeader = ({ children }) => {
  const index = useContext(AccordionItemContext); // 자동으로 index 가져옴
  const { setOpenIndex, openIndex } = useAccordion();

  return (
    <button onClick={() => setOpenIndex(openIndex === index ? null : index)}>
      {children}
    </button>
  );
};

// 이제 index를 반복해서 전달하지 않아도 됨!
<Accordion>
  <Accordion.Item index={0}>
    <Accordion.Header>제목</Accordion.Header>
    <Accordion.Panel>내용</Accordion.Panel>
  </Accordion.Item>
</Accordion>

6. 서브 컴포넌트의 순서 의존성

문제: 특정 순서로만 사용해야 하는 경우

// ❌ 순서가 잘못됨
<Accordion.Item index={0}>
  <Accordion.Panel>내용</Accordion.Panel>
  <Accordion.Header>제목</Accordion.Header>  // Header가 아래에 있음
</Accordion.Item>

해결책: CSS나 로직으로 순서 무관하게 만들기

// ✅ Flexbox로 순서 조정
.accordion-item {
  display: flex;
  flex-direction: column;
}

.accordion-header {
  order: 1; /* 항상 위에 표시 */
}

.accordion-panel {
  order: 2; /* 항상 아래에 표시 */
}

7. 중첩된 Compound Component

문제: Compound Component 안에 또 다른 Compound Component를 사용할 때 Context 충돌

// ❌ Context 충돌 가능
<Tabs>
  <Tabs.Tab value="tab1">탭 1</Tabs.Tab>
  <Tabs.Panel value="tab1">
    <Tabs>  {/* 중첩된 Tabs */}
      <Tabs.Tab value="nested1">중첩 탭</Tabs.Tab>
    </Tabs>
  </Tabs.Panel>
</Tabs>

해결책: 고유한 Context 이름 사용

// ✅ 각 레벨마다 독립적인 Context
const TabsContext = createContext();  // 자동으로 격리됨

// 또는 명시적으로 id 부여
const Tabs = ({ children, id }) => {
  const contextId = useId(); // React 18+
  // ...
};

실전 활용 가이드

언제 Compound Pattern을 사용해야 할까?

저의 경험상, 다음 조건 중 2개 이상 해당하면 Compound Pattern을 고려하세요:

✅ 사용하면 좋은 경우

  1. 컴포넌트가 여러 하위 요소로 구성됨
    • Modal (Header, Body, Footer)
    • Card (Image, Title, Description, Actions)
    • Accordion (Item, Header, Panel)
  2. 하위 요소들이 상태를 공유함
    • Tabs: activeTab 상태 공유
    • Select: selectedValue 상태 공유
    • Accordion: openIndex 상태 공유
  3. 레이아웃의 유연성이 필요함
    • 헤더에 아이콘, 버튼, 텍스트 등 다양한 요소 배치
    • 순서 변경 가능
    • 선택적 요소 표시/숨김
  4. 재사용성이 중요함
    • 여러 페이지에서 다양한 형태로 사용
    • 디자인 시스템의 일부

❌ 사용하지 말아야 할 경우

  1. 단순한 컴포넌트 ```tsx // ❌ 과한 추상화

클릭

</Button>

// ✅ 그냥 props 사용 <Button icon={}>클릭</Button>


2. **고정된 구조**
```tsx
// ❌ 항상 같은 구조라면 불필요
<Header>
  <Header.Logo />
  <Header.Nav />
  <Header.Actions />
</Header>

// ✅ 그냥 단일 컴포넌트
<Header logo={logo} nav={nav} actions={actions} />
  1. 성능이 매우 중요한 경우
    // Context 리렌더링이 부담스러운 경우
    // 대신 props drilling이나 상태 관리 라이브러리 고려
    

다른 패턴과의 비교

1. Compound Pattern vs Render Props

// Compound Pattern
<Select value={value} onChange={setValue}>
  <Select.Trigger />
  <Select.Content>
    <Select.Option value="a">A</Select.Option>
    <Select.Option value="b">B</Select.Option>
  </Select.Content>
</Select>

// Render Props
<Select
  value={value}
  onChange={setValue}
  render={({ isOpen, selected }) => (
    <>
      <SelectTrigger isOpen={isOpen} selected={selected} />
      <SelectContent isOpen={isOpen}>
        <SelectOption value="a">A</SelectOption>
        <SelectOption value="b">B</SelectOption>
      </SelectContent>
    </>
  )}
/>

Compound Pattern 장점:

  • 더 선언적이고 읽기 쉬움
  • IDE 자동완성 지원 우수
  • 타입 안전성 확보 쉬움

Render Props 장점:

  • 더 많은 제어권
  • 복잡한 로직 처리 용이

2. Compound Pattern vs Higher-Order Components (HOC)

// Compound Pattern
<Tabs defaultTab="home">
  <Tabs.List>
    <Tabs.Tab value="home"></Tabs.Tab>
  </Tabs.List>
  <Tabs.Panel value="home">홈 내용</Tabs.Panel>
</Tabs>

// HOC
const TabPanel = withTabContext(({ isActive, children }) => (
  isActive ? <div>{children}</div> : null
));

Compound Pattern 장점:

  • 명시적이고 직관적
  • Props 충돌 없음
  • 디버깅 쉬움

3. Compound Pattern vs Custom Hooks

// Compound Pattern - UI와 로직이 함께
<Tabs>
  <Tabs.Tab></Tabs.Tab>
</Tabs>

// Custom Hooks - 로직만 분리
function MyTabs() {
  const { activeTab, setActiveTab } = useTabs();
  return (
    <div>
      <button onClick={() => setActiveTab('tab1')}></button>
    </div>
  );
}

언제 뭘 쓸까?

  • Compound Pattern: UI 컴포넌트 라이브러리, 일관된 디자인
  • Custom Hooks: 로직 재사용, 완전한 커스터마이징 필요

실전 팁

1. 점진적 도입

처음부터 완벽한 Compound Component를 만들려 하지 마세요. 단계적으로 발전시키세요.

// 1단계: 기본 컴포넌트
function SimpleAccordion({ items }) {
  return items.map(item => <div>{item}</div>);
}

// 2단계: Props 추가
function BetterAccordion({ items, onItemClick }) {
  // ...
}

// 3단계: Compound Pattern 적용 (유연성 필요할 때)
<Accordion>
  <Accordion.Item>
    <Accordion.Header>커스텀 가능!</Accordion.Header>
  </Accordion.Item>
</Accordion>

2. 기본 컴포넌트도 함께 제공

모든 사용자가 유연성을 원하는 건 아닙니다. 간단한 사용을 위한 래퍼도 제공하세요.

// 복잡한 사용 (유연함)
<Accordion>
  <Accordion.Item index={0}>
    <Accordion.Header index={0}>제목</Accordion.Header>
    <Accordion.Panel index={0}>내용</Accordion.Panel>
  </Accordion.Item>
</Accordion>

// 간단한 사용 (편리함)
<SimpleAccordion
  items={[
    { title: '제목', content: '내용' }
  ]}
/>

3. 스토리북으로 문서화

Compound Component는 사용법이 다양하므로 충분한 예시가 필요합니다.

// Button.stories.tsx
export default {
  title: 'Components/Card',
  component: Card,
};

export const Basic = () => (
  <Card>
    <Card.Title>기본 카드</Card.Title>
    <Card.Body>내용</Card.Body>
  </Card>
);

export const WithImage = () => (
  <Card>
    <Card.Image src="..." />
    <Card.Title>이미지 카드</Card.Title>
  </Card>
);

export const WithActions = () => (
  <Card>
    <Card.Title>액션 카드</Card.Title>
    <Card.Footer>
      <Button>확인</Button>
    </Card.Footer>
  </Card>
);

장점과 단점

장점

1. 높은 유연성과 확장성

// 새로운 요구사항에도 컴포넌트 수정 없이 대응
<Modal>
  <Modal.Header>
    <SearchIcon />  {/* 새로운 요소 추가 */}
    <Modal.Title>제목</Modal.Title>
    <Badge>New</Badge>  {/* 또 다른 요소 추가 */}
  </Modal.Header>
</Modal>

2. 명확한 의도 표현

// 코드만 봐도 UI 구조가 한눈에 보임
<Card>
  <Card.Image src="thumb.jpg" />
  <Card.Title>제목</Card.Title>
  <Card.Description>설명</Card.Description>
  <Card.Actions>
    <Button>버튼</Button>
  </Card.Actions>
</Card>

3. 재사용성과 조합 가능성

// 서브 컴포넌트를 다른 곳에서도 사용 가능
<CustomContainer>
  <Modal.Header>  {/* Modal 밖에서도 사용 */}
    <h2>재사용!</h2>
  </Modal.Header>
</CustomContainer>

4. 타입 안전성

// TypeScript와 완벽한 조합
<Select value={value}>
  <Select.Option value="hello">  {/* value 타입이 자동 추론 */}
    Hello
  </Select.Option>
</Select>

단점

1. 초기 설정의 복잡성

  • Context 설정
  • 타입 정의
  • 서브 컴포넌트 연결
  • 문서화

단순한 컴포넌트에는 과한 추상화일 수 있습니다.

2. 러닝 커브

// 새로운 개발자가 이해하기 어려울 수 있음
<Accordion>
  <Accordion.Item index={0}>  {/* 왜 index가 필요하지? */}
    <Accordion.Header index={0}>  {/* 왜 또 index? */}
      제목
    </Accordion.Header>
  </Accordion.Item>
</Accordion>

3. 잘못된 사용 가능성

// 필수 컴포넌트 누락
<Modal>
  <Modal.Body>내용</Modal.Body>
  {/* Header 없음 - 괜찮은가? */}
</Modal>

// 잘못된 순서
<Accordion.Header>제목</Accordion.Header>
<Accordion>  {/* 순서가 잘못됨 */}
  ...
</Accordion>

4. Context 성능 오버헤드

// Context 값이 바뀔 때마다 모든 자식이 리렌더링
<Accordion>  {/* 상태가 여기 있음 */}
  <Accordion.Item />  {/* 리렌더링 */}
  <Accordion.Item />  {/* 리렌더링 */}
  <Accordion.Item />  {/* 리렌더링 */}
</Accordion>

모범 사례

1. 명확하고 일관된 네이밍

// ✅ 좋은 예 - 역할이 명확
<Dialog>
  <Dialog.Title>제목</Dialog.Title>
  <Dialog.Description>설명</Dialog.Description>
  <Dialog.Actions>액션</Dialog.Actions>
</Dialog>

// ❌ 나쁜 예 - 역할이 불명확
<Dialog>
  <Dialog.Top>제목</Dialog.Top>
  <Dialog.Middle>설명</Dialog.Middle>
  <Dialog.Bottom>액션</Dialog.Bottom>
</Dialog>

2. 적절한 기본값 제공

// ✅ 기본 동작 제공
const Modal = ({
  isOpen,
  onClose,
  closeOnEscape = true,  // 기본값
  closeOnOverlayClick = true,  // 기본값
  children
}) => {
  // ESC 키 처리
  useEffect(() => {
    if (!closeOnEscape) return;

    const handleEscape = (e) => {
      if (e.key === 'Escape') onClose();
    };

    document.addEventListener('keydown', handleEscape);
    return () => document.removeEventListener('keydown', handleEscape);
  }, [closeOnEscape, onClose]);

  // ...
};

3. 접근성 고려

// ✅ ARIA 속성 추가
const Tabs = ({ children }) => {
  return (
    <div role="tablist">  {/* ARIA role */}
      {children}
    </div>
  );
};

const Tab = ({ value, children, isActive }) => {
  return (
    <button
      role="tab"
      aria-selected={isActive}
      aria-controls={`panel-${value}`}
      id={`tab-${value}`}
    >
      {children}
    </button>
  );
};

const TabPanel = ({ value, children, isActive }) => {
  return (
    <div
      role="tabpanel"
      aria-labelledby={`tab-${value}`}
      id={`panel-${value}`}
      hidden={!isActive}
    >
      {children}
    </div>
  );
};

4. 에러 경계 설정

// ✅ 에러 처리
function useAccordion() {
  const context = useContext(AccordionContext);

  if (!context) {
    throw new Error(
      '❌ Accordion 서브 컴포넌트는 Accordion 컴포넌트 내부에서만 사용할 수 있습니다.\n\n' +
      '올바른 사용법:\n' +
      '<Accordion>\n' +
      '  <Accordion.Item>\n' +
      '    <Accordion.Header>제목</Accordion.Header>\n' +
      '  </Accordion.Item>\n' +
      '</Accordion>'
    );
  }

  return context;
}

5. 포워딩 ref 지원

// ✅ ref 전달 가능
const AccordionHeader = forwardRef<HTMLButtonElement, AccordionHeaderProps>(
  ({ children, ...props }, ref) => {
    const { toggle } = useAccordion();

    return (
      <button ref={ref} onClick={toggle} {...props}>
        {children}
      </button>
    );
  }
);

AccordionHeader.displayName = 'AccordionHeader';

6. 성능 최적화

// ✅ 메모이제이션
const Accordion = ({ children, defaultIndex = null }) => {
  const [openIndex, setOpenIndex] = useState(defaultIndex);

  // Context value를 메모이제이션
  const contextValue = useMemo(
    () => ({ openIndex, setOpenIndex }),
    [openIndex]
  );

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

// 서브 컴포넌트도 메모이제이션
const AccordionItem = memo(({ children, index }) => {
  const { openIndex } = useAccordion();
  const isOpen = openIndex === index;

  return (
    <div className={`item ${isOpen ? 'open' : ''}`}>
      {children}
    </div>
  );
});

실제 라이브러리 사례 분석

1. Radix UI

Radix UI는 Compound Pattern의 교과서적인 예시입니다.

import * as Dialog from '@radix-ui/react-dialog';

<Dialog.Root>
  <Dialog.Trigger>열기</Dialog.Trigger>
  <Dialog.Portal>
    <Dialog.Overlay />
    <Dialog.Content>
      <Dialog.Title>제목</Dialog.Title>
      <Dialog.Description>설명</Dialog.Description>
      <Dialog.Close>닫기</Dialog.Close>
    </Dialog.Content>
  </Dialog.Portal>
</Dialog.Root>

특징:

  • 매우 세밀한 제어 가능
  • 접근성 자동 처리
  • 스타일 없음 (headless)

2. Chakra UI

Chakra UI는 스타일까지 포함한 Compound Pattern입니다.

import {
  Modal,
  ModalOverlay,
  ModalContent,
  ModalHeader,
  ModalBody,
  ModalFooter,
  ModalCloseButton,
} from '@chakra-ui/react';

<Modal isOpen={isOpen} onClose={onClose}>
  <ModalOverlay />
  <ModalContent>
    <ModalHeader>제목</ModalHeader>
    <ModalCloseButton />
    <ModalBody>내용</ModalBody>
    <ModalFooter>
      <Button>확인</Button>
    </ModalFooter>
  </ModalContent>
</Modal>

특징:

  • 스타일 포함
  • 테마 시스템 통합
  • 간단한 사용법

3. Headless UI

Tailwind팀이 만든 Headless UI도 Compound Pattern을 사용합니다.

import { Menu } from '@headlessui/react';

<Menu>
  <Menu.Button>Options</Menu.Button>
  <Menu.Items>
    <Menu.Item>
      {({ active }) => (
        <a className={active ? 'bg-blue-500' : ''}>
          Account settings
        </a>
      )}
    </Menu.Item>
  </Menu.Items>
</Menu>

특징:

  • Render Props와 Compound Pattern 혼합
  • 완전한 스타일 제어
  • 접근성 내장

나의 경험담

제가 처음 Compound Pattern을 도입했던 프로젝트는 사내 디자인 시스템이었습니다. 처음에는 모든 컴포넌트에 Compound Pattern을 적용하려 했습니다. Button, Input, Checkbox까지도요. 하지만 곧 깨달았습니다. 모든 것을 Compound Component로 만들 필요는 없다는 것을.

단순한 Button은 그냥 props로 충분했습니다. 하지만 Modal, Accordion, Tabs 같은 복잡한 컴포넌트에서는 Compound Pattern이 빛을 발했습니다. 특히 디자이너들이 “모달 헤더에 검색창을 넣고 싶어요”라고 할 때, 코드 수정 없이 바로 대응할 수 있었던 것이 큰 장점이었습니다.

// 디자이너: "헤더에 검색창 넣어주세요"
// 나: "알겠습니다! 코드 수정 없이 가능합니다"
<Modal>
  <Modal.Header>
    <Modal.Title>사용자 찾기</Modal.Title>
    <SearchBar />  {/* 그냥 추가! */}
    <Modal.CloseButton />
  </Modal.Header>
  <Modal.Body>...</Modal.Body>
</Modal>

하지만 실수도 많았습니다. 한 번은 Context 성능 최적화를 하지 않아서 한 Accordion의 항목을 열 때마다 페이지 전체가 느려지는 버그를 만들었습니다. 그때부터 useMemo와 Context 분리의 중요성을 깨달았죠.

참고 자료

공식 문서 및 가이드

심화 아티클

실전 구현 예시

오픈소스 라이브러리

  • Radix UI - Headless UI 라이브러리, 접근성 최고
  • Headless UI - Tailwind팀의 Headless 컴포넌트
  • Chakra UI - 스타일 포함 컴포넌트 라이브러리
  • Reach UI - 접근성 중심 컴포넌트
  • Ariakit - 현대적인 Compound Pattern 구현
  • React Aria - Adobe의 접근성 컴포넌트

블로그 및 튜토리얼

동영상 강의

도구 및 유틸리티

댓글