올해 졸업을 했다. 학부 때 진행했던 (불과 몇 개월 전이지만) 프로젝트들에서는 대부분 프론트엔드를 혼자 맡았다. 당시에도 나름 유지보수를 고려하면서 코드를 작성하려고 했던 것 같은데, 아쉬운 부분이 많이 보인다. 특히 UI를 구성하는 방식이나 컴포넌트를 나누는 기준에서 더 고민할 수 있었겠다는 생각이 든다.
최근에는 기본적인 부분부터 다시 정리해 보고 싶어 프론트엔드 UI 관련 책을 읽고 있는데, 그러던 중에 컴파운드 컴포넌트 패턴을 보게 되었다. 이전에도 이런 구조가 있다는 정도는 알고 있었지만, 패턴으로 인식하거나 실제로 사용해 본 적은 없었다.
그래서 이번 글에서는 컴파운드 컴포넌트 패턴을 간단히 정리하고, 기존 프로젝트의 UI 컴포넌트에 적용해 보며 느낀 점을 정리해 보려고 한다.
컴파운드 컴포넌트는 여러 개의 서브 컴포넌트가 하나의 부모 컴포넌트와 묶여 함께 동작하는 패턴이다. HTML의 <select>와 <option>처럼, 각각을 독립적으로 사용하기보다는 함께 조합될 때 하나의 UI를 이루는 구조를 떠올리면 이해하기 쉽다.
React에서는 보통 Context API를 활용해 부모와 서브 컴포넌트 사이에서 상태와 핸들러를 공유한다. 외부에서는 <Modal>, <Modal.Header>, <Modal.Content>와 같은 형태로 사용하게 된다.
기존에 사용하던 모달의 props는 다음과 같았다.
interface ModalProps {
isOpen: boolean;
onClose: () => void;
title?: string;
showCloseButton?: boolean;
disableDismiss?: boolean;
children: React.ReactNode;
}
사용 측에서는 다음과 같이 작성했다.
<Modal isOpen={isOpen} onClose={onClose} title="크레딧 충전">
<CreditChargeForm />
</Modal>
겉보기에는 단순하지만, 내부에서는 여러 파생 상태를 계산하며 렌더링을 분기하고 있었다.
const hasTitle = Boolean(title);
const hasHeader = hasTitle || showCloseButton;
{hasHeader && (
<S.ModalHeader hasTitle={hasTitle}>
{hasTitle && <S.ModalTitle>{title}</S.ModalTitle>}
{showCloseButton && (
<S.CloseButton onClick={onClose}>
<CloseIcon />
</S.CloseButton>
)}
</S.ModalHeader>
)}
<S.ModalContent hasHeader={hasHeader}>{children}</S.ModalContent>
hasTitle, hasHeader 같은 파생 상태를 계산하고, showCloseButton 값에 따라 렌더링이 달라진다. 이후에 “헤더에 아이콘 추가”, “닫기 버튼 위치 변경” 같은 요구사항이 생기면 props가 계속 늘어나게 된다.
실제로도 title이 없는 경우나 showCloseButton={false}인 경우처럼, props 조합에 따라 렌더링이 달라지는 케이스가 여러 개 존재했다.
이번 리팩토링의 핵심은 모달이 내부에서 UI 구조를 결정하지 않도록 만드는 것이다. 대신 사용하는 쪽에서 필요한 구조를 조합할 수 있도록 변경했다.
먼저 Context를 통해 공유할 상태를 정의했다.
interface ModalContextValue {
onClose: () => void;
disableDismiss: boolean;
}
const ModalContext = createContext<ModalContextValue | null>(null);
const useModalContext = () => {
const ctx = useContext(ModalContext);
if (!ctx) throw new Error('Modal sub-components must be used within <Modal>');
return ctx;
};
루트 컴포넌트는 Portal 렌더링, 애니메이션, 키보드 이벤트 처리 등 모달의 공통 동작만 담당한다. 그리고 Context를 통해 onClose와 disableDismiss을 하위 컴포넌트에 전달한다.
const ModalRoot: React.FC<ModalProps> = ({ isOpen, onClose, disableDismiss = false, children }) => {
const contextValue = useMemo(
() => ({ onClose, disableDismiss }),
[onClose, disableDismiss],
);
return (
<ModalContext.Provider value={contextValue}>
{createPortal(
<AnimatePresence>{isOpen && children}</AnimatePresence>,
document.body,
)}
</ModalContext.Provider>
);
};
서브 컴포넌트들은 Context에서 필요한 값만 가져와 사용한다. 예를 들어 CloseButton은 onClose만 필요하다.
const CloseButton: React.FC = () => {
const { onClose } = useModalContext();
return (
<S.CloseButton onClick={onClose}>
<CloseIcon />
</S.CloseButton>
);
};
Overlay는 backdrop 클릭 시 모달을 닫는 동작을 담당한다.
const Overlay: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { onClose, disableDismiss } = useModalContext();
return (
<S.Backdrop
onClick={(e) => {
if (e.target === e.currentTarget && !disableDismiss) onClose();
}}
>
{children}
</S.Backdrop>
);
};
마지막으로 서브 컴포넌트들을 Object.assign으로 묶어 하나의 컴포넌트처럼 사용할 수 있도록 만들었다.
const Modal = Object.assign(ModalRoot, {
Overlay,
Container,
Header,
Title,
CloseButton,
Content,
});
export default Modal;
기존 코드에서는 hasTitle, hasHeader 같은 boolean props를 styled component에 전달해 스타일을 분기하고 있었다.
<S.ModalHeader hasTitle={hasTitle}>
<S.ModalContent hasHeader={hasHeader}>
컴파운드 컴포넌트 구조로 바꾸면서 이런 파생 상태는 필요 없어졌지만, 헤더가 있을 때 Content의 패딩을 다르게 적용해야 했다. 이를 위해 Emotion의 컴포넌트 인터폴레이션과 인접 형제 선택자를 사용했다.
export const ModalContent = styled.div`
padding: 24px;
${ModalHeader} + & {
padding-top: 0;
}
`;
${ModalHeader} + &는 ModalHeader 바로 뒤에 오는 ModalContent에만 적용되는 선택자다. ModalHeader가 존재하면 padding-top이 제거되고, 없으면 기본 스타일이 그대로 유지된다. 별도의 props나 조건 계산이 필요 없다.
hasTitle 역시 레이아웃만으로 해결할 수 있었다. ModalHeader를 justify-content: flex-end로 두고, ModalTitle에 flex: 1을 사용함으로 타이틀 유무에 따라 기존과 같은 헤더 배치를 구현할 수 있었다.
export const ModalHeader = styled.div`
display: flex;
align-items: center;
justify-content: flex-end;
`;
export const ModalTitle = styled.div`
flex: 1;
`;
이렇게 하니 렌더링 로직과 스타일 모두에서 파생 상태가 사라지고, 컴포넌트 구조 자체가 스타일을 결정하도록 만들 수 있었다.
<Modal isOpen={isOpen} onClose={onClose}>
<Modal.Overlay>
<Modal.Container>
<Modal.Header>
<Modal.Title>크레딧 충전</Modal.Title>
<Modal.CloseButton />
</Modal.Header>
<Modal.Content>
<CreditChargeForm />
</Modal.Content>
</Modal.Container>
</Modal.Overlay>
</Modal>
<Modal isOpen={isOpen} onClose={onClose}>
<Modal.Overlay>
<Modal.Container>
<Modal.Content>
<LoginForm />
</Modal.Content>
</Modal.Container>
</Modal.Overlay>
</Modal>
이전에는 title, showCloseButton 같은 props 조합에 따라 내부에서 렌더링을 분기했지만, 이제는 사용하는 쪽에서 직접 구조를 조합해서 사용한다.
기존 방식에서 title이나 showCloseButton 같은 props는 모달 내부의 렌더링 방식을 외부에서 제어하기 위한 수단이었다. 반면 컴파운드 컴포넌트 방식에서는 외부에서 구조 자체를 조합하기 때문에, 모달은 더 이상 어떻게 그려질지를 알 필요가 없다.
대신 코드가 길어진다는 단점도 있다. <Modal title="제목">처럼 한 줄로 작성하던 코드가 Overlay, Container, Header, Title 등을 모두 작성하는 구조로 바뀌기 때문이다.
컴파운드 컴포넌트는 조합의 경우가 다양하거나 앞으로 구조 변경 가능성이 높은 컴포넌트에 적용하면 좋을 것 같다.