개발

Ref를 이용해 전역 모달 시스템 구축하기

사낙 2025. 3. 24. 20:21

종료 모달 예시

모달이란?
모달이란 사용자에게 중요한 정보를 제시하거나, 특정 액션을 수행하기 전에 사용자의 확인을 받기 위해 기존 화면 위에 레이어 형태로 표시되는 컴포넌트를 말합니다.

 

프로젝트를 진행하다 보면 모달을 만들어야 하는 상황이 자주 생기는데, 단순히 isVisible 같은 state를 이용해 화면에 표시하거나 숨기는 방법으로도 간단하게 구현할 수 있습니다.

다만, 복잡한 형태의 모달이 많이 생기거나, 여러 화면에서 재사용해야 하는 상황이라면 자칫 코드가 중복되고 유지보수가 어려워질 수 있습니다. 이 때문에 많은 프로젝트에서는 재사용 가능한 모달 컴포넌트를 만들어 사용하거나, 상태관리 도구(Redux 등)를 활용해 전역에서 모달 표시 여부를 관리하는 방식을 택하곤 합니다.

이번 글에서는 Ref를 이용해 모달을 제어하는 또 다른 방법을 간단히 살펴보겠습니다.


폴더구조

예시 코드는 다음과 같은 구조를 가정합니다.

pages
 └─ components
     └─ custom-modal.tsx
 └─ index.tsx

components
 └─ basic-modal.tsx

 

  • pages/index.tsx
    해당 페이지에서 모달과 관련된 여러 상태를 관리합니다.
  • pages/components/custom-modal.tsx
    공통 모달 컴포넌트(BasicModal)를 가져와 표시하는 중간 다리 역할을 합니다.
  • components/basic-modal.tsx
    실제로 모달 동작과 Ref를 통해 노출되는 메서드들을 정의하는 컴포넌트입니다.

 

 

Index에서 Ref 생성하기

가장 먼저 pages/index.tsx에서 모달을 제어하기 위한 Ref를 생성합니다. 예시로 BasicModalRef라는 타입의 Ref를 선언했다고 가정하겠습니다.

 

// pages/index.tsx
import React, { useRef } from 'react'
import CustomModal from './components/custom-modal'
import { BasicModalRef } from '@/components/basic-modal'

const IndexPage = () => {
  // BasicModalRef 타입으로 지정된 Ref를 생성합니다.
  const modalRef = useRef<BasicModalRef>(null)

  // 페이지에서 사용할 기타 로직들 ...
  // 예: 함수, 상태 등

  return (
    <>
      <div>
        {/* 해당 페이지에서 보여지는 화면 */}
        <p>이곳은 메인 페이지 내용입니다.</p>
        <button
          onClick={() => {
            if (modalRef.current) {
              modalRef.current.showModal({
                message: '정말 종료하시겠습니까?',
                onConfirm: () => {
                  modalRef.current?.doneModal('confirmed')
                },
              })
            }
          }}
        >
          모달 열기
        </button>
      </div>

      {/* 모달 컴포넌트에 ref 전달 */}
      <CustomModal ref={modalRef} />
    </>
  )
}

export default IndexPage

 

ForwardRef를 통한 모달 컴포넌트

다음으로 Modal 컴포넌트(pages/components/custom-modal.tsx)에서 forwardRef를 사용해 넘겨받은 ref를 BasicModal로 다시 전달합니다.

참고
React 19 기준으로는 ref를 prop처럼 그대로 넘기면 됩니다. (과거 React 18 이하 버전에서는 forwardRef가 필수였으며, 향후 deprecated될 수도 있으니 버전에 따라 확인이 필요합니다.)

 

// pages/components/modal.tsx
import React, { forwardRef } from 'react'
import BasicModal, { BasicModalRef } from '@/components/basic-modal'

interface ModalProps {
  // 필요하다면 모달 컴포넌트에 전달할 prop을 정의하세요.
}

const CustomModal = forwardRef<BasicModalRef, ModalProps>((props, ref) => {
  return (
    <BasicModal
      ref={ref}
      render={
        <div style={{ padding: '20px' }}>
          <h2>공통 모달</h2>
          <p>이 영역 안에 원하는 콘텐츠를 넣을 수 있습니다.</p>
        </div>
      }
    />
  )
})

export default CustomModal

 

useImperativeHandle로 모달 기능 정의

마지막으로 BasicModal 컴포넌트(components/basic-modal.tsx)에서 useImperativeHandle 훅을 통해 Ref로 노출할 메서드들을 정의합니다.

import React, {
  forwardRef,
  Ref,
  useImperativeHandle,
  useState,
  useCallback,
} from 'react'
import { Modal } from 'antd'

export interface ShowModalProps {
  message?: string
  onConfirm?: () => void
}

export type ActionType = 'show' | 'confirmed' | 'done'

export interface BasicModalRef {
  showModal: (props: ShowModalProps) => void
  doneModal: (type?: ActionType) => void
  closeModal: () => void
}

interface BasicModalProps {
  render?: React.ReactNode
}

const BasicModal = forwardRef((props: BasicModalProps, ref: Ref<BasicModalRef>) => {
  const [open, setOpen] = useState(false)
  const [message, setMessage] = useState('')
  const [type, setType] = useState<ActionType>('show')
  const [onConfirm, setOnConfirm] = useState<() => void>(() => () => {})

  // 모달 열기
  const showModal = useCallback((modalProps: ShowModalProps) => {
    setOpen(true)
    setMessage(modalProps.message || '')
    setOnConfirm(() => modalProps.onConfirm || (() => {}))
    setType('show')
  }, [])

  // 모달 완료 처리
  const doneModal = useCallback(
    (actionType?: ActionType) => {
      setType(actionType || 'done')
      if (actionType === 'confirmed') onConfirm()
    },
    [onConfirm]
  )

  // 모달 닫기
  const closeModal = useCallback(() => {
    setOpen(false)
    setType('show')
  }, [])

  // Ref에 노출할 메서드 정의
  useImperativeHandle(ref, () => ({
    showModal,
    doneModal,
    closeModal,
  }))

  return (
    <Modal
      open={open}
      onOk={() => {
        doneModal('confirmed')
        closeModal()
      }}
      onCancel={closeModal}
    >
      <p>{message}</p>
      {props.render}
    </Modal>
  )
})

export default BasicModal

 

Ref를 이용한 방식의 장단점

이렇게 Ref를 통해 모달을 제어하는 방식은 명령형 프로그래밍(Imperative)입니다.
React는 기본적으로 선언형 프로그래밍(Declarative)을 지향하지만, 특정 상황에서는 명령형 접근이 편리하게 느껴질 수 있습니다. 예를 들어, 자주 바뀌지 않는 특정 로직을 캡슐화하거나, 코드 구조상 선언형으로 구현했을 때 오히려 복잡도가 올라가는 경우 등이 그 예입니다.

다만, 명령형 방식의 코드는 리액트의 선언형 철학과는 다소 어긋나고, 구조 파악과 유지보수가 어려워질 수 있으므로 신중히 선택해야 합니다.

명령형 프로그래밍 vs 선언형 프로그래밍
명령형 프로그래밍: 코드가 어떻게(How) 동작하는지에 초점을 맞춥니다.
선언형 프로그래밍: 코드가 무엇을(What) 동작시키는지에 초점을 맞춥니다.

 

따라서 모달 제어도 상황에 따라 상태관리 도구나, 컴포넌트 내부 state(부모에서 전달받는 props) 등을 사용하는 기존 방식이 더 적합할 수 있습니다. 그래도 이러한 Ref + useImperativeHandle 방식이 있다는 것을 이해해 두시면, 특정 상황에서 유용하게 사용할 수 있을 것입니다.