[React] Portal, Render의 차이점, 활용방안 알아보기!

2021년 09월 03일

TOC

최근 Kakao Map ApiReact Component 라이브러리로 개발을 진행하며 자주 사용하게 된 ReactDomPortal 기능에 대해서 공부를 하면서 Render어떠한 차이점이 있는지 그리고 활용방안에 대해서 정리 해보았습니다!

Portal 이란?

ReactDom의 Portal 기능부모 컴포넌트의 DOM 계층 구조 바깥에 있는 DOM 노드로 자식을 렌더링 하는 기능을 제공합니다.

즉 외부에 존재하는 DOM 노드React App DOM 계층 안에 존재하는 것처럼 연결을 해주는 포탈 기능을 제공합니다.

이렇게 createPortal()로 연결된 경우에도 컴포넌트 생명주기(Component LifeCycle)합성 이벤트(SyntheticEvent) 이 적용이 됩니다.

아래와 같이 예시를 작성하고 Children 에 해당하는 컴포넌트에 onClick 이벤트를 발생시 이벤트 버블링 이 적용이 되어 Parent 에 대한 onClick 이벤트가 발생하는 것을 볼 수 있습니다.

import React, { useRef } from "react"
import ReactDom from "react-dom"

const PortalToBody: React.FC = ({ children }) => {
  const container = useRef(document.body)
  return ReactDom.createPortal(children, container.current)
}

const Parent: React.FC = ({ children }) => {
  const handleClick = () => console.log("Parent!")

  return (
    <div
      style={{
        width: "480px",
        height: "360px",
        border: "2px solid red",
        backgroundColor: "red",
        color: "white",
        fontSize: "2rem",
      }}
      onClick={handleClick}
    >
      Parent
      {children}
    </div>
  )
}

const Children: React.FC = () => {
  const handleClick = () => console.log("Children!")

  return (
    <div
      style={{
        width: "360px",
        height: "240px",
        border: "2px solid blue",
        backgroundColor: "blue",
        color: "white",
        fontSize: "2rem",
      }}
      onClick={handleClick}
    >
      Children
    </div>
  )
}

const App = () => {
  return (
    <div className="App">
      <Parent>
        <PortalToBody>
          <Children />
        </PortalToBody>
      </Parent>
    </div>
  )
}

export default App

image-20210903161418171

실제로 Children 에 대한 컴포넌트는 실제 Dom 위치는 body 안에 있지만 React의 Component Tree 상에서는 App 하위에 존재하는 것을 React Development Tool를 통해 확인 할 수 있습니다.

또한 해당 Children 의 클릭 이벤트 발생을 시키면 이벤트 버블링이 발생하여 Parent 의 이벤트 까지 발생 하는 것을 볼 수 있습니다.

Portal의 활용 방안!

Portal은 React 생명주기와 밀접한 연관이 있기 때문에 기존 Dom를 다루는 JavaScript 라이브러리와 함께 사용을 할 때 유용하게 사용 하거나 Modal과 같은 기능을 구현 할 때 활용 할 수 있습니다.

Kakao Map 과 같이 특정 Dom에 렌더링을 시키거나 자체적으로 Dom를 관리 하는 경우 Portal 를 이용하여 해당 관리 함수를 React Component으로 랩핑하여 React의 life cycle과 함께 사용할 수 있습니다.

아래는 간단하게 Kakao MapInfoWindow 기능을 React 컴포넌트로 랩핑한 예시 입니다.

import React, { useEffect, useMemo, useRef } from "react"
import ReactDom from "react-dom"

interface InfoWindowProps {
  map: kakao.maps.Map | kakao.maps.Roadview
  position: kakao.maps.LatLng | kakao.maps.Viewpoint
  marker?: kakao.maps.Marker
  /**
   * 인포윈도우를 열 때 지도가 자동으로 패닝하지 않을지의 여부
   * @default false
   */
  disableAutoPan?: boolean

  /**
   * 인포윈도우 엘리먼트의 z-index 속성 값
   */
  zIndex?: number

  /**
   * 로드뷰에 올라있는 인포윈도우의 높이 값(m 단위)
   */
  altitude?: number

  /**
   * 로드뷰 상에서 인포윈도우의 가시반경(m 단위), 두 지점 사이의 거리가 지정한 값보다 멀어지면 인포윈도우는 보이지 않게 된다
   */
  range?: number

  /**
   * 인포윈도우 객체 생성후 해당 객체를 반환하는 함수
   */
  onInfoWindowCreated?: (infoWindow: kakao.maps.InfoWindow) => void
}

const InfoWindow: React.FC<InfoWindowProps> = ({
  map,
  position,
  marker,
  children,
  altitude,
  disableAutoPan,
  range,
  removable,
  zIndex,
  onInfoWindowCreated,
}) => {
  const container = useRef(document.createElement("div"))

  const infoWindow = useMemo(() => {
    const kakaoInfoWindow = new kakao.maps.InfoWindow({
      altitude: altitude,
      disableAutoPan: disableAutoPan,
      range: range,
      zIndex: zIndex,
      content: container.current,
      position: position,
    })
    if (onInfoWindowCreated) onInfoWindowCreated(kakaoInfoWindow)
    return kakaoInfoWindow
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [])

  useEffect(() => {
    infoWindow.open(map, marker)
    return () => {
      infoWindow.close()
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [map, marker])

  useEffect(() => {
    if (!infoWindow) return
    infoWindow.setPosition(position)
  }, [infoWindow, position])

  useEffect(() => {
    if (!infoWindow || !altitude) return
    infoWindow.setAltitude(altitude)
  }, [infoWindow, altitude])

  useEffect(() => {
    if (!infoWindow || !range) return
    infoWindow.setRange(range)
  }, [infoWindow, range])

  useEffect(() => {
    if (!infoWindow || !zIndex) return
    infoWindow.setZIndex(zIndex)
  }, [infoWindow, zIndex])

  return ReactDom.createPortal(children, container.current)
}

export default InfoWindow

위 코드를 살펴 보면 kakao.maps.InfoWindow 객체를 useRef 를 통해 관리되는 HTML Div Element 를 Content로 설정하여 생성을 하고 해당 객체를 통해 useEffect를 이용하여 props 의 변화에 따른 해당 상태 변화를 전달 할 수 있습니다.

또한 children 를 content로 사용된 객체에 ReactDom.createPortal 를 통해 해당 Dom 에 렌더링이 되고 React의 컴포넌트로써 관리가 가능하게 됩니다.

image-20210903164055051

Render 이란?

ReactDOM.render(element, container[, callback])

React 엘리먼트를 container DOM에 렌더링 하는 함수 입니다.

단순하게 보기에는 createPortal() 과 동일하게 보이지만 이 때 주요하게 확인 해야 하는 부분은 새로운 React LifeCycle 생성 한다는 점 입니다.

그말은 즉 React Component 내부에서 해당 함수를 동작 시켜도 별개의 라이프 사이클을 가지고 있기 때문에 함수를 호출 하는 것만으로 react dom를 렌더링이 가능합니다.

사실 일반적으로는 React application 최초 생성을 하기 위해서 사용하는 함수 이지만 함수를 통해 React Component를 생성 할 수 있다 라는 점을 이용하여 아래와 같이 활용이 가능합니다.

Render의 활용 방안!

함수를 통해 React Component를 생성 할 수 있다 라는 점을 이용해서 Notification과 같은 기능을 구현이 가능합니다.

일반적으로 html상에서의 Notification 의 기능은 특정 위치에 일정 시간동안 안내 사항을 보여주는 기능이기 때문에 이 부분을 React의 LifeCycle에 종속되어서 함수를 통해 생성이 가능하도록 만든다고하면, 최상위에 Context API 를 통한 Notification 정보를 관리를 하고 가지고 있는 Notification 를 렌더링 하는 부분등 여러가지를 만들어서 사용을 해야하게 되는데, 이 때 발생하는 부분은 최상위에 Notification Context, Render 를 관리하는 컴포넌트로 감싸야 하는 부분이 있기 때문에 Dom Tree의 변화로 인한 Effect 발생, 상태관리의 어려움등 여러가지 발생합니다.

이때 ReactDOM.render() 를 이용해서 Notification 를 만든다면 기존 React의 상태와 상관 없이 새롭게 생성이 가능이 가능합니다. 단 호출한 부모의 React LifeCycle과 별개로 동작을 하기 때문에 내부에서 외부의 Context 와 같은 부분은 접근은 불가능 합니다. 하지만 함수 같은 경우는 실행이 가능하기 때문에 호출단에서 해당 함수를 작성해서 인자로 전달하여 동작 시키도록 하면 정상적으로 동작을 할 수 있습니다.

그리고 해당 React가 Notification의 기능을 다하고 사라져야 하는 경우 ReactDom.unmountComponentAtNode 를 이용하여 해당 Compnent 를 직접 unMount 하여 제거가 가능합니다.

아래의 소스코드는 위에서 설명한 부분을 실제 코드로 구현한 부분 입니다.

import React from "react"
import ReactDom from "react-dom"

let container: null | HTMLDivElement = null

const notification = (
  content: React.ReactNode | React.ReactNodeArray | string
) => {
  if (container === null) {
    container = document.createElement("div")
    container.style.display = "flex"
    container.style.flexDirection = "column-reverse"
    container.style.position = "fixed"
    container.style.bottom = "10px"
    container.style.right = "10px"
    document.body.appendChild(container)
  }

  const div = document.createElement("div")

  container.appendChild(div)

  const removeNotication = () => {
    ReactDom.unmountComponentAtNode(div)
    if (div?.parentNode) {
      div.parentNode.removeChild(div)
    }
  }

  ReactDom.render(<Notification>{content}</Notification>, div, () => {
    setTimeout(removeNotication, 1000)
  })

  return removeNotication
}

interface NotificationProps {}

const Notification: React.FC<NotificationProps> = ({ children }) => {
  return (
    <div
      style={{
        display: "flex",
        border: "1px solid #e6e6e6",
        backgroundColor: "#fff",
        borderRadius: "5px",
        padding: "10px",
        marginTop: "10px",
        minWidth: "360px",
        zIndex: 2,
      }}
    >
      {children}
    </div>
  )
}

const App = () => {
  return (
    <div className="App">
      <button
        onClick={() => {
          notification(<span>hello world!</span>)
        }}
      >
        Create Notification
      </button>
    </div>
  )
}

export default App

image-20210903172617558

해당 notification 함수를 호출 시 위와 같이 React Component가 렌더링 되지만 최상위 APP 과 별개로 생성이 되는 것을 볼 수 있습니다.

Portal vs Render

최종적으로 아래와 같이 표를 통해 차이점과 공통점에 대해서 표로 만들어 봤습니다.

Portal Render
공통점 특정 dom으로 component를 렌더링 특정 dom으로 component를 렌더링
호출 위치 React Render 내부 어디서든 상관 없이 함수로 호출 가능
LifeCycle 호출위치의 하위 컴포넌트로 관리됨 새로운 최상위 component 으로 생성됨
unMount LifeCycle에 의해 자동으로 unMount 됨 직접 ReactDom.unmountComponentAtNode() 함수를 사용 하여 관리
event 실제 렌더링 위치는 다르지만 부모 component의 하위 dom처럼 동작함 (버블링, 캡쳐링 발생) 최상위 compoent 로써 동작
일반적인 활용처 특정 Component를 부모 외부에 Rendering 하려고 할 때 React App를 새롭게 생성 할 때
Buy me a coffeeBuy me a coffee
Written by

@JaeSeoKim

보안과 개발을 좋아하는 학생 입니다~!
©JaeSeoKim