React

합성 컴포넌트 적용하기

shuai 2025. 2. 11. 15:58

디자인시스템 modal을 만들다가 합성 컴포넌트에 대해 알게 되었다.

 

합성 컴포넌트(Composition Component)란?

합성 컴포넌트 패턴은 하나의 컴포넌트를 여러 집합체로 분리한 뒤, 분리된 각 컴포넌트를 사용하는 쪽에서 조합해 사용하는 컴포넌트 패턴을 의미한다.

 

합성 컴포넌트를 사용하지 않는 경우 발생하는 props drilling

 

합성 컴포넌트를 사용하지 않으면, props를 사용하여 UI를 구성한다. 하지만 부모 컴포넌트에서 자식 컴포넌트로 전달되는 데이터가 많아지면 props drilling 현상이 발생할 수 있다.

예를 들어, 디자인시스템 modal 구현 상황을 예로 들어보자. 우리는 아래와 같이 다양한 모달들을 구현할 수 있는 디자인시스템 제작이 필요한 상황이다. 구성요소는 크게 Header, Content, Footer가 있고, Footer는 button일 수도 있고, link일 수도 있다.

 

그러면 보통은 아래와 같이 코드를 작성할 것이다.

 		 <Modal
        isShow={isShow}
        headerTitle="Face ID를 등록해주세요"
        onClose={() => setIsShow(false)}
        contentText="기기에 Face ID가 설정되어야 사용할 수 있어요."
        footerLeftButton={{
          label: '확인',
          action: () => console.log('왼쪽 버튼 클릭'),
        }}
        footerRightButton={{
          label: '설정으로 이동',
          action: () => console.log('오른쪽 버튼 클릭'),
        }}
      />

 

 

이렇게 되면 문제가 있다. 많은 props가 전달되고, 여러 단계에 걸쳐 전달하게 되어 코드가 복잡해진다. 또한, 확장성이 낮아진다. 만약, button이 아닌 link를 사용하고 싶다면, props를 계속 수정해야 한다. 만약, 요구사항이 늘어나면 props는 더 추가될 것이다.

 

해결책인 합성 컴포넌트

 

합성 컴포넌트를 사용하면 이 문제를 해결할 수 있다. 아래처럼 코드를 구현해보았다.

...
export interface ModalProps {
    isShow: boolean
    children: React.ReactNode
}

export function Modal({isShow, children}: ModalProps) {
    if (!isShow) return null

    return (
        <div>
            <Dimmed isShow={isShow} />
            <div className={cx('modal-container', {open: isShow})}>{children}</div>
        </div>
    )
}

interface HeaderProps {
    text: string
    onClose: () => void
}

function Header({text, onClose}: HeaderProps) {
    return (
        <div className={cx({'modal-header': true})}>
            <div className={cx({'close-btn': true})}>
                <button onClick={onClose}>
                    <span className={`material-symbols-outlined ${cx({'material-symbols-outlined': true})}`}>
                        close
                    </span>
                </button>
            </div>
            <div>
                <Text size="body1" bold={true}>
                    {text}
                </Text>
            </div>
        </div>
    )
}

interface ContentProps {
    children: React.ReactNode
}

export function ModalContent({children}: ContentProps) {
    return <div className={cx('modal-contents')}>{children}</div>
}

interface FooterProps {
    children: React.ReactNode
}

export function ModalFooter({children}: FooterProps) {
    return <div className={cx('modal-footer')}>{children}</div>
}

interface ButtonProps {
    leftButton?: {
        label: string
        action: () => void
        color?: Color
        backgroundColor?: Color
        outlineColor?: Color
    }
    rightButton?: {
        label: string
        action: () => void
        color?: Color
        backgroundColor?: Color
        outlineColor?: Color
    }
}

function Buttons({leftButton, rightButton}: ButtonProps) {
    return (
        <div className={cx({'modal-buttons': true})}>
            {leftButton ? (
                <Button
                    onClick={leftButton.action}
                    full={true}
                    color={leftButton.color}
                    backgroundColor={leftButton.backgroundColor}
                    outlineColor={leftButton.outlineColor}
                >
                    {leftButton.label}
                </Button>
            ) : null}
            {rightButton ? (
                <Button
                    onClick={rightButton.action}
                    full={true}
                    color={rightButton.color}
                    backgroundColor={rightButton.backgroundColor}
                    outlineColor={rightButton.outlineColor}
                >
                    {rightButton.label}
                </Button>
            ) : null}
        </div>
    )
}

interface LinkProps {
    label: string
    action: () => void
    color?: Color
}

function Link({label, action, color}: LinkProps) {
    return (
        <div className={cx({'modal-link': true})} onClick={action}>
            <Text bold={true} color={color} size="body3">
                {label}
            </Text>
        </div>
    )
}

Modal.Header = Header
Modal.Content = ModalContent
Modal.Footer = ModalFooter
Modal.Buttons = Buttons
Modal.Link = Link
         ...
         <Modal isShow={isShow}>
                <Modal.Header text="Face ID를 등록해주세요" onClose={handleModal} />
                <Modal.Content>
                    <Text size="body2">기기에 Face ID가 설정되어야 사용할 수 있어요.</Text>
                </Modal.Content>
                <Modal.Footer>
                    <Modal.Buttons
                        leftButton={{
                            label: '확인',
                            action: () => console.log('왼쪽 버튼 클릭'),
                            color,
                            backgroundColor,
                            outlineColor,
                        }}
                        rightButton={{
                            label: '설정으로 이동',
                            action: () => console.log('오른쪽 버튼 클릭'),
                            color,
                            backgroundColor,
                            outlineColor,
                        }}
                    />
                </Modal.Footer>
            </Modal>
            ...

 

Footer에 button 대신 link를 사용하고 싶다면, Modal.Link를 사용하면 된다!