회사 업무를 하다 보면 자연스럽게 공통 컴포넌트를 개발하게 되는 경우가 많습니다. 저 역시 다양한 프로젝트에서 수많은 공통 컴포넌트를 만들어봤는데요, 초기에는 대부분 props를 기반으로 조건을 분기 처리하는 방식으로 구현했습니다.
처음엔 큰 문제가 없었습니다. 단순한 구조일 때는 size
, variant
, loading
같은 조건 몇 가지를 props로 넘겨주고, 내부에서 분기 처리하면 끝. 간결하고 깔끔해 보였죠.
하지만 시간이 지날수록 문제가 생기기 시작했습니다. 요구사항이 추가되거나 디자인이 변경될 때마다 새로운 조건이 props로 늘어났고, 그 수많은 조건들을 모두 내부에서 분기 처리하게 되니 코드가 점점 복잡하고 지저분해졌습니다.
복잡한 로직은 곧 협업의 어려움으로 이어졌습니다. 동료 개발자들이 코드를 이해하는 데 시간이 오래 걸렸고, 코드 리뷰에서도 반복적으로 같은 질문이 나왔습니다. "이건 왜 이렇게 처리된 거지?", "이 조건은 어디서 오는 거야?"
결국, 구조 자체를 바꾸는 것이 장기적으로 낫겠다는 판단을 하게 되었습니다.
마침, 업무 중 사용하던 Mantine이라는 UI 라이브러리가 떠올랐습니다. Mantine은 전반적으로 props 기반이긴 하지만, 일부 컴포넌트는 합성 컴포넌트 형태로 구성되어 있었습니다.
예를 들어 Tabs
, Accordion
, Menu
, RadioGroup
등은 각각의 하위 요소를 조합해서 쓰는 구조였는데요, 이 구조는 마치 블록을 조립하듯 유연하고, 필요할 때만 필요한 요소만 조합할 수 있어 매우 인상 깊었습니다.
무엇보다 마음에 들었던 점은, 공통 컴포넌트의 일부분만 커스터마이징해야 하는 경우에도 유연하게 대응할 수 있었다는 점이었습니다. props만으로 모든 UI나 상태를 제어할 필요가 없었기 때문에 오히려 코드가 더 단순해졌습니다.
Mantine의 구조를 참고해, 공통 컴포넌트를 합성 컴포넌트 패턴으로 리팩토링해보자고 제안했고, 직접 적용해봤습니다.
children
을 통해 UI 요소를 유연하게 구성합성 컴포넌트(Compound Component) 패턴은 말 그대로 하나의 큰 컴포넌트를 여러 개의 작은 컴포넌트로 조합해서 구성하는 방식입니다.
예를 들어, 하나의 <Modal />
컴포넌트를 사용할 때 내부에
<Modal.Header />
, <Modal.Body />
, <Modal.Footer />
와 같은 서브 컴포넌트를 조립해서 사용하는 형태입니다.
<Modal>
<Modal.Header>제목</Modal.Header>
<Modal.Body>내용</Modal.Body>
<Modal.Footer>버튼</Modal.Footer>
</Modal>
이런 구조를 사용하면,
합성 컴포넌트는 마치 블록을 조립하는 것처럼 유연한 구조를 가능하게 합니다.
예시로 기존 프로젝트에서 사용했던 Dialog
컴포넌트를 소개하겠습니다. 이 컴포넌트는 다양한 상황에 대응하기 위해 점점 많은 props를 받게 되었습니다.
처음에는 단순한 title
, size
, onConfirm
등 단순한 정도로 시작했지만, 요구사항이 쌓이면서 커스터마이징 옵션, 상태 플래그, 스타일 props까지 점점 늘어났죠.
결국 props만으로는 감당하기 어려운 구조가 되었고, 내부 로직도 점점 복잡해졌습니다.
정말 지금 보면 너무나 부끄럽고 말도 안되는 코드입니다.
const Dialog = ({
title = '',
children,
size = 'lg',
confirmBtnText = '확인',
closeBtnText = '취소',
confirmDisabled,
onConfirm = () => {},
onClose = () => {},
onClickCloseBtn,
useHeader = true,
useFooter = true,
useCloseBtn = true,
customStyle,
headerCustomStyle,
bodyCustomStyle,
footerCustomStyle,
isLoading = false,
isDirty,
hasCancelBtn = false,
// ...
}) => {
// ...
}
<Dialog
title={couponType.title}
onClose={handleClose}
customStyle={{ ... }}
bodyCustomStyle={{ ... }}
confirmBtnText={couponType.btnText}
onConfirm={handleSubmit(handlePostCoupon)}
isDirty={isDirty || formIsDirty}
confirmDisabled={selectedMemberList?.length === 0}
>
// ...
</Dialog>
더 큰 문제는, header나 footer 같은 일부 영역만 커스터마이징해야 할 때였습니다. props만으로는 표현할 수 없어, 결국 새로 컴포넌트를 만들거나 기존 구조를 억지로 끼워 맞춰야 했습니다.
또 props를 추가할 때마다 기존 사용처에 사이드 이펙트가 발생하는 경우도 있었고, “공통 컴포넌트”를 써야 하는데 오히려 재사용성과 유지보수가 더 힘들어졌습니다.
이 구조는 점점 확장에는 약하고, 복잡도만 커지는 구조가 되었습니다.
그래서 합성 컴포넌트 구조라면 이런 문제를 해결할 수 있을거라 판단했습니다.
일단 기존 Dialog 컴포넌트를 Title / Content / Footer 구조로 역할을 나누었습니다.
그리고, 각 역할 단위를 서브 컴포넌트로 정의하고 displayName을 지정해 children에서 식별할 수 있도록 구성했습니다.
const Title = props => <SC.Title {...props}>{props.children}</SC.Title>
Title.displayName = 'PopupTitle'
const Content = props => <SC.Content {...props}>{props.children}</SC.Content>
Content.displayName = 'PopupContent'
const Footer = props => <SC.Footer {...props}>{props.children}</SC.Footer>
Footer.displayName = 'PopupFooter'
간단한 텍스트 기반 팝업의 경우, 여전히 props로 간편하게 사용할 수 있도록 PopupMain
에서 기본 구조를 props로 정의했습니다.
export const PopupMain = ({
children,
title,
content,
confirmText = '확인',
cancelText = '취소',
direction = 'row',
onConfirm,
onCancel,
buttonTheme = 'primary',
disabled = false,
}) => {
// ...
}
서브 컴포넌트는 displayName
을 기준으로 children
안에서 탐색하여 정해진 위치에 렌더링되도록 처리했습니다. 중첩된 Fragment 안에서도 찾을 수 있도록 재귀 로직도 포함했습니다.
이렇게 찾은 PopupTitle
, PopupContent
, PopupFooter
를 우선적으로 렌더링하고, 없을 경우 props
값을 렌더링하도록 했습니다.
// displayName으로 컴포넌트 찾기
const findChildByDisplayName = (children, targetDisplayName) => {
let found = null;
React.Children.toArray(children).some(child => {
if (React.isValidElement(child)) {
if (child.type.displayName === targetDisplayName) {
found = child;
return true;
}
if (child.type === React.Fragment) {
found = findChildByDisplayName(child.props.children, targetDisplayName);
return found !== null;
}
}
return false;
});
return found;
};
// 서브 컴포넌트를 가져옴
const getPopupTitle = children => findChildByDisplayName(children, 'PopupTitle');
const getPopupContent = children => findChildByDisplayName(children, 'PopupContent');
const getPopupFooter = children => findChildByDisplayName(children, 'PopupFooter');
export const PopupMain = ({
// ...
}) => {
// ...
if (ref.current && mounted) {
return createPortal(
<SC.Popup>
<SC.Background />
<SC.Container>
{popupTitle}
{!popupTitle && title && (
<Title>
// ...
</Title>
)}
{popupContent}
{!popupContent && content && (
<Content>
// ...
</Content>
)}
{popupFooter ?? (
<SC.Footer col={direction}>
// ...
</SC.Footer>
)}
</SC.Container>
</SC.Popup>,
ref.current
)
}
return null
}
최종적으로 PopupMain
에 서브 컴포넌트를 바인딩해 하나의 컴포넌트처럼 사용할 수 있도록 했습니다.
export const Popup = Object.assign(PopupMain, {
Title,
Content,
Footer,
})
기본적인 title
, content
, confirmText
, onConfirm
등은 props를 통해 간단하게 사용할 수 있도록 기본 구조도 그대로 유지했습니다.
<Popup
title="전자 결제 신청이 완료되었습니다"
content="30일 내 입점 심사 및 카드사 심사가 필요합니다."
confirmText="제출"
onConfirm={close}
/>
하지만 필요한 경우, children으로 합성 컴포넌트 방식의 유연한 구성도 가능합니다.
<Popup onConfirm={close} onCancel={close} direction="row">
<Popup.Title>
<Typography.Title3>전자 결제 신청이 완료되었습니다</Typography.Title3>
</Popup.Title>
<Popup.Content>
<List>
<List.Item>30일 내 입점 심사 및 카드사 심사가 필요합니다.</List.Item>
</List>
</Popup.Content>
</Popup>
처음엔 그냥 props 몇 개 넘겨주면 되겠지 싶었습니다.
하지만 시간이 지나면서 "이 컴포넌트… 내가 만든 거 맞나?" 싶은 상황이 벌어졌죠.
조건문 덕지덕지, props 수십 개, 이해하기 어려운 분기들로 점점 괴물이 되어버린 공통 컴포넌트...
그러던 중 Mantine을 사용하면서 합성 컴포넌트 구조를 접하게 되었고, 역할 단위로 나누고 조립하는 방식이 훨씬 유연하고 직관적이라는 걸 깨달았습니다.
합성 컴포넌트 패턴을 도입한 뒤로는
props 기반의 간단한 사용 방식은 유지하면서도, 필요한 경우엔 블록처럼 조립 가능한 구조로 확장할 수 있는 점이 특히 유용했습니다.
복잡해진 공통 컴포넌트로 고민하고 있다면, 합성 컴포넌트 패턴은 충분히 도입해볼 만한 좋은 선택지라고 생각합니다.