Compound Component 패턴은 무엇이고 React에서 어떻게 적용할까

Frontend

React로 UI를 만들다 보면 어떤 컴포넌트는 점점 한 덩어리 prop 컴포넌트로 비대해집니다.

예를 들어 이런 UI를 떠올려보면 됩니다.

  • Tabs
  • Accordion
  • Select
  • Modal
  • Dropdown

처음에는 items, activeIndex, onChange 정도만 받던 컴포넌트가, 시간이 지나면 레이아웃 옵션과 스타일 옵션, 아이콘 옵션, disabled 규칙까지 한 파일 안으로 몰려들기 쉽습니다.

이럴 때 자주 검토하게 되는 패턴이 Compound Component입니다. 핵심은 하나의 큰 컴포넌트를 세부 역할을 가진 하위 컴포넌트 조합으로 쪼개되, 내부 상태와 맥락은 함께 공유하는 것입니다.

한눈에 보면

짧게 정리하면 이렇습니다.

  • 부모 컴포넌트가 상태와 컨텍스트를 관리합니다.
  • 자식 컴포넌트는 그 컨텍스트를 읽어서 각자의 역할만 수행합니다.
  • 사용하는 쪽은 작은 부품들을 조합해서 UI를 선언합니다.

즉, 아래처럼 "사용자가 구조를 읽을 수 있는 API"를 만드는 패턴에 가깝습니다.

<Tabs defaultValue="overview">
  <Tabs.List>
    <Tabs.Trigger value="overview">Overview</Tabs.Trigger>
    <Tabs.Trigger value="settings">Settings</Tabs.Trigger>
  </Tabs.List>
  <Tabs.Content value="overview">개요 화면</Tabs.Content>
  <Tabs.Content value="settings">설정 화면</Tabs.Content>
</Tabs>

이 구조가 좋은 이유는 prop 몇 개를 외워서 쓰는 것이 아니라, UI 구조 자체가 JSX로 드러난다는 점입니다.

언제 이 패턴이 필요할까?

아래 상황이라면 꽤 잘 맞습니다.

  • 여러 하위 요소가 같은 상태를 공유해야 할 때
  • 사용자가 배치 순서와 조합을 어느 정도 결정할 수 있어야 할 때
  • 하나의 거대한 props API보다 선언적인 조합 API가 더 읽기 쉬울 때

반대로 단순 버튼이나 작은 카드처럼 상태 공유가 거의 없고 조합 자유도도 필요 없는 컴포넌트라면 굳이 쓸 이유가 없습니다.

props 중심 설계가 답답해지는 순간

예를 들어 단순 Tabs를 props 기반으로 만들면 보통 이렇게 시작합니다.

type TabItem = {
  value: string;
  label: string;
  content: React.ReactNode;
};
 
type TabsProps = {
  items: TabItem[];
  defaultValue?: string;
};
 
export function Tabs({ items, defaultValue }: TabsProps) {
  const [activeValue, setActiveValue] = useState(defaultValue ?? items[0]?.value);
 
  return (
    <div>
      <div>
        {items.map((item) => (
          <button key={item.value} onClick={() => setActiveValue(item.value)}>
            {item.label}
          </button>
        ))}
      </div>
      <div>{items.find((item) => item.value === activeValue)?.content}</div>
    </div>
  );
}

초기에는 충분히 단순합니다. 하지만 실제 요구사항이 붙기 시작하면 아래 문제가 생깁니다.

  • 탭 헤더 사이에 뱃지를 넣고 싶다
  • 특정 탭은 버튼이 아니라 링크처럼 그리고 싶다
  • 콘텐츠 위치를 바꾸고 싶다
  • 레이아웃을 세로형으로 바꾸고 싶다

이쯤 되면 items 배열 한 개로 표현하는 API가 점점 답답해집니다.

Tabs 예제로 보는 Compound Component

이번에는 같은 UI를 compound component로 바꿔보겠습니다.

1. 컨텍스트 정의

import { createContext, useContext, useMemo, useState } from 'react';
 
type TabsContextValue = {
  value: string;
  setValue: (value: string) => void;
};
 
const TabsContext = createContext<TabsContextValue | null>(null);
 
function useTabsContext() {
  const context = useContext(TabsContext);
 
  if (!context) {
    throw new Error('Tabs compound components must be used inside Tabs');
  }
 
  return context;
}

2. 부모 컴포넌트

type TabsRootProps = {
  defaultValue: string;
  children: React.ReactNode;
};
 
function TabsRoot({ defaultValue, children }: TabsRootProps) {
  const [value, setValue] = useState(defaultValue);
  const contextValue = useMemo(() => ({ value, setValue }), [value]);
 
  return <TabsContext.Provider value={contextValue}>{children}</TabsContext.Provider>;
}

3. 하위 컴포넌트

function TabsList({ children }: { children: React.ReactNode }) {
  return <div role="tablist">{children}</div>;
}
 
type TabsTriggerProps = {
  value: string;
  children: React.ReactNode;
};
 
function TabsTrigger({ value, children }: TabsTriggerProps) {
  const { value: activeValue, setValue } = useTabsContext();
 
  return (
    <button role="tab" aria-selected={activeValue === value} onClick={() => setValue(value)}>
      {children}
    </button>
  );
}
 
type TabsContentProps = {
  value: string;
  children: React.ReactNode;
};
 
function TabsContent({ value, children }: TabsContentProps) {
  const { value: activeValue } = useTabsContext();
 
  if (activeValue !== value) return null;
 
  return <div role="tabpanel">{children}</div>;
}

4. 조합해서 export

export const Tabs = Object.assign(TabsRoot, {
  List: TabsList,
  Trigger: TabsTrigger,
  Content: TabsContent,
});

사용 예시는 이렇게 됩니다.

<Tabs defaultValue="overview">
  <Tabs.List>
    <Tabs.Trigger value="overview">Overview</Tabs.Trigger>
    <Tabs.Trigger value="settings">Settings</Tabs.Trigger>
  </Tabs.List>
  <Tabs.Content value="overview">개요 화면</Tabs.Content>
  <Tabs.Content value="settings">설정 화면</Tabs.Content>
</Tabs>

이 API의 장점은 탭 구조를 읽는 사람이 prop 문서를 보지 않아도, JSX만 보고 화면 구조를 거의 이해할 수 있다는 점입니다.

Modal에도 잘 맞을까?

Modal도 compound component가 잘 맞는 대표 예제입니다.

<Modal>
  <Modal.Trigger>열기</Modal.Trigger>
  <Modal.Content>
    <Modal.Header>삭제 확인</Modal.Header>
    <Modal.Body>정말 삭제하시겠습니까?</Modal.Body>
    <Modal.Footer>
      <button>취소</button>
      <button>삭제</button>
    </Modal.Footer>
  </Modal.Content>
</Modal>

이 구조의 장점은 명확합니다.

  • Trigger, Header, Body, Footer 역할이 드러난다
  • 상태는 부모가 가지고 있어 자식끼리 자연스럽게 공유된다
  • 사용자가 필요한 부분만 골라 조합할 수 있다

즉, compound component는 복잡한 UI를 조립 가능한 작은 조각으로 나누면서도, 상태는 한 문맥으로 유지하고 싶을 때 특히 유용합니다.

Context를 쓸 때 주의할 점

이 패턴은 대부분 Context와 같이 갑니다. 그래서 아래 지점을 같이 봐야 합니다.

1. 내부 전용 훅을 만들어두는 편이 좋다

useTabsContext처럼 전용 훅을 두면 잘못된 사용을 빨리 잡을 수 있습니다.

2. 렌더링 비용을 생각해야 한다

상태가 자주 바뀌는 큰 트리라면 컨텍스트 값이 바뀔 때 하위 컴포넌트가 같이 렌더링됩니다. 필요하면 컨텍스트를 나누거나, 더 작은 상태 범위로 제한하는 설계가 필요합니다.

3. 접근성 역할을 함께 설계해야 한다

Tabs, Accordion, Modal 같은 컴포넌트는 패턴만 맞추면 끝이 아닙니다. role, aria-*, 키보드 이동 같은 접근성 요구사항을 같이 설계해야 합니다.

TypeScript에서는 무엇을 신경 써야 할까?

주로 아래 두 가지가 중요합니다.

1. 하위 컴포넌트 props를 명확히 나누기

Tabs.Trigger, Tabs.Content처럼 역할별 props가 다르기 때문에 타입을 분리하는 편이 읽기 쉽습니다.

2. Object.assign 패턴 타입 추론

Tabs.Root를 따로 export하지 않고 Tabs.List, Tabs.Trigger를 정적 프로퍼티처럼 붙일 경우, IDE 자동완성과 타입 추론이 자연스럽게 되도록 export 패턴을 정리해두는 것이 좋습니다.

언제 잘 맞고 언제 과할까?

이 패턴이 잘 맞는 경우는 이렇습니다.

  • 복합 UI를 여러 조각으로 나눠 조합해야 할 때
  • 사용자에게 레이아웃 자유도를 어느 정도 열어주고 싶을 때
  • 내부 상태를 여러 하위 요소가 함께 읽어야 할 때

반대로 아래 상황에서는 과할 수 있습니다.

  • 상태 공유가 거의 없는 단순 컴포넌트
  • 자식 조합 자유도가 필요 없는 정적인 UI
  • 팀이 패턴을 과하게 일반화해서 사용법이 오히려 어려워지는 경우

즉, compound component는 "멋져 보여서" 쓰는 패턴이 아니라, 하나의 큰 컴포넌트를 더 선언적이고 읽기 쉬운 API로 바꾸기 위한 도구라고 보는 편이 맞습니다.

정리하면

Compound Component를 한 줄로 줄이면, 부모가 상태를 관리하고 자식 컴포넌트들이 그 문맥을 공유하면서 역할별 API를 제공하는 패턴입니다.

실무에서 기억할 포인트는 이렇습니다.

  • 거대한 props 컴포넌트가 답답해질 때 좋은 대안이 될 수 있고
  • Tabs, Modal, Accordion, Select 같은 복합 UI에 잘 맞으며
  • Context, 접근성, 렌더링 범위를 같이 설계해야 하고
  • 단순 컴포넌트에는 오히려 과할 수 있습니다

결국 중요한 것은 패턴 이름보다, 사용하는 사람이 JSX를 읽을 때 구조가 더 잘 드러나는가입니다. 그 질문에 "그렇다"라고 답할 수 있다면 compound component는 꽤 강력한 선택이 됩니다.

같이 보면 좋은 글