
React에서 일반적으로 컴포넌트를 렌더링하면, 그 컴포넌트는 부모 컴포넌트의 DOM 계층 구조 안에 포함된다.
예를 들어 App.tsx의 루트 컴포넌트는 보통 public/index.html 파일 안의 #root 엘리먼트에 렌더링되며, 자식 컴포넌트도 모두 그 안에서 렌더링된다.
하지만 어떤 UI는 이와 같은 구조에서 벗어나 "다른 DOM 위치에 렌더링되어야" 할 때가 있다.
대표적인 예로는 다음과 같은 UI들이 있다:
이러한 UI 요소들은 시각적으로 항상 최상위에 떠 있어야 하고,
부모 요소의 CSS 속성(overflow: hidden, z-index, position: relative 등)의 영향을 받지 않아야 한다.
이때 사용하는 것이 바로 React Portal이다.
"Portals provide a first-class way to render children into a DOM node that exists outside the DOM hierarchy of the parent component."
즉, 포털은 부모 컴포넌트의 DOM 계층 외부에 있는 다른 DOM 노드에 자식 컴포넌트를 렌더링하는 방법이다.
| 상황 | Portal이 필요한 이유 |
|---|---|
모달을 띄우고 싶은데 부모의 z-index나 overflow에 가려진다 | Portal로 DOM 위치를 이동시켜 해결 가능 |
| 툴팁/알림창이 레이아웃의 제약 없이 떠 있어야 한다 | 부모 컴포넌트의 스타일 영향 없이 최상단에서 렌더링 |
| 시각적인 계층과 코드 구조를 분리하고 싶다 | 코드 구조는 유지하되, 렌더링은 독립된 DOM에 수행 |
index.html에 렌더링 위치를 만든다<!-- public/index.html -->
<body>
<div id="root"></div>
<div id="modal-root"></div>
</body>
### 2. React에서 createPortal을 사용한다
```tsx
// Modal.tsx
import ReactDOM from 'react-dom';
import React from 'react';
const Modal = ({ children }: { children: React.ReactNode }) => {
const modalRoot = document.getElementById('modal-root');
if (!modalRoot) return null;
return ReactDOM.createPortal(
<div className="modal-backdrop">
<div className="modal-content">
{children}
</div>
</div>,
modalRoot
);
};
export default Modal;
<Modal>
<h1>정말 삭제하시겠습니까?</h1>
<button>취소</button>
<button>삭제</button>
</Modal>
이렇게 사용하면 컴포넌트 트리 상으로는 부모-자식 구조이지만,
실제로는 #modal-root에 렌더링되기 때문에 레이아웃 충돌 없이 최상단에 위치할 수 있다.
나는 이번 프로젝트에서 공통 모달 컴포넌트를 만들면서 처음으로 React Portal을 사용하였다.
초기에는 일반적인 구조로 모달을 렌더링했지만,
부모 컴포넌트의 overflow나 z-index 속성에 가려지는 문제가 발생하였다.
따라서 모달을 루트 DOM에서 분리된 별도의 위치에 렌더링해야 했고,
이때 React Portal을 도입하게 되었다.
import ReactDOM from "react-dom";
import React from "react";
import styled from "@emotion/styled";
import Button from "./Button";
interface ModalProps {
isOpen: boolean;
onClose: () => void;
onConfirm: () => void;
message: string;
leftButtonText: string;
rightButtonText: string;
}
const Modal = ({
isOpen,
onClose,
onConfirm,
message,
leftButtonText,
rightButtonText,
}: ModalProps) => {
if (!isOpen) return null;
const modalRoot = document.getElementById("modal-root");
if (!modalRoot) return null;
const handleConfirm = () => {
onClose(); // 모달 닫기
onConfirm(); // 사용자 정의 동작 실행
};
return ReactDOM.createPortal(
<Backdrop>
<Container>
<Message>{message}</Message>
<ButtonGroup>
<Button className="left-button" onClick={onClose}>
{leftButtonText}
</Button>
<Button className="right-button" onClick={handleConfirm}>
{rightButtonText}
</Button>
</ButtonGroup>
</Container>
</Backdrop>,
modalRoot
);
};
export default Modal;
isOpen이 true일 때만 렌더링된다.ReactDOM.createPortal(...)을 통해 실제 렌더링은 #modal-root에 일어난다.onClose와 onConfirm 이벤트가 순서대로 실행된다.처음엔 “Portal이 어렵고 낯선 개념”처럼 느껴졌지만,
실제로는 단순히 렌더링 위치를 분리해주는 도구였다.
구조적으로 Modal 컴포넌트는 부모 DOM과 분리되어 렌더링되지만,
여전히 React 트리 안에서 상태와 이벤트를 그대로 공유할 수 있었다.
앞으로는 모달, 드롭다운, 툴팁 등은 Portal을 활용하는 게 더 안정적이라는 걸 깨달았다.
ReactDOM.createPortal(element, container)