
Modal을 구현하다 보니 예전에 읽었던 자바스크립트+리액트 디자인 패턴 책이 떠올랐습니다. 이번 글에서는 그때 정리했던 내용들을 실제 구현과 엮어 함께 이야기해보려 합니다. 😎
고차 컴포넌트는 다른 컴포넌트를 인자로 받아 새로운 컴포넌트를 반환하는 컴포넌트이다.
HOC 패턴이란, 애플리케이션 내 여러 컴포넌트에서 동일한 로직을 사용하고 싶을 때 사용된다. 이 패턴을 사용하면 애플리케이션 전체에서 컴포넌트 로직을 재사용할 수 있다.
type ModalControlProps = {
isOpen: boolean;
openModal: () => void;
closeModal: () => void;
};
function withModalControls<P>(
Component: React.ComponentType<P & ModalControlProps>
) {
return (props: Omit<P, keyof ModalControlProps>) => {
const [isOpen, setIsOpen] = useState(false);
const openModal = () => setIsOpen(true);
const closeModal = () => setIsOpen(false);
// 원본 props와 모달 제어 props를 결합하여 전달
return (
<Component
{...props as unknown as P}
isOpen={isOpen}
openModal={openModal}
closeModal={closeModal}
/>
);
};
}
const EnhancedModal = withModalControls(Modal);
<EnhancedModal>
<Header title="제목" />
<Body>내용</Body>
</EnhancedModal>
코드에서 보이는 "동일 로직"은 모달 컴포넌트의 상태 관리와 관련된 로직이다.
isOpen 상태openModal과 closeModal 함수이러한 로직은 여러 종류의 모달(알림 모달, 확인 모달, 입력 모달 등)에서 반복적으로 사용되는데, HOC 패턴을 사용하면 이 로직을 한 곳에서 정의하고 여러 컴포넌트에서 재사용할 수 있다.
const EnhancedComponent = withModalControls(withAuth(withTheme(BaseComponent)));export default withA(withB(withC(withD(MyComponent))));const withUserData = (Component) => (props) => {
return <Component {...props} user={{name: "John"}} />;
};
const withAdminData = (Component) => (props) => {
return <Component {...props} user={{role: "admin"}} />; // user prop 충돌!
};const AlertModal = ({ isOpen, onClose, title, alertMessage }: BaseModalProps & { alertMessage: string }) => (
<Modal isOpen={isOpen} onClose={onClose} title={title}>
<div className="alert-content">{alertMessage}</div>
<button onClick={onClose}>확인</button>
</Modal>
);
const EnhancedAlertModal = withModalControls(
({ isOpen, openModal, closeModal, alertMessage, title }: ModalControlProps & Omit<AlertModalProps, 'isOpen' | 'onClose'>) => (
<AlertModal
isOpen={isOpen}
onClose={closeModal}
title={title}
alertMessage={alertMessage}
/>
)
);
import { EnhancedAlertModal } from './EnhancedAlertModal';
function App() {
return (
<div>
<h1>HOC 기반 AlertModal 사용</h1>
<EnhancedAlertModal title="경고" content="이 작업을 수행하시겠습니까?" />
</div>
);
}
동일한 withModalControls HOC를 활용하여 다양한 종류의 모달(확인 모달, 입력 모달, 이미지 모달 등)을 쉽게 만들 수 있으며, 모달마다 상태 관리 로직을 반복해서 작성할 필요가 없다.
고차 컴포넌트와 비슷하게 컴포넌트를 재사용하는 다른 방법이다. Render props 패턴은 JSX요소를 반환하는 함수 값을 가지는 컴포넌트의 prop이다. 컴포넌트 자체는 렌더링 prop 이외에는 아무것도 렌더링하지 않는다.
export const Modal = ({
isOpen,
onClose,
children,
...props
}: ModalProps & { children: (onClose: VoidFunction) => React.ReactNode }) => {
const { refContainer, handleKeyDown } = useFocusTrap(isOpen);
return (
<BaseModal isOpen={isOpen} onClose={onClose} closeOnOutsideClick={props.closeOnOutsideClick}>
<ModalContent
ref={refContainer}
onKeyDown={handleKeyDown}
{...props}
>
{children(onClose)}
</ModalContent>
</BaseModal>
);
};
<Modal isOpen={isOpen} onClose={onClose}>
{(close) => (
<>
<ModalHeader title="Title" onClose={close} />
<div>내용</div>
</>
)}
</Modal>
코드에서 Modal 컴포넌트는 모달의 기본 동작을 담당하고, 내부 콘텐츠는 완전히 사용자에게 위임한다. 이를 통해 동일한 Modal 컴포넌트를 다양한 용도로 재사용할 수 있다.
또, 공통 상태 관리 로직을 한 곳에서 관리하면서도, 그 상태를 사용하는 UI는 자유롭게 변경할 수 있다.
<Modal isOpen={isOpen} onClose={onClose}>
{(close) => <LoginForm onSuccess={close} />}
</Modal>
<Modal isOpen={isConfirmOpen} onClose={onConfirmClose}>
{(close) => <ConfirmDialog onConfirm={() => { handleConfirm(); close(); }} onCancel={close} />}
</Modal>// 명시적으로 onClose를 전달받아 사용하는 모습
<Modal isOpen={isOpen} onClose={onClose}>
{(close) => (
// ...
)}
</Modal>// Modal: 상태 관리 및 동작 로직 담당
export const Modal = ({ isOpen, onClose, children }) => {
const { refContainer, handleKeyDown } = useFocusTrap(isOpen);
// 모달의 핵심 로직
return (
<BaseModal isOpen={isOpen} onClose={onClose}>
<ModalContent ref={refContainer} onKeyDown={handleKeyDown}>
{children(onClose)} // 렌더링은 children 함수에 위임
</ModalContent>
</BaseModal>
);
};
// AlertModal: 순수하게 표현만 담당
export const AlertModal = ({ title, content, confirmText }) => {
// 내부 상태 없이 전달받은 props로만 렌더링
return (
<>
<h2>{title}</h2>
<p>{content}</p>
<button>{confirmText}</button>
</>
);
};<DataProvider>
{(data) => (
<ThemeProvider>
{(theme) => (
<AuthProvider>
{(auth) => (
// 깊게 중첩된 렌더링 로직
)}
</AuthProvider>
)}
</ThemeProvider>
)}
</DataProvider><Modal isOpen={isOpen} onClose={onClose}>
{(close) => {
// 여기서는 useEffect 같은 것을 직접 사용할 수 없음
return <div>컨텐츠</div>;
}}
</Modal>export const AlertModal = ({
isOpen,
onClose,
title,
content,
confirmText = '확인',
}: AlertModalProps) => {
return (
<Modal isOpen={isOpen} onClose={onClose}>
{(close) => (
<>
<h2>{title}</h2>
<p>{content}</p>
<button onClick={close}>{confirmText}</button>
</>
)}
</Modal>
);
};
export function App() {
const [isOpen, setIsOpen] = useState(false);
const open = () => setIsOpen(true);
const close = () => setIsOpen(false);
return (
<div>
<button onClick={open}>Alert 열기</button>
<AlertModal
isOpen={isOpen}
onClose={close}
title="경고"
content="정말 삭제하시겠습니까?"
/>
</div>
);
}
이 패턴의 핵심은 Modal이 내부에서 아무것도 렌더링하지 않고, children 함수에 전적으로 위임하는 점이다. 그래서 AlertModal은 단순한 표현 로직만 담당하게 되어 재사용성이 극대화된다.
Hooks 패턴은 디자인 패턴이라고 할 수는 없지만, 애플리케이션 설계에서 중요한 역할을 한다. 특히 Hooks 패턴은 전통적인 디자인 패턴을 대체할 수 있다.
import { useState, useCallback } from 'react';
export function useModal() {
const [isOpen, setIsOpen] = useState(false);
const open = useCallback(() => setIsOpen(true), []);
const close = useCallback(() => setIsOpen(false), []);
return { isOpen, open, close };
}
import { ReactNode } from 'react';
export type ModalProps = {
isOpen: boolean;
onClose: () => void;
children: ReactNode;
};
export const Modal = ({ isOpen, onClose, children }: ModalProps) => {
if (!isOpen) return null;
return (
<div className="modal-backdrop">
<div className="modal">
{children}
<button onClick={onClose}>닫기</button>
</div>
</div>
);
};
const { isOpen, open, close } = useModal();
const { data, loading, error } = useFetch('/api/data');
const { theme, toggleTheme } = useTheme();// 클래스 컴포넌트에서는 라이프사이클 메서드에 여러 로직이 혼합됨
componentDidMount() {
// 모달 관련 로직
// 데이터 페칭 로직
// 이벤트 리스너 등록 로직
}
// Hooks 사용 시 관심사별로 분리 가능
function Component() {
// 모달 관련 로직
const { isOpen, open, close } = useModal();
// 데이터 페칭 로직
const { data, loading } = useFetch('/api/data');
// 이벤트 리스너 관련 로직
useEventListener('scroll', handleScroll);
// ...
}
type AlertModalProps = {
isOpen: boolean;
onClose: () => void;
title?: string;
content: string;
};
export const AlertModal = ({ isOpen, onClose, title, content }: AlertModalProps) => {
return (
<Modal isOpen={isOpen} onClose={onClose}>
<h2>{title}</h2>
<p>{content}</p>
<button onClick={onClose}>확인</button>
</Modal>
);
};
import { useModal } from './useModal';
import { AlertModal } from './AlertModal';
function App() {
const { isOpen, open, close } = useModal();
return (
<>
<button onClick={open}>경고 열기</button>
<AlertModal isOpen={isOpen} onClose={close} title="경고" content="정말 삭제하시겠습니까?" />
</>
);
}
분리된 useModal 훅은 어떤 컴포넌트에서도 쉽게 사용이 가능하다. 또, 여러 훅을 조합해 더 복잡한 기능도 할 수있기에 코드의 재사용성을 크게 높인다.
Compound 패턴은 여러 개의 역할 기반 하위 컴포넌트들로 분리하면서, 이들 내부적으로 상태를 공유하며 하나의 기능을 완성하는 구조이다.
복잡한 UI를 더 작고 재사용 가능한 단위로 나누되, 명확한 부모-자식 관계와 상태 공유를 유지하는 것이 핵심이다.
const ModalRoot = ({ isOpen, onClose, children, ...props }: ModalProps) => {
const { refContainer, handleKeyDown } = useFocusTrap(isOpen);
return (
<BaseModal isOpen={isOpen} onClose={onClose} closeOnOutsideClick={props.closeOnOutsideClick}>
<ModalContent
ref={refContainer}
onKeyDown={handleKeyDown}
{...props}
>
{children}
</ModalContent>
</BaseModal>
);
};
const Header = ({ title }: { title: string }) => (
<StyledModalHeader>{title}</StyledModalHeader>
);
const Body = ({ children }: { children: React.ReactNode }) => <div>{children}</div>;
export const Modal = Object.assign(ModalRoot, {
Header,
Body,
});
<Modal isOpen={isOpen} onClose={onClose}>
<Modal.Header title="제목" />
<Modal.Body>
<p>내용</p>
</Modal.Body>
</Modal>
Modal.Header, Modal.Body처럼 각 컴포넌트가 명확한 역할을 갖고 분리되어 있는 것을 볼 수 있다. 각 하위 컴포넌트는 UI의 특정 영역(헤더, 바디 등)만을 책임지며, 독립적으로 설계되어 가독성과 재사용성이 향상된다.
<Modal>
<Modal.Header />
<Modal.Body />
<Modal.Footer />
</Modal>import { createContext, useContext, useState, ReactNode } from 'react';
type ModalContextType = {
isOpen: boolean;
open: () => void;
close: () => void;
};
const ModalContext = createContext<ModalContextType | null>(null);
export function Modal({ children }: { children: ReactNode }) {
const [isOpen, setIsOpen] = useState(false);
const open = () => setIsOpen(true);
const close = () => setIsOpen(false);
return (
<ModalContext.Provider value={{ isOpen, open, close }}>
{children}
</ModalContext.Provider>
);
}
function Trigger({ children }: { children: ReactNode }) {
const ctx = useContext(ModalContext);
return <button onClick={ctx.open}>{children}</button>;
};
function Content({ children }: { children: ReactNode }) {
const ctx = useContext(ModalContext);
return (
<div className="modal-backdrop">
<div className="modal">
{children}
<button onClick={ctx.close}>닫기</button>
</div>
</div>
);
};
Modal.Trigger = Trigger;
Modal.Content = Content;
export function AlertModal({ title, content }: { title: string; content: string }) {
return (
<Modal>
<Modal.Trigger>알림 열기</Modal.Trigger>
<Modal.Content>
<h2>{title}</h2>
<p>{content}</p>
<button onClick={() => alert('확인 누름')}>확인</button>
</Modal.Content>
</Modal>
);
}
컴파운드 패턴은 구성요소간 구조와 관계를 유지하면서 UI를 유연하게 조합할 수 있다. 따라서 재사용성과 확장성이 뛰어나고, 디자인 시스템 구성시에 유용하다.
| 패턴 | 장점 | 단점 |
|---|---|---|
| HOC | 관심사 분리, 반복 로직 제거 가능 | 디버깅/props 전달 흐름 파악 어려움 가능 |
| Render Props | 로직 재사용 유연함 | 코드 중첩 발생, 가독성 저하 가능 |
| Hook | 로직/표현 분리, 재사용성 ↑ | 상태 추적이 어려울 수 있음 |
| Compound | 명확한 구성, 선언적 사용 가능 | 내부 구조 복잡할 수 있음 |
실제로 자바스크립트+리액트 디자인 패턴 책을 읽으면서 언제 써먹어볼 수 있을까 고민을 많이 했는데, 실제 구현 중인 Modal에 적용해보니 너무 재미있네요.
단순히 리액트를 사용하는 것도 좋지만, 왜 이렇게 설계했을까?,더 나은 구조는 없을까? 고민해보는 시간이 정말 값졌습니다.
( ++ 추가 예정.. )
https://patterns-dev-kr.github.io/design-patterns/
자바스크립트 + 리액트 디자인 패턴
책에서 본 개념이나 코드가 놀라울 정도로 Patterns-dev-kr 페이지에 존재하기에 해당 사이트 참고해서 공부하는 것도 큰 도움이 될 것 같습니다.