모달 만들 때 왜 createPortal 쓰세요?

keemsebeen·2025년 5월 11일
37

어릴 적 도라에몽이나 해리포터에서 보았던 포탈은 현실의 한 지점에서 다른 지점으로 즉시 이동할 수 있는 마법 같은 통로였습니다. 리액트createPortal도 이와 비슷한 마법을 부린다고 생각합니다. 컴포넌트 트리의 한 위치에서 선언된 요소를 DOM의 완전히 다른 위치로 "텔레포트"시켜주는 역할을 하기 때문입니다.

모달을 개발할 때 이 createPortal을 사용하는 것은 단순한 선택이 아닌 필수적인 요소일까요? 이번 글에서는 createPortal 장단점과 어느 상황에서 사용하는 것이 적절한지에 대해 알아보겠습니다.

Portal이 무엇인가요?

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와 스타일링 이슈 해결

모달은 페이지의 다른 모든 요소 위에 떠 있어야 합니다. 하지만 일반적인 리액트 렌더링 방식으로는 부모 컴포넌트의 스타일링 제약(특히 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) 상속 이슈

포탈로 렌더링된 컴포넌트도 리액트 트리상에서의 위치 기준으로 컨텍스트(Context)에 접근할 수 있습니다. 하지만 DOM 기준으로 생각하면 이 동작이 직관적이지 않아 혼란을 줄 수 있습니다.

즉, 물리적으로 DOM이 분리되어 있어도, React의 트리 안에서는 여전히 연결되어 있다는 점을 이해해야 합니다.

접근성(A11y) 관련 이슈

createPortal을 사용하면 DOM 구조가 시맨틱하지 않게 변할 수 있어 접근성 문제가 발생할 수 있습니다. 특히 모달이나 툴팁과 같은 요소를 구현할 때 적절한 aria-* 속성과 포커스 트랩, ESC 키 닫기 등의 키보드 내비게이션 처리가 필요합니다.

서버 사이드 렌더링(SSR) 복잡성

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의 자식이기 때문에 발생하는 현상입니다.

모달을 제외하고 어디에서 사용 가능한가요?

툴팁 (Tooltips)

툴팁은 특정 요소에 마우스를 올렸을 때 추가 정보를 표시하는 작은 팝업입니다. 이러한 툴팁은 특히 버튼이나 아이콘이 중첩된 복잡한 레이아웃에서 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>
  );
}

고정 헤더/푸터 (Sticky Headers/Footers)

페이지 스크롤과 무관하게 항상 상단 또는 하단에 고정되어야 하는 UI 요소는 포탈을 활용해 메인 콘텐츠 트리 바깥에 위치시킴으로써 레이아웃 간섭 없이 구현할 수 있습니다.

function StickyHeader({ children }) {
  return createPortal(
    <header className="sticky-header">
      {children}
    </header>,
    document.getElementById('header-root')// 별도의 DOM 노드
  );
}

마치며

모달 개발에 있어 createPortal선택이 아닌 필수에 가깝다고 생각합니다. 물론 단순한 구현에는 없어도 되지만, 프로젝트가 복잡해질수록 z-index 관리와 스타일링 이슈, 이벤트 전파 문제, 접근성 등을 고려했을 때 포탈을 사용하는 것이 훨씬 효율적이고 안정적이라고 생각합니다.

React의 createPortal은 마치 해리포터의 “플루 가루”처럼, 컴포넌트를 정확히 원하는 위치로 이동시켜주는 마법 같은 기능을 제공합니다. 다만, 이 기능은 제대로 이해하고 현명하게 사용할 때 비로소 진정한 가치가 발휘된다고 생각합니다. 해리포터가 처음 플루 가루를 사용해 "다이애건 앨리" 대신 "녹턴 앨리"에 도착했던 것처럼, Portal를 제대로 이해하지 못하면 예상치 못한 결과를 만날 수 있다고 생각합니다.

따라서 단순히 동작만을 목표로 하지 말고, 왜 포탈이 필요한지, 어떻게 사용하는 것이 바람직한지 이해한 후 활용해 보시길 바랍니다!

profile
프론트엔드 공부 중인 김세빈입니다. 👩🏻‍💻

13개의 댓글

comment-user-thumbnail
2025년 5월 18일

좋은 글이네요 감사합니다 잘 보고 갑니다!

답글 달기
comment-user-thumbnail
2025년 5월 18일
  1. 모달 구현 시 portal을 사용하는 주된 이유 중 하나가, 동시에 여러개의 모달이 생성되지 않도록 하기 위함이라고 알고 있습니다.
  2. 모달을 만들 때, portal를 사용하지 않고도 position: fixed를 사용하면 간단히 z-index 문제를 해결할 수 있습니다.
9개의 답글