React 에서 렌더링이란.

React 에서 렌더링이란.

React 에서 렌더링의 개념에 대해 공부해봤습니다.

·

7 min read


👩🏻‍🌾 React 파먹기 - Rendering

최근들어 개념적인 부분, 원리적인 부분들을 공부하다보니 계속 ‘왜?’라는 생각을 하게 되었다. 지금까지 호기심이 많이 없었던게 문제였기도 하겠지만 말이다. 그래서 늦었지만 내가 주로 사용하는 React 를 조금 더 자세히 이해해야겠다는 욕심이 들어서 공부를 시작했다.

한 번에 너무 많은 것을 하면 금방 질릴 것을 알기에, 조금씩 깊게 파보려고 한다.

🥲 나는 렌더링의 개념을 설명할 줄 아는가?

누군가가 나에게 “리액트에서 렌더링이 뭔가요?”라고 질문한다면 나는 자신 있게 설명할 수 있을까? ← 라는 생각을 문득 고향 가는 기차에서 했었는데, 고개를 푹 숙였다. 리액트에서 말하는 렌더링은 단순히 Painting 을 의미하는 것은 아니기 때문일 것이기에.

그래서 이번에는 렌더링에 대해 제대로 이해를 해보기로 했다. 리액트에서 다루는 렌더링은 그 개념이 방대한데, 우선은 전반적인 기초 개념과 순서적인 부분만 다루면서 친해져보자.


🤔 rendering 이란?

리액트에서 이야기하는 렌더링이란 컴포넌트가 현재의 propsstate 의 상태에 기초해서 UI 를 어떻게 구성하면 되는지 컴포넌트에게 요청하는 작업을 의미한다.

아래의 예시를 보면, Components 라는 함수형 컴포넌트가 선언이 되어있고, propsstate 의 값을 이용해 View 를 구성하고 있다. 아래의 컴포넌트를 그려내기 위해서는 컴포넌트에게 “난 화면을 그릴테니, 네가 가지고 있는 props 와 state 를 알려줘!” 라는 단계가 필요하다는 의미이다.

import { useState } from "react";

const Component = (props) => {
    const [age, setAge] = useState<number>(20);

    return <div>{`Hello, my nams is ${props.name} and i'm ${age} years old.`}</div>
};

export default Component;

♻️ React 에서의 렌더링

기본적으로 리액트는 root DOM(<div id=”root”></div>) 에서부터 시작해서 업데이트가 필요한 플래그가 지정된 모든 컴포넌트를 찾는다.

React 프로젝트를 생성하면 index.tsx 파일 또는 main.tsx 파일에 아래와 같이 생성된 코드를 확인할 수 있을 것이다. 아래 코드를 보면 "root" 라는 id 를 가진 HTMLElementReactDOM.createRoot() 에 전달한 다음, 해당하는 Element 를 .render() 메서드에 전달한다.

ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
  <React.StrictMode>
    <App />
  </React.StrictMode>
);

리액트가 모든 컴포넌트 가지들을 훑으며 업데이트가 필요한 플래그가 지정된 컴포넌트를 찾으면, 클래스 컴포넌트는 classComponentInstance.render() 를 호출하고 함수형 컴포넌트라면 FunctionComponent() 를 호출해 렌더링된( 👉🏻 propsstate에 기초해 UI를 구성하는 방법에 대해 질문해서 받아온!) 결과를 저장하며 렌더링을 진행한다.

컴포넌트의 렌더링 결과는 일반적으로 JSX 문법으로 구성되어 있으며, 이것은 javaScript가 컴파일이 되고 배포 준비가 되는 순간 React.createElement() 를 호출해 변환된다. createElement 는 UI 구조를 설명하는 일반적인 javaScript 객체인 React Element 를 반환한다.


🧀 Element : DOM Element, Component Element

Element 는 무엇일까? Element 는 화면에 렌더링 할 DOM 노드들의 정보를 React 에 알려주기 위한 하나의 수단이다. Element 는 DOM node 또는 컴포넌트를 표현하는 JavaScript 의 일반 불변 객체이며, typeprops 를 가진다.

type and props

React Element 의 type 은 문자열 혹은 함수형/클래스형 컴포넌트이며, props 는 하나의 객체이다.

Let’s create Element

React Element 는 React.createElement() 함수 또는 JSX의 태그 문법으로 작성된다.

  • createElement() 를 이용해서 생성하기
React.createElement(
    'div',
    { className: 'pokemon' },
    'Pikachu'
);

위와 같은 방법은 사실 사용이 쉽지않고 직관적이지 못한 단점이 있긴 하다. (그렇다고 해서 몰라도 되는건 아니지만!) 그래서 보통은 이 방법 보다는, 아래의 JSX 문법을 많이 사용한다.

  • JSX 문법을 이용해서 생성하기
<div className='pokemon'>Pikachu</div>

위 두 가지 방법을 이용해서 React Element 를 생성하면, 아래와 같은 객체가 형성된다. 이러한 element 들이 모여 트리를 만들면 이를 element tree 라 부르며, 이는 곧 메모리상에만 존재하게 되는 virtual DOM 이 된다.

{
    type: 'div',
    props: {
        className: 'pokemon',
        children: 'Pikachu'
    }
}

간단하게 정리하면 element 는 컴포넌트를 JSON 으로 표현한 것이다.

<div class="leetrue">
  <b>leetrue</b>
</div>
{
  "type": "div",
  "props": {
    "className": "leetrue",
    "children": {
      "type": "b",
      "children": "leetrue"
    }
  }
}

만약 type 이 string 인 경우라면 type 은 해당 컴포넌트가 어떤 HTML 태그인지를 표현한다. 그리고 이 경우, props 는 해당 HTML 태그의 속성을 명시한다.

DOM Element

element 의 type 이 태그 이름에 해당하는 문자열인 경우(소문자로 시작)를 말한다.

해당 태그를 가진 DOM 노드를 표현하며, props 정보를 통해서 해당 노드의 attribute 들을 표현한다. React 가 실제로 화면에 렌더링 하는 대상에 해당한다.

Component Element

element 의 type 이 클래스형/함수형 컴포넌트인 경우를 의미한다.

사용자가 직접 정의한 컴포넌트를 표현하며, 입력으로 props 를 받으면 렌더링할 element tree 를 반환한다. 이 element tree 는 어떠한 element tree 를 반환하는지 묻는 것을 반복한다. 클래스형 컴포넌트의 경우 당연히 컴포넌트 인스턴스의 생성이 선행될 것이다.

// 🤔 React: Form이 뭔데..? 
{
  type: SignUpForm
  props: {
    isSubmitted: false,
    buttonText: 'OK!'
  }
}
// 🤔 React: Form에 있는 이 Button은 또 뭐야?
{
  type: Button,
  props: {
    children: 'OK!',
    color: 'blue'
  }
}
// 😜 React: Form에 있는 Button을 보니 Dom Node 였구나! ㅇㅋㅇㅋ 그만!
  type: 'button',
  props: {
    className: 'button button-blue',
    children: {
      type: 'b',
      props: {
        children: 'OK!'
      }
    }
  }
}
  • 클래스형 컴포넌트

    지역 상태를 가질 수 있고, 해당 컴포넌트 인스턴스에 대응하는 DOM 노드가 생성, 수정, 삭제될 때의 동작을 제어할 수 있다.(생명 주기)

  • 함수형 컴포넌트

    render() 함수만 가지는 클래스형 컴포넌트와 동일하며, 지역 상태를 가질 수 없지만 구현이 단순하다.

🥔 Component Instance

클래스로 선언된 컴포넌트들만 인스턴스를 가지며, 이것을 컴포넌트 인스턴스라고 부른다. 컴포넌트 클래스 내부에서 this 키워드를 통해 참조하는 대상에 해당한다. 지역 상태를 저장하고 생명 주기 이벤트들에 대한 반응을 제어할 때 매우 유용하다. 함수형 컴포넌트는 인스턴스를 가지지 않는다.

🐟 인스턴스가 뭔데?

비슷한 성질을 가진 여러 개의 객체를 생성하기 위해 사용되는, 생성자 함수(constructor)를 하나의 붕어빵 틀이라고 생각한다면 이렇게 찍어낸 붕어빵들을 인스턴스라고 한다.

function FishBread(anggo, price) {
  this.anggo = anggo;
  this.price = price;
  this.desciption = function () {
    console.log(`이 붕어빵 앙꼬는 ${this.anggo}이고 ${price}원입니다!`);
  };
}

const creamFishBread = new FishBread("슈크림", 2000);
console.log(creamFishBread); // FishBread { anggo: '슈크림', price: 2000, is: f }

creamFishBread.desciption(); // 이 붕어빵 앙꼬는 슈크림이고 2000원입니다!

🐟 그럼 React 에서 인스턴스는 뭔데

함수형 컴포넌트와 클래스형 컴포넌트부터 다시 들어가보자. 두 가지 타입의 컴포넌트는 모두 props 객체 인자를 받고 React element 를 반환하는 컴포넌트이지만 유형이 다르다.

class ClassComponent extends React.Component {
    render() {
        return <h1>Hello, {this.props.name}<h1>
    }
}

위 코드의 경우는 클래스형 컴포넌트로 React Component Class 또는 React Component type 이라고 한다. 각각의 컴포넌트는 props 라는 매개변수를 받고 render 함수를 통해 표시할 뷰 계층 구조를 반환한다.

function FuntionalComponent(props) {
  return <h1>Hello, {props.name}</h1>;
}

위 코드 같은 경우는 인스턴스가 아니다. 이 함수는 팩토리 형태이며, 실제 DOM 에 렌더링 되는 컴포넌트의 인스턴스들을 생성한다.


다시 렌더링으로 돌아와서,

어쨌든, element 를 다루며 하고 싶었던 이야기는, 어떤식으로 DOM 노드를 반환하는지에 대해 이야기하고 싶었다. 내가 리액트를 이해하는 데에 큰 도움이 되었기 때문에!

다시 돌아와서- 렌더링이 일어나면 프로젝트의 전체 컴포넌트에서 렌더링 결과물을 수집하고, 리액트는 새로운 Object tree 와 비교하며 실제 DOM 을 의도한 출력처럼 보이게 적용해야하는 모든 변경사항을 수집한다. 이렇게 DOM 의 변경사항을 비교하고 계산하는 과정을 리액트에서는 reconciliation (재조정)이라고 부른다. 계산이 끝나면 리액트는 모든 변경 사항을 하나의 동기 시퀀스로 DOM 에 적용하게 된다.


🌍 Virtual DOM

React 의 대표적 특징 중 하나인 Virtual DOM 은 실제 DOM 구조와 비슷한 React 객체의 트리를 의미한다.

브라우저에서는 유저와의 다양한 인터랙션을 통해 DOM 구조의 빈번한 변화가 일어나는데, 이 때 마다 DOM 수정으로 인한 Render Tree 생성, Reflow, Repaint 과정이 일어난다면 브라우저의 성능은 ㅈㅓ.. 심해 속으로 쳐박힐 것잉ㄹ.ㄹ..다.

React 에서는 이 경우 Virtual DOM 을 실제 DOM 에 필요한 부분만 적절히 반영해 불필요한 수정이 일어나지 않도록 돕는다. 이 Virtual DOM 의 가장 큰 장점은 개발자가 직접 DOM 을 조작하지 않아도 된다는 점이고 이 과정이 모두 자동화가 된다는 점이다.

또한 DOM 수정에 대한 것은 batch 로 한 번에 수행이 되므로 불필요한 리렌더링을 최소화 할 수 있다.


♻️ Reconciliation

Reconciliation(이하 재조정) 은 다룰 내용이 많기 때문에, 이번에는 조금 간단하게만 정리하려한다. 👉🏻 재조정은 간단히 생각하면 기존의 Virtual DOM 과 새로운 Virtual DOM 을 비교하는 과정이다.

하지만 모든 트리를 순회하며 탐색하고 비교한다면 최첨단의 알고리즘을 이용해도 O(n^3) 의 시간복잡도를 가진다고 한다. 물론 리액트는 그런식으로 비교하지 않겠지만..

리액트에서는 브라우저 렌더링 시 기존 컴포넌트와 어떤 점이 변경되었는지 비교하기 위해 diffing 알고리즘을 사용해 컴포넌트를 갱신한다. 리액트는 아래의 2가지 가정을 기반으로 O(n) 의 시간 복잡도를 가지는 휴리스틱 알고리즘을 구현했다.

  1. 서로 다른 타입의 두 엘리먼트는 서로 다른 트리를 생성해낸다.

    • 같은 타입인 경우는 변경된 속성들에 대해서만 갱신한다.
  2. 개발자가 key prop 을 통해 컴포넌트 인스턴스를 식별해 여러 렌더링 사이에서 어떤 자식 엘리먼트가 변경되지 말아야할 것인지 표시해줄 수 있다.

위의 재조정 단계를 거쳐 이전 elements 와 새롭게 생성된 elements 를 비교해서 elements 가 변경되었다면 렌더링을 수행한다.


🖼️ Re-rendering

리액트에서는 초기 렌더링이 한 번 진행되고, 이후에는 특정 조건 발생 시 리렌더링을 진행한다. 그렇다면 리렌더링을 진행하는 조건은 무엇이 있을까?

리렌더링을 유발하는 조건

  • state 변경 시

  • 부모 요소로부터 전달 받은 props 변경 시

  • 중앙 상태값 (Context value 또는 redux store ) 변경 시

  • 부모 컴포넌트의 리렌더링이 일어날 시

리렌더링 과정 간단 정리

  1. 위 조건 충족 시 리렌더링이 큐에 들어감

  2. 구현부의 실행 → props 취득, hooks 실행, 내부 변수 및 함수 재생성

  3. return 실행, 렌더링 시작

  4. Render Phase(렌더 단계) : 새로운 Virtual DOM 생성 후 이전 Virtual DOM 과 비교해 달라진 부분 확인

  5. Commit Phase(커밋 단계) : 달라진 부분만 실제 DOM 에 반영

  6. useLayoutEffect : 브라우저가 화면에 Paint 하기 전 useLayoutEffect 에 등록해둔 effect 가 동기적으로 실행되며 이 과정에서 state, redux store 등의 변경이 있다면 리렌더링이 일어남

  7. Paint : 브라우저가 실제 DOM 을 화면에 그림 ( → didUpdate 완료 )

  8. useEffect : update 되어 화면에 그려진 직후, useEffect 에 등록해둔 effect 가 비동기적으로 실행됨


Render Phase and Commit Phase

  • 🧮 Render Phase : 컴포넌트를 렌더링하고 변경사항을 계산하는 모든 작업

  • ♻️ Commit Phase : DOM 에 변경사항을 적용하는 과정


👩🏻‍🌾 마무리

이렇게 리액트 한 스푼 떠먹었다. 이제는 렌더링이 무엇인가요? 라고 물으면 “어느 정도는” 대답할 수 있을 것 같다. 100% 자신있게 대답할 수 있기 위해서는 계속 뜯어봐야겠지만! 그래도 어느정도 렌더링 된다 ← 라는 이야기를 들었을 때 어떤 것들이 일어나는지에 대한 그림이 그려지는 것 같다.

위 내용을 공부하는 과정에서 Reconciliation 과 Fiber 에 대해서도 좀 자세히 다뤄봐야겠다는 생각이 들었다. 리스트에 올려두는걸로-! 구럼 오늘도 수고했다 잍을우.


참고