컴파운드 컴포넌트 패턴으로 리액트 코드 이쁘게 구조화하기

홍규진·2025년 3월 19일
18
post-thumbnail

리액트를 사용하다 보면 컴포넌트가 점점 복잡해지는 경험을 해본 적이 있을 것이다. 처음에는 단순하게 시작했던 컴포넌트가 기능이 추가될수록 props가 늘어나고, 내부 로직이 복잡해지는 현상을 겪게 된다.

(👨🏻‍🏫 : 저도 처음 리액트 프로젝트를 할 때 Header 컴포넌트에 온갖 props를 다 넣었다가 나중에 유지보수하기 너무 힘들었답니다. 그때 이 패턴을 알았더라면... 어땠을까요? 마치 초대형 레고를 5인이서 조립을 할 때, 각 부품별로 나눠서 조립을 하는 것이 이 방법론이랍니다.)

이런 문제를 해결하기 위한 방법 중 하나가 바로 컴파운드 컴포넌트 패턴(Compound Component Pattern)이다. HTML의 <select><option> 태그처럼 서로 밀접하게 연관된 컴포넌트들을 더 직관적이고 유연하게 구성할 수 있는 패턴이다. 오늘은 이 패턴에 대해 자세히 알아보자.

💡급하신 분들을 위해서 결론 먼저!

  1. 컴파운드 컴포넌트는 여러 관련 컴포넌트를 하나의 부모 아래 논리적으로 그룹화하는 패턴이다.
  2. HTML의 <select><option> 관계처럼 직관적인 컴포넌트 구조를 만들 수 있다.
  3. Context API를 활용해 내부 상태를 공유하면서도 사용자에게는 깔끔한 API를 제공한다.
  4. 복잡한 UI 컴포넌트(탭, 아코디언, 드롭다운 등)에 특히 유용하다.
  5. 컴포넌트 간 결합도를 낮추면서도 일관된 동작을 보장한다. 단, 그 과정에서 규칙을 잘 세워둬야한다.

1. 컴포넌트란?

리액트의 기본 단위

리액트는 컴포넌트(Component)라는 개념을 중심으로 UI를 구성한다. 컴포넌트는 리액트 애플리케이션을 구성하는 가장 기본적인 단위로, UI의 독립적이고 재사용 가능한 조각이다. 마치 레고 블록처럼 작은 조각들을 조합해 전체 애플리케이션을 구성하는 방식이다.

컴포넌트의 종류

리액트에서 컴포넌트는 크게 두 가지 방식으로 정의할 수 있다:

  1. 함수형 컴포넌트(Function Component): 자바스크립트 함수를 사용해 정의하는 방식
  2. 클래스형 컴포넌트(Class Component): React.Component를 상속받아 정의하는 방식
*// 함수형 컴포넌트*
function MyComponent() {
  return <div>Hello React!</div>;
}

*// 화살표 함수로도 표현 가능*
const MyComponent = () => {
  return <div>Hello React!</div>;
};

*// 클래스형 컴포넌트*
class MyComponent2 extends Component {
  render() {
    return <div>Hello React!</div>;
  }
}

출처: React 컴포넌트란?

현재는 React 팀에서 공식적으로 함수형 컴포넌트Hooks를 사용하는 방식을 권장하고 있다. 함수형 컴포넌트는 코드가 더 간결하고, 이해하기 쉬우며, 테스트하기 쉽다는 장점이 있다.

2. 컴파운드 컴포넌트란?

개념 이해하기

컴파운드 컴포넌트(Compound Component)는 서로 관련된 여러 컴포넌트를 하나의 부모 컴포넌트 아래에 논리적으로 그룹화하는 패턴이다. 이 패턴의 핵심은 상태와 동작을 부모 컴포넌트에서 관리하면서도, 실제 UI 렌더링의 제어권은 사용자에게 유연하게 제공하는 것이다.

(👨🏻‍🏫 : 쉽게 말해서 HTML의 <select><option> 관계를 리액트 컴포넌트로 구현한 것이라고 생각하시면 됩니다. 부모-자식 관계로 개별적으로 구현되어있지만, 이 둘은 서로 밀접하게 연결되어 있죠!)

HTML의 select-option 예시

HTML에서 <select><option> 태그는 컴파운드 컴포넌트 패턴의 좋은 예시다:

<select>
  <option>Option 1</option>
  <option>Option 2</option>
  <option>Option 3</option>
  <option>Option 4</option>
</select>

이 예시에서 <select>는 부모 컴포넌트로, 드롭다운의 상태(열림/닫힘, 선택된 항목 등)를 관리한다. <option> 태그들은 자식 컴포넌트로, 각각의 선택 항목을 표현한다. 사용자는 이 두 컴포넌트를 함께 사용하여 완전한 드롭다운 UI를 구성한다.

컴파운드 컴포넌트의 구현 방식

리액트에서 컴파운드 컴포넌트를 구현하는 주요 방법은 Context API를 활용하는 것이다. 이를 통해 부모 컴포넌트는 자식 컴포넌트들과 상태와 메서드를 공유할 수 있다.

*// 간단한 컴파운드 컴포넌트 예시*
import React, { createContext, useContext, useState } from 'react';

*// 컨텍스트 생성*
const ToggleContext = createContext();

*// 부모 컴포넌트*
function Toggle({ children, onToggle }) {
  const [on, setOn] = useState(false);
  
  const toggle = () => {
    setOn(!on);
    if (onToggle) {
      onToggle(!on);
    }
  };
  
  return (
    <ToggleContext.Provider value={{ on, toggle }}>
      {children}
    </ToggleContext.Provider>
  );
}

*// 자식 컴포넌트들*
function ToggleOn({ children }) {
  const { on } = useContext(ToggleContext);
  return on ? children : null;
}

function ToggleOff({ children }) {
  const { on } = useContext(ToggleContext);
  return on ? null : children;
}

function ToggleButton() {
  const { on, toggle } = useContext(ToggleContext);
  return <button onClick={toggle}>{on ? 'ON' : 'OFF'}</button>;
}

*// 컴포넌트들을 Toggle의 속성으로 할당*
Toggle.On = ToggleOn;
Toggle.Off = ToggleOff;
Toggle.Button = ToggleButton;

export default Toggle;

출처: Compound Components In React

이렇게 구현된 컴파운드 컴포넌트는 다음과 같이 사용할 수 있다:

function App() {
  return (
    <Toggle onToggle={(on) => console.log(on)}>
      <Toggle.On>The button is on</Toggle.On>
      <Toggle.Off>The button is off</Toggle.Off>
      <Toggle.Button />
    </Toggle>
  );
}

출처: React Hooks: Compound Components

3. 나쁜 예, 좋은 예

나쁜 예: Props 비만 컴포넌트

컴파운드 컴포넌트를 사용하지 않으면, 종종 "Props 비만(Props Obesity)" 문제에 직면하게 된다. 이는 하나의 컴포넌트에 너무 많은 props를 전달하여 컴포넌트가 비대해지는 현상이다. 아래의 예시를 들겠다.

*// 나쁜 예: Props 비만 컴포넌트*
interface HeaderProps {
  title?: string | null | undefined;
  leftIcon?: ReactNode;
  rightIcon?: ReactNode;
  rightAction?: () => void;
  leftAction?: () => void;
  showBackButton?: boolean;
  onBackButtonClick?: () => void;
  backgroundColor?: string;
  textColor?: string;
  isFixed?: boolean;
  hasShadow?: boolean;
  *// ... 더 많은 props들*
}

const Header = ({ 
  title, 
  leftIcon, 
  rightIcon, 
  rightAction, 
  leftAction,
  *// ... 더 많은 props들* 
}) => {
  *// 복잡한 조건부 렌더링 로직*
  return <div>...</div>;
};

출처: 매번 찾아오는 컴포넌트에 대한 고민

이런 방식은 다음과 같은 문제를 야기한다:

  1. 컴포넌트 사용 방법을 이해하기 어렵다
  2. 내부 로직이 복잡해진다
  3. 유지보수가 어려워진다
  4. 테스트하기 어렵다

좋은 예: 컴파운드 컴포넌트 패턴

컴파운드 컴포넌트 패턴을 사용하면 관련 기능을 논리적으로 그룹화하고, 각 컴포넌트가 자신의 역할에만 집중할 수 있다.

*// 좋은 예: 컴파운드 컴포넌트 패턴*
import React, { createContext, useContext, useState } from 'react';

const MenuContext = createContext();

function Menu({ children }) {
  const [isOpen, setIsOpen] = useState(false);
  const [selectedItem, setSelectedItem] = useState(null);
  
  const toggleMenu = () => setIsOpen(!isOpen);
  const selectItem = (item) => setSelectedItem(item);
  
  return (
    <MenuContext.Provider value={{ isOpen, toggleMenu, selectedItem, selectItem }}>
      <div className="menu-container">
        {children}
      </div>
    </MenuContext.Provider>
  );
}

function MenuButton({ children }) {
  const { toggleMenu } = useContext(MenuContext);
  return <button onClick={toggleMenu}>{children}</button>;
}

function MenuList({ children }) {
  const { isOpen } = useContext(MenuContext);
  return isOpen ? <ul className="menu-list">{children}</ul> : null;
}

function MenuItem({ value, children }) {
  const { selectItem, selectedItem } = useContext(MenuContext);
  const isSelected = selectedItem === value;
  
  return (
    <li 
      className={isSelected ? 'selected' : ''} 
      onClick={() => selectItem(value)}
    >
      {children}
    </li>
  );
}

*// 컴포넌트들을 Menu의 속성으로 할당*
Menu.Button = MenuButton;
Menu.List = MenuList;
Menu.Item = MenuItem;

export default Menu;

출처: Beyond the Basics: Exploring React's Compound Components

이렇게 구현된 컴파운드 컴포넌트는 다음과 같이 사용할 수 있다:

function App() {
  return (
    <Menu>
      <Menu.Button>Click Me</Menu.Button>
      <Menu.List>
        <Menu.Item value="item1">Item 1</Menu.Item>
        <Menu.Item value="item2">Item 2</Menu.Item>
        <Menu.Item value="item3">Item 3</Menu.Item>
      </Menu.List>
    </Menu>
  );
}

이 방식의 장점은:

  1. 각 컴포넌트가 자신의 역할에만 집중한다
  2. 사용법이 직관적이고 선언적이다
  3. 내부 상태 관리가 캡슐화되어 있다
  4. 컴포넌트 구성이 유연하다

(👨🏻‍🏫 : 그러나, context API 를 구현하는 곳에서만 구현해야하는 것은 아닙니다. 이렇게 여러 UI 를 하나의 Context API 내에서 구현하는 것보다는, 보통 components 폴더를 통해서 따로 구현합니다. 프로젝트의 사이즈나, 팀원들의 선호도, 팀 내 지정된 Context API의 역할 등에 맞는 것인지 한 번 생각해봐야만 합니다. 저는 보통 components 폴더로 분리해두고 내부적으로 결합해서 사용합니다. )

4. 주의할 점

과도한 추상화 피하기

컴파운드 컴포넌트 패턴은 강력하지만, 모든 상황에 적합한 것은 아니다. 단순한 UI 요소에 이 패턴을 적용하면 불필요한 복잡성을 추가할 수 있다.

(👨🏻‍🏫 : 망치를 들면 모든 것이 못으로 보인다는 말이 있죠. 패턴도 마찬가지입니다. 간단한 버튼에 컴파운드 컴포넌트 패턴을 적용하는 건 과도할 수 있어요!)

명확한 문서화

컴파운드 컴포넌트는 일반적인 컴포넌트보다 사용 방법이 복잡할 수 있다. 따라서 각 컴포넌트의 역할과 사용법을 명확하게 문서화하는 것이 중요하다.

네이밍 컨벤션 유지하기

컴파운드 컴포넌트의 이름은 그 관계를 명확히 드러내야 한다. 예를 들어, Menu, Menu.Button, Menu.List, Menu.Item과 같이 일관된 네이밍 컨벤션을 유지하는 것이 좋다.

테스트 전략 수립하기

컴파운드 컴포넌트는 여러 컴포넌트가 함께 작동하기 때문에 테스트가 복잡할 수 있다. 개별 컴포넌트와 통합된 상태 모두를 테스트하는 전략을 수립해야 한다.

5. 결론 및 정리

컴파운드 컴포넌트 패턴은 리액트에서 복잡한 UI 컴포넌트를 구성하는 강력한 방법이다. 이 패턴은 다음과 같은 상황에서 특히 유용하다:

  1. 탭 또는 아코디언 컴포넌트: 여러 패널로 구성된 UI 요소.
  2. 폼 컴포넌트: 여러 입력 필드가 상태 관리와 유효성 검사를 공유해야 하는 경우.
  3. 모달 컴포넌트: 열림/닫힘 상태를 공유하는 팝업 형태의 UI.
  4. 드롭다운 메뉴: 선택 항목과 메뉴 상태를 관리해야 하는 경우.

이 패턴의 핵심 장점은:

  • 향상된 유연성과 사용자 정의 가능성
  • 관심사의 더 나은 분리
  • 더 직관적이고 선언적인 API
  • 향상된 재사용성

컴파운드 컴포넌트 패턴은 리액트의 Context API와 함께 사용하면 더욱 강력해진다. 이를 통해 컴포넌트 간의 상태 공유를 효과적으로 관리하면서도, 사용자에게는 깔끔하고 직관적인 API를 제공할 수 있다.

결론적으로, 컴파운드 컴포넌트 패턴은 복잡한 UI 컴포넌트를 구성할 때 고려해볼 만한 강력한 도구다. 이 패턴을 적절히 활용하면 더 유지보수하기 쉽고, 유연하며, 직관적인 컴포넌트를 만들 수 있다. 다만, 모든 상황에 적합한 것은 아니므로 컴포넌트의 복잡성과 사용 맥락을 고려하여 적용해야 한다.

실제 활용 사례

많은 인기 있는 UI 라이브러리들이 컴파운드 컴포넌트 패턴을 활용하고 있다:

  • Material-UI: Tabs, Tab, TabPanel 컴포넌트
  • Chakra UI: Menu, MenuButton, MenuList, MenuItem 컴포넌트
  • Ant Design: Form, Form.Item, Form.List 컴포넌트

이러한 라이브러리들의 구현 방식을 참고하면 자신의 프로젝트에서도 효과적으로 컴파운드 컴포넌트를 활용할 수 있을 것이다.

*// Chakra UI의 Menu 컴포넌트 사용 예시*
import { Menu, MenuButton, MenuList, MenuItem, Button } from "@chakra-ui/react"

function Example() {
  return (
    <Menu>
      <MenuButton as={Button}>
        Actions
      </MenuButton>
      <MenuList>
        <MenuItem>Download</MenuItem>
        <MenuItem>Create a Copy</MenuItem>
        <MenuItem>Mark as Draft</MenuItem>
        <MenuItem>Delete</MenuItem>
      </MenuList>
    </Menu>
  )
}

출처: Chakra UI - Menu

컴파운드 컴포넌트 패턴은 리액트의 컴포지션 철학과 완벽하게 일치하며, 복잡한 UI를 더 관리하기 쉽고 유연한 방식으로 구성할 수 있게 해준다. 이 패턴을 마스터하면 리액트 개발 능력이 한 단계 더 성장할 것이다.

(👨🏻‍🏫 : 리엑트 팀이 컴포넌트 화를 주도하자마자, 어쩌면 가장 자연스럽게 떠올랐을 패턴이 이 컴파운드 컴포넌트 패턴입니다. 그 의도를 제대로 알고서 해당 라이브러리를 사용하는 게 좋지 않을까요? )

🙇🏻 글 내에 틀린 점, 오탈자, 비판, 공감 등 모두 적어주셔도 됩니다. 감사합니다..! 🙇🏻

profile
읽는 사람이 가장 이해하기 쉽게끔 적으려 노력합니다. 그 과정에서 스스로가 완전한 이해를 할 수 있다고 생각합니다. 그렇게 Taker 보다는 Giver이 되려 노력합니다.

6개의 댓글

오 잘봤습니다!
실무적인 부분에서 궁금한게 있습니다!

프로젝트에서 엄청난 양의 컴포넌트 파일과 많은 코드들이 존재할때 기준에서

  1. A라는 좋은 개선 방법으로 개선하려고 할 때 어떤식으로 수정을 진행하시나요?

  2. 추후 B, C, ... 등등 좋은 개선 방법이 생겼을때에도 어떻게 대응하는지도 궁금해요!

1개의 답글
comment-user-thumbnail
2025년 4월 2일

예전에 Modal도 컴파운드 컴포넌트로 구현해본적이 있는데 확실히 유지보수하기 좋더라구요

1개의 답글