
리액트를 사용하다 보면 컴포넌트가 점점 복잡해지는 경험을 해본 적이 있을 것이다. 처음에는 단순하게 시작했던 컴포넌트가 기능이 추가될수록 props가 늘어나고, 내부 로직이 복잡해지는 현상을 겪게 된다.
(👨🏻🏫 : 저도 처음 리액트 프로젝트를 할 때 Header 컴포넌트에 온갖 props를 다 넣었다가 나중에 유지보수하기 너무 힘들었답니다. 그때 이 패턴을 알았더라면... 어땠을까요? 마치 초대형 레고를 5인이서 조립을 할 때, 각 부품별로 나눠서 조립을 하는 것이 이 방법론이랍니다.)
이런 문제를 해결하기 위한 방법 중 하나가 바로 컴파운드 컴포넌트 패턴(Compound Component Pattern)이다. HTML의 <select>와 <option> 태그처럼 서로 밀접하게 연관된 컴포넌트들을 더 직관적이고 유연하게 구성할 수 있는 패턴이다. 오늘은 이 패턴에 대해 자세히 알아보자.
<select>와 <option> 관계처럼 직관적인 컴포넌트 구조를 만들 수 있다.리액트는 컴포넌트(Component)라는 개념을 중심으로 UI를 구성한다. 컴포넌트는 리액트 애플리케이션을 구성하는 가장 기본적인 단위로, UI의 독립적이고 재사용 가능한 조각이다. 마치 레고 블록처럼 작은 조각들을 조합해 전체 애플리케이션을 구성하는 방식이다.
리액트에서 컴포넌트는 크게 두 가지 방식으로 정의할 수 있다:
*// 함수형 컴포넌트*
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를 사용하는 방식을 권장하고 있다. 함수형 컴포넌트는 코드가 더 간결하고, 이해하기 쉬우며, 테스트하기 쉽다는 장점이 있다.
컴파운드 컴포넌트(Compound Component)는 서로 관련된 여러 컴포넌트를 하나의 부모 컴포넌트 아래에 논리적으로 그룹화하는 패턴이다. 이 패턴의 핵심은 상태와 동작을 부모 컴포넌트에서 관리하면서도, 실제 UI 렌더링의 제어권은 사용자에게 유연하게 제공하는 것이다.
(👨🏻🏫 : 쉽게 말해서 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
컴파운드 컴포넌트를 사용하지 않으면, 종종 "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>;
};
이런 방식은 다음과 같은 문제를 야기한다:
컴파운드 컴포넌트 패턴을 사용하면 관련 기능을 논리적으로 그룹화하고, 각 컴포넌트가 자신의 역할에만 집중할 수 있다.
*// 좋은 예: 컴파운드 컴포넌트 패턴*
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>
);
}
이 방식의 장점은:
(👨🏻🏫 : 그러나, context API 를 구현하는 곳에서만 구현해야하는 것은 아닙니다. 이렇게 여러 UI 를 하나의 Context API 내에서 구현하는 것보다는, 보통 components 폴더를 통해서 따로 구현합니다. 프로젝트의 사이즈나, 팀원들의 선호도, 팀 내 지정된 Context API의 역할 등에 맞는 것인지 한 번 생각해봐야만 합니다. 저는 보통 components 폴더로 분리해두고 내부적으로 결합해서 사용합니다. )
컴파운드 컴포넌트 패턴은 강력하지만, 모든 상황에 적합한 것은 아니다. 단순한 UI 요소에 이 패턴을 적용하면 불필요한 복잡성을 추가할 수 있다.
(👨🏻🏫 : 망치를 들면 모든 것이 못으로 보인다는 말이 있죠. 패턴도 마찬가지입니다. 간단한 버튼에 컴파운드 컴포넌트 패턴을 적용하는 건 과도할 수 있어요!)
컴파운드 컴포넌트는 일반적인 컴포넌트보다 사용 방법이 복잡할 수 있다. 따라서 각 컴포넌트의 역할과 사용법을 명확하게 문서화하는 것이 중요하다.
컴파운드 컴포넌트의 이름은 그 관계를 명확히 드러내야 한다. 예를 들어, Menu, Menu.Button, Menu.List, Menu.Item과 같이 일관된 네이밍 컨벤션을 유지하는 것이 좋다.
컴파운드 컴포넌트는 여러 컴포넌트가 함께 작동하기 때문에 테스트가 복잡할 수 있다. 개별 컴포넌트와 통합된 상태 모두를 테스트하는 전략을 수립해야 한다.
컴파운드 컴포넌트 패턴은 리액트에서 복잡한 UI 컴포넌트를 구성하는 강력한 방법이다. 이 패턴은 다음과 같은 상황에서 특히 유용하다:
이 패턴의 핵심 장점은:
컴파운드 컴포넌트 패턴은 리액트의 Context API와 함께 사용하면 더욱 강력해진다. 이를 통해 컴포넌트 간의 상태 공유를 효과적으로 관리하면서도, 사용자에게는 깔끔하고 직관적인 API를 제공할 수 있다.
결론적으로, 컴파운드 컴포넌트 패턴은 복잡한 UI 컴포넌트를 구성할 때 고려해볼 만한 강력한 도구다. 이 패턴을 적절히 활용하면 더 유지보수하기 쉽고, 유연하며, 직관적인 컴포넌트를 만들 수 있다. 다만, 모든 상황에 적합한 것은 아니므로 컴포넌트의 복잡성과 사용 맥락을 고려하여 적용해야 한다.
많은 인기 있는 UI 라이브러리들이 컴파운드 컴포넌트 패턴을 활용하고 있다:
Tabs, Tab, TabPanel 컴포넌트Menu, MenuButton, MenuList, MenuItem 컴포넌트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를 더 관리하기 쉽고 유연한 방식으로 구성할 수 있게 해준다. 이 패턴을 마스터하면 리액트 개발 능력이 한 단계 더 성장할 것이다.
(👨🏻🏫 : 리엑트 팀이 컴포넌트 화를 주도하자마자, 어쩌면 가장 자연스럽게 떠올랐을 패턴이 이 컴파운드 컴포넌트 패턴입니다. 그 의도를 제대로 알고서 해당 라이브러리를 사용하는 게 좋지 않을까요? )
🙇🏻 글 내에 틀린 점, 오탈자, 비판, 공감 등 모두 적어주셔도 됩니다. 감사합니다..! 🙇🏻
오 잘봤습니다!
실무적인 부분에서 궁금한게 있습니다!
프로젝트에서 엄청난 양의 컴포넌트 파일과 많은 코드들이 존재할때 기준에서
A라는 좋은 개선 방법으로 개선하려고 할 때 어떤식으로 수정을 진행하시나요?
추후 B, C, ... 등등 좋은 개선 방법이 생겼을때에도 어떻게 대응하는지도 궁금해요!