goongoguma's blog

Managing DOM components with ReactDOM

리액트 프로젝트를 새로 만들때, react 패키지와 함게 react-dom을 처음으로 설치해야합니다. 그런데 이것을 왜 설치해야 하는지 궁금하지 않으셨나요?

놀라우실수도 있지만, 단지 react 패키지 만으로는 UI 컴포넌트들을 랜더할 수 없습니다. 브라우저에서 UI들을 랜더하려면, react-dom을 사용해야 합니다. 이 글에서, 샘플 앱을 만들어 보면서 ReactDOM을 사용하여 DOM 컴포넌트들을 관리해보도록 하겠습니다. 예제를 따라하다보면, 사용 가능한 여러가지 방법들을 배우기 위해 코드에 변화를 줄겁니다.

깃헙에서 코드의 샘플배포된 버전의 사이트를 확인하실 수 있습니다.

ReactDOM

ReactDOM은 컴포넌트 혹은 JSX 요소를 DOM에 랜더합니다. ReactDOM 객체는 몇몇 메소드들 밖에는 없습니다: 아마 브라우저에 앱을 랜더해주는 render() 메소드를 사용해 보신적이 있으실겁니다.

react-dom 패키지는 DOM의 시작 포인트 역할을 합니다. 보통 프로젝트의 맨 위에 아래처럼 import 하죠.

import ReactDOM from 'react-dom';

ReactDOM 메소드를 자세히 알아보기 전에, 왜 DOM 대신 ReactDOM을 사용해야 하는지를 먼저 알아보도록 하겠습니다.

Virtual DOM (VDOM) vs DOM

자바스크립트와 HTML은 직접적으로 소통할 수 없습니다. 그래서 DOM은 해당 문제를 해결하기 위해 개발되었죠. 웹페이지를 열었을때, 브라우저 엔진은 모든 컨텐츠를 자바스크립트가 이해할 수 있는 포맷으로 변형시킵니다. - 이것이 DOM 트리입니다.

DOM 트리의 구조는 해당 HTML의 구조와 같습니다. 만약 하나의 요소가 또 다른 HTML 코드안에 존재한다면, 이것은 DOM 트리에 반영이 됩니다.

브라우저 개발자 도구의 Elements탭을 보시면 DOM이 어떻게 생겼는지 확인하실 수 있습니다. Elements탭에서 보이는 DOM 구조는 HTML코드와 매우 비슷하게 생겼지만, 여러분은 HTML 태그가 아니라 사실 DOM 트리를 보고 있다는거죠.

DOM은 사용자의 브라우저에 의해 생성되고 저장된 웹페이지의 논리적인 표현법입니다. 브라우저가 사이트의 HTML을 가져와 DOM으로 변형한 후, 사용자들이 볼 수 있도록 사용자의 화면에 DOM을 그립니다.

실제 작동하는 DOM를 보도록 하겠습니다. 아래의 표는 DOM이 어떻게 HTML을 보는지 설명해줍니다.

<body>
   <nav> 
       <ul>
          <li>Home</li>
          <li>Contact</li>
      </ul>
  </nav>
    <section class="cards">
         <img src="" alt="" />
            <div class="post-content">
                <h1>Virtual DOM vs DOM</h4> 
            </div>
    </section>
</body>

dom1

여기서 DOM은 문제가 있습니다. 사용자가 버튼을 클릭해서 아이템을 삭제한다고 생각해보죠. 해당 아이템 노드와 그 노드를 의존하고 있는 다른 노드들 까지 DOM에서 제거될겁니다.

브라우저에서 DOM의 변화를 감지할때마다, 브라우저는 전체 페이지를 다시 그립니다. 하지만 페이지 전체를 다시 그릴 필요가 있을까요? 어떤 부분이 변경되었는지 알기위해 두개의 DOM을 비교하는건 시간이 많이 걸립니다.

결과적으로, 사용자가 사이트와 상호작용을 할때마다 브라우저가 페이지를 전체적으로 다시 그리는 방법이 더 빠릅니다. 여기서 가상 DOM이 나옵니다.

가상 DOM이란 리액트가 자신을 대표하는 DOM을 자바스크립트 객체의 형태로 생성한것 입니다. DOM에 변화가 있을때마다, 리액트는 이 자바스크립트 객체의 복사본을 만들고, 해당 복사본에 변화를 준 뒤, 어떤곳이 수정되었는지 알기 위해 원본과 복사본의 자바스크립트 객체를 비교합니다. 그 후에, 이 변화를 브라우저에 알리고 DOM에서 수정된 부분들만 다시 그려집니다.

자바스크립트 객체에 변화를 주고 비교하는것이 DOM을 비교하는것보다 훨씬 빠릅니다. 이 DOM의 복사본들은 메모리에 자바스크립트 객체의 형태로 저장되기 때문에 가상 DOM으로 불립니다.

가상 DOM은 변화된 요소나 그룹들만을 수정하기 때문에 불필요하게 다시 그리지 않습니다. 가상돔은 가볍고, 빠르며, 실제 DOM을 대표하지만 메모리에 저장이 됩니다.

리액트가 가상 DOM과 함께 항상 일하지만, 주기적으로 실제 DOM과도 상호작용을 합니다. 리액트가 실제 DOM을 가상 DOM과 일치시키기 위해 업데이트 하는 과정을 조정(reconciliation)이라고 합니다.

ReactDOM.render()

이제 DOM과 가상 DOM에 대해 알아보았으니, 첫번째 메소드인 ReactDOM.render에 대해 알아보겠습니다. 이 메소드는 아래와 같이 사용됩니다.

ReactDOM.render(element, container[, callback])
ReactDOM.render(<h1>ReactDOM</h1>, document.getElementById("app"))

메소드의 첫번째 인자는 랜더하고 싶은 요소나 컴포넌트를 그리고 두번째 인자는 추가하려는 HTML 요소(타겟 노드)입니다.

일반적으로 create-react-app을 사용해 프로젝트를 생성하면, index.html 파일 안에 id값을 root으로 가지고 있는 div를 만들어줍니다.

그래서, ReactDOM.render() 메소드를 사용하면, 컴포넌트를 첫번째 인자로 넘기게 되고, 두번째 인자로서 document.getElementById("root")와 함께 id="root"를 참조합니다.

<!DOCTYPE html>
<html lang="en">
  <head>
    <!-- ... -->
  </head>
  <body>
   <!-- ... -->
    <div id="root"></div>
  </body>
</html>
// src/index.js
import React from 'react';
import ReactDOM from 'react-dom';

// create App component
const App = () => {
   return <div>Render Me!</div>
}

// render App component and show it on screen
ReactDOM.render(<App />, document.getElementById('root'));

Goodbye ReactDOM.render()

6월달에, 리액트 팀은 리액트 18버전을 발표했습니다. 그리고 새로운 업데이트와 함깨, ReactDOM.render()은 사용하지 않습니다. 대신에 ReactDOM.createRoot를 사용할겁니다.

리액트 18의 알파버전은 사용이 가능하지만 베타버전의 경우에는 여러 달이 걸릴겁니다. 만약에 리액트 18의 알파버전을 사용해복 싶다면 아래의 명령어를 실행하시면 됩니다:

npm install react@alpha react-dom@alpha

리액트 18버전에서는 root를 생성하기 위해 ReactDOM.createRoot를 사용할것이며, 해당 루트를 랜더 함수에 전달할겁니다. createRoot로 바꾸게 되면, 리액트 18버전의 새로운 기능들을 사용할 수 있습니다.

import ReactDOM from "react-dom";
import App from "App";

const container = document.getElementById("app");
const root = ReactDOM.createRoot(container);

root.render(<App />);

ReactDOM.createPortal()

두번째로 알아볼 ReactDOM의 메소드는 createPortal입니다.

오버레이나 모달을 만들어야하는 일이 있었나요? 리액트는 모달이나 툴팁 혹은 이와 비슷한 기능들을 처리하기 위한 함수가 있습니다. 그중 하나가 바로 ReactDOM.createPortal() 함수 입니다.

모달이나 오버레이를 랜더하기 위해 z-index를 사용해 어떤 요소가 화면에 나타나야 할지 관리해야 합니다. z-index는 z-축을 따라 깊이 측면에서 요소들을 배치할 수 있습니다.

그러나, 여러분도 알다시피, root div안에 있는 모든 요소들 중, 오직 하나의 div만을 랜더해야 합니다. createPortal 함수가 도와줌으로써, 모달을 메인 컴포넌트 트리 밖에서 랜더할 수 있습니다. 해당 모달은 body 요소의 자식 요소가 됩니다. 어떻게 사용되는지 아래의 예제를 보도록 하겠습니다.

index.html안에서 모달을 만들기 위해 div 태그를 추가해 보겠습니다.

// index.html
  <body>
    <noscript>You need to enable JavaScript to run this app.</noscript>
    <div id="root"></div>
    <div id="modal"></div>
 </body>

ReactDOM.createPortal() 함수는 두개의 인자를 받습니다: 첫번째 인자는 JSX 혹은 화면에 랜더하고 싶은것, 두번째는 인자는 모달을 붙이고 싶은 요소의 참조입니다:

// Modal.js

import { createPortal } from 'react-dom';
const modalRoot = document.querySelector('#modal');

const Modal = ({ children }) => createPortal(children, modalRoot);

export default Modal;

이제, 컴포넌트를 랜더하기 위해서, 모달 컴포넌트의 여는 태그와 닫는 태그 사이에 표시하려는 모든것을 전달할 수 있습니다. 전달할 내용은 모달 컴포넌트 안의 children으로서 랜더됩니다. 저는 App.js안에서 모달을 랜더했습니다.

App.js파일 안에는 모달을 열기 위한 버튼이 있습니다. 사용자가 그 버튼과 상호작용 하게되면 닫는 버튼과 함께 모달을 보여줄겁니다.

// App.js

import React, { useCallback, useState } from 'react';
import Modal from '../Modal';

const App = () => {
  const [showModal, setShowModal] = useState(false);

  const openModal = useCallback(() => setShowModal(true), []);
  const closeModal = useCallback(() => setShowModal(false), []);

  return (
    <div className='App'>
      <button onClick={openModal} className='button node'>
        Click To See Modal
      </button>
      {showModal ? (
        <Modal>
          <div className='modal-container'>
            <div class='modal'>
              <h1>I'm a modal!</h1>
              <button onClick={closeModal} className='close-button'></button>
            </div>
          </div>
        </Modal>
      ) : null}
    </div>
  );
};

export default App;

dom2

ReactDOM.unmountComponentAtNode()

이 메소드는 DOM 노드가 마운트 된 후, 제거하고 이벤트 핸들러와 상태(state)를 정리하기 위해 사용합니다.

이번에는 root div를 언마운트(unmount)하는 방법으로 예시 코드를 사용해 보도록 하겠습니다.

ReactDOM.unmountComponentAtNode(container)

코드를 감싸고 있는 컨테이너(container)에 root div를 전달하고 있으므로, 사용자가 버튼을 클릭할때, 앱이 언마운트가 될겁니다.

만약에 modal을 언마운트 시키려고 한다면, 에러가 발생할겁니다. 왜냐하면 모달은 마운트된게 아니기 때문에 false를 반환하기때문입니다:

// App.js

const App = () => {
  ReactDOM.unmountComponentAtNode(document.getElementById("root"));

  return (
    <button onClick={handleUnmount} className='button'>
      Unmount App
    </button>
  )
}

위의 코드로 root를 언마운트 시킬 수 있습니다.

dom3

ReactDOM.findDOMNode()

DOM 요소들을 render 메소드 안에서 랜더할 수 있다는것은 이제 아실겁니다. 또한 findDOMNode를 사용해 기본 DOM 노드에도 접근이 가능합니다. 리액트 문서에 따르면, 컴포넌트 추상화를 관통하기 때문에 사용하는것을 추천하지는 않습니다.

NOTE: findDOMNode method has been deprecated in StrictMode.

일반적으로, 만약 어떤 DOM 요소를 참조해야 한다면 (useRef훅)[https://blog.logrocket.com/a-guide-to-react-refs/] 사용을 권장하고 있습니다. 대부분의 케이스에서, findDOMNode의 사용을 피하면서 DOM 노드에 ref를 붙일 수 있기때문입니다.

또 하나의 키 포인트는 접근하고 싶은 노드 요소는 반드시 마운트 되어야 한다는겁니다. 즉, 해당 노드는 반드시 DOM안에 있어야 한다는 거죠. 만약 마운트가 안됐다면, findDOMNode는 null을 반환합니다. 일단 마운트된 DOM 노드에 접근하면, 노드를 검사하기 위해 친숙한 노드 API를 사용할 수 있습니다.

findDOMNode는 하나의 컴포넌트로 된 인자를 받습니다.

ReactDOM.findDOMNode(component)

createPortal메소드 예시 코드를 계속 사용해보겠습니다. 새로운 버튼을 생성했고 Find The Modal Button and Change its Background Color라는 텍스트를 추가했습니다. 그리고 onClick 이벤트 핸들러도 추가했습니다. 이 함수를 사용함으로써 document.querySelector메소드를 사용해 className에 접근해서 배경화면색을 검은색으로 바꿔보도록 하겠습니다:

const App = () => {
  const handleFindDOMNode = () => {
    const node = document.querySelector('.node');
    ReactDOM.findDOMNode(node).style.backgroundColor = 'black';
  };

 return (
  <button onClick={handleFindDOMNode} className='button'>
    Find The Modal Button and Change its Background Color
  </button>
  )
}

dom4

ReactDOM.hydrate() and Server-Side Rendering (SSR)

hydrate메소드는 서버사이드에서 미리 랜더하고 유저에게 완성된 마크업을 보내주도록 도와주는 메소드 입니다. 이 메소드는 ReactDOMServer에서 랜더된 컨테이너에 컨텐츠를 추가하기위해 사용됩니다.

지금은 무슨말인지 아렵겠지만 여기서 말하고자 하는것은 리액트 어플리케이션을 클라이언트 혹은 서버사이드에서 랜더할 수 있다는 겁니다. 클라이언트 사이드 랜더링 (CSR)과 서버 사이드 랜더링 (SSR)의 차이점에 대해 간략히 알아보도록 하겠습니다.

Client-Side Rendering (CSR)

create-react-app을 생성하고 실행하게되면, 페이지의 컨텐츠를 보여주지 않습니다.

dom5

위의 이미지에서 보셨다시피, div 태그들과 자바스크립트 번들밖에 없는것을 보실 수 있습니다. 즉 이것은 빈 페이지라고 할 수 있지요. 이 뜻은 처음 페이지가 로드됐을때, 서버는 HTML, CSS 그리고 자바스크립트에게 요청을 보낸다는 겁니다. 맨 처음 랜더된 이후에, 서버는 번들화된 자바스크립트 (여기에서는 리액트 코드가 되겠죠)를 체크하고 UI를 그립니다. 이 접근방법에는 장점과 단점이 존재합니다.

장점:

  • 빠르다.
  • 정적 배포 (Static deployment)
  • SPA 지원한다.

단점:

  • 처음 로드되었을때 빈 페이지를 랜더한다.
  • 번들된 파일의 사이즈가 클수도 있다.
  • SEO에 좋지않다.

Server- Side Rendering (SSR)

서버사이드 랜더링을 사용하게되면 더이상 빈 페이지를 랜더하지 않습니다. 이 접근법에서, 서버는 브라우저가 랜더하는 정적 HTML 파일을 생성합니다.

SSR이 동작하는 방식입니다: 유저가 웹사이트에 요청을 보내면, 서버는 정적인 버전의 앱을 랜더하고, 유저에게 로드된 웹사이트를 보여줍니다. 웹사이트는 아직 상호작용이 가능한 상태가 아니므로, 유저가 앱과 상호작용을 할 때, 서버는 자바스크립트를 다운받고 실행합니다.

웹사이트는 정적인 컨텐츠를 동적인 컨텐츠로 바꾸면서 반응형이 됩니다. ReactDOM.hydrate() 함수는 실제로 이러한 스크립트의 로드 이벤트에서 호출되고 랜더링된 마크업과 기능을 연결합니다.

만약에 궁금하시다면, SSR을 사용해서 프로젝트를 생성했을때, 처음 로드되었을때 랜더된 HTML과 자바스크립트 코드를 볼 수 있습니다.

dom6

장점:

  • 성능면에서 좋다.
  • 쉽게 색인을 생성하고 크롤링 할 수 있는 웹사이트를 만드는데 도움이되는 SEO에 적합하다.
  • 상호작용이 빠르다.
  • 사용자에게 요청하기전에 서버에서 리액트를 실행하여 업로드 시간을 단축한다.

단점:

  • 많은 서버 요청을 한다.
  • 만약 웹사이트에 상호작용 요소가 많으면, 랜더되는 속도가 늦어질 수 있다.

Demo of ReactDOM.hydrate()

알고계셨듯이, 이 작업을 하려면, 우리는 먼저 서버를 생성해야 합니다. Express를 이용해 서버를 생성하기전에 정리부터 먼저 하겠습니다.

hydrate을 사용해서 Node.js 서버를 실행하기 위해, 먼저 windowdocument 객체를 참조하고 있는 모든 코드를 제거해야 합니다. 왜냐하면 브라우저가 아닌 서버에서 마크업을 랜더하기 때문이죠.

Modal.js파일에서 document.querySelector를 모달 컴포넌트 안에 위치시킵니다:

// Modal.js

import { createPortal } from 'react-dom';
let modalRoot;

const Modal = ({ children }) => {
  modalRoot = modalRoot ? modalRoot : document.querySelector('#modal');
  return createPortal(children, modalRoot);
};

export default Modal;

다음으로 src/index.js 파일 안에 있는 ReactDOM.renderReactDOM.hydrate으로 수정합니다.

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

이제 서버를 생성해봅시다. server라는 새로운 폴더를 생성하고 해당 폴더안에 server.js 파일을 만듭니다.

npm install express로 Express를 설치합니다.

// server/server.js

import express from 'express';
import fs from 'fs';
import path from 'path';
import React from 'react';
import { renderToString } from 'react-dom/server';
import App from '../src/App';

const app = express();

app.use('^/$', (req, res, next) => {
  fs.readFile(path.resolve('./build/index.html'), 'utf-8', (err, data) => {
    if (err) {
      console.error(err);
      return res.status(500).send('Error');
    }
    return res.send(
      data.replace(
        '<div id="root"></div>',
        `<div id="root">${renderToString(<App />)}</div>`
      )
    );
  });
});

app.use(express.static(path.resolve(__dirname, '..', 'build')));

app.listen(3000, () => {
  console.log('Listening on port 3000');
});

이제 Express와 fs(fjile system) 모듈, 경로, 리액트, ReactDOMServer.renderToString 그리고 src 폴더안의 App이 요구됩니다.

ReactDOMServer.renderToString은 앱의 정적 HTML 버전을 반환합니다.

다음으로, build 폴더를 생성하기 위해 빌드 명령어인 npm run build를 실행해 보겠습니다. 바벨을 설정하기 위해서, npm i @babel/preset-env @babel/preset-react @babel/register ignore styles을 실행하세요. 마지막으로 server/index.js라는 이름의 새로운 파일을 생성합니다.

// server/index.js

require('ignore-styles');
require('@babel/register')({
  ignore: [/(node_modules)/],
  presets: ['@babel/preset-env', '@babel/preset-react'],
});
require('./server');

package.json안에 SSR을 위한 스크립트를 추가하세요: "ssr": "node server/index.js". 그리고 npm run ssr로 서버를 실행합니다.

만약 앱을 수정하려고 한다면, 처음 npm run build를 실행뒤, npm run ssr을 실행해야 한다는것을 기억하세요.

Changes to hydrate with React 18

리액트 18에서, 새로운 Suspense 기반의 SSR 아키텍처가 도입되었습니다. hydrate 메소드는 hydrateRoot로 대체되었습니다.

Conclusion

리액트돔에 대해 많은 부분들을 알아보았습니다. 정리하자면, 이 글에서 배운 핵심 내용들은 이렇습니다:

  • 리액트는 가상돔을 사용해서 불필요하게 DOM이 다시 그려지는것을 방지하고 수정되야할 UI만 업데이트한다.
  • render 메소드를 사용해서 브라우저에 UI 컴포넌트를 랜더하며 리액트 돔에서 가장 많이 쓰이는 메소드 이기도 하다.
  • 리액트 18버전에서는 render 메소드 대신 createRoot 메소드를 사용한다.
  • createPortal을 사용해서 모달과 툴팁을 생성할 수 있다.
  • unmountComponentAtNode 메소드를 사용해 컴포넌트를 언마운트 시킬 수 있다.
  • findDOMNode 메소드를 사용해서 DOM 노드의 어느곳이든 접근할 수 있지만 ref 사용을 추천한다.
  • hydrate를 사용하여 리액트에서 SSR을 사용할 수 있지만 리액트 18버전의 Suspens 기반 SSR 아키텍처가 나온다.
  • SSR은 더 나은 SEO를 위해 서버에서 미리 모든것을 랜더하도록 한다.