어릴 적 도라에몽이나 해리포터에서 보았던 포탈은 현실의 한 지점에서 다른 지점으로 즉시 이동할 수 있는 마법 같은 통로였습니다. 리액트의 createPortal
도 이와 비슷한 마법을 부린다고 생각합니다. 컴포넌트 트리의 한 위치에서 선언된 요소를 DOM의 완전히 다른 위치로 "텔레포트"시켜주는 역할을 하기 때문입니다.
모달을 개발할 때 이 createPortal
을 사용하는 것은 단순한 선택이 아닌 필수적인 요소일까요? 이번 글에서는 createPortal
장단점과 어느 상황에서 사용하는 것이 적절한지에 대해 알아보겠습니다.
createPortal
은 리액트에서 자식 컴포넌트를 DOM 트리의 다른 부분으로 렌더링할 수 있도록 해주는 기능입니다.일반적으로 React 컴포넌트의 자식 요소는 부모 컴포넌트의 DOM 노드 안에 물리적으로 배치됩니다. 하지만 createPortal을 사용하면 자식 요소의 DOM 물리적 배치를 변경할 수 있습니다.
import { createPortal } from 'react-dom';
function MyComponent() {
return (
<div className="parent">
<h1>일반적인 자식 요소</h1>
{createPortal(
<div className="portal-child">포탈을 통해 이동한 요소</div>,
document.getElementById('portal-root')!
)}
</div>
);
}
이 코드에서 포탈을 통해 이동한 요소는 리액트 컴포넌트 트리에서는 MyComponent
의 자식이지만, DOM에서는 #portal-root
요소의 자식으로 렌더링됩니다.
모달은 페이지의 다른 모든 요소 위에 떠 있어야 합니다. 하지만 일반적인 리액트 렌더링 방식으로는 부모 컴포넌트의 스타일링 제약(특히 z-index
, overflow: hidden
, position
등)에 영향을 받게 됩니다.
function App() {
const [showModal, setShowModal] = useState(false);
return (
<div className="app" style={{ position: 'relative', overflow: 'hidden' }}>
<button onClick={() => setShowModal(true)}>모달 열기</button>
<Modal isOpen={showModal} className="modal">
<h2>일반 모달</h2>
<p>이 모달은 부모의 overflow: hidden에 가려질 수 있습니다.</p>
<button onClick={() => setShowModal(false)}>닫기</button>
</Modal>
)}
</div>
);
}
반면, createPortal
을 사용하면 모달을 DOM 트리의 최상위(보통 body
바로 아래)에 렌더링할 수 있어, 어떤 컴포넌트 내에서 모달을 호출하더라도 스타일링 제약 없이 항상 최상위에 표시됩니다.
createPortal로 렌더링된 요소는 리액트 컴포넌트 트리를 따라 이벤트가 버블링되기 때문에, 이벤트 위임이나 외부 영역 클릭 감지(onClickOutside) 등의 구현이 용이합니다.
일반적인 예상
포탈을 사용하면 DOM 트리에서 분리되기 때문에, 이벤트도 해당 DOM 위치에서만 버블링될 것이다.
실제 동작
리액트에서는 createPortal
로 렌더링된 요소의 이벤트가 물리적인 DOM 구조가 아닌, 리액트 내부 컴포넌트 트리를 기준으로 버블링됩니다. 즉, 화면상으로는 DOM이 분리되어 있더라도, 리액트 입장에서는 해당 컴포넌트가 원래 있던 위치에서 발생한 것처럼 인식합니다.
이러한 동작 덕분에, 모달 컴포넌트를 루트 외부에 렌더링하더라도 상위 컴포넌트에서 손쉽게 이벤트를 감지하고 제어할 수 있습니다. 특히 외부 클릭으로 모달을 닫는 로직이나, 이벤트 위임 기반의 인터랙션 구현 시 유용합니다.
createPortal
을 사용하면 컴포넌트의 논리적 구조와 실제 DOM 구조 간에 불일치가 발생합니다. 이는 디버깅을 어렵게 만들고, 특정 스타일링이나 레이아웃 문제를 추적하기 까다롭게 만들 수 있습니다.
포탈로 렌더링된 컴포넌트도 리액트 트리상에서의 위치 기준으로 컨텍스트(Context)에 접근할 수 있습니다. 하지만 DOM 기준으로 생각하면 이 동작이 직관적이지 않아 혼란을 줄 수 있습니다.
즉, 물리적으로 DOM이 분리되어 있어도, React의 트리 안에서는 여전히 연결되어 있다는 점을 이해해야 합니다.
createPortal
을 사용하면 DOM 구조가 시맨틱하지 않게 변할 수 있어 접근성 문제가 발생할 수 있습니다. 특히 모달이나 툴팁과 같은 요소를 구현할 때 적절한 aria-* 속성과 포커스 트랩, ESC 키 닫기 등의 키보드 내비게이션 처리가 필요합니다.
SSR 환경에서는 DOM이 존재하지 않기 때문에, 포탈 사용 시 클라이언트 환경에서만 안전하게 렌더링되도록 처리해주어야 합니다.
function SafePortal({ children, containerSelector }) {
// 클라이언트 사이드에서만 포탈 생성
const [mounted, setMounted] = useState(false);
const containerRef = useRef(null);
useEffect(() => {
containerRef.current = document.querySelector(containerSelector);
setMounted(true);
return () => setMounted(false);
}, [containerSelector]);
return mounted && containerRef.current
? createPortal(children, containerRef.current)
: null;
이처럼 SSR을 고려한 추가 로직이 필요하며, 이는 코드 복잡도를 높이고 렌더링 타이밍에 따라 깜빡임이 발생할 수 있습니다.
createPortal
을 사용하면 이벤트 버블링이 DOM 트리가 아닌 리액트 트리를 따라 진행됩니다. 이는 의도적인 설계이지만, 예상치 못한 동작을 야기할 수 있습니다.
function App() {
return (
<div onClick={() => console.log('App 클릭됨')}>
<Modal>
<button>모달 내부 버튼</button>
</Modal>
</div>
);
}
function Modal({ children }) {
return createPortal(
<div className="modal">
{children}
</div>,
document.body
);
}
위 예제에서 버튼을 클릭하면 App 클릭됨
이 출력됩니다. 이는 버튼이 document.body에 렌더링되었음에도 불구하고, React 트리 상에서는 여전히 App의 자식이기 때문에 발생하는 현상입니다.
툴팁은 특정 요소에 마우스를 올렸을 때 추가 정보를 표시하는 작은 팝업입니다. 이러한 툴팁은 특히 버튼이나 아이콘이 중첩된 복잡한 레이아웃에서 z-index
문제가 발생하기 쉽습니다. 이때 포탈을 활용하면 레이아웃 제약을 벗어나 자유롭게 위치시킬 수 있습니다.
function Tooltip({ children, text, isVisible }) {
if (!isVisible) return null;
return createPortal(
<div className="tooltip">{text}</div>,
document.body
);
}
function Button({ tooltipText }) {
const [showTooltip, setShowTooltip] = useState(false);
return (
<div
onMouseEnter={() => setShowTooltip(true)}
onMouseLeave={() => setShowTooltip(false)}
>
<button>Hover me!</button>
<Tooltip isVisible={showTooltip} text={tooltipText} />
</div>
);
}
페이지 스크롤과 무관하게 항상 상단 또는 하단에 고정되어야 하는 UI 요소는 포탈을 활용해 메인 콘텐츠 트리 바깥에 위치시킴으로써 레이아웃 간섭 없이 구현할 수 있습니다.
function StickyHeader({ children }) {
return createPortal(
<header className="sticky-header">
{children}
</header>,
document.getElementById('header-root')// 별도의 DOM 노드
);
}
모달 개발에 있어 createPortal
은 선택이 아닌 필수에 가깝다고 생각합니다. 물론 단순한 구현에는 없어도 되지만, 프로젝트가 복잡해질수록 z-index
관리와 스타일링 이슈, 이벤트 전파 문제, 접근성 등을 고려했을 때 포탈을 사용하는 것이 훨씬 효율적이고 안정적이라고 생각합니다.
React의 createPortal
은 마치 해리포터의 “플루 가루”처럼, 컴포넌트를 정확히 원하는 위치로 이동시켜주는 마법 같은 기능을 제공합니다. 다만, 이 기능은 제대로 이해하고 현명하게 사용할 때 비로소 진정한 가치가 발휘된다고 생각합니다. 해리포터가 처음 플루 가루를 사용해 "다이애건 앨리" 대신 "녹턴 앨리"에 도착했던 것처럼, Portal를 제대로 이해하지 못하면 예상치 못한 결과를 만날 수 있다고 생각합니다.
따라서 단순히 동작만을 목표로 하지 말고, 왜 포탈이 필요한지, 어떻게 사용하는 것이 바람직한지 이해한 후 활용해 보시길 바랍니다!
끗
좋은 글이네요 감사합니다 잘 보고 갑니다!