리액트 디자인 패턴으로 Modal 구성해보기

keemsebeen·2025년 5월 11일
post-thumbnail

Modal을 구현하다 보니 예전에 읽었던 자바스크립트+리액트 디자인 패턴 책이 떠올랐습니다. 이번 글에서는 그때 정리했던 내용들을 실제 구현과 엮어 함께 이야기해보려 합니다. 😎

HOC 패턴

고차 컴포넌트는 다른 컴포넌트를 인자로 받아 새로운 컴포넌트를 반환하는 컴포넌트이다.

HOC 패턴이란, 애플리케이션 내 여러 컴포넌트에서 동일한 로직을 사용하고 싶을 때 사용된다. 이 패턴을 사용하면 애플리케이션 전체에서 컴포넌트 로직을 재사용할 수 있다.

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>

코드에서 보이는 "동일 로직"은 모달 컴포넌트의 상태 관리와 관련된 로직이다.

  1. 상태 관리 - 모달이 열려있는지 닫혀있는지를 나타내는 isOpen 상태
  2. 상태 변경 함수 - 모달을 열고 닫는 openModalcloseModal 함수

이러한 로직은 여러 종류의 모달(알림 모달, 확인 모달, 입력 모달 등)에서 반복적으로 사용되는데, HOC 패턴을 사용하면 이 로직을 한 곳에서 정의하고 여러 컴포넌트에서 재사용할 수 있다.

장점

  • 교차 관심사(cross-cutting concerns)를 효과적으로 처리할 수 있다.
  • 기존 컴포넌트를 수정하지 않고 기능을 확장할 수 있다.
  • 여러 HOC를 조합하여 다양한 기능을 가진 컴포넌트를 만들 수 있다.
    const EnhancedComponent = withModalControls(withAuth(withTheme(BaseComponent)));

단점

  • 코드를 구성하는데 어려움이 있다. (개인적으로 의식하면서 짜지 않는 이상 어렵다고 느꼈다.)
  • 래퍼 지옥이 발생할 수 있다.
    export default withA(withB(withC(withD(MyComponent))));
  • HOC가 여러 개 중첩되면 어떤 prop이 어디서 왔는지 추적하기 어려워진다.
  • 여러 HOC가 동일한 이름의 prop을 주입하면 충돌이 발생할 수 있습니다.
    const withUserData = (Component) => (props) => {
      return <Component {...props} user={{name: "John"}} />;
    };
    
    const withAdminData = (Component) => (props) => {
      return <Component {...props} user={{role: "admin"}} />; // user prop 충돌!
    };

파생시켜본 AlertModal

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 패턴

고차 컴포넌트와 비슷하게 컴포넌트를 재사용하는 다른 방법이다. Render props 패턴은 JSX요소를 반환하는 함수 값을 가지는 컴포넌트의 prop이다. 컴포넌트 자체는 렌더링 prop 이외에는 아무것도 렌더링하지 않는다.

Render Props 패턴 코드

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>
  • props를 명시적으로 전달함으로써 props를 자동으로 병합하지 않고, 부모 컴포넌트에서 제공하는 값을 그대로 전달한다. 따라서 어떤 데이터가 어디서 왔는지 추적하기가 쉽다. (고차컴포넌트의 단점 해결)
    // 명시적으로 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>
        </>
      );
    };

단점

  • Render Props 컴포넌트를 사용할 경우 콜백 지옥이 발생할 수 있다. 이는 가독성을 해친다.
    <DataProvider>
      {(data) => (
        <ThemeProvider>
          {(theme) => (
            <AuthProvider>
              {(auth) => (
                // 깊게 중첩된 렌더링 로직
              )}
            </AuthProvider>
          )}
        </ThemeProvider>
      )}
    </DataProvider>
  • 함수 컴포넌트 내에서 렌더링되기 때문에, children 함수 내에서는 리액트의 라이프사이클 메서드에 직접 접근할 수 없다.
    <Modal isOpen={isOpen} onClose={onClose}>
      {(close) => {
        // 여기서는 useEffect 같은 것을 직접 사용할 수 없음
        return <div>컨텐츠</div>;
      }}
    </Modal>
  • 리렌더링 시마다 새로운 함수가 생성되면 불필요한 리렌더링이 발생할 수 있다.

파생시켜본 AlertModal

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 패턴은 디자인 패턴이라고 할 수는 없지만, 애플리케이션 설계에서 중요한 역할을 한다. 특히 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 };
}

Hooks 사용 예시

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>
  );
};

장점

  • Hooks는 컴포넌트의 상태 관련 로직을 추출하여 독립적인 함수로 분리할 수 있게 해준다.
    const { isOpen, open, close } = useModal();
    const { data, loading, error } = useFetch('/api/data');
    const { theme, toggleTheme } = useTheme();
  • Hooks를 사용하면 관련 있는 로직끼리 그룹화할 수 있다. 이는 라이프사이클 메서드에 여러 관심사가 혼합되는 클래스 컴포넌트의 문제를 해결한다.
    // 클래스 컴포넌트에서는 라이프사이클 메서드에 여러 로직이 혼합됨
    componentDidMount() {
      // 모달 관련 로직
      // 데이터 페칭 로직
      // 이벤트 리스너 등록 로직
    }
    
    // Hooks 사용 시 관심사별로 분리 가능
    function Component() {
      // 모달 관련 로직
      const { isOpen, open, close } = useModal();
      
      // 데이터 페칭 로직
      const { data, loading } = useFetch('/api/data');
      
      // 이벤트 리스너 관련 로직
      useEventListener('scroll', handleScroll);
      
      // ...
    }
  • 순수 함수와 합성을 통해 코드의 예측 가능성과 테스트 용이성을 향상시킨다. (함수형 프로그래밍)

단점

  • Hooks에는 반드시 지켜야 할 규칙이 있다. 이러한 제약은 실수하기 쉽고, 런타임 에러를 발생시킬 수 있다.
  • 상태를 명시적으로 넘기기 때문에 HOC처럼 완전히 숨길 수는 없습니다.
  • 훅 사용 시 여러 곳에서 같은 훅을 중복 선언할 위험이 있습니다.

파생시켜본 AlertModal


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 패턴

Compound 패턴은 여러 개의 역할 기반 하위 컴포넌트들로 분리하면서, 이들 내부적으로 상태를 공유하며 하나의 기능을 완성하는 구조이다.

복잡한 UI를 더 작고 재사용 가능한 단위로 나누되, 명확한 부모-자식 관계와 상태 공유를 유지하는 것이 핵심이다.

Compound 패턴 사용 코드

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,
});

Compound 사용 예시

<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>
  • 각 하위 컴포넌트를 독립적으로 관리하거나 재조합할 수 있다.
  • 상태와 UI 로직이 컴포넌트 내부에 캡슐화되어 있어 관리가 편리하다.
  • props가 명확하게 전달되어, 의도치 않은 속성 오염을 방지할 수 있다.

단점

  • Context 기반 구조이기 때문에 상태 공유를 위한 코드가 추가된다.
  • Object.assign이나 컴포넌트 속성 확장을 위한 구현 복잡도가 약간 증가할 수 있다.
  • 하위 컴포넌트는 반드시 상위 컴포넌트 내부에서만 동작하기 때문에 사용 위치에 주의가 필요하다.

파생시켜본 AlertModal

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 페이지에 존재하기에 해당 사이트 참고해서 공부하는 것도 큰 도움이 될 것 같습니다.

profile
프론트엔드 개발자 김세빈입니다. 👩🏻‍💻

0개의 댓글