shadcn Radix Dialog 교체 시 비동기 처리 중 닫기 가드 3중 재구현 패턴¶
맥락¶
기존 자체 구현 모달에서 shadcn Radix Dialog로 교체할 때, 비동기 mutation(isPending)이 진행 중인 동안 모달이 닫히는 것을 막는 가드 로직을 반드시 재구현해야 한다.
기존 모달은 세 가지 닫기 경로를 각각 직접 핸들링했다:
document.addEventListener("keydown")— ESC 키- overlay 클릭 핸들러 (
onMouseDown/onClick) - 닫기 버튼 onClick
이 세 곳에 if (isPending) return;을 직접 달았기 때문에 가드가 동작했다.
shadcn Radix Dialog로 교체 시 Radix가 ESC와 overlay 클릭을 내부적으로 가로채 onOpenChange를 호출하는 구조로 바뀐다. 단순히 onOpenChange={(o) => !o && onClose()}만 연결하면 Radix가 isPending 상태를 알지 못하므로, 처리 중에도 ESC나 overlay 클릭 한 번으로 모달이 닫혀 진행 중인 mutation이 고아 상태가 되고 데이터 손실이 발생한다.
자산관리 프로젝트 AssignBatchModal(묶음 지급)에서 Radix Dialog 전환 시 이 패턴을 적용했다. (Phase 9-C 사전 분석 함정 2번)
가이드¶
비동기 mutation을 사용하는 모달을 shadcn Dialog로 교체할 때 3곳 모두 재구현한다.
<Dialog
open={open}
onOpenChange={(nextOpen) => {
// ① onOpenChange — Radix 내부 close 이벤트 통합 차단
if (isPending) return;
if (!nextOpen) onClose();
}}
>
<DialogContent
onEscapeKeyDown={(e) => {
// ② ESC 키 — Radix가 처리 전에 preventDefault()로 차단
if (isPending) e.preventDefault();
}}
onPointerDownOutside={(e) => {
// ③ overlay 클릭 — Radix가 처리 전에 preventDefault()로 차단
if (isPending) e.preventDefault();
}}
>
{/* ... */}
</DialogContent>
</Dialog>
왜 3곳 모두 필요한가:
| 경로 | Radix 동작 | 가드 방법 |
|---|---|---|
| ESC 키 | onEscapeKeyDown 콜백 발행 → onOpenChange(false) 호출 | e.preventDefault()로 Radix 처리 차단 |
| overlay 클릭 | onPointerDownOutside 콜백 발행 → onOpenChange(false) 호출 | e.preventDefault()로 Radix 처리 차단 |
| 프로그래밍적 close | 직접 onOpenChange(false) 호출 | if (isPending) return |
onEscapeKeyDown과 onPointerDownOutside에서 e.preventDefault()를 호출하지 않으면, 콜백이 실행된 후 Radix가 자동으로 onOpenChange(false)를 호출하여 ①의 가드를 우회한다. 즉 ①만 구현해도 ESC·overlay 경로에서는 가드가 동작하지 않는다.
AlertDialog의 경우: AlertDialog는 닫기 동작이 AlertDialogCancel 버튼에만 연결되어 포커스 트래핑 중 ESC 키가 기본 비활성화되어 있으므로, 이 3중 가드 패턴이 필요하지 않다. 비동기 mutation이 있는 AlertDialogAction은 버튼의 onClick에만 isPending 체크를 추가하면 된다.
왜 중요한가¶
데이터 손실 방지가 핵심이다. 묶음 지급처럼 서버에 여러 자산 상태를 한 번에 변경하는 mutation이 진행 중에 모달이 닫히면, 응답이 돌아왔을 때 onSuccess?.() 콜백이 이미 unmount된 컴포넌트를 참조하거나, 중간 상태로 끝난 mutation을 재시도할 UI가 사라진다.
이 패턴의 또 다른 중요성은 회귀 위험이다. document.addEventListener 방식에서는 각 핸들러에 직접 isPending 가드를 달아야 했으므로 빠뜨리기 어려웠다. Radix Dialog로 교체하면 닫기 경로가 Radix 내부로 추상화되어, 개발자가 onOpenChange만 연결하고 "됐겠지"라고 착각하기 쉽다. shadcn Dialog 교체 PR 리뷰 체크리스트에 이 항목이 항상 포함되어야 하는 이유다.
적용 시기¶
- 적용:
useMutation/isPending을 사용하는 모달을 shadcn Dialog로 교체할 때 - 적용:
isLoading상태 중 모달이 닫히면 안 되는 모든 Dialog 구현 - 비적용: AlertDialog — Radix AlertDialog는 ESC가 기본 비활성화되어 있어 이 패턴이 불필요
- 비적용: 닫기를 언제든 허용해도 되는 단순 읽기 전용 Dialog (BatchDetailModal 등)
관련 자료¶
- 실제 적용 커밋: AssetManagement/Frontend
cf9a2ef(Phase 9-C) - 적용 파일:
src/components/common/AssignBatchModal/AssignBatchModal.tsx - 작업지시서 함정 2번: Planning/작업_지시서_Phase9C_Dialog_모달_교체
- 관련 기록: BestPractices/2026-04-10-shadcn-파일럿-검증-패턴-터치영역-함정 — 44px 터치 영역 함정
- Radix Dialog API: https://www.radix-ui.com/primitives/docs/components/dialog#content