React에서 재사용 가능한 UI 구축: Compound Components, Render Props, 그리고 API 디자인

발행: (2025년 12월 27일 오전 07:08 GMT+9)
14 min read
원문: Dev.to

Source: Dev.to

React 컴포넌트를 처음엔 깔끔하게 만들었지만, 며칠 지나면 억대의 props와 충돌하는 불리언, 그리고 아무도 믿지 않는 README가 생긴 적이 있나요?

새로운 불리언 하나가 추가될 때마다 가능한 UI 상태 수가 두 배가 됩니다. 불리언이 여섯 개가 되면 64가지 UI 변형을 지원하게 되는데, 그 대부분은 문서화되지 않은 상태입니다.

재사용 가능한 UI는 코드를 적게 쓰는 것이 아니라, 변화에 견디는 API를 설계하는 것입니다.

대부분의 React 컴포넌트가 버그 때문에 실패하는 것이 아니라, 실제 환경 요구사항에 부딪히면서 API가 무너지기 때문에 실패합니다.

이 글은 공유 컴포넌트, 디자인 시스템, 혹은 복잡한 UI 프리미티브를 구축하는 개발자를 위한 것입니다.

우리는 다음을 사용해 그리고 어떻게 재사용 가능한 UI를 구축할지 살펴볼 것입니다:

  • Compound Components
  • Render Props
  • 실제 Accordion 구현
  • 트레이드‑오프, 대안, 그리고 개선점

핵심 문제: Prop‑Driven API는 확장되지 않는다

대부분의 컴포넌트는 다음과 같이 시작합니다:

const [activeItems, setActiveItems] = useState([]);

const toggleItem = (id: string) => { /* … */ };

처음엔 괜찮아 보이지만, 요구사항이 늘어나면:

  • 다양한 레이아웃
  • 커스텀 헤더
  • 조건부 동작
  • 디자인 시스템 제약

갑자기: props가 폭발한다

Accordion
 ├─ activeItems
 ├─ allowMultiple
 ├─ showIcon
 ├─ animated
 ├─ collapsible
 ├─ headerLayout
 └─ onToggle

   상호 의존 로직
   소비자 전역에 퍼짐

각 prop은 개별적으로는 무해해 보이지만, 함께 사용되면 숨겨진 결합과 문서화되지 않은 동작을 만들게 됩니다.

  • 로직이 컴포넌트 내부에 흩어져 있는 경우가 많고, 소비자는 상태를 직접 관리해야 하므로 컴포넌트를 “블랙 박스”처럼 만들어 수정하기 어렵습니다.
  • 작은 변경에도 모든 것이 깨집니다.

근본 원인: 레이아웃과 동작을 props에 인코딩하고 있다는 점입니다.

우리는 이를 복합 컴포넌트(compound component)로 리팩터링할 것입니다.

사고 모델 전환

기존 모델 (Prop‑Driven)

“컴포넌트를 설정합니다.”

  • 컴포넌트가 레이아웃을 결정합니다.
  • 미리 정의된 props에 제한됩니다.
  • 새로운 기능이 추가될 때마다 복잡성이 증가합니다.

새로운 모델 (Composition‑Driven)

“부품을 조합해 동작을 구성합니다.”

  • 소비자가 레이아웃을 결정합니다.
  • 요소를 감싸고 재배열하는 높은 유연성을 제공합니다.
  • 복잡성이 평탄하게 유지되며, 부품은 독립됩니다.

컴포넌트에게 어떻게 보여야 하는지를 지시하는 대신, 우리는 상태와 규칙을 제공하고, 구조를 소비자가 결정하도록 합니다. 여기서 Compound ComponentsRender Props가 빛을 발합니다.

Source:

복합 컴포넌트란?

복합 컴포넌트는 관련된 컴포넌트 집합을 노출합니다. 이 컴포넌트들은:

  • 상태를 암묵적으로 공유합니다.
  • 특정 부모 안에서만 동작합니다.
  • 선언적이고 가독성 높은 API를 형성합니다.

HTML 패턴을 그대로 반영하기 때문에 자연스럽게 느껴집니다.

사용 예시

<Accordion>
  <Accordion.Item>
    <Accordion.Header>Title</Accordion.Header>
    <Accordion.Body>Content</Accordion.Body>
  </Accordion.Item>
</Accordion>
  • Prop drilling이 없습니다.
  • 설정 지옥이 없습니다.
  • 구조 자체가 스스로를 설명합니다.

복합 컴포넌트의 사고 모델

Accordion (state owner)
 ├─ Item (scopes state)
 │   ├─ Header (reads + triggers)
 │   └─ Body   (reads)
  • Accordion는 전역 상태를 소유합니다.
  • Item은 그 상태를 단일 항목으로 좁힙니다.
  • Header / Body는 필요한 것만 사용합니다.

상태는 아래로 흐르고, 이벤트는 위로 흐릅니다 — 하지만 좁은 범위 내에서만 그렇습니다. 각 레이어는 책임을 줄여서 각 컴포넌트를 독립적으로 이해할 수 있게 합니다. 이러한 격리는 복합 컴포넌트가 대규모에서도 유지 보수 가능하도록 하는 핵심 요소입니다.

스크래치에서 Accordion 만들기

1. State 설계

const [activeItems, setActiveItems] = useState<Set<string>>(...);

const toggleItem = useCallback(
  (itemId: string) => {
    if (allowMultiple) {
      setActiveItems(prev => {
        const newItems = new Set(prev);
        if (newItems.has(itemId)) {
          newItems.delete(itemId);
        } else {
          newItems.add(itemId);
        }
        return newItems;
      });
      return;
    }

    setActiveItems(prev =>
      prev.has(itemId) ? new Set() : new Set([itemId])
    );
  },
  [allowMultiple]
);

Accordion 상태는 이 항목이 열려 있는가? 라는 질문에 효율적으로 답해야 합니다.

Set인가?

  • O(1) 조회.
  • 중복 ID를 자동으로 방지.
  • “다중 항목 열기”에 자연스럽게 맞음.
  • 명확한 의미 전달.

이 결정만으로도 여러 UX 패턴에서 재사용이 가능해지고 다음을 쉽게 지원할 수 있습니다:

  • 제어형과 비제어형 사용 모두.
  • 단일‑ 및 다중‑열기 항목 모두.

예제는 비제어형 사용에 초점을 맞추고 있으며, 제어형 버전은 valueonChange를 받아 외부에서 activeItems를 유도하도록 구현할 수 있습니다.

2. 내부 계약으로서의 Context

type AccordionContextValue = {
  activeItems: Set<string>;
  toggleItem: (id: string) => void;
};

이 컨텍스트는 내부적으로 Accordion, Item, Header, Body 간에 상태와 동작을 공유하기 위해 사용되며, 구현 세부 사항을 소비자에게 노출하지 않습니다.

Public vs Internal API

Public API
-------------------
<Accordion>
  <Accordion.Item>
    <Accordion.Header />
    <Accordion.Body />
  </Accordion.Item>
</Accordion>

Internal API
-------------------
AccordionContext
  ├── activeItems
  └── toggleItem

소비자는 컴포넌트에 의존하며, 그 컴포넌트가 내부적으로 어떻게 동작하는지는 신경 쓰지 않습니다. 소비자가 컨텍스트 구조에 의존한다면 내부 구현이 유출된 것입니다.

복합 컴포넌트를 사용하면 소비자를 깨뜨리지 않고도 모든 것을 배경에서 업데이트할 수 있습니다.

핵심 포인트: 소비자는 조합합니다 — 상태를 관리하지 않습니다.

컨텍스트 생성

import { createContext, useContext } from 'react';

// Accordion Context
type AccordionContextValue = {
  allowMultiple?: boolean;
  activeItems: Set<string>;
  toggleItem: (id: string) => void;
};

export const AccordionContext = createContext<AccordionContextValue | null>(null);

export const useAccordion = () => {
  const ctx = useContext(AccordionContext);
  if (!ctx) {
    throw new Error('useAccordion must be used within <Accordion>');
  }
  return ctx;
};

// Accordion Item Context
type AccordionItemContextValue = {
  id: string;
  isActive: boolean;
};

export const AccordionItemContext = createContext<AccordionItemContextValue | null>(null);

export const useAccordionItem = () => {
  const ctx = useContext(AccordionItemContext);
  if (!ctx) {
    throw new Error('useAccordionItem must be used within <Accordion.Item>');
  }
  return ctx;
};

AccordionContext.Provider 사용

const getInitialValue = (defaultValue?: string | string[]): string[] => {
  if (!defaultValue) return [];
  if (Array.isArray(defaultValue)) return defaultValue;
  return [defaultValue];
};

function Accordion({ children, allowMultiple, defaultValue }: Props) {
  const [activeItems, setActiveItems] = useState<Set<string>>(
    () => new Set(getInitialValue(defaultValue))
  );

  const toggleItem = useCallback(
    (itemId: string) => {
      if (allowMultiple) {
        setActiveItems(prev => {
          const newItems = new Set(prev);
          if (newItems.has(itemId)) {
            newItems.delete(itemId);
          } else {
            newItems.add(itemId);
          }
          return newItems;
        });
        return;
      }
      setActiveItems(prev =>
        prev.has(itemId) ? new Set() : new Set([itemId])
      );
    },
    [allowMultiple]
  );

  const value = useMemo(
    () => ({ allowMultiple, activeItems, toggleItem }),
    [allowMultiple, activeItems, toggleItem]
  );

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

Accordion.Item 로 범위 지정

type ItemProps = {
  children: React.ReactNode;
  itemId: string;
};

function AccordionItem({ children, itemId }: ItemProps) {
  const { activeItems } = useAccordion();

  const isActive = activeItems.has(itemId);
  const { element } = renderChildren(children, { isActive });

  return (
    <AccordionItemContext.Provider value={{ id: itemId, isActive }}>
      {element}
    </AccordionItemContext.Provider>
  );
}

왜 두 번째 컨텍스트가 필요할까?

  • HeaderBody자신의 아이템에 대해서만 알면 된다.
  • 추상화 누수를 방지한다.
  • 각 서브 컴포넌트가 집중할 수 있게 하여 결합도와 복잡성을 낮춘다.

Source:

아코디언 컴포넌트 구현

헤더 컴포넌트

type HeaderProps = {
  children: Children<{ isActive: boolean; onClick: () => void }>;
  className?: string;
};

function AccordionHeader({ children, className }: HeaderProps) {
  const { toggleItem } = useAccordion();
  const { id, isActive } = useAccordionItem();

  const handleClick = () => toggleItem(id);

  const { element } = renderChildren(children, {
    isActive,
    onClick: handleClick,
  });

  return <div className={className}>{element}</div>;
}

렌더‑프롭 지원

export type Children<T = any> = React.ReactNode | ((props: T) => React.ReactNode);

export const renderChildren = <T,>(children: Children<T>, props: T) => {
  if (typeof children === 'function')
    return { element: children(props), isRenderProp: true };
  return { element: children, isRenderProp: false };
};

바디 컴포넌트

type BodyProps = {
  children: Children;
};

function AccordionBody({ children }: BodyProps) {
  const { isActive } = useAccordionItem();
  const { element } = renderChildren(children, { isActive });

  return <div>{element}</div>;
}

복합 컴포넌트 내보내기

Accordion.Item = AccordionItem;
Accordion.Header = AccordionHeader;
Accordion.Body = AccordionBody;

export { Accordion };

장점

  • 하나의 컴포넌트를 import 하면 모든 하위 컴포넌트를 함께 사용할 수 있습니다.
  • TypeScript가 구조를 이해하므로 완전한 타입 안전성을 제공합니다.

사용 예시

import { Accordion } from './components/accordion';
import { FAQs } from './data/faqs';

function App() {
  return (
    <Accordion allowMultiple>
      {FAQs.map(faq => (
        <Accordion.Item key={faq.id} itemId={faq.id}>
          <Accordion.Header>{faq.question}</Accordion.Header>
          <Accordion.Body>{faq.answer}</Accordion.Body>
        </Accordion.Item>
      ))}
    </Accordion>
  );
}

export default App;

Source:

추가 사용 사례

function App() {
  return (
    <>
      <Modal>
        <Modal.Trigger>Open Modal</Modal.Trigger>
        <Modal.Window>
          <Modal.Close>Close Modal</Modal.Close>
        </Modal.Window>
      </Modal>
    </>
  );
}
  • 윈도우를 어디서든 렌더링하고 Modal 컨텍스트를 통해 어떤 하위 컴포넌트에서도 열고 닫을 수 있습니다.
  • 프로그래밍 방식 제어를 위한 useModal 훅을 내보냅니다:
function App() {
  const { openWindow, closeWindow } = useModal();

  const someHandler = () => {
    // 일부 로직 수행
    openWindow('window-1');
  };
}

Mental Model: 하나의 상태 소유자, 다수의 선언형 소비자.

파일 입력 컴포넌트와 Trigger, Preview, Dropzone, Error

function App() {
  return (
    <FileInput>
      <FileInput.Trigger>Upload Image</FileInput.Trigger>
      <FileInput.Dropzone />
      <FileInput.Preview>
        {(files, removeFile) => (
          <>
            {/* 각 파일을 삭제 버튼과 함께 렌더링 */}
          </>
        )}
      </FileInput.Preview>
    </FileInput>
  );
}

렌더‑프롭 패턴이 여기서 빛을 발합니다. 프리뷰 레이아웃을 완전히 제어하면서도 컴포넌트 API를 깔끔하게 유지할 수 있습니다.

아코디언 개선: 성능 및 아키텍처

개선 1 – 더 적은 리렌더를 위한 컨텍스트 분리

현재 상황

  • activeItems가 변경될 때마다 모든 소비자가 리렌더됩니다.

제안된 변경

type AccordionState = {
  allowMultiple?: boolean;
  activeItems: Set<string>;
};
const AccordionStateContext = createContext<AccordionState | null>(null);

type AccordionActions = {
  toggleItem: (id: string) => void;
};
const AccordionActionsContext = createContext<AccordionActions | null>(null);

결과

  • 메모이제이션과 결합하면 불필요한 리렌더를 줄입니다.
  • 액션 함수들을 안정적으로 유지합니다.

이러한 최적화는 컴포넌트가 널리 재사용되거나 동시에 많은 아코디언 항목이 열려 있을 때(예: 100개 항목) 가장 큰 효과를 발휘합니다.

⚠️ Warning: 추가 컨텍스트를 도입하면 복잡성이 증가합니다. 최적화하기 전에 반드시 측정하세요.

개선 2 – 헤드리스 훅 추출

function useAccordionState({
  defaultActive,
  allowMultiple,
}: { defaultActive?: string[]; allowMultiple?: boolean }) {
  // state + logic only
}

장점

  • UI와 분리된 테스트 가능하고 재사용 가능한 로직.
  • 다양한 복합 컴포넌트(예: 탭, 콜랩시블 패널) 간에 공유될 수 있습니다.
Back to Blog

관련 글

더 보기 »