모달을 body로 옮겼더니 바깥 클릭 닫기가 오작동한 이유
문제 발생
모달을 createPortal로 document.body에 렌더했습니다. 카드 안에 있던 시절의 잘림 문제는 사라졌는데, 카드의 클릭 핸들러가 모달 내부 클릭에도 실행됐습니다.
<div onClick={() => setSelected(id)}> {/* 카드 */}
{open && createPortal(<Modal />, document.body)}
</div>모달 안의 버튼을 누를 때마다 카드가 선택됐습니다.
원인 분석
포털은 DOM 위치만 옮깁니다. React 문서가 그대로 적습니다 — 포털은 DOM 노드의 물리적 배치만 바꾸며, 그 외의 모든 면에서 포털에 렌더한 JSX는 그것을 렌더한 React 컴포넌트의 자식 노드처럼 동작합니다.
그래서 이벤트도 React 트리를 따릅니다 — 포털에서 발생한 이벤트는 DOM 트리가 아니라 React 트리에 따라 전파됩니다. 예를 들어 포털 내부를 클릭했고 그 포털이 <div onClick>으로 감싸져 있다면 그 onClick 핸들러가 실행됩니다.
즉 우리가 본 것은 버그가 아니라 문서화된 설계입니다. 컨텍스트가 포털 너머로도 전달되는 것과 같은 이유입니다.
해결 방안
-
포털을 트리 상 바깥으로 올립니다. 가장 깔끔한 해결입니다 — 모달을 카드의 자식이 아니라 페이지 수준에서 렌더하고 상태만 끌어올립니다.
-
트리 위치를 유지해야 한다면 전파를 끊습니다.
{createPortal(
<div onClick={(event) => event.stopPropagation()}>
<Modal />
</div>,
document.body,
)}-
바깥 클릭 닫기는 별도 기준으로 판정합니다. "모달 밖 클릭"을 React 이벤트 버블링으로 판단하면 이 문제가 반복됩니다 — 모달 요소의
contains()로 확인하거나<dialog>의 네이티브 동작을 씁니다. -
컨텍스트가 전달된다는 점은 이점으로 씁니다. 문서 설명대로 포털 자식은 부모 트리의 컨텍스트에 접근할 수 있으므로, 테마·i18n·라우터가 그대로 동작합니다.
-
접근성은 여전히 우리 몫입니다. DOM 위치가 바뀌었을 뿐 포커스 트랩,
aria-modal, Escape 처리는 자동으로 생기지 않습니다. 네이티브<dialog>를 먼저 검토합니다. -
왜 포털을 쓰는지 확인합니다. 조상의
overflow: hidden이나transform때문이라면, 그 스타일을 고치는 편이 더 단순할 때가 많습니다.
댓글0
댓글을 남기려면 로그인이 필요해요. 로그인
아직 댓글이 없어요. 첫 의견을 편하게 남겨 보세요.