2026년 3월 15일

선언형 오버레이의 의미와 React의 모달 관리 패턴

실무에서 마주치는 모달 코드의 형태

프론트엔드 실무에서 모달이나 다이얼로그를 다루다 보면 흔히 다음과 같은 형태의 코드를 마주치게 됩니다.

const { showAlert } = useAlert();
 
const handleClickDelete = () => {
  showAlert({
    title: "등록된 문의 삭제",
    confirmText: "문의 삭제",
    cancelText: "아니오",
    onConfirm: () => {
      deleteMutation.mutate(id);
    },
    content: "문의를 삭제하시겠습니까?\n삭제된 문의는 복구할 수 없습니다.",
    width: "420px",
    variant: "emergency",
  });
};

이 패턴은 함수 호출 하나로 모달을 띄우고 제목, 문구, 확인 콜백까지 객체로 전달할 수 있어 초기 작성 속도가 빠릅니다.

하지만 프로젝트 규모가 커질수록 유지보수 부담이 생깁니다.
width: '420px', variant: 'emergency', content: (...)처럼 UI의 외형과 구조를 결정하는 설정값들이 비즈니스 로직을 다루는 이벤트 핸들러 안에 섞이기 때문입니다.

일반적인 컴포넌트의 읽기 흐름:
state / hook 선언  ──>  이벤트 핸들러 (비즈니스 로직)  ──>  return (UI 마크업 구조)
 
명령형 모달 훅 사용 시:
이벤트 핸들러 안에 비즈니스 로직과 모달 UI 디자인 설정이 함께 포함됨

삭제 버튼을 눌렀을 때 실행되는 비즈니스 로직을 확인하려다가 모달의 가로폭과 버튼 스타일 설정까지 함께 읽어야 하므로, 로직과 UI의 경계가 흐려지는 문제가 발생합니다.


useOverlay와 선언형 오버레이의 등장

이러한 불편함을 해결하기 위해 토스(Toss)에서 공개한 라이브러리가 overlay-kit(useOverlay)입니다.1

const overlay = useOverlay();
 
const handleClick = () => {
  overlay.open(({ isOpen, close }) => (
    <Dialog open={isOpen} onClose={close}>
      <DialogTitle>문의를 삭제하시겠습니까?</DialogTitle>
      <DialogActions>
        <Button onClick={close}>취소</Button>
        <Button
          onClick={() => {
            deleteMutation.mutate(id);
            close();
          }}
        >
          삭제
        </Button>
      </DialogActions>
    </Dialog>
  ));
};

이 방식은 정해진 옵션 객체 틀 대신 실제 React JSX 컴포넌트를 원하는 형태로 조합해서 전달할 수 있습니다.

토스는 이 방식을 선언형 오버레이라 부르며, 반대로 useState로 모달 열림/닫힘을 관리하는 전통적인 방식을 명령형에 가깝다고 설명했습니다.

그렇다면 React의 기본 상태 관리 모델은 왜 명령형으로 평가되었으며, useOverlay는 어떤 관점에서 선언형이라 불리는 것일까요?


useState 패턴과 React의 선언적 모델

먼저 전통적인 React의 모달 관리 코드를 살펴보겠습니다.

function DeleteButton() {
  const [isOpen, setIsOpen] = useState(false);
 
  return (
    <>
      <Button onClick={() => setIsOpen(true)}>삭제</Button>
      <Dialog open={isOpen} onClose={() => setIsOpen(false)}>
        <DialogTitle>문의를 삭제하시겠습니까?</DialogTitle>
        <DialogActions>
          <Button onClick={() => setIsOpen(false)}>취소</Button>
        </DialogActions>
      </Dialog>
    </>
  );
}

이 코드는 React의 본래 설계 철학 관점에서 전형적인 선언형(Declarative) 모델입니다.2

  • Dialog 컴포넌트가 return JSX 블록 안에 명시적으로 선언되어 있습니다.
  • open={isOpen}을 통해 "상태가 true일 때 모달이 열려 있어야 한다"는 상태와 UI 결과의 관계를 선언합니다.
  • setIsOpen(true)는 DOM을 직접 조작하는 명령이 아니라 상태를 변경하여 React가 새로운 UI를 계산하도록 트리거합니다.

UI = f(state)라는 관점에서 보면, useState를 활용한 모달 관리는 상태 기반의 선언형 방식입니다.


토스가 useOverlay를 선언형이라 부른 기준: 추상화 수준

토스가 useOverlay를 선언형이라고 표현한 이유는 추상화 수준(Level of Abstraction)의 관점에서 접근했기 때문입니다.

한눈에 보기다이어그램
다이어그램 렌더링 중...

토스의 관점에서는 모달을 띄우기 위해 필요한 반복적인 절차(상태 선언, 포털 처리, 열림/닫힘 핸들러)를 라이브러리 내부로 숨기고, 개발자가 오직 띄우고자 하는 대상(UI)에만 집중할 수 있게 해 준다는 의미에서 선언적이라 정의한 것입니다.

비교 항목useState + isOpen 패턴overlay.open(...) 패턴
상태 관리개발자가 useState와 열림/닫힘 핸들러를 직접 관리라이브러리가 내부에서 상태와 포털을 캡슐화
UI 선언 위치컴포넌트의 return JSX 블록이벤트 핸들러 내부 콜백 인자
추상화 수준React 기본 모델 (Low Level)오버레이 전용 추상화 (High Level)
선언성의 기준결과 UI와 상태의 관계를 명시한다반복되는 제어 절차를 감추고 의도만 드러낸다

결국 React 코어가 바라보는 선언성(상태-UI의 관계식)과 토스가 바라본 선언성(제어 절차의 캡슐화)의 기준이 서로 다른 층위에 위치해 있는 셈입니다.


실무 모달 관리 패턴 비교와 선택 기준

실무에서는 용어의 정의보다 각 패턴이 주는 구조적 장단점을 파악하고 프로젝트 요구사항에 맞게 선택하는 것이 중요합니다.

1. useState와 컴포넌트 선언 패턴

const [isOpen, setIsOpen] = useState(false);
return (
  <>
    <Button onClick={() => setIsOpen(true)}>열기</Button>
    <Dialog open={isOpen} onClose={() => setIsOpen(false)}>...</Dialog>
  </>
);
  • 장점: 컴포넌트의 return만 보면 어떤 UI가 렌더링되는지 한눈에 파악할 수 있으며, 상위 Context나 상태를 자연스럽게 전달할 수 있습니다.
  • 단점: 모달이 늘어날수록 isOpen1, isOpen2 같은 상태와 핸들러 보일러플레이트가 증가합니다.

2. useOverlay (컴포넌트 주입형 훅) 패턴

overlay.open(({ isOpen, close }) => (
  <Dialog open={isOpen} onClose={close}>...</Dialog>
));
  • 장점: 상태 선언 보일러플레이트가 줄어들며, await overlay.open(...) 형태로 비동기 확인 흐름을 작성하기 수월합니다.
  • 단점: JSX가 이벤트 핸들러 안으로 들어가므로, 컴포넌트의 return 구조만으로는 화면 전체 구성을 파악하기 어렵습니다.

3. Config 기반 훅 (showAlert) 패턴

showAlert({
  title: "삭제 확인",
  width: "420px",
  onConfirm: handleDelete,
});
  • 장점: 디자인 시스템이 완전히 정형화된 환경에서 가장 빠른 개발 생산성을 제공합니다.
  • 단점: 정해진 옵션 구조를 벗어나는 커스텀 UI가 필요해질 때 확장이 어렵습니다.

참고 자료

2

React Docs, Describing the UI

KHLogo