유연하고 재사용 가능한 모달

content-logo

Table of Contents

모달이란?

모달은 주로 사용자의 주의를 필요한 내용이나 작업에 집중시키기 위해 사용한다.

일반적으로 현재 인터페이스 위에 오버레이 형식으로 나타나며, 사용자가 모달 외부의 다른 요소와 상호작용하는 것을 일시적으로 방지한다.

모달의 기본 동작

모달의 기본 동작은 사용자의 주의를 필요로 하는 내용을 중앙에 띄우면서, 나머지 배경 페이지를 어둡게 하여 현재 사용자의 초점을 모달로 맞추는 것이다. 조금 더 세부적으로 나눠본다면 다음과 같다.

  • 화면 중앙에 사용자의 주의를 필요로 하는 내용이 출력된다.
  • 모달을 제외한 나머지 영역에 어두운 불투명한 배경이 깔린다.
  • 닫기 버튼을 누르면 모달이 닫힌다.
    • 닫기 버튼은 모달의 푸터와 헤더 우측에 위치할 수 있다.
  • CTA (Call to Action) 버튼이 존재할 경우 CTA 버튼 클릭 시 추가 동작이 발생하고 모달이 닫힌다.

유연하고 재사용성이 높은 모달

React에서 모달과 같은 컴포넌트를 설계할 때, 유연함과 재사용성은 중요한 고려 사항 중 하나이다. 유연한 모달 컴포넌트는 다양한 컨텍스트와 요구 사항에 맞춰 사용될 수 있어야 한다. 이를 위해 모달의 구성 요소를 특정 기능에 종속적인 요소공용으로 사용될 수 있는 요소로 분리하는 전략을 채택할 수 있다. 또한 공용으로 사용될 수 있는 요소는 한 번 만들어놓고 계속해서 재사용 할 수 있다.

모달의 형태와 기능을 분리해보면 다음과 같다.

공통 요소의 추출

모달 컴포넌트의 기본적인 형태는 모달의 헤더, 바디, 푸터 등의 영역과 모달의 배경, 닫기 버튼, portal 등이 있다. 이러한 형태는 특정 기능에 종속적이지 않으므로 공통 요소로 추출할 수 있다.

모달 컴포넌트의 기본적인 기능은 여는 동작, 닫는 동작, 닫히는 애니메이션 등이 있다. 이러한 기능 역시 특정 기능에 종속적이지 않고, 모든 모달에서 같이 사용되는 기본 동작이므로 공통 요소에서 다룰 수 있다.

사용처에서의 커스터마이징

특정 기능에 종속적인 요소와 사용 사례별로 다를 수 있는 내용은 사용처에서 직접 제작하여 children 프로퍼티를 통해 모달 컴포넌트에 주입할 수 있다. 예를 들어, 모달 내부의 폼, 특정 비즈니스 로직을 처리하는 버튼 등은 각각의 사용처에 맞춰서 개별적으로 구현할 수 있다. 이를 통해, 기본 모달 컴포넌트는 매우 간결하게 유지되면서도, 다양한 상황에서 유연하게 활용될 수 있다.

구현 (합성 컴포넌트 패턴)

모달의 구성 요소를 분리하기 위한 방법으로 모달 컴포넌트를 더 작은 단위로 쪼개볼 수 있다. 이를 위해서 합성 컴포넌트 (Compound Components) 패턴을 사용할 수 있다. 합성 컴포넌트 패턴이란 컴포넌트를 더 작은 단위로 분리하고, 이들을 조합하여 더 복잡한 UI를 구성하는 방식이다. 합성 컴포넌트에 대한 설명은 이미 더 훌륭한 아티클이 많기 때문에 생략하겠다. (카카오엔터테인먼트: 합성 컴포넌트로 재사용성 극대화하기) 또한 대부분의 오픈소스 UI Kit 라이브러리에서는 합성 컴포넌트 패턴을 지원하고 있으므로 쉽게 접할 수 있다.

공통 요소의 추출

1) 루트 컴포넌트

모달의 최상단에 위치하는 컴포넌트이다. 해당 컴포넌트는 다음과 같은 역할을 수행한다.

  • 모달의 열기 닫기 동작
  • 모달 내부 컴포넌트에서 공유하는 내부 상태를 내려주기 위한 ContextAPI 세팅
    • ContextProvider 세팅
  • Portal 세팅
const Modal = ({ children, isOpen }) => {

	return (
		<ModalContext.Provider value={...}>
			{isOpen && (
				<Portal>
					{children}
				</Portal>
			)}
		</ModalContext.Provider>
	)
}

모달이 열린 상태에서 키보드 esc 를 눌렀을 때 모달이 닫히도록 하고싶다면 루트 컴포넌트에서 구현할 수 있다.

또한 모달의 오버레이 배경을 클릭했을 때 모달이 닫히도록 하고싶다면, 모달 오버레이 역할의 내부 컴포넌트에서도 닫기 함수를 실행할 수 있어야 한다.

이를 위해 onClose 함수를 ContextAPI로 내려줄 수 있다.

const Modal = forwardRef(function Modal({ children, isOpen, onClose }, ref) {
  // esc를 눌렀을 때 onClose 함수 실행한다.
  useOnEscapeEffect(onClose);

  return (
    // onClose 함수를 ContextAPI로 전달한다.
    <ModalContext.Provider value={{ onClose }}>
      {isOpen && (
        <Portal>
          <div ref={ref}>{children}</div>
        </Portal>
      )}
    </ModalContext.Provider>
  );
});

모달의 열기 닫기 동작의 제어는 전적으로 사용처에 위임하며 모달 내부에서는 isOpen, onClose prop에 따른 동작만 다루도록 한다.

2) 오버레이 컴포넌트

불투명한 어두운 색으로 배경을 덮는 컴포넌트이다. 해당 컴포넌트는 다음과 같은 역할을 수행한다.

  • 모달 외부 요소와의 상호작용 방지
  • 모달 외부 요소의 스크롤 방지
  • 클릭 시 모달 닫기
const Overlay = forwardRef(function Overlay({ ...props }, ref) {
	// 루트 컴포넌트에서 내려주는 onClose 함수를 useContext를 통해 불러온다.
  const { onClose } = useModalContext()

  // 오버레이 클릭 시 onClose 함수를 실행한다.
  const handleClick = (e) => {
    onClick(e)
    onClose()
  }

  return <div ref={ref} onClick={handleClick} {...props} />
})

...

Modal.Overlay = Overlay

3) 컨텐츠 컴포넌트

모달의 실질적인 컨텐츠를 다루는 컴포넌트이다. 헤더, 바디, 푸터 등의 서브 컴포넌트를 포함하고 있다.

const Content = forwardRef(function Content({ children, ...props }, ref) {
  return (
    <article ref={ref} {...props}>
      {children}
    </article>
  )
})

...

Modal.Content = Content

얼핏 보기에는 큰 역할을 수행하지 않는 것 처럼 보이지만 컨텐츠 컴포넌트의 역할은 꽤 중요하며 내가 이 글을 쓰게 된 계기이기도 하다. 이는 다음 섹션에서 서술하겠다.

4) 헤더 컴포넌트

모달의 헤더 영역을 담당하는 컴포넌트이다. 일반적으로 다음과 같은 역할을 수행한다.

  • 모달의 목적이나 내용의 요약을 설명하는 제목을 포함
  • 모달을 닫을 수 있는 닫기(X) 버튼을 포함

모달의 타이틀과 닫기 버튼 등을 포함하고 있다. 구체적인 사용 사례는 제작자에 따라 차이가 있을 수 있다.

const Header = forwardRef(function Header({ title, children, ...props }, ref) {
  return (
    <header ref={ref} {...props}>
	    {/* title이 존재할 경우 title을 출력한다. */}
      {!!title && title}
	    {/* title 대신 별도의 UI가 필요할 경우 children으로 입력할 수 있다. */}
      {children}
    </header>
  )
})

...

Modal.Header = Header

5) 바디 컴포넌트

모달의 바디 영역을 담당하는 컴포넌트이다. 사용자에게 전달하고자 하는 주요 내용이나 폼 요소 등을 포함한다. 즉, 모달에서 보여주고자 하는 주 컨텐츠는 바디 컴포넌트에 위치하게 된다.

const Content = forwardRef(function Content({ children, ...props }, ref) {
  return (
    <div ref={ref} {...props}>
      {children}
    </div>
  )
})

...

Modal.Content = Content

6) 푸터 컴포넌트

모달의 푸터 영역을 담당하는 컴포넌트이다. 저장, 취소, 확인 등 사용자가 취할 수 있는 행동을 나타내는 CTA 버튼을 포함한다. 버튼의 개수나 정렬은 제작자에 따라 다르므로 어디까지를 공통으로 패턴화 할 수 있는지 디자이너 및 프론트엔드 개발자와 협의 후 공통 컴포넌트에 반영하는 것이 중요하다.

const Footer = forwardRef(function Footer({ children, ...props }, ref) {
  return (
    <footer ref={ref} {...props}>
      {children}
    </footer>
  )
})

...

Modal.Footer = Footer

컴포넌트 합성

이렇게 작성한 컴포넌트를 합성하면 다음과 같은 형태를 띄운다. 작은 단위의 컴포넌트를 합성했기 때문에 변화에 유연하다.

<Modal isOpen={true} onClose={handleCloseModal}>
  <Modal.Overlay />
  <Modal.Content>
    <Modal.Header title="This is title" />
    <Modal.Body>{/* ... */}</Modal.Body>
    <Modal.Footer>
      <button>{/* ... */}</button>
    </Modal.Footer>
  </Modal.Content>
</Modal>

만약 모달이 폼의 역할을 수행해야 한다면 다음과 같이 변형할 수 있다.

<Modal isOpen={true} onClose={handleCloseModal}>
  <Modal.Overlay />
  <Modal.Content>
    <Modal.Header title="This is title" />
    <form onSubmit={handleSubmit}>
      <Modal.Body>{/* ... */}</Modal.Body>
      <Modal.Footer>
        <button type="submit">{/* ... */}</button>
      </Modal.Footer>
    </form>
  </Modal.Content>
</Modal>

만약 모달의 헤더나 바디, 푸터 중 하나가 필요없다면 해당 컴포넌트를 제외하면 된다.

<Modal isOpen={true} onClose={handleCloseModal}>
  <Modal.Overlay />
  <Modal.Content>
    <Modal.Header title="Modal without body" />
    <Modal.Footer>
      <button>{/* ... */}</button>
    </Modal.Footer>
  </Modal.Content>
</Modal>

이처럼 구성 요소를 작은 단위로 쪼개어 합성하면 각각의 단위의 컴포넌트를 계속 재사용 할 수 있다. 또한 불필요한 컴포넌트를 쉽게 제거할 수 있고 모달 이외의 필요한 요소를 중간에 쉽게 끼워넣을 수 있기 때문에 변화에 유연하다.

컨텐츠 컴포넌트의 역할

앞서 서술했던 것 처럼 컨텐츠 컴포넌트의 역할은 꽤 중요하다.

<Modal isOpen={true} onClose={handleCloseModal}>
  <Modal.Overlay />
  <Modal.Content>
    <Modal.Header title="This is title" />
    <Modal.Body>{/* ... */}</Modal.Body>
    <Modal.Footer>
      <button>{/* ... */}</button>
    </Modal.Footer>
  </Modal.Content>
</Modal>

모달 컴포넌트에 비즈니스 로직을 주입한 형태는 다음과 같을 수 있다.

가령 userId를 받아서 사용자의 상세 정보 (userInfo)를 불러오는 커스텀 훅을 포함하는 UserInfoModal 컴포넌트가 있다고 해보자.

const UserInfoModal = ({ isOpen, onClose, userId }) => {
  const userInfo = useGetUserInfo(userId);

  return (
    <Modal isOpen={isOpen} onClose={onClose}>
      <Modal.Overlay />
      <Modal.Content>
        <Modal.Header title="This is title" />
        <Modal.Body>{/* ... */}</Modal.Body>
        <Modal.Footer>
          <button>{/* ... */}</button>
        </Modal.Footer>
      </Modal.Content>
    </Modal>
  );
};

UserInfoModal 컴포넌트는 isOpen, onClose prop을 주입 받아 모달의 루트 컴포넌트로 전달한다. 즉, 해당 모달의 열기, 닫기의 제어는 UserInfoModal 컴포넌트의 사용처에서 전적으로 담당한다.

문제점

하지만 지금 형태의 UserInfoModal 컴포넌트는 다음과 같은 문제가 있을 수 있다.

💡 조건부 렌더링이 훅의 실행을 제어하지 않는다.

React에서는 컴포넌트가 렌더링될 때 그 안에 포함된 모든 훅이 무조건 한 번은 실행된다. 따라서 isOpenfalse임에도 불구하고 useGetUserInfo 훅은 무조건 한 번은 실행이 된다.

이는 특히 성능 저하나 불필요한 API 호출 등의 부작용을 일으킬 수 있는 중요한 이슈가 될 수 있다. 또한 useGetUserInfo 에서 필수로 받아야 하는 userId가 만약에 UserInfoModal이 열릴 때에만 할당되는 값이라면 이 구조는 더욱 문제가 될 수 있다.

UserInfoModal이 열리지 않았기 때문에 userId가 제대로 할당되지 않은 상태에서, useGetUserInfo가 호출될 경우 API 에러가 발생할 수 있다. 에러 처리가 제대로 되어있지 않은 경우 예기치 못한 동작이 발생할 수 있다.

따라서 isOpen이 훅의 실행을 제어하지 않으며, isOpenfalse여도 컴포넌트는 여전히 렌더링되며 (내용이 브라우저에 표시되지 않더라도), 그 내부의 훅이 불필요하게 실행된다.

대응 방안

이러한 문제점은 조건부 렌더링(isOpen)이 훅의 실행을 제어할 수 있도록 하면 된다.

내가 생각하는 가장 효율적인 방법은 컴포넌트의 계층을 한 단계 더 나누는 것이다. useGetUserInfo 훅이 실질적으로 필요한 내용은 모달의 Content 영역에 위치해있다. 따라서 Modal.Content 영역을 별도의 컴포넌트로 분리해볼 수 있다.

const UserInfo = () => {
  const userInfo = useGetUserInfo(userId);

  return (
    <Modal.Content>
      <Modal.Header title="This is title" />
      <Modal.Body>{/* ... */}</Modal.Body>
      <Modal.Footer>
        <button>{/* ... */}</button>
      </Modal.Footer>
    </Modal.Content>
  );
};

const UserInfoModal = ({ isOpen, onClose, userId }) => {
  return (
    <Modal isOpen={isOpen} onClose={onClose}>
      <Modal.Overlay />
      <UserInfo />
    </Modal>
  );
};

앞서 모달 컴포넌트의 기본 뼈대를 제작할 때 isOpenfalse 일 경우 루트 컴포넌트에서 children을 렌더링하지 않도록 구현했다.

const Modal = forwardRef(function Modal({ children, isOpen, onClose }, ref) {
  // esc를 눌렀을 때 onClose 함수 실행한다.
  useOnEscapeEffect(onClose);

  return (
    // onClose 함수를 ContextAPI로 전달한다.
    <ModalContext.Provider value={{ onClose }}>
      {isOpen && (
        <Portal>
          <div ref={ref}>{children}</div>
        </Portal>
      )}
    </ModalContext.Provider>
  );
});

따라서 UserInfo 컴포넌트로 분리한 Modal.Content 영역은 isOpenfalse이면 렌더링되지 않으며, useGetUserInfo 훅 역시 실행되지 않는다.

맺으며

이와 같은 방식으로 모달 컴포넌트와 그 구성 요소들을 조건부 렌더링과 함께 분리하여 관리함으로써, 불필요한 렌더링과 API 호출을 최소화하고, 컴포넌트의 재사용성과 유지보수성을 극대화할 수 있다. 이 접근법은 모달 뿐만 아니라 다양한 UI 구성 요소에 적용될 수 있으며, React 애플리케이션의 전반적인 성능과 코드의 질을 향상시키는 데 기여할 수 있다고 생각한다.

컴포넌트의 구조를 분리하고 합성 컴포넌트 패턴을 적용함으로써, 개발자는 보다 세밀하게 UI를 제어할 수 있게 되며, 애플리케이션의 다양한 요구 사항에 유연하게 대응할 수 있다.

모달 컴포넌트를 비롯한 재사용 가능한 컴포넌트를 설계할 때는, 이와 같은 분리와 합성의 원칙을 적극적으로 활용하여, 사용성이 높고 접근성이 강화된, 유지보수가 쉬운 애플리케이션을 개발하는 것이 주요한 목적이라고 생각한다.