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컴포넌트가returnJSX 블록 안에 명시적으로 선언되어 있습니다.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가 필요해질 때 확장이 어렵습니다.
참고 자료
Toss Open Source, overlay-kit Documentation / slash/useOverlay
React Docs, Describing the UI