React

createRoot와 createRoot.render

GoJay 2024. 11. 18. 23:18

리액트 17 버전까진 프로젝트를 빌드하면 아래와 같이 ReactDomrender 메서드에 최상단 컴포넌트인 <App />과 최상단 DOM 요소인 <div id="root"></div>가 아래와 같은 방식으로 기본 추가되어 있었다.

import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';

ReactDOM.render( 
    <React.StrictMode>
        <App />
    </React.StrictMode>,
    document.getElementById('root') 
);

하지만, 리액트 18 버전부터 createRoot라는 메서드와, createRoot가 리턴하는 객체의 메서드인 render 메서드가 새롭게 추가되었다. npm create vite@latest 명령어로 리액트 18 버전의 프로젝트를 빌드하면 main.jsx 모듈에 아래와 같은 코드가 제공된다.

import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.jsx'

createRoot(document.getElementById('root')).render(
  <StrictMode>
    <App />
  </StrictMode>,
)

createRootrender가 각각 어떤 역할을 해주는지, 리액트 18 버전으로 오면서 왜 이러한 변화가 생겼는지를 간단히 알아봤다.

createRoot

vite로 빌드한 boiler plate 코드는 아래와 같이 쪼갤 수 있다.

// ... import 문

const $root = document.getElementById('root');
const root = createRoot($root);
root.render(
  <StrictMode>
    <App />
  </StrictMode>,
)

const $root = document.getElementById('root') 라인은 자바스크립트의 document 객체의 getElementById 메서드를 사용해서 id="root"인 요소를 가져오는 코드이다. main.jsx가 연결된 index.html 파일은 아래와 같이 되어있기 때문에, 해당 코드는 <body> 태그 바로 아래에 있는 <div> 요소를 가지고 온다.

<!doctype html>
<html lang="ko">
  <!-- <head> 태그 -->
  <body>
    <div id="root"></div>
    <script type="module" src="/src/main.jsx"></script>
  </body>
</html>

여기까진 어렵지 않다. 다음으로, createRoot($root)이다. createRoot는 리액트의 클라이언트 API에서 제공해주고 있는 메서드이다. 리액트 공식문서에 따르면 createRoot로 브라우저 DOM 노드 안에 React 컴포넌트를 표시하는 루트를 생성할 수 있다.

'루트를 생성한다'라는 표현이 생소하다. 루트(Root)란, 리액트가 관리하는 요소의 진입점을 의미한다. 여러 문서에서 리액트가 렌더링 할 DOM 트리를 추적하는 데 사용되는 최상위 노드에 대한 포인터라고도 설명하는 것 같다.

예를 들어, HTML 파일이 생성될 때 전체 DOM 트리는 아래와 같이 생성될 것이다.

리액트는 DOM의 모든 Node의 변경에 관심을 갖지 않는다. 대신, <body> 태그 하위에 위치해서 실제 웹 페이지 UI에 표시되는 부분들의 변화를 추적해서 DOM을 변경한다. 즉, 위의 예시처럼 <body> 태그 바로 밑에 있는 <div id="app"> 태그를 리액트 컴포넌트를 표시하는 루트로 지정하면 리액트는 해당 지점부터 컴포넌트의 상태 변경 여부를 추적한다.

리액트 가상 돔이 메인으로 관리하는 영역, div#app은 가상 돔의 루트 노드(최상단 노드)임

unmountrender

createRoot($root)의 결과로 반환된 rootrenderunmount라는 메서드를 갖는다. 먼저, unmountcreateRoot로 생성된 루트 노드를 포함해서, 리액트 가상 DOM에 달려있는 노드(컴포넌트)들을 unmount 해준다. 단, 리액트만으로 작성된 웹 애플리케이션에선 unmount에 대한 호출은 생길 가능성이 거의 없다고 한다. 때문에, 해당 메서드가 있다는 걸 참고만 하자.

createRoot 결과로 뱉어진 객체의 render 메서드는 JSX 문법으로 작성된 리액트 컴포넌트를 실제 브라우저의 DOM 트리에 반영해 주는 메서드이다(JSX 문법에 대해서도 나중에 좀 더 공부해 봐야겠다. 정말 공부해야 할 건 끝이 없는 것 같다). 공식 문서에 따르면, 만약에 웹 애플리케이션이 온전히 리액트로만 만들어졌다면 추가적으로 root(가상 돔의 최상위 노드)를 더 만들거나, 또는 root.render를 다시 호출해야 할 필요는 없다고 한다.

즉, root.render() 메서드가 최초 1회 실행될 때에만 리액트가 관리하는 브라우저의 DOM 노드들(div#app 하위 태그들)을 초기화하고(HTML 콘텐츠들을 지우고) render 메서드에 전달된 컴포넌트 <App />에 중첩된 컴포넌트들을 분석하여 DOM 트리를 구성한 후 실제 DOM을 만들어낸다. 최초 1회만 이렇게 동작하고, 그 다음부터 리렌더가 필요할 때(리액트에서 관리되는 상태가 변경될 때)에는 실제 DOM 영역과 가상 돔을 비교한 후 변경된 부분만을 선택적으로 리렌더링 해준다.

위에서 계속 언급했듯이, 특별한 상황(웹 애플리케이션이 리액트로만 작성되지 않은 경우)이 아니면 root 노드 생성 메서드(createRoot)와 render 메서드는 최초 리액트 웹 애플리케이션이 시작할 때 1회만 호출된다고 한다. 즉, 리액트로 만들어진 웹 애플리케이션이 있는 서버로 http 네트워크 요청이 오고, 그 요청에 대한 응답으로 처음 index.html 문서가 내려간 시점을 의미하는 것 같다. 처음 html 문서가 요청으로 전달되면 브라우저는 태그들을 파싱해서 DOM을 구성하고, <script type="module" src="/src/main.jsx"></script> 부분을 파싱할 때 서버에 main.jsx 파일을 요청하면서, 해당 파일이 실행될 때 createRootrender 메서드가 실행되어 리액트가 관리하는 DOM 영역이 정의되고, 해당 시점 이후부턴 특별한 경우가 아니면 두 메서드가 실행될 일이 없는 것이다.

createRoot 등장 배경

리액트 17에서 18로 버전업 하면서 생긴 리액트 DOM 세팅 방식의 핵심은 'Legacy root API(ReactDOM.render'에서 'New root API(ReactDOM.createRoot)'로 root를 지정하는 방식이 바뀌었다는 것이다. 이러한 변경 배경엔 성능 root API의 향상을 통한 웹 애플리케이션 성능 향상이 있다.

먼저, 리액트 17 버전까지의 Legacy root API는 리액트가 관리하는 DOM의 최상위 노드인 root에 접근하기 위한 다른 방안이 제공되지 않았고, 실제 root를 부착한 실제 DOM 요소를 통해서만 root에 접근이 가능했다(리액트의 root와 DOM 요소의 구분이 opaque(불투명)한 상황이었다). 그렇다 보니, 일부 ReactDOM.render가 다시 호출돼야 하는 상황에서 root이자 DOM 요소인 container가 값으로 계속 다시 전달돼야 하는 아쉬움이 있었다. root 요소인 DOM 노드가 변경되지 않았는데 ReactDOM.render의 재호출 시 값으로 계속 전달되는 건 효율적이지 못했기 때문이다.

In the legacy API, the root was opaque to the user because we attached it to the DOM element, and accessed it through the DOM node, never exposing it to the user... in the legacy API, you need to continue to pass the container into render, even though it never changes. (Replacing render with createRoot참고)

 

또한, ReactDOM.hydrate 메서드가 ReactDOMClient.hydrateRoot로 변경되는 변화도 있었다. SSR 구현 시 클라이언트에서는 서버가 만들어준 DOM을 받아서 그대로 그리고, 이벤트 리스너 등록과 같은 '하이드레이션(Hydration)' 작업만 수행해 주면 된다. 해당 경우를 리액트 17 버전까진 React.hydrate로 수행하였으며, 18 버전 이후로 hydrateRoot가 추가되면서 root를 변수에 저장해 손쉽게 update 해주는 게 가능해졌다(const root = ReactDOMClient.hydrateRoot (container, <App tab="home" />)root를 기준으로 하이드레이션을 수행하고, root.render(<App tab="profile" />)로 필요 시 root를 손쉽게 업데이트해줄 수 있다).

잘 이해가 되진 않지만, 나름대로 해석해본 건, 결국, SSR로 구현된 웹 애플리케이션의 하이드레이션 시에도 개발자가 root에 손쉽게 접근할 수 있도록 제공되지 않아 아쉬움이 있었다는 게 아닐까 싶다.

마지막으로, 과거 'Legacy root API' 방식에서는 ReactDOM.render의 세 번째 인자로 컴포넌트가 리렌더링 되거나 업데이트될 때 실행시킬 수 있는 콜백 함수를 전달할 수 있었다. 하지만, SSR 동작 시 해당 콜백 함수 실행이 되는 시점이 사용자가 기대하는 시점과 일치하는 않는 문제가 있어 혼란이 가중되는 문제가 있었다고 한다. 이런 문제 해결을 위해, createRoot().render는 세 번째 인자로 전달하는 콜백 인자를 없애고 requestIdleCallback, setTimeout, 또는 root에서 ref callback을 사용하는 것으로 권장되었다.

아직까진 리액트 17 이전까지의 Legacy root API를 함께 지원하고 있지만, 앞으론 차차 createRoot로 변경해갈 수 있게 권장될 예정이라고 하니, 앞으론 createRoot만 사용해야겠다.

결론

공식 문서와 React 18 오픈 소스 Discussions에 있는 내용을 어렵게 읽어가면 대략적으로나마 알게 된 내용을 정리 차 남겨둔다. 아직까진 리액트에 대한 이해가 깊지 않고, 17 이전 버전의 리액트를 사용해 본 적이 없어서 createRoot가 등장한 배경에 대한 내용이 너무 이해가 어려웠다. 실제로 아직 10%도 이해가 안된 것 같아서, 계속 공부해서 지식과 경험을 쌓은 후에 다시 한번 공부해 봐야겠다. 아무튼, createRoot의 등장 배경에 root에 사용자(아마도 개발자)가 직접 접근하기 어려웠다는 점과 함께, SSR에 좀 더 좋은 방식으로 개선이 된 것 같다는 약간의 느낌(?)만 얻어가고 오늘 포스팅을 마친다.

'React' 카테고리의 다른 글

useState 상태 업데이트와 useEffect 실행 시점  (2) 2024.12.06
BrowserRouter와 createBrowserRouter  (1) 2024.12.03
제어 컴포넌트와 비제어 컴포넌트  (1) 2024.11.25
JSX란?  (3) 2024.11.21
리액트 Virtual DOM(가상 돔)  (0) 2024.11.20