sungyup's.

modern_react_deep_dive / 리액트 핵심 요소 / 1.4 렌더링은 어떻게 일어나는가?

1.4렌더링은 어떻게 일어나는가?

리액트에서 렌더링의 개념과 프로세스 및 세부 단계

TL;DR

추억의 쪽지 시험

1. 리액트의 렌더링이란?

렌더링(rendering)은 본래 브라우저에서 HTML과 CSS 리소스를 기반으로 웹페이지에 필요한 UI를 그리는 과정을 뜻한다.

React에서 렌더링은 컴포넌트 트리에 속한 컴포넌트들이 가지고 있는 props와 state의 값을 기반으로 어떻게 UI를 생성하고 이를 바탕으로 어떤 DOM 결과를 브라우저에 제공할지 계산하는 과정이다.

2. 리액트의 렌더링이 일어나는 이유

React의 핵심 중 하나는 렌더링이 언제 발생하는지 이해하는 것이다. 렌더링은 리액트에서 아래와 같은 시나리오에서 발생한다.

  1. 최초 렌더링: 사용자가 애플리케이션에 진입하면, 리액트는 루트 컴포넌트를 처음 렌더한다.
  2. 리렌더링: 리렌더링은 최초 렌더링 이후 발생한 모든 렌더링을 통칭하는 것으로, 함수형 컴포넌트 기준으론 아래의 5개 시나리오에서만 일어난다.
    • useState()의 두 번째 배열 요소인 setter가 실행되는 경우. 즉, state가 업데이트되면 리렌더링이 일어난다.
    • useReducer()의 두 번째 배열 요소인 dispatch가 실행되는 경우. 위와 마찬가지로 state의 업데이트로 일어나는 리렌더링이다.
    • 내부 컴포넌트의 key props가 변경되는 경우. React에서 key는 명시적으로 선언되지 않았더라도 모든 컴포넌트에서 사용할 수 있는 props다. 주로 key는 배열에서 map 메소드를 통해 리스트를 렌더하고 싶을때, 리스트(배열)의 각 요소들을 형제 요소들과 식별하기 위해 쓰인다. key가 변경되면 React는 요소가 바뀐 것으로 간주하고 컴포넌트를 재생성한다.
    • props가 변경되는 경우. 부모로부터 전달받는 props가 달라지면 자식 컴포넌트에서도 변경이 필요하므로 리렌더링이 일어나는 것이다.
    • 부모 컴포넌트가 렌더링될 경우. 즉, 부모 컴포넌트가 리렌더링되면 자식 컴포넌트도 반드시 리렌더링된다. 이렇게 상위 컴포넌트에서(특히 루트) 렌더링을 발생시키는 작업이 일어나면 하위 모든 컴포넌트의 리렌더링이 트리거되기 때문에, 성능 최적화를 위해 memo 기능 등이 쓰인다.
클래스 컴포넌트의 경우 setState가 실행되는 경우와 forceUpdate가 실행되는 경우 리렌더링이 발생한다.

3. 리액트의 렌더링 프로세스

렌더링 프로세스가 시작되면 리액트는 루트에서부터 아래쪽으로 순차적으로 내려가며 업데이트가 필요하다고 지정되어 있는 모든 컴포넌트를 찾는다. 여기서 루트란, 최초 렌더링 시는 최상위 컴포넌트(<App/>등)지만, 리렌더링일 경우, 예를 들어 setState()가 어떤 컴포넌트에서 호출된 경우 해당 컴포넌트가 루트가 되고 해당 컴포넌트의 하위 subtree를 렌더링 후보로 하여 재귀적으로 렌더링 대상을 찾는다.

그렇게 컴포넌트 트리를 타고 내려가며 업데이트가 필요하다고 지정되어 있는 컴포넌트를 찾으면, 해당 함수 컴포넌트를 호출하고, JSX로 반환되는 결과물을 저장한다.

jsx
function Greet(){ return ( <TestComponent age={33} name="sungyup"> Hello! </TestComponent> ) }

이 JSX 코드는 자바스크립트로 컴파일될 때 React.createElement()를 호출하는 구문으로 변환된다.

javascript
function Greet(){ return React.createElement( TestComponent, { age: 33, name: 'sungyup' }, 'Hello!', ) }

createElement는 브라우저의 UI 구조를 설명할 수 있는 자바스크립트 객체를 반환한다.

javascript
{type: TestComponent, props: {age: 33, name: "sungyup", children: "Hello!"}};

렌더링 프로세스가 실행되면 각 컴포넌트의 렌더링 결과물을 수집한 다음, React의 가상 DOM과 비교해 실제 DOM에 반영하기 위한 모든 변경 사항들을 수집한다. 이 변경 사항 계산이 React의 Reconciliation(재조정)이라고 한다. 이 재조정 과정이 모두 끝나면 변경 사항을 하나의 동기 시퀀스로 DOM에 적용해 변경된 결과가 보인다.

4. 렌더와 커밋

위의 렌더링 프로세스는 렌더 단계와 커밋 단계라는 두 단계로 이루어진다.

첫 번째, 렌더 단계(Render Phase)컴포넌트를 렌더링하고 변경 사항을 계산하는 모든 작업이다. 즉, 컴포넌트를 실행한 결과가 이전 가상 DOM과 비교해 무엇이 달라졌는지 체크하는 단계다. 여기서 비교하는 것은 type, props, key 세 가지로, 이 세 가지 중 하나라도 변경된 것이 있으면 변경이 필요한 컴포넌트로 체크된다.

type, prop, key는 React가 컴포넌트를 실행한 결과물인 가상 DOM 노드 자바스크립트 객체의 키 값들이다. 즉, {type: TestComponent, props: {name: "sungyup"}, key: null}과 같은 형태이다. 이 세 가지는 React가 리렌더링 시 가상 DOM을 비교할 때 사용하는 핵심 식별자다. 예를 들어, 리렌더링 시 type이 같다면 React는 같은 컴포넌트 인스턴스의 업데이트(diff)를 수행하고, type이 다르면 완전히 다른 UI 요소로 간주하고 기존 컴포넌트를 언마운트하고 새 컴포넌트를 마운트한다.

두 번째, 커밋 단계(Commit Phase)는 렌더 단계의 변경 사항을 실제 DOM에 적용하는 과정이다. 변경 사항을 적용해 DOM이 업데이트되면, 이렇게 만들어진 모든 DOM 노드 및 인스턴스를 가리키도록 리액트 내부의 참조가 업데이트(Fiber 트리와 연결)된다. 이후, useLayoutEffect가 실행된다.

생명주기 개념이 있는 클래스 컴포넌트에선 이 타이밍, 즉 커밋이 완료된 후 componentDidMount, componentDidUpdate 메소드를 호출한다.

이렇게 렌더 단계와 커밋 단계가 나뉘어져있기 때문에, 렌더링이 일어났다고 해서 반드시 DOM 업데이트가 일어나는 것은 아니다. 렌더링을 해서 변경 사항을 계산했는데 아무런 변경이 없다면 커밋 단계는 생략될 수도 있다.

또, 커밋 단계에서 변경 사항을 적용해 DOM을 업데이트한다고 했는데, 이 변경점을 사용자가 볼 수 있게 화면에 그리는 것은 브라우저의 역할로 이는 커밋이 끝난 후에 이루어지는 페인트(Paint) 과정이다. Paint는 React의 역할이 아니다. React는 DOM 변경점을 계산(렌더)해 실제 DOM 트리에 그 변경점을 반영(커밋)하는 것까지만 담당한다.

React 18 버전 이전까지 렌더링은 동기식이었다. 다시 말해, 렌더링을 시작하면 끝날때까지 멈출 방법이 없었다. 따라서 렌더링이 시작되면, React가 재귀적으로 컴포넌트들을 호출하면서 트리를 전부 그릴때까지 JS 스레드를 점유하고, 그 사이에는 다른 이벤트나 브라우저 작업이 끼어들 수 없었다.

이러한 특징은 컴포넌트 트리가 크거나 복잡한 앱에서 브라우저의 응답성(responsiveness)을 크게 떨어뜨렸는데, 예를 들어 렌더 도중엔 스크롤이 버벅이거나, 클릭이 반응하지 않거나, 애니메이션이 끊겼다.

React 18 버전에선 렌더링을 비동기적(asynchronous)으로 수행할 수 있게 되었다. 즉, 필요시 렌더링을 중단하고 나중에 재개하는 식의 동작이 가능해졌다. 이를 동시성 렌더링(Concurrent Rendering)이라고 하는데, 이에 대해선 이후 보다 상세히 다룰 기회가 있을 것이다.

동시성 렌더링의 요점은 React가 렌더링을 작업 단위(Task Unit)로 나누고 자체 스케줄러(Scheduler)로 우선 순위를 관리해, 매 단위마다 브라우저의 Event Loop과 협력해 상대적으로 빠르게 렌더링할 수 있는 작업(예: 입력 반응)에 더 높은 우선순위를 주고, 무거워서 느린 작업(예: 대용량 리스트)에 낮은 우선순위를 줘서 사용자 경험을 보다 매끄럽게 하는 것이다.