본문 바로가기

React

[React] Virtual DOM(가상돔)과 Rendering(랜더링)

가상돔을 알아보기 전에 DOM이란?

Document Object Model의 줄임말로 '문서 객체 모델'을 의미한다. 여기서의 문서 객체는 XML이나 HTML 문서에 접근하여 읽고 컨트롤할 수 있도록 API를 제공하는 일종의 인터페이스로 이해하면 된다. 즉, 자바스크립트와 같은 스크립팅 언어가 페이지에 접근하여 조작할 수 있게 연결시켜주는 역할이다.

 

DOM은 웹 페이지, 즉 HTML 문서를 계층적 구조와 정보로 표현하며, 이를 제어할 수 있는 프로퍼티와 메서드를 제공하는 트리 자료구조이다. 따라서 HTML DOM Tree로 부르기도 한다.

 

가상돔이란?

실제 돔에 접근하여 조작하기보다 이것을 추상화 시킨 자바스크립트 객체라고 이해하면 된다.

쉽게 말해 실제 돔의 사본이라고 이해하면 된다.

 

리액트에서의 Virtual DOM(가상돔)이란?
화면에 변화를 감지하기 위한 일종의 사본이라고 이해하면 된다. (공식적으로 인정하는 표현은 아니니 참고하자)

state(상태)를 react에서 구독(subscript)하다가 만약 state의 변화가 생긴다면 그 때 가상돔과 실제돔을 비교하여 수정된 부분만을 실제돔에 리랜더링하는 방법으로 랜더링 최적화를 위한 방법으로 이해하자.
여기서 알아두고 가야하는 것은 '수정된 부분'이라고 하지만 실제로는 수정된 부분을 포함하는 컴포넌트가 리랜더링 되는 것이니 혼동하지말자!


Rendering(랜더링)이란?

props랑 state를 기반으로 UI를 어떻게 그릴지 컴포넌트에게 작업을 요청하는 리액트의 process다. 즉, 코드로 정의된 내용이 실제 브라우저 화면에 그려지는 과정을 의미한다. 랜더링 프로세스 동안 리액트는 컴포넌트 트리의 루트에서 시작해 업데이트가 필요하다고 마킹된 컴포넌트를 찾으러 하위 컴포넌트로 순회한다.

함수 컴포넌트의 경우 FunctionComponent(props) 를 호출하고 랜더 출력을 저장하는데, 랜더 출력은 일반적으로 JSX 구문으로 작성된다. 이는 자바스크립트가 컴파일되고 배포를 준비할 때 React.createElement() 호출로 변환되는데, CreateElement는 의도된 UI의 구조를 설명하는 일반 자바스크립트 객체인 React 요소를 반환한다.

 

랜더링에는 랜더단계와 커밋 단계가 있다.

- 랜더 단계

  컴포넌트를 랜더링하고 변경사항 계산하는 단계. ReactDOM.render()와 setState 메서드에 의해 시작된다.

  실제 돔에 반영할 변경사항을 파악하는 단계

 

- 커밋 단계

  랜더 단계에서 파악된 변경 사항을 실제 돔에 반영하는 단계.

  리액트는 커밋 단계에서 DOM을 업데이트한 후 요청된 DOM 요소 및 컴포넌트 인스턴스를 가리키도록 모든 참조를 업데이트 한다. 그 다음 componentDidMount  componentDidUpdate 클래스 라이프 사이클 메서드와 useLayoutEffect 훅을 동기적으로 실행한다.

 

여기서 포인트는 "랜더링 !== DOM 업데이트"이다. 즉 눈에 보이는 변화가 없더라도 컴포넌트가 랜더링 될 수 있다.

 

일반적인 랜더링 동작으로는 루트 혹은 상위 컴포넌트가 변경되어 리랜더링 된다면 그의 하위 컴포넌트 전체가 다 리랜더링 된다. 이때 props 값의 변화 여부는 고려하지 않는다.

 

랜더링 규칙

XX안돼요XX

  >> 기존 변수 및 객체 변경

  >> Math.random() 또는 Date.now()와 같은 임의의 값을 생성

  >> 네트워크 요청

  >> 상태 업데이트를 큐에 추가

 

OO돼요OO

  >> 랜더링 도중 새로 생성된 객체 변경

  >> 오류 발생

  >> 캐싱된 값과 같이 아직 생성되지 않은 데이터에 대한 "지연 초기화"

 

첫 랜더링 이후 리랜더링을 큐에 등록하도록 지시하는 방법은 아래와 같이 있다.
  >> useState(), useReducer() => 함수형 컴포넌트
  >> this.setState(), this.forceUpdate() => 클래스형 컴포넌트
  >> 최상위 render() 메서드를 다시 호출(루트에서 forceUpdate를 호출하는 것과 동일) => 기타

 

https://wavez.github.io/react-hooks-lifecycle/

 

리액트는 새로운 가상돔과 비교해 실제 돔을 원하는 결과대로 적용하기 위해서 적용해야할 모든 변경 사항 목록을 수집하는데, 비교 및 계산 프로세스를 재조정(Reconciliation)이라고 한다.


잠시 재조정을 알아보자. 재조정(Reconciliation)이란?

실제돔을 원하는 결과처럼 보이게 하기 위해 변경 사항을 수집하고 가상돔과 실제돔을 비교 및 계산하는 프로세스로 가상돔과 실제돔 사이의 랜더링되기까지의 전체 과정을 '재조정'이라고 한다. 한마디로 변경해야 할 부분을 결정하기 위해 한 트리를 다른 트리와 비교하는 데 사용하는 알고리즘 (유사표현으론 비교 알고리즘, Diffing Algorithm).

 

리액트는 재조정과 랜더링을 별개의 단계가 되도록 설계됐다. Reconciler(조정자)는 트리의 어떤 부분이 변경됐는지 계산하고 그 이후 Renderer는 계산된 정보를 앱을 실제로 업데이트하는 데 사용한다.

 

https://callmedevmomo.medium.com/virtual-dom-react-%ED%95%B5%EC%8B%AC%EC%A0%95%EB%A6%AC-bfbfcecc4fbb

 

https://velog.io/@jangws/React-Fiber


랜더링 일괄처리(Render Batching)

기본적으로 setState()는 리액트가 새 랜더 패스를 시작하고 이를 동기적으로 실행하고 반환하도록 한다. 이 때 랜더링을 한 번에 하나씩 처리하는 것이 아닌 여러 setState() 호출 결과를 한번에 실행하여 최적화를 자동으로 적용하기도 하는데 이를 '랜더링 일괄처리'라고 말한다. 이 때 일반적으로 약간의 지연이 발생한다.

 

리액트 17 및 이전 버전에서 리액트는 onClick 콜백과 같은 리액트 이벤트 핸들러에서만 일괄 처리를 수행했다. 

setTimeout, await 이후 또는 일반 JS 이벤트 핸들러와 같은 리액트 이벤트 핸들러 외에서의 업데이트는 큐에 추가되지 않았으며 각각 별도의 리랜더링이 발생했었다. 그러나 리액트 18에서는 이제 단일 이벤트 루프 틱에 대기 중인 모든 업데이트의 "자동 일괄 처리"를 수행한다. 따라서 필요한 전체 랜더링 수를 줄일 수 있다.

 

예를 들어

const [counter, setCounter] = useState(0);

const onClick = async () => {
  setCounter(0);  // 패스1
  setCounter(1);  // 패스2

  const data = await fetchSomeData();

  setCounter(2);  // 패스3
  setCounter(3);  // 패스4
};

리액트 17 혹은 이전 버전을 사용하면 위의 코드에서 패스1과 패스2를 일괄처리하고, await 이후 패스3과 패스4를 각각 처리해서 총 3번의 랜더 패스를 실행한다. 하지만 리액트 18을 사용하면 패스1과 패스2가 일괄처리되고 await 이후 패스3과 패스4를 일괄처리되어 총 2번의 랜더 패스를 실행하게 된 것이다.

 

 

비동기 랜더링, 클로저

function MyComponent() {
  const [counter, setCounter] = useState(0);

// handleClick을 실행하여 콘솔창에 1이 출력되는 것을 의도했다.

  const handleClick = () => {
      setCounter(counter + 1);
      // 하지만 의도한대로 동작 ❌
      console.log(counter);
      // 0이 콘솔창에 출력된다.
    };
}

코드를 작성할 때 콘솔창에 '1'이 출력되는 것을 의도했지만 '0'이 출력되었다. 왜 의도한 대로 1이 출력되지 않은걸까?

그 이유는 리액트에서의 state 업데이트는 비동기지만 비동기이면서 동기적으로 랜더링하기 때문이다. 그리고 handleClick()이 '클로저(Closure)'이기 때문에 의도대로 동작하기 않았다. 클로저란 함수가 정의되었을 때 존재했던 변수의 값만 알수 있다.(특정 시점의 스냅샷으로 이해해도 좋다) 즉 실행 당시 setCounter()가 동작하기 이전 값인 counter의 값인 '0'만 알고 있다는 의미이다.

 

따라서 업데이트된 값을 설정한 직후 변수를 사용하려는 것은 잘못된 접근 방식이므로 주의 하자!

 

함수형 컴포넌트의 랜더링 최적화 방법
  1. 메모이제이션 활용
      대표적으로 useMemo(), useCallback()가 있다. 간략하게 연산된 '값'을 반환하는 useMemo()와 '함수'를 반환하는 useCallback()을 사용하여 의존성 배열의 변화가 있을 경우에만 재연산 혹은 리랜더링하기 .

  2. React.memo()
      전달된 props의 변화만 감지하여 변화가 없을 경우는 리랜더링하지 않기.
      리액트는 props가 변경되지 않은 경우에도 중첩된 모든 컴포넌트를 리랜더링하는데, 이때 React.memo()로 래핑한다면 props가 하나도 전달되지 않았을 경우에는 리랜더링을 하지 않는다.

  3. key 값 활용
      key는 컴포넌트 타입의 특정 인스턴스를 구별하는데 사용할 수 있는 고유 식별자이다.
      만약 map()을 예로 들어 처음 배열의 length가 10이었고, 배열의 key값으로 index 값을 넣었다고 가정해보자.
      이 때 배열의 6,7번째 값을 삭제하고 맨 뒤에 몇개를 추가했다면?? 컴포넌트는 삭제된 6,7번째 index의 변화를 인지하지 못한다. 왜냐? 중간에 삭제된 index값은 그 뒤로 이어지는 값들로 앞당겨 채워지기 때문에 index는 변화하지 않기 때문이다.

  4. 불변성 지키기
      리액트의 핵심인 변화를 감지하여 변화된 부분만 리랜더링하는 방법에 혼란을 주는 절대 지켜야하는 방법이다.
      불변성을 지키지 않으면 랜더링할 것으로 예상한 컴포넌트가 예상과 다르게 동작할 수 있다.
      즉, state 혹은 데이터가 실제로 업데이트된 시기와 원인에 대해 혼란을 일으키니 절대로 지키자.

메모이제이션(메모아이제이션)이란?

메모이제이션(memoization)은 컴퓨터 프로그램이 동일한 계산을 반복해야 할 때, 이전에 계산한 값을 메모리에 저장(캐싱)함으로써 동일한 계산의 반복 수행을 제거하여 프로그램 실행 속도를 빠르게 하는 기술이다.

 

왜 모든 컴포넌트를 메모이제이션하지 않을까?

=> 메모이제이션 하더라도 props를 비교하는 비용이 발생하며, 컴포넌트는 항상 새로운 props를 받기 때문에 메모이제이션 체크로는 리랜더링을 막을 수 없는 경우가 많기 때문.

 


+ 파이버(Fiber)

리액트는 애플리케이션에 현재 존재하는 모든 컴포넌트 인스턴스를 추적하는 내부 데이터 구조를 저장한다. 이 데이터 구조의 핵심 부분은 파이버(Fiber)라고 불리는 객체이다. (파이버는 리액트 16에서 출시되어 이후 모든 버전은 파이버를 사용하고 있다.) 파이버는 일종의 공략집이나 요약본 느낌으로 생각하면 쉽게 이해할 수 있을 듯 하다.

파이버는 다음과 같은 메타 데이터 필드를 포함한다.
   - 컴포넌트 트리의 해당 지점에서 렌더링되어야 할 컴포넌트 타입
   - 해당 컴포넌트와 관련된 prop, 상태
   - 상위, 형제 및 하위 컴포넌트에 대한 포인터
   - 리액트가 렌더링 프로세스를 추적하는 데 사용하는 기타 내부 메타데이터

 

파이버는 실제 컴포넌트 props와 state값을 저장하는데, 컴포넌트에서 props와 state를 사용할 때 실제로는 파이버 객체에 저장된 값에 대한 접근을 제공하는 것이다.  마찬가지로 리액트가 컴포넌트의 모든 훅(hook)을 해당 컴포넌트의 파이버 객체에 연결 리스트로 저장하기 때문에 리액트 훅이 동작하는 것이다.

 

 


참고

React에서의 가상돔 개념

[React] 렌더링(리렌더링)

(번역) 블로그 답변: React 렌더링 동작에 대한 (거의) 완벽한 가이드

Blogged Answers: A (Mostly) Complete Guide to React Rendering Behavior