본문으로 건너뛰기

Portal과 createRoot의 차이 — pubuilder 하이드레이션 이슈 정리

·17 min read

React로 모달이나 개발 도구를 만들다 보면 자연스럽게 createPortal을 선택한다. 화면의 가장 바깥에 UI를 띄우고, 부모의 overflow: hidden이나 stacking context에서 벗어나기 좋기 때문이다.

나도 pubuilder의 개발용 페이지 맵 오버레이를 Portal로 구현했다. 오버레이 DOM은 document.body 아래에 있었고, 겉보기에는 호스트 애플리케이션과 완전히 떨어져 있었다.

하지만 Next.js 앱의 root layout에 <PageMap />을 추가하자 예상하지 못한 일이 생겼다. 형제 서브트리에 있는 Radix UI 컴포넌트의 aria-controls 같은 useId 기반 속성이 서버와 클라이언트에서 달라졌고, React가 hydration mismatch 경고를 출력했다. <PageMap />을 제거하면 경고는 100% 사라졌다.

이 문제를 해결하면서 가장 크게 바로잡은 오해는 이것이었다.

Portal은 DOM 위치를 옮기지만 React 트리를 분리하지 않는다.

문제 상황: 오버레이를 추가했을 뿐인데 형제 컴포넌트가 깨졌다

pubuilder의 PageMap은 개발 중인 앱 위에 페이지 구조, iframe 미리보기, 블록 선택기 등을 표시하는 인앱 개발 도구다. Next.js의 root layout에서는 다음과 같이 사용한다.

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="ko">
      <body>
        {children}
        <PageMap config={ia} />
      </body>
    </html>
  )
}

문제는 PageMap 자체가 아니라 {children} 안의 Radix 컴포넌트에서 나타났다. 서버가 만든 aria-controls 값과 클라이언트가 hydration 중 계산한 값이 달랐다.

Server: aria-controls=":R1abc:"
Client: aria-controls=":R2abc:"

Radix는 접근성 속성을 연결하기 위해 React의 useId를 사용한다. useId는 단순한 전역 증가 카운터가 아니다. SSR과 hydration에서도 같은 ID를 만들 수 있도록 렌더링되는 React 트리와 관련된 정보를 사용한다.

당시 PageMap에는 다음 요소가 한꺼번에 들어 있었다.

  • 클라이언트 mount guard
  • createPortal
  • Zustand의 useSyncExternalStore 기반 구독
  • @xyflow/react
  • 내부 Radix UI 컴포넌트
  • 조건부로 열리는 여러 오버레이

정확히 어느 내부 렌더가 ID 흐름을 바꿨는지는 더 세밀한 런타임 바이섹트가 필요한 영역이다. 다만 두 가지 사실은 재현으로 확인할 수 있었다.

  1. <PageMap />을 root layout에 두면 경고가 발생했다.
  2. <PageMap />을 제거하면 경고가 완전히 사라졌다.

이번 사례가 "Portal은 언제나 hydration 문제를 만든다"는 뜻은 아니다. 서버와 클라이언트에서 동일한 트리를 렌더하는 Portal은 일반적인 React 기능이다. 이번 문제의 핵심은 복잡한 개발 오버레이가 호스트 앱과 같은 React 트리 및 hydration 생명주기에 참여할 이유가 없었다는 데 있었다.

기존 구조: DOM만 body로 이동한 Portal

기존 구현을 단순화하면 다음과 같다.

'use client'
 
import { createPortal } from 'react-dom'
 
export function PageMap({ config }: PageMapProps) {
  const [mounted, setMounted] = useState(false)
  const isMapOpen = usePubuilderStore((state) => state.isMapOpen)
  const viewerPath = usePubuilderStore((state) => state.viewerPath)
 
  useLayoutEffect(() => {
    ensureStyles()
    setMounted(true)
  }, [])
 
  if (!mounted) return null
 
  return createPortal(
    <>
      <Fab />
      {isMapOpen && <CanvasOverlay config={config} />}
      {viewerPath && <PageViewer config={config} />}
    </>,
    document.body,
  )
}

브라우저의 DOM만 보면 오버레이는 앱 콘텐츠와 떨어져 있다.

하지만 React가 보는 트리는 다르다.

Portal의 자식은 DOM상 다른 위치에 그려질 뿐, React 트리에서는 여전히 PageMap의 자식이다. 그래서 Portal 안에서도 다음 동작이 유지된다.

  • 부모가 제공한 React Context를 읽을 수 있다.
  • 이벤트가 DOM 계층이 아니라 React 트리를 따라 버블링한다.
  • 부모의 Error Boundary와 Suspense 경계에 속한다.
  • 호스트 Root의 렌더링 생명주기에 참여한다.

이것은 Portal의 단점이 아니라 설계 목적이다. 모달과 툴팁은 DOM 위치만 옮기면서 기존 앱의 theme context나 이벤트 흐름을 유지해야 하는 경우가 많다.

해결 구조: 브라우저 안에 두 번째 React Root 만들기

pubuilder에서 필요했던 것은 DOM 재배치가 아니라 React 애플리케이션 경계 자체의 분리였다. 이를 위해 React 18의 createRoot를 사용했다.

새로운 PageMap은 호스트 트리에서 UI를 렌더하지 않는다. useEffect에서 빈 DOM 컨테이너를 만들고, 그 컨테이너를 대상으로 별도의 React Root를 생성한다.

'use client'
 
import { useEffect, useRef } from 'react'
import { createRoot, type Root } from 'react-dom/client'
 
export function PageMap(props: PageMapProps) {
  const active = props.enabled !== false && process.env.NODE_ENV !== 'production'
  const rootRef = useRef<Root | null>(null)
 
  useEffect(() => {
    if (!active) return
    if (window.self !== window.top) return
 
    const host = document.createElement('div')
    host.dataset.pubuilder = 'page-map'
    document.body.appendChild(host)
 
    const root = createRoot(host)
    rootRef.current = root
 
    return () => {
      rootRef.current = null
      root.unmount()
      host.remove()
    }
  }, [active])
 
  useEffect(() => {
    if (!active) return
    rootRef.current?.render(<PageMapInner {...props} />)
  })
 
  return null
}

이제 React가 보는 구조는 두 개의 Root로 갈라진다. 호스트 Root는 PageMap 지점에서 null로 끝나고, 오버레이 UI는 완전히 별개의 Root 아래에 있다.

호스트 Root에 남는 PageMap은 별도 Root를 생성하고 제거하는 얇은 마운터다. 실제 오버레이 UI와 그 안의 Hook은 모두 PageMapInner 아래로 이동했다.

function PageMapInner({ config, serverUrl, sessionToken }: InnerProps) {
  const isMapOpen = usePubuilderStore((state) => state.isMapOpen)
  const viewerPath = usePubuilderStore((state) => state.viewerPath)
  const skillDrawerOpen = usePubuilderStore((state) => state.skillDrawerOpen)
 
  const ia = useMemo(() => normalizeIA(config), [config])
  const api = useMemo(
    () => new PubuilderApi(serverUrl, sessionToken),
    [serverUrl, sessionToken],
  )
 
  return (
    <PubuilderServerContext.Provider value={api}>
      <Fab />
      {isMapOpen && <CanvasOverlay ia={ia} />}
      {viewerPath && <PageViewer ia={ia} />}
      {skillDrawerOpen && <SkillDrawer />}
    </PubuilderServerContext.Provider>
  )
}

정확히 무엇이 분리되고 무엇이 공유될까

별도 Root를 만든다고 브라우저 환경까지 분리되는 것은 아니다. iframe이나 Shadow DOM과도 다르다.

영역Portal별도 createRoot
DOM 마운트 위치다른 위치 가능별도 컨테이너
React Fiber 트리호스트와 공유독립
React Context부모 Context 상속자동 상속되지 않음
Error Boundary / Suspense호스트 경계에 포함독립
React 이벤트 전파Portal 부모 방향으로 전파Root 경계가 분리됨
hydration 계보호스트 Root에 참여클라이언트 전용 Root로 분리 가능
window, document공유공유
전역 CSS공유공유
모듈 싱글턴 / Zustand store공유 가능공유 가능

pubuilder는 Zustand store를 모듈 싱글턴으로 사용하므로 Root를 분리한 뒤에도 상태를 공유할 수 있다. 반면 호스트 앱의 React Context는 새 Root로 자동 전달되지 않는다. 필요한 Context가 있다면 PageMapInner 내부에서 Provider를 다시 구성하거나 값을 명시적으로 전달해야 한다.

CSS도 그대로 공유된다. 완전한 스타일 격리가 필요하다면 별도 Root만으로는 부족하며 Shadow DOM 또는 iframe을 함께 검토해야 한다.

useEffect 안에서 Root를 만드는가

Next.js 서버 렌더링 중에는 windowdocument가 없다. 또한 호스트 앱이 hydration을 마치기 전에 개발 오버레이가 DOM을 변경할 필요도 없다.

따라서 다음 작업은 모두 useEffect 안에서 수행한다.

  1. 컨테이너 DOM 생성
  2. document.body에 컨테이너 추가
  3. createRoot 호출
  4. 오버레이 렌더링

서버와 클라이언트의 첫 렌더에서 PageMap은 모두 null을 반환한다.

return null

그 후 브라우저에서 effect가 실행되면 독립 Root가 시작된다. 이 Root는 서버 HTML을 hydration하는 것이 아니라 빈 컨테이너에 클라이언트 UI를 새로 렌더한다. 따라서 이 용도에서는 hydrateRoot가 아니라 createRoot가 맞다.

Root는 한 번 만들고 props만 갱신한다

config, serverUrl 같은 prop이 바뀔 때마다 컨테이너와 Root를 다시 만들면 오버레이 내부 상태가 모두 초기화된다. 그래서 Root 생성과 렌더 갱신을 두 effect로 나눴다.

// active가 바뀔 때만 생성하거나 제거한다.
useEffect(() => {
  const root = createRoot(host)
  rootRef.current = root
 
  return () => root.unmount()
}, [active])
 
// 매 렌더의 최신 props를 기존 Root로 전달한다.
useEffect(() => {
  rootRef.current?.render(<PageMapInner {...props} />)
})

root.render()를 다시 호출해도 같은 컴포넌트 트리 구조라면 React는 상태를 보존하면서 props만 갱신한다.

cleanup에서는 root.unmount()와 DOM 제거를 모두 수행해야 한다. 특히 개발 환경의 Fast Refresh와 React Strict Mode에서는 mount와 cleanup이 반복될 수 있으므로 정리 로직이 빠지면 중복 FAB나 이벤트 리스너가 남을 수 있다.

여러 Root에서 useId를 쓸 때의 추가 안전장치

독립 Root는 hydration 계보를 분리하지만, 여러 Root가 같은 문서에 출력하는 DOM ID의 충돌도 고려해야 한다. React의 createRoot에는 identifierPrefix 옵션이 있다.

const root = createRoot(host, {
  identifierPrefix: 'pubuilder-',
})

이 옵션은 해당 Root에서 useId가 생성하는 ID에 prefix를 붙인다. 오버레이의 ID가 호스트 앱 ID와 DOM 문서 수준에서 겹치지 않도록 만드는 방어선이다. 여러 Root가 있고 각각 useId를 사용한다면 고유한 prefix를 지정하는 편이 명확하다.

Portal과 독립 Root, 언제 무엇을 선택할까

Portal은 다음 상황에 적합하다.

  • 모달, 드롭다운, 툴팁처럼 DOM 위치만 바꾸고 싶을 때
  • 호스트의 theme, locale, router 같은 Context를 그대로 써야 할 때
  • 부모의 이벤트 처리나 Error Boundary에 포함돼야 할 때
  • UI가 본질적으로 호스트 애플리케이션의 일부일 때

별도 Root는 다음 상황에 적합하다.

  • DevTools, 개발 오버레이, 위젯처럼 독립적인 도구일 때
  • 호스트 앱의 hydration과 렌더링 생명주기에 개입하지 않아야 할 때
  • 자체 Provider, 상태, 오류 처리를 가진 작은 애플리케이션일 때
  • 기존 페이지 또는 다른 프레임워크가 관리하는 화면에 React UI를 삽입할 때

위치만 바꾸려면 Portal, React 생명주기까지 나누려면 별도 Root를 사용한다.

적용 후 확인한 것

pubuilder 변경에서는 다음 항목을 확인했다.

  • PageMap의 기존 public props 유지
  • production 환경에서 아무것도 마운트하지 않는 가드 유지
  • 썸네일 iframe 내부에서 재귀적으로 실행되지 않는 가드 유지
  • prop 변경 시 기존 독립 Root에 반영
  • unmount 시 React Root와 컨테이너 DOM 제거
  • TypeScript 검사 및 라이브러리 빌드 통과
  • 기존 테스트 139개 중 포트 점유와 관련 없는 138개 통과

실제 소비 앱에서는 여기에 다음 회귀 테스트를 더하는 것이 좋다.

  1. Next.js 페이지를 새로고침한다.
  2. hydration 경고가 없는지 확인한다.
  3. Radix 컴포넌트의 trigger와 content를 연결하는 ARIA 속성이 일치하는지 확인한다.
  4. PageMap을 열고 닫아 호스트 UI가 정상 동작하는지 확인한다.
  5. Fast Refresh 후 중복 오버레이가 생기지 않는지 확인한다.
  6. enabled를 변경했을 때 Root와 컨테이너가 정리되는지 확인한다.

처음에는 document.body에 그려지고 있으니 pubuilder가 호스트 앱과 분리되어 있다고 생각했다. 하지만 그것은 DOM 관점의 분리일 뿐이었다. DOM 트리와 React 트리는 같은 것이 아니다. React에서 Portal은 원래 트리의 연결성을 유지하기 위해 존재한다.

개발 오버레이는 호스트의 일부처럼 보이지만 성격은 독립 애플리케이션에 가깝다. 자체 상태와 Provider를 갖고 있고, 호스트의 hydration 결과에 영향을 주지 않아야 한다. 나눌 기준은 오버레이가 그려지는 위치가 아니라 생명주기 경계였다. pubuilder에서는 그 경계를 createRoot로 코드에 명시하면서 문제를 구조적으로 차단했다.

참고 자료