본문으로 건너뛰기
개발 머꼬
개발 노트React
hohyeon.dev20

모달을 body로 옮겼더니 바깥 클릭 닫기가 오작동한 이유

  • #Common Pitfall
  • #Engineering Note
  • #React

문제 발생

모달을 createPortaldocument.body에 렌더했습니다. 카드 안에 있던 시절의 잘림 문제는 사라졌는데, 카드의 클릭 핸들러가 모달 내부 클릭에도 실행됐습니다.

<div onClick={() => setSelected(id)}>        {/* 카드 */}
  {open && createPortal(<Modal />, document.body)}
</div>

모달 안의 버튼을 누를 때마다 카드가 선택됐습니다.

원인 분석

포털은 DOM 위치만 옮깁니다. React 문서가 그대로 적습니다 — 포털은 DOM 노드의 물리적 배치만 바꾸며, 그 외의 모든 면에서 포털에 렌더한 JSX는 그것을 렌더한 React 컴포넌트의 자식 노드처럼 동작합니다.

그래서 이벤트도 React 트리를 따릅니다 — 포털에서 발생한 이벤트는 DOM 트리가 아니라 React 트리에 따라 전파됩니다. 예를 들어 포털 내부를 클릭했고 그 포털이 <div onClick>으로 감싸져 있다면 그 onClick 핸들러가 실행됩니다.

즉 우리가 본 것은 버그가 아니라 문서화된 설계입니다. 컨텍스트가 포털 너머로도 전달되는 것과 같은 이유입니다.

해결 방안

  1. 포털을 트리 상 바깥으로 올립니다. 가장 깔끔한 해결입니다 — 모달을 카드의 자식이 아니라 페이지 수준에서 렌더하고 상태만 끌어올립니다.

  2. 트리 위치를 유지해야 한다면 전파를 끊습니다.

{createPortal(
  <div onClick={(event) => event.stopPropagation()}>
    <Modal />
  </div>,
  document.body,
)}
  1. 바깥 클릭 닫기는 별도 기준으로 판정합니다. "모달 밖 클릭"을 React 이벤트 버블링으로 판단하면 이 문제가 반복됩니다 — 모달 요소의 contains()로 확인하거나 <dialog>의 네이티브 동작을 씁니다.

  2. 컨텍스트가 전달된다는 점은 이점으로 씁니다. 문서 설명대로 포털 자식은 부모 트리의 컨텍스트에 접근할 수 있으므로, 테마·i18n·라우터가 그대로 동작합니다.

  3. 접근성은 여전히 우리 몫입니다. DOM 위치가 바뀌었을 뿐 포커스 트랩, aria-modal, Escape 처리는 자동으로 생기지 않습니다. 네이티브 <dialog>를 먼저 검토합니다.

  4. 왜 포털을 쓰는지 확인합니다. 조상의 overflow: hidden이나 transform 때문이라면, 그 스타일을 고치는 편이 더 단순할 때가 많습니다.

공식 문서

마지막 수정

좋아요북마크

댓글0

아직 댓글이 없어요. 첫 의견을 편하게 남겨 보세요.