2025-09-22 01:31

  • React createPortal은 컴포넌트를 부모 DOM 계층 외부의 다른 위치에 렌더링하는 기능이다.
  • 모달, 툴팁 등에서 발생하는 z-indexoverflow 문제를 해결하는 데 매우 효과적이다.
  • 물리적 DOM 위치는 변경되지만, 이벤트 버블링과 컨텍스트는 논리적 React 컴포넌트 트리를 그대로 따른다.

리액트 createPortal 완벽 정복 가이드 DOM 탈출의 기술

웹 개발을 하다 보면, 특정 UI 요소를 화면 최상단에 보여줘야 하는 경우가 빈번하게 발생한다. 대표적인 예시가 바로 모달(Modal), 툴팁(Tooltip), 팝오버(Popover)다. 하지만 이 간단해 보이는 요구사항은 CSS의 z-indexoverflow, 그리고 position 속성이 얽히면서 개발자를 깊은 좌절의 늪으로 빠뜨리곤 한다.

컴포넌트가 DOM 트리 깊숙한 곳에 위치하고 있는데, 그 부모 요소 중 하나라도 overflow: hidden이나 transform 속성을 가지고 있다면 어떻게 될까? 자식 요소는 아무리 높은 z-index 값을 가져도 부모의 경계를 벗어날 수 없다. 마치 잠수함 안에서 구명 부표를 띄우려 해도, 잠수함의 천장을 뚫고 나갈 수 없는 것과 같다.

React 팀은 이 고질적인 문제를 해결하기 위해 아주 우아하고 강력한 무기를 제공했는데, 그것이 바로 createPortal이다. 이 핸드북에서는 createPortal이 탄생한 배경부터 기본 사용법, 그리고 이벤트 처리와 접근성 같은 심화 주제까지 모든 것을 깊이 있게 다룰 것이다.


1. 포탈은 왜 만들어졌을까 탄생 배경과 해결 과제

createPortal의 존재 이유를 이해하려면 먼저 React의 기본 렌더링 방식을 알아야 한다. React 애플리케이션은 기본적으로 하나의 루트(root) DOM 노드 안에 모든 컴포넌트를 계층적으로 렌더링한다.

<body>
  <div id="root"></div>
</body>

#root div 안에서 모든 컴포넌트가 부모-자식 관계를 형성하며 그려진다. 이 구조는 매우 직관적이고 예측 가능하지만, 특정 시나리오에서는 한계를 드러낸다.

CSS의 덫 스태킹 컨텍스트(Stacking Context)

문제의 핵심은 **스태킹 컨텍스트(Stacking Context)**라는 개념에 있다. 스태킹 컨텍스트는 z-index가 적용되는 범위를 결정하는 일종의 독립된 레이어다. 어떤 요소에 position: relative, position: absolute와 함께 z-index가 적용되거나, opacity가 1 미만이거나, transform, filter 속성 등이 적용되면 새로운 스태킹 컨텍스트가 형성된다.

새로운 스태킹 컨텍스트가 생성되면, 그 내부의 요소들은 아무리 높은 z-index를 가져도 부모 스태킹 컨텍스트의 z-index를 넘어설 수 없다.

예를 들어, 아래와 같은 컴포넌트 구조를 생각해보자.

function App() {
  return (
    <div style={{ position: 'relative', zIndex: 1 }}>
      <Header />
      <MainContent>
        {/* 이 안에서 Modal을 렌더링하고 싶다. */}
        <DeeplyNestedComponent />
      </MainContent>
      <Footer />
    </div>
  );
}

만약 MainContent 컴포넌트나 그 상위 컴포넌트 중 하나가 overflow: hidden이나 z-index: 2를 가진 다른 형제 요소보다 낮은 z-index를 가지고 있다면 DeeplyNestedComponent 안에서 렌더링되는 모달은 화면 전체를 덮지 못하고 잘리거나 다른 요소 뒤에 가려지게 된다.

이 문제를 해결하기 위해 과거에는 jQuery를 사용해 모달 엘리먼트를 <body> 태그 직속 자식으로 강제로 옮기거나, 비상태(stateless) DOM 노드를 직접 조작하는 등 ‘React스럽지 않은’ 방법을 사용해야 했다.

createPortal은 바로 이 지점에서 등장한다. 컴포넌트의 논리적 계층 구조는 그대로 유지하면서, 렌더링 결과물(DOM 노드)만 물리적으로 다른 위치에 “텔레포트”시키는 것이다.


2. createPortal의 구조와 작동 원리

createPortal은 이름 그대로 ‘포탈’을 만들어준다. A라는 위치에 있는 컴포넌트가 B라는 물리적 DOM 위치로 나갈 수 있는 문을 열어주는 것이다.

기본 문법

createPortalreact-dom 라이브러리에 포함되어 있으며, 두 개의 인자를 받는다.

import ReactDOM from 'react-dom';
 
ReactDOM.createPortal(child, container);
인자 (Argument)설명예시
child렌더링하고 싶은 모든 React 엘리먼트. JSX, 문자열, 숫자, 컴포넌트 등이 될 수 있다.<div>모달 컨텐츠</div> 또는 <ModalContent />
containerchild가 렌더링될 실제 DOM 노드. document에 존재하는 유효한 노드여야 한다.document.getElementById('modal-root')

사용법은 매우 간단하다. 렌더링할 JSX(child)와 그것을 붙여넣을 DOM 컨테이너를 지정해주기만 하면 된다.

포탈의 두 세계 React 트리와 DOM 트리

createPortal의 가장 중요한 특징은 React 컴포넌트 트리와 실제 DOM 트리를 분리하여 생각할 수 있게 해준다는 점이다.

  • React 컴포넌트 트리 (논리적 구조): 포탈 내부에 렌더링된 컴포넌트는 여전히 원래 위치의 부모 컴포넌트로부터 props를 상속받고, 컨텍스트(Context)에 접근할 수 있으며, 이벤트를 부모로 전파(버블링)한다. 논리적으로는 여전히 원래 자리에 있는 셈이다.
  • DOM 트리 (물리적 구조): 실제 브라우저에 그려지는 결과물은 container로 지정된 DOM 노드의 자식으로 삽입된다. CSS 스타일링, z-index 등은 이 물리적 위치의 영향을 받는다.

이 이중적인 구조 덕분에 우리는 CSS 스타일링의 제약에서 벗어나면서도 React의 선언적이고 예측 가능한 상태 관리 및 이벤트 시스템의 이점을 그대로 누릴 수 있다.


3. 실전 사용법 모달 컴포넌트 만들기

백문이 불여일견이다. createPortal을 사용해 고전적인 모달 컴포넌트를 만들어보자.

1단계: 포탈의 목적지(DOM 컨테이너) 준비하기

먼저, 우리 모달이 렌더링될 ‘별도의 공간’을 public/index.html 파일에 만들어야 한다. React 앱이 렌더링되는 #root와는 별개의 공간이다.

<!DOCTYPE html>
<html lang="en">
  <head>
    </head>
  <body>
    <noscript>You need to enable JavaScript to run this app.</noscript>
    <div id="root"></div>
    <div id="modal-root"></div>
  </body>
</html>

이렇게 하면 #root 내부의 어떤 CSS 제약에도 영향을 받지 않는 독립적인 렌더링 공간이 확보된다.

2단계: 재사용 가능한 Portal 컴포넌트 만들기

createPortal을 직접 사용해도 되지만, 일반적으로는 이를 감싸는 재사용 가능한 컴포넌트를 만드는 것이 좋다. 이렇게 하면 SSR(서버 사이드 렌더링) 환경 대응이나 클라이언트 측에서만 DOM 노드를 참조하는 로직을 캡슐화하기 용이하다.

// src/components/Portal.js
import { useEffect, useState } from 'react';
import ReactDOM from 'react-dom';
 
const Portal = ({ children }) => {
  // SSR 환경에서는 document가 없으므로, 클라이언트에서만 실행되도록 처리
  const [element, setElement] = useState(null);
 
  useEffect(() => {
    setElement(document.getElementById('modal-root'));
  }, []);
 
  if (!element) {
    return null;
  }
 
  return ReactDOM.createPortal(children, element);
};
 
export default Portal;

여기서 잠깐, 왜 useStateuseEffect를 사용할까? Next.js와 같은 SSR 환경에서는 서버에서 HTML을 미리 렌더링할 때 document 객체가 존재하지 않는다. 만약 컴포넌트가 렌더링되는 시점에 document.getElementById를 바로 호출하면 에러가 발생한다. useEffect는 컴포넌트가 마운트된 후, 즉 클라이언트 환경의 브라우저에서 실행되므로 이 시점에는 document 객체에 안전하게 접근할 수 있다. useState를 통해 element가 설정되기 전까지는 null을 반환하여 에러를 방지하는 것이다.

3단계: Modal 컴포넌트 구현하기

이제 Portal 컴포넌트를 사용하여 실제 Modal 컴포넌트를 만들어보자.

// src/components/Modal.js
import React from 'react';
import Portal from './Portal';
import './Modal.css'; // 모달 스타일링을 위한 CSS 파일
 
const Modal = ({ isOpen, onClose, children }) => {
  if (!isOpen) {
    return null;
  }
 
  return (
    <Portal>
      <div className="modal-overlay" onClick={onClose}>
        <div className="modal-content" onClick={(e) => e.stopPropagation()}>
          <button className="close-button" onClick={onClose}>
            &times;
          </button>
          {children}
        </div>
      </div>
    </Portal>
  );
};
 
export default Modal;
  • isOpen prop을 통해 모달의 노출 여부를 제어한다.
  • 모달의 배경(overlay)을 클릭하면 onClose 함수가 호출되어 모달이 닫히도록 했다.
  • 모달 컨텐츠 자체를 클릭했을 때 이벤트가 배경으로 전파되어 모달이 닫히는 것을 막기 위해 e.stopPropagation()을 사용했다.
  • 모든 모달 UI는 우리가 만든 Portal 컴포넌트로 감싸여 있다. 따라서 이 Modal 컴포넌트가 앱의 아무리 깊은 곳에 위치하더라도 실제 DOM은 #modal-root에 렌더링된다.

4단계: App 컴포넌트에서 사용하기

// src/App.js
import React, { useState } from 'react';
import Modal from './components/Modal';
 
function App() {
  const [isModalOpen, setIsModalOpen] = useState(false);
 
  const openModal = () => setIsModalOpen(true);
  const closeModal = () => setIsModalOpen(false);
 
  return (
    <div style={{ padding: '20px' }}>
      <h1>React Portal 예제</h1>
      <p>아래 버튼을 눌러 모달을 열어보세요.</p>
      
      <div style={{ position: 'relative', zIndex: 1, padding: '20px', border: '2px solid red', overflow: 'hidden' }}>
        <h3>여기는 overflow: hidden 컨테이너</h3>
        <p>일반적으로라면 이 컨테이너 밖으로 모달이 나갈 수 없습니다.</p>
        <button onClick={openModal}>모달 열기</button>
      </div>
 
      <Modal isOpen={isModalOpen} onClose={closeModal}>
        <h2>이것이 바로 포탈입니다!</h2>
        <p>이 모달은 DOM 트리 상에서는 body의 직계 자식이지만, React 트리에서는 여전히 App 컴포넌트의 자식입니다.</p>
        <button onClick={closeModal}>닫기</button>
      </Modal>
    </div>
  );
}
 
export default App;

이제 overflow: hidden 스타일이 적용된 div 내부의 버튼을 클릭해도, 모달은 화면 전체를 덮는 최상단 레이어로 깔끔하게 나타난다. createPortal이 CSS 스태킹 컨텍스트의 제약을 마법처럼 해결해준 것이다.


4. 심화 탐구 포탈의 숨겨진 힘

createPortal의 진정한 강력함은 단순히 DOM 위치를 옮기는 것을 넘어, React의 생태계와 완벽하게 통합된다는 점에 있다.

이벤트 버블링(Event Bubbling)의 마법

가장 중요한 개념이다. 포탈 내부에서 발생한 이벤트는 DOM 트리를 따라 버블링되지 않고, React 컴포넌트 트리를 따라 버블링된다.

DOM 트리 (물리적 구조)React 컴포넌트 트리 (논리적 구조)
html > body > div#modal-root > div.modal-overlayApp > Modal > div.modal-overlay (Portal 내부)
이벤트가 div.modal-overlay에서 발생하면, div#modal-root를 거쳐 body로 전파된다.이벤트가 div.modal-overlay에서 발생하면, Modal 컴포넌트를 거쳐 App 컴포넌트로 전파된다.

이것이 왜 중요할까? App 컴포넌트에서 모달 내부의 클릭 이벤트를 감지하고 싶을 때, DOM 구조를 신경 쓸 필요 없이 React 컴포넌트 구조만 보고 이벤트를 처리할 수 있다는 의미다.

// App.js
function App() {
  // ...
  return (
    <div onClick={() => console.log('App 컴포넌트 클릭됨')}>
      {/* ... */}
      <Modal isOpen={isModalOpen} onClose={closeModal}>
        {/* ... */}
        <button>모달 내부 버튼</button>
      </Modal>
    </div>
  );
}

위 코드에서 모달 내부의 버튼을 클릭하면, 이벤트는 Modal 컴포넌트를 지나 App 컴포넌트의 div에 설정된 onClick 핸들러까지 전파된다. 콘솔에는 “App 컴포넌트 클릭됨”이 출력될 것이다. 이는 포탈이 DOM 구조와 상관없이 React의 논리적 흐름을 완벽하게 따른다는 강력한 증거다.

컨텍스트(Context)와의 완벽한 호환

이벤트 버블링과 마찬가지로, 포탈 내의 컴포넌트는 자신의 물리적 DOM 위치와 상관없이 React 컴포넌트 트리 상의 조상(ancestor) 컴포넌트가 제공하는 컨텍스트에 접근할 수 있다.

// ThemeContext.js
const ThemeContext = React.createContext('light');
 
// App.js
function App() {
  return (
    <ThemeContext.Provider value="dark">
      <div>
        {/* ... */}
        <Modal>
          <ThemedComponent />
        </Modal>
      </div>
    </ThemeContext.Provider>
  );
}
 
// ThemedComponent.js (Modal 내부에서 사용)
function ThemedComponent() {
  const theme = useContext(ThemeContext); // 'dark' 값을 정상적으로 받아옴
  return <div style={{ background: theme === 'dark' ? '#333' : '#FFF' }}>테마 적용!</div>;
}

비록 ThemedComponent가 DOM 상으로는 #modal-root 아래에 있지만, React 트리 상으로는 ThemeContext.Provider의 자손이므로 ‘dark’라는 컨텍스트 값을 아무 문제 없이 받아올 수 있다.

접근성(Accessibility, a11y) 고려사항

포탈은 강력하지만, 웹 접근성을 해치지 않도록 주의 깊게 사용해야 한다. 특히 모달의 경우 다음 사항을 반드시 고려해야 한다.

  1. 키보드 포커스 관리:

    • 모달이 열리면, 키보드 포커스는 반드시 모달 내부로 이동해야 한다.
    • Tab 키와 Shift+Tab 키를 눌렀을 때, 포커스가 모달 외부로 빠져나가지 않고 모달 내부에서만 순환하도록 해야 한다. (포커스 트래핑, Focus Trapping)
    • 모달이 닫히면, 포커스는 이전에 모달을 열었던 버튼이나 요소로 다시 돌아가야 한다.
  2. 스크린 리더:

    • 모달이 활성화되었을 때, 스크린 리더가 모달 뒤의 배경 컨텐츠를 읽지 않도록 aria-modal="true" 속성을 모달 컨테이너에 추가해야 한다.
    • 모달의 역할을 명확히 하기 위해 role="dialog" 또는 role="alertdialog"를 지정하는 것이 좋다.

이러한 접근성 기능은 직접 구현할 수도 있지만, 복잡성을 줄이기 위해 react-focus-lock, headless-ui, radix-ui와 같은 잘 만들어진 라이브러리를 활용하는 것을 적극 권장한다.


결론: 언제 포탈을 사용해야 하는가

createPortal은 React 개발자에게 주어진 강력한 도구다. 정리하자면, 포탈은 다음과 같은 경우에 사용하는 것이 가장 이상적이다.

  • 부모 컴포넌트의 CSS 스타일(overflow, z-index, transform 등)에서 벗어나 화면 최상단에 UI를 렌더링해야 할 때 (모달, 드롭다운 메뉴, 툴팁, 알림 메시지 등)
  • DOM 구조는 분리하되, React의 상태, props, 컨텍스트, 이벤트 흐름은 그대로 유지하고 싶을 때

포탈은 복잡한 UI 문제를 매우 선언적이고 React스러운 방식으로 해결해준다. 더 이상 DOM을 직접 조작하며 상태 불일치를 걱정할 필요가 없다. 이 핸드북을 통해 createPortal의 원리를 깊이 이해하고, 여러분의 다음 React 프로젝트에서 더욱 견고하고 유연하며 접근성 높은 컴포넌트를 구축하는 데 자신감을 얻기를 바란다.