콘텐츠로 이동

shadcn Radix Dialog 교체 시 비동기 처리 중 닫기 가드 3중 재구현 패턴

맥락

기존 자체 구현 모달에서 shadcn Radix Dialog로 교체할 때, 비동기 mutation(isPending)이 진행 중인 동안 모달이 닫히는 것을 막는 가드 로직을 반드시 재구현해야 한다.

기존 모달은 세 가지 닫기 경로를 각각 직접 핸들링했다:

  1. document.addEventListener("keydown") — ESC 키
  2. overlay 클릭 핸들러 (onMouseDown / onClick)
  3. 닫기 버튼 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 등)

관련 자료