[React 모달] e.stopPropagation()과 createPortal()
🍈 프롤로그
이번 주 블로그 주제로는 React로 모달을 구현할 때 알아두면 좋은 두 가지 개념,
stopPropagation()과 createPortal에 대해 다뤄보려고 한다.
모달을 구현하다 보면 내부를 클릭했는데 모달이 닫혀버린다든지,
레이아웃이 깨진다든지 하는 상황을 한 번쯤 겪게 되는데,
이 두 가지 개념을 제대로 이해하고 사용하면 이러한 문제들을 해결할 수 있다.
🍈 상황
우테코 레벨2 상품 목록 미션에서,
장바구니에 담긴 상품 정보를 보여주는 모달을 구현했다.
닫기 버튼을 클릭해서 모달을 닫을 수는 있지만,
UX를 고려해서 배경을 클릭했을 때도 모달이 닫히는 기능을 추가했다.
이후 리뷰어가 e.stopPropagation()을 사용하는 이유에 대해 질문해 주셨다.
대충 어떤 역할을 하는지 짐작만 하면서 사용했어서,
이번 기회에 조금 더 자세히 알아보고 정리하고자 한다.
🍈 e.stopPropagation()의 필요성
우리가 원하는 동작은 다음과 같다.
배경을 클릭하면 모달이 닫히고, 모달 내부를 클릭하면 아무 동작도 하지 않는다.
// Overlay와 ModalContainer는 emotion으로 만든 스타일 컴포넌트입니다.
return (
<Overlay onClick={onClose}>
<ModalContainer>
...
</ModalContainer>
</Overlay>
);
다음과 같이 배경(Overlay) 클릭 이벤트 핸들러로 모달을 닫는 onClose 함수를 연결했다.
그런데 실행시켜 보면, 예상치 못한 방식으로 작동한다.
배경을 클릭했을 때뿐만 아니라, 모달 내부를 클릭했을 때도 모달이 닫힌다. (ㅎ?)
해당 문제를 해결하기 위해 다음과 같은 코드를 추가해 보자.
<Overlay onClick={onClose}>
<ModalContainer onClick={(e) => e.stopPropagation()}>
...
</ModalContainer>
</Overlay>
이제는 모달 내부를 클릭해도 닫히지 않고,
배경을 클릭했을 때만 모달이 닫히는 동작이 정상적으로 수행된다.
🍈 e.stopPropagation()의 원리
e.stopPropagation()은 이벤트가 상위 요소로 전파되는 버블링 현상을 막기 위한 메서드이다.
여기서는 ModalContainer의 클릭 이벤트가 Overlay로 전달되지 않도록 막아주는 역할을 한다.
따라서 이 코드를 생략하면, 모달 내부 클릭 → Overlay까지 이벤트 전달 → onClose 호출 흐름이 되어
의도하지 않게 모달이 닫혀버리는 문제가 생긴다.
🍈 또 어디에 쓰일 수 있을까?
이벤트 전파를 막고 싶은 상황은 모달 외에도 많다.
ex) 드롭다운 메뉴
<div onClick={handleOutsideClick}>
<div onClick={(e) => e.stopPropagation()}>
내부 클릭은 드롭다운 유지
</div>
</div>
드롭다운이 열렸을 때, 바깥을 클릭하면 닫히도록 하는 경우가 많다.
하지만 드롭다운 내부를 클릭했을 때는 닫히지 않아야 하므로,
e.stopPropagation()을 활용해 내부 클릭 이벤트가 외부로 전파되지 않도록 막을 수 있다.
🍈 ReactDOM.createPortal
특정 React 컴포넌트를 현재 트리 바깥의 DOM 요소에 렌더링 하는 기능
컴포넌트를 작성하다 보면, 부모 요소의 영향을 받아 의도치 않은 스타일(CSS)이 적용되는 문제가 발생하기도 한다.
예를 들면,
부모 요소에 overflow: hidden이 설정되어 있으면,
자식 요소(예: 모달)가 부모의 크기를 넘는 경우
넘는 부분은 브라우저가 잘라내기 때문에 화면에 보이지 않게 된다.
그래서 모달이 화면 전체에 떠야 하는데, 일부만 보이거나 아예 안 보이는 문제가 발생할 수 있다.
cf) overflow: hidden은 자식 요소가 부모의 범위를 벗어날 때, 벗어난 부분을 잘라서 숨기는 CSS 속성이다.
이럴 때 createPortal을 사용하면 문제를 해결할 수 있다.
🍈 createPortal은 어떻게 동작할까?
컴포넌트의 논리적 위치는 그대로 유지하면서, 실제 렌더링 위치만 DOM 트리 바깥으로 옮겨준다.
예를 들어 <App /> 내부에서 선언한 컴포넌트를 실제로는 document.body 하위에 렌더링 할 수 있다.
즉, React의 상태나 Context 구조는 유지하면서도
부모 요소의 스타일 제약에서 벗어나 독립적으로 렌더링할 수 있는 것이다.
🍈 사용 예시
1. HTML에 포탈용 DOM 추가
<!-- public/index.html -->
<body>
<div id="root"></div>
<div id="modal-root"></div> <!-- 여기서 렌더링할 예정 -->
</body>
2. 컴포넌트 만들기
// Modal.tsx
import ReactDOM from 'react-dom';
const Modal = ({ children }: { children: React.ReactNode }) => {
return ReactDOM.createPortal(
<div className="modal">{children}</div>,
document.getElementById('modal-root') as HTMLElement // 🎨 modal-root에서 해당 컴포넌트 렌더링
);
};
export default Modal;
또는 DOM 노드를 따로 만들지 않고 document.body를 직접 지정할 수도 있다
return ReactDOM.createPortal(
<div className="modal">{children}</div>,
document.body // 별도의 DOM 없이 바로 body에 렌더링
);
🍈 언제 createPortal을 쓰면 좋을까?
- 모달, 드롭다운, 알림처럼 화면 최상단에 떠야 하는 UI
- 부모 요소의 overflow, z-index, position 등에 영향을 받으면 안 되는 UI
🍈 정리
✔️ e.stopPropagation(): 이벤트가 상위 요소로 전달되는 버블링을 방지
✔️ createPortal(): 특정 컴포넌트를 현재 React 트리 바깥의 DOM에 렌더링
모달처럼 시각적으로 최상단에 떠야 하면서,
동시에 사용자 인터랙션도 정확하게 제어해야 하는 UI를 만들 때
이 두 가지 개념을 함께 이해하고 적용하면 더 견고한 컴포넌트를 구현할 수 있다.