[Front-End] React 렌더링 방식 (React 탄생 배경)
2024.10.09- -
1. 들어가며
[Front-end] - [Front-End] 웹 브라우저 동작 방식 이해 (feat. 렌더링)
지난 포스팅에서 브라우저가 어떤 방식으로 화면을 그리는지에 대해서 알아보았습니다. 이번 포스팅에서는 그렇다면 React는 어떤 과정들을 거쳐 브라우저에게 파일을 전달하며, 렌더링은 어떻게 되는 것인지에 대해 알아보도록 하겠습니다.
지난 시간 다루었던 웹 페이지가 렌더링 되는 과정을 간단히 요약하면 아래와 같습니다.
1. HTML parser가 HTML을 바탕으로 DOM Tree를 그린다.
2. CSS parser가 CSS를 바탕으로 CSSOM을 그린다.
3. DOM에 CSSOM을 적용하여 Render Tree를 그린다.
4. Render Tree를 바탕으로 Painting 하여 실제 화면에 렌더링 한다
* HTML 코드를 읽어 내려가다가 <script> 태그를 만나게 되면 파싱을 잠시 중단하고 JS 파일을 로드합니다.
2. 웹의 동적인 변화와 DOM 조작
초기 웹은 정적인 정보만 제공했으나(이메일, 문서 등...) 점차 데이터가 복잡한 형태를 띄기 시작하면서 화면의 각 요소를 동적으로 움직일 필요가 있게 되었습니다.
그러한 기능을 가능하게 만들어줄 수 있었던 이유는 JavaScript에서 각종 DOM API를 통해 DOM을 조작할 수 있는 제어권을 가질 수 있었기 때문입니다. DOM API는 JavaScript에 내장된 기능으로 많은 액션들을 다룰 수 있다는 특징이 있습니다.
그러나 DOM을 조작한다는 것은 생각처럼 그렇게 쉬운 일이 아닙니다. 그래서 이를 쉽게 만들어주는 "jQuery"라는 라이브러리가 등장하여 많은 사랑을 받기도 했었지만, 현대의 웹 애플리케이션에서는 사용자의 인터랙션으로 인한 수많은 DOM 조작이 일어나게 되면서 jQuery에서 배치와 화면표시에 많은 연산을 발생시켜 브라우저의 성능에 영향을 끼치는 문제가 생겼었습니다.
앞서 DOM을 직접 조작하기 어렵다고 했는데 그 이유는 안정성이 떨어질 뿐만아니라, DOM API 스펙을 보면 매우 low-level로 구성되어 코드가 난해한 경우가 많이 존재했기 때문입니다. 그러한 이유로 DOM을 직접 조작하는 일은 곧 코드의 복잡도를 높이는 일이며, 앱의 규모가 커질수록 모듈화하기도 어려워 점점 사용하는 것을 꺼려하기 시작했습니다.
이러한 배경 속에서 SPA(Single Page Application) 기술이 나오게 되고 이에 최적화된 React가 Virtual DOM을 들고 급부상하게 됩니다.
사용자가 Virtual DOM을 조작하는 건 React와 같은 라이브러리가 비교적 쉽게 해줄 수 있고, Virtual DOM이 직접 실제 DOM을 조작해 주기 때문에 개발자 입장에서는 많은 부담이 덜어지게 된 것입니다.
3. React가 렌더링하는 방식
3.1 과거 리액트
리액트는 처음에 클래스형 컴포넌트를 사용하여 상태와 생명주기 메서드를 관리했습니다. 클래스형 컴포넌트다보니 this를 사용하여 상태를 관리하였고, 다양한 생명주기 메서드(componentDidMount, componentDidUpdate, componentWillUnmount)를 정의해야 했었죠.
하지만 리액트 v16.8(2019년)부터 React Hooks가 도입되면서 함수형 컴포넌트에서 상태(state)와 사이드 이펙트를 손쉽게 관리할 수 있게 되었는데요. 우리가 잘 아는 useState, useEffect와 같은 훅을 사용하여 기존의 클래스형 컴포넌트에서만 가능했던 기능을 함수형 컴포넌트에서도 쉽게 구현할 수 있게 된 것입니다.
이때부터 리액트는 함수형 컴포넌트 사용을 권장하기 시작했으며, 기존의 클래스형 컴포넌트는 유지보수나 레거시 코드에 남아있을 뿐, 새로운 리액트 프로젝트에서는 거의 사용되지 않는 것을 보실 수 있습니다.
3.2 리액트의 렌더링 과정: 함수형 컴포넌트의 실행
리액트는 함수형 컴포넌트를 함수로서 실행(호출)하여 UI를 구성하는 방식으로 진행됩니다. 이 과정은 코드를 작성하는 개발자 입장에서는 함수가 실행되는 것처럼 생각할 수 있겠지만, 사실은 리액트의 내부 렌더링 엔진인 Reconciliation과 Fiber가 함수 호출 및 그에 따른 렌더링을 매우 정교하게 관리하고 있습니다.
리액트 함수형 컴포넌트의 렌더링 라이프사이클은 다음과 같이 요약할 수 있습니다.
초기 렌더링
함수형 컴포넌트가 처음 호출될 때, 리액트는 이 함수의 반환 값(주로 JSX)을 이용해 가상 DOM(Virtual DOM)을 생성합니다.
리액트는 이 가상 DOM을 기반으로 실제 DOM을 생성하여 브라우저에 반영하게 됩니다.
상태 변경
함수형 컴포넌트 내에서 useState나 useReducer와 같은 훅으로 상태를 관리할 수 있습니다. 상태가 변경되면, 해당 컴포넌트가 다시 실행됩니다. 이 때, 리액트는 이전 가상 DOM과 새로 생성된 가상 DOM을 비교하여 필요한 부분만 실제 DOM에 반영하는 Diffing 이라는 알고리즘을 사용합니다.
이로인해 변경된 부분만 DOM에 업데이트할 수가 있어 성능을 최적화시킬 수 있습니다.
Side Effects 관리
useEffect는 컴포넌트가 렌더링된 후 실행되며, 주로 비동기 작업, DOM 조작, 구독(subscription) 등을 처리합니다.
상태나 Props가 변경될 때마다 useEffect가 다시 실행되며, 필요한 경우 정리 함수(clean-up function)를 이용해 이전의 이펙트를 해제할 수도 있습니다.
3.3 리액트와 브라우저 렌더링 예시
리액트에서 간단한 함수형 컴포넌트를 생각해봅시다.
function Counter() {
const [count, setCount] = useState(0);
return (
<div>
<p>{count}</p>
<button onClick={() => setCount(count+1)}>Increment</button>
</div>
)
}
이 코드의 흐름을 살펴보면:
- 초기 렌더링: Counter 함수가 호출되고, 초기 count 값은 0입니다. 리액트는 이 값을 이용해 가상 DOM을 만들고, 실제 DOM으로 변환하여 브라우저에 표시합니다.
- 상태 업데이트: 사용자가 버튼을 클릭하면 setCount가 호출되고, count 값이 1로 업데이트됩니다. 리액트는 다시 Counter 함수를 호출하고 새로운 가상 DOM을 생성합니다.
- DOM 업데이트: 리액트는 이전 가상 DOM과 새로운 가상 DOM을 비교한 뒤, p 요소의 텍스트가 0에서 1로 바뀌었음을 감지합니다. 이 변경 사항만 실제 DOM에 반영되므로 브라우저는 필요한 부분만 리렌더링합니다.
4. 가상 DOM(Virtual DOM)
리액트에서 자주 언급되는 가상 DOM(Virtual DOM)은 실제 DOM을 더 효율적으로 다루기 위한 기법입니다. 이를 이해하려면 우선 사용자가 웹사이트에 접속할 때 어떤 일이 일어나는지, 그리고 가상 DOM이 어떻게 실제 DOM과 상호작용하는지를 알아야 합니다.
기존 웹서버에서 HTML 문서를 생성하여 웹 브라우저로 전송하는 정적 HTML 방식에서 벗어나 웹 브라우저에서 자바스크립트 코드를 실행하여 동적으로 HTML을 생성하는 방식으로 동작하는 웹 기술로, 이것이 발전하여 오늘날의 리액트와 같은 프론트엔드 프레임워크가 되었다.
4.1 DOM과 렌더링
잘 생각해보면 과거에는 댓글을 달면 해당 페이지가 새로고침이 한 번 된 다음 변경사항이 반영되는 과정을 거쳤었는데 요즘에는 새로고침이 되지 않더라고 댓글이 딱! 하고 나오는 것을 알 수 있을 겁니다.
먼저 DOM(Document of Model)이라는 용어에 대해 먼저 정의를 해보면 아래와 같습니다.
- 웹 페이지를 이루는 태그들을 자바스크립트가 이용할 수 있게끔 브라우저가 트리구조로 만든 객체 모델
- 말 뜻 그대로 문서 객체 모델을 의미하며 문서 객체(html, head, body와 같은 태그)를 자바스크립트가 이용할 수 있는 객체(memorable)를 의미
- 한마디로, DOM은 HTML과 스크립팅 언어를 서로 이어주는 역할이라고 볼 수 있음
DHTML 방식은 자바스크립트가 "<div>Hello World!</div>"와 같은 텍스트를 만드는 방식이 아니라 객체 지향 언어의 상속 관계를 기반으로 설계된 DOM 타입스크립트 객체를 생성하는 방식으로 동작합니다.
비록 웹 브라우저는 <div>, <h1>처럼 HTML 형태로 보여주게 되지만 ,자바스크립트 코드가 바라보는 관점에서는 <div>는 HTMLDivElement 클래스의 인스턴스이고, <h1>은 HTMLHeadingElement 클래스의 인스턴스입니다.
그래서 웹 브라우저가 HTML을 parsing해서 자바스크립트 DOM 구조로 만드는 행위 자체를 "렌더링"이라고 할 수 있습니다.
4.2 물리 DOM과 가상 DOM
리액트 프레임워크에는 독특하게 물리 DOM과 가상 DOM이 나뉘어져 있는데 이 둘의 차이는 다음과 같습니다.
- 물리 DOM(Physical DOM): 웹 브라우저에서 자바스크립트 코드가 생성하는 실제 DOM 구조
- 가상 DOM(Virtual DOM): 리액트 코드가 생성한 자바스크립트 객체 주고
리액트는 특정 시점에 이 가상 DOM 구조를 물리 DOM 구조에 반영을 시키는데, 이 과정을 "리액트의 렌더링"이라고 부르는 것이며 기능을 수행하는 패키지를 렌더러(renderer)라는 것으로 따로 구분을 해 놓았습니다.
- 리액트는 가상 DOM 구조를 react-dom이라는 렌더러 패키지를 사용하여 물리 DOM 구조로 렌더링하는 방식으로 동작
4.3 가상 DOM이 필요한 이유
요즘 가장 핫한 웹 애플리케이션(X(구 트위터), 페이스북)들을 사용해보면 스크롤바를 내릴 수록 수많은 데이터가 로딩되는 것을 볼 수 있습니다. 그리고 각 데이터를 표현하는 요소들이 존재하는데요.
요소의 갯수가 몇백개, 몇천개 단위로 규모가 굉장히 큰 웹 애플리케이션에서는 DOM에 직접적인 변화를 주다보면 성능 이슈가 조금씩 발생하기 시작합니다.
성능 이슈는 즉, 느려진다는 말인데 이 얘기는 사실 정확한 말은 아닙니다.
DOM 자체는 굉장히 빠르게 동작합니다. 읽고 쓰는 성능은 자바스크립트에서 객체를 처리할 때와 동등한 성능으로 동작하기 때문에 빠른 것이지만, 웹브라우저 단에서 DOM에게 변화가 일어나면 웹 브라우저가 CSS를 다시 연산하고 레이아웃을 구성하고, 페이지를 리페인트 하는 과정을 거쳐야 하기 때문에 이 과정에서 시간이 허비되는 것입니다.
- 브라우저 로딩 과정 중 스타일 이후의 과정(스타일 -> 레이아웃 -> 페인트 -> 합성)을 렌더링이라고 합니다.
그런데 이 일련의 렌더링 과정은 상황에 따라서 여러 번 반복될 수 있고, DOM의 추가, 삭제 혹은 태그의 위치 변화만 감지되어도 렌더링이 일어나기 때문에 수많은 렌더링이 발생할 여지가 굉장히 많다고 볼 수 있습니다.
결론적으로 "가상 DOM"이 나오게 된 이유는 속도적인 부분과 많은 연산을 수행 중에 발생하는 버그나 브라우저가 도중에 죽는 일 등의 일들을 개선하고자 나왔다고 볼 수 있습니다.
4.4 react 패키지의 역할
리액트는 react라는 패키지로 구성되어 있는데 react 패키지는 App.tsx와 같은 파일을 가상 DOM 구조로 만드는 역할을 하는 패키지입니다.
4.5 웹사이트 접속 시 리소스 로딩 과정
HTML 다운로드
사용자가 웹 사이트에 접속하면 브라우저는 해당 사이트의 서버로부터 index.html 파일을 다운로드 받습니다. 하지만 SPA(Single Page Application)처럼 리액터를 사용하는 웹 애플리케이션의 경우, 이 index.html 파일은 대부분 비어 있거나 최소한의 마크업만 포함되어 있습니다.
자바스크립트 파일 다운로드
index.html에 포함된 <sciprt> 태그가 브라우저에게 자바스크립트 파일을 로드하라고 지시합니다. 이 자바스크립트 파일들은 웹 페이지의 전체 구조를 정의하고, 모든 동적 콘텐츠를 렌더링하는 역할을 합니다.
자바스크립트 실행 및 가상 DOM 생성
브라우저가 자바스크립트 파일을 실행하면, 리액트는 자바스크립트로 작성된 컴포넌트를 실행하여 가상 DOM을 생성합니다. 이 가상 DOM은 실제 브라우저의 DOM과 1:1로 대응되는 자바스크립트 객체입니다.
실제 DOM에 가상 DOM 반영
리액트는 가상 DOM을 실제 DOM으로 변환하여 브라우저에 표시합니다. 이 과정에서 초기 렌더링이 이루어집니다.
4.6 Virtual DOM과 Real DOM의 관계
리액트는 효율적인 업데이트를 위해 가상 DOM을 사용합니다. 가상 DOM은 자바스크립트로 이루어진 가벼운 트리 구조 객체입니다. 이 객체는 컴포넌트들이 반환하는 JSX의 구조를 반영하며, 실제 DOM과는 별개로 메모리 상에서만 유지됩니다.
- 가상 DOM 생성: 리액트 컴포넌트가 렌더링될 때, JSX로 작성된 구조는 자바스크립트 객체 형태로 가상 DOM을 생성합니다.
- 변경 감지: 컴포넌트의 상태나 props가 변경되면, 리액트는 해당 컴포넌트를 다시 호출하고, 새로운 가상 DOM을 만듭니다.
- 비교(Diffing): 새로운 가상 DOM과 이전 가상 DOM을 비교하여 변경된 부분만 찾아냅니다. 이 과정을 Diffing 알고리즘이라고 부릅니다.
- 패치(Patching): 변경된 부분이 발견되면 리액트는 실제 DOM(Real DOM)에 그 변경 사항을 적용합니다. 이 때 리액트는 변경된 부분만 효율적으로 업데이트합니다.
4.7 자바스크립트 안에서의 가상 DOM 처리
가상 DOM은 결국 자바스크립트 코드 안에서 생성되고 관리되는 자바스크립트 객체입니다. 예를 들어, 다음과 같은 JSX가 있다고 가정해봅시다.
function MyComponent() {
return <div><h1>Hello, World!</h1></div>;
}
이 JSX 코드는 실제로는 바벨(babel)과 같은 트랜스파일러에 의해 자바스크립트로 변환됩니다.
function MyComponent() {
return React.createElement("div", null,
React.createElement("h1", null, "Hello, World!"));
}
리액트는 React.createElement를 통해 자바스크립트 객체(가상 DOM)를 생성합니다. 이 객체는 대략 다음과 같은 형태를 가집니다.
{
type: 'div',
props: {
children: {
type: 'h1',
props: {
children: 'Hello, World!'
}
}
}
}
이처럼 가상 DOM은 트리 구조를 가진 자바스크립트 객체이며, 브라우저의 실제 DOM을 조작하기 전에 변경 사항을 미리 계산하고 최적화된 방법으로 반영하는 역할을 합니다.
4.8 가상 DOM -> 실제 DOM
가상 DOM은 자바스크립트 객체일 뿐이기 때문에, 이걸 브라우저에 표시하려면 실제 DOM과 동기화하는 과정이 필요합니다. 이 과정은 다음과 같이 이루어져있습니다.
- 초기 렌더링:
- 리액트 컴포넌트가 처음 렌더링되면, 가상 DOM이 생성됩니다. 이 가상 DOM을 바탕으로 리액트는 실제 DOM에 해당하는 요소를 생성하고 브라우저에 표시합니다.
- 상태 변경 시:
- 컴포넌트의 상태나 props가 변경되면, 리액트는 해당 컴포넌트를 다시 렌더링하고 새로운 가상 DOM을 생성합니다.
- 가상 DOM 비교 (Diffing):
- 리액트는 새로 생성된 가상 DOM과 이전 가상 DOM을 비교하여 변경된 부분을 찾습니다.
- 최소한의 DOM 업데이트:
- 리액트는 변경된 부분만 실제 DOM에 업데이트합니다. 예를 들어, 텍스트가 변경되었다면 해당 텍스트 노드만 수정하고, 전체 DOM 트리를 다시 생성하지 않습니다.
- 이렇게 하면 DOM 조작의 부담을 줄여 성능을 최적화할 수 있습니다.
리액트가 변경 사항을 실제 DOM에 반영할 때, document.createElement나 element.setAttirbute 등의 DOM 조작 메서드를 사용해 가상 DOM에서 계산한 결과를 실제 DOM에 전달합니다. 따라서 최종적으로는 브라우저가 제공하는 표준 DOM API를 이용해 화면에 변화를 반영하는 것이죠.
5. JSX의 반환과 변환 과정(React.createElement API가 하는 일)
먼저 리액트 컴포넌트에서 JSX를 반환하면 그 내용은 직접적으로 브라우저가 처리할 수 있는 HTML이나 DOM 노드가 아닙니다. JSX는 자바스크립트 문법에 가깝기 때문에 브라우저가 이해하기 전에 변환 과정이 필요합니다.
리액트의 React.createElement API는 가장 저수준 기능으로서 가상 DOM 객체를 생성하게 됩니다.
JSX 구문은 React.createElement 대신 가상 DOM 객체를 쉽게 만들 수 있습니다. 리액트 코드 작성자는 복잡한 여러 번의 React.createElement 호출 코드를 작성하는 대신 훨씬 간결한 JSX 코드만 작성하면 되기 때문에 빠르고 간결한 코드를 작성할 수 있어 개발 생산성이 대폭 향상됩니다.
JSX 구문이 있는 타입스크립트 코드는 확장자가 .tsx로 구성되며 JSX 구문이 있는 코드는 import React from 'react'와 같은 import 문이 필요합니다.
- JSX 변환: JSX는 브라우저가 바로 해석할 수 없으므로,이를 컴파일하기 위해 @babel/plugin-transform-react-jsx라는 바벨 플러그인을 실행해 여러 개의 React.createElement 함수를 호출하는 평범한 자바스크립트로 변환하게 됩니다. 이 과정에서 JSX가 자바스크립트 객체로 변환되는 것입니다.
예를 들어, 다음 JSX 코드가 있다고 가정해봅시다.
return <div>Hello, World!</div>;
이것은 바벨에 의해 다음과 같은 코드로 변환됩니다.
// 가상_DOM_객체 = createElement(컴포넌트_이름_또는_문자열, 속성_객체, 자식_컴포넌트)
return React.createElement('div', null, 'Hello, World!');
- 가상 DOM 생성: React.createElement는 실제 DOM 요소를 만들지 않고, 가상 DOM(Virtual DOM)이라는 자바스크립트 객체를 반환합니다. 이 객체는 컴포넌트의 구조를 설명하는 일종의 청사진(blueprint) 역할을 합니다.
가상 DOM은 메모리 상에서만 존재하며, 브라우저의 실제 DOM과는 별개로 관리됩니다. 이 시점에서는 브라우저가 아직 DOM을 수정하지 않았습니다.
JSX 구문에서 중괄호({})의 의미
JSX는 다음 코드에서 보듯 XML 마크업 구조에 중괄호({})를 사용하여 자바스크립트 코드를 감싸는 형태의 문법을 제공합니다.
<Text>
{person}
</Text>
이런 식으로 자바스크립트의 변수값을 XML 구문 안에 표현할 수 있는 것입니다.
리액트는 이 가상 DOM을 기반으로 최종적인 렌더링 작업을 수행하는데, 그 과정은 앞서 말했던 과정이 수행됩니다.
- 가상 DOM 비교(Diffing):
- 리액트는 컴포넌트에서 반환된 JSX(변환된 가상 DOM)을 실제 DOM에 적용하기 전에, 현재의 가상 DOM과 이전의 가상 DOM을 비교합니다. 이를 Diffing이라고 하며, 변경된 부분만을 찾아냅니다.
- 실제 DOM 업데이트:
- 변경된 부분을 찾으면, 리액트는 그에 맞게 브라우저의 실제 DOM(Real DOM)을 업데이트합니다. 이때 리액트는 최소한의 DOM 조작만을 수행하여 성능을 최적화합니다. 예를 들어, 기존 노드에 텍스트만 변경되었다면 새로운 노드를 만들지 않고 기존 DOM 요소의 텍스트만 변경합니다.
- 이 과정에서 리액트는 브라우저의 DOM API를 이용합니다. (document.createElement, setAttribute, removeChild 등)
브라우저의 DOM 트리 생성
리액트가 가상 DOM을 통해 DOM 업데이트 명령을 내리면, 브라우저는 그 명령을 받아서 실제 DOM 트리를 업데이트합니다. 이 과정은 브라우저가 표준 HTML 페이지를 렌더링할 때와 동일하게 동작합니다.
브라우저가 실제 DOM 트리를 만들고 관리하는 과정은 다음과 같습니다.
- DOM 트리 생성:
- 리액트가 명령을 내리면 브라우저는 이를 바탕으로 새로운 DOM 노드를 생성하거나 기존 DOM을 수정합니다. 이 DOM 트리는 브라우저의 메모리 상에 존재하며, 화면에 표시될 요소들의 계층 구조를 정의합니다.
- CSSOM 생성:
- 브라우저는 CSS를 파싱하여 CSSOM(CSS Object Model)을 생성합니다. CSSOM은 DOM과 결합하여 각 DOM 요소의 스타일과 레이아웃 정보를 결정합니다.
- 렌더 트리 생성
- 브라우저는 DOM 트리와 CSSOM을 결합하여 렌더 트리를 생성합니다. 이 렌더 트리는 화면에 실제로 그려질 요소들만 포함하고 있으며, 숨겨진 요소(display: none)는 제외됩니다.
- 레이아웃 계산 및 페인팅:
- 브라우저는 각 요소의 크기와 위치를 계산하고, 최종적으로 화면에 픽셀을 그리는 페인팅(Painting) 작업을 수행합니다. 이 단계에서 사용자가 화면에 보게 되는 실제 렌더링이 이루어집니다.
6. 재렌더링
리액트에서 props나 state가 변경되면 리액트는 컴포넌트 단위로 렌더링을 다시 수행합니다. 이 과정에서 효율적으로 실제 DOM을 업데이트하는 방법이 리액트의 중요한 핵심입니다.
6.1 리액트 컴포넌트의 렌더링 흐름
리액트에서 상태(state)나 속성(props)이 변경되면, 해당 컴포넌트와 관련된 부분만 다시 렌더링이 이루어집니다. 이 렌더링 과정은 다음과 같은 흐름을 따릅니다.
- 상태나 props 변경:
- 컴포넌트의 setState 함수가 호출되거나 새로운 props가 전달되면, 리액트는 그 컴포넌트를 다시 렌더링해야 한다고 인식합니다.
- 컴포넌트 재렌더링:
- 리액트는 변경된 상태나 props를 반영하기 위해 해당 컴포넌트를 다시 호출합니다. 이때 새로운 JSX(또는 React.createElement 호출)가 생성됩니다.
- 가상 DOM 생성 및 비교:
- 컴포넌트가 반환한 새로운 JSX로부터 새로운 가상 DOM을 생성합니다.
- 리액트는 새로 생성된 가상 DOM과 이전에 저장된 가상 DOM을 비교합니다. 이 비교 과정은 매우 빠르게 이루어지며, Diffing 알고리즘을 통해 실제 DOM과의 차이점을 계산합니다.
- 최소한의 DOM 업데이트:
- 리액트는 변경된 부분만 찾아내어 실제 DOM을 업데이트합니다. 이렇게 함으로써 불필요한 DOM 조작을 피하고 성능을 최적화합니다.
6.2 리액트가 렌더링 성능을 최적화하는 방법
리액트는 기본적으로 전체 컴포넌트를 다시 렌더링하는 대신, 변경된 부분만 다시 계산하여 DOM을 최소한으로 업데이트하는 방식으로 성능을 최적화합니다. 여기서 중요한 두 가지 개념은 리플로우(Reflow)와 리페인트(Repaint)입니다.
리플로우와 리페인트
- 리플로우: DOM 요소의 위치나 크기가 변경될 때 브라우저는 레이아웃을 다시 계산해야 합니다. 이 과정에서 리플로우가 발생합니다. 리플로우는 모든 요소의 위치와 크기를 다시 계산하는 작업이기 때문에 성능 부담이 큽니다.
- 리페인트: 요소의 색상이나 배경이 변경되면 화면을 다시 그리는 작업이 필요합니다. 이때는 레이아웃 계산 없이 리페인트만 수행됩니다.
리액트의 효율성
리액트가 DOM 조작의 성능을 최적화하는 방법은 가상 DOM을 통해 필요하지 않은 리플로우와 리페인트를 최소화하는 것입니다.
- 가상 DOM을 통한 차별적 업데이트:
- 리액트는 상태나 props가 변경될 때, 실제 DOM을 바로 업데이트하지 않고 가상 DOM을 먼저 변경합니다.
- 새로운 가상 DOM과 이전 가상 DOM을 비교하여 최소한의 변화만 찾습니다.
- 예를 들어, 텍스트가 바뀌었으면 해당 텍스트 노드만 업데이트하고 ,다른 DOM 요소는 건드리지 않습니다. 이를 통해 전체 DOM 트리의 리플로우를 방지하고, 필요한 부분만 리페인트됩니다.
- 최소한의 리플로우:
- DOM에서 요소의 위치나 크기가 변경되면 전체 레이아웃을 다시 계산하는 리플로우가 발생합니다. 리액트는 가상 DOM을 사용하여 변경이 필요한 요소만 DOM에 반영하므로, 불필요한 리플로우가 일어나지 않도록 합니다.
- 예를 들어, 화면에서 보이지 않는 곳이나 영향을 주지 않는 영역은 업데이트되지 않으므로, 리플로우가 최소화됩니다.
- 최소한의 리페인트:
- 가상 DOM 비교를 통해 변경된 요소의 스타일이나 색상만 수정하는 경우, 리페인트 작업만 수행됩니다. 리액트는 DOM을 직접적으로 재조작하기보다는 필요한 경우에만 수정함으로써, 리페인트가 일어날 부분만 선택적으로 변경합니다.
예시: 리액트의 가상 DOM 최적화
function Counter() {
const [count, setCount] = useState(0);
return (
<div>
<p>Current count: {count}</p>
<button onClick={() => setCount(count + 1)}>Increment</button>
</div>
);
}
이 예시에서는 count가 변경될 때 리액트는 다음과 같은 최적화 과정을 거칩니다.
- 상태 변화: setCount가 호출되면 리액트는 Counter 컴포넌트를 다시 호출하고, 새로운 가상 DOM을 생성합니다.
- 가상 DOM 비교: 리액트는 이전 가상 DOM과 새로운 가상 DOM을 비교합니다. 비교 결과, <p> 태그의 텍스트가 Current count: 0에서 Current count: 1로 변경된 것을 감지합니다.
- 최소한의 DOM 조작: 리액트는 <p> 태그의 텍스트만 업데이트하고, 나머지 <button>이나 <div>태그는 그대로 유지합니다. 이로 인해 DOM 전체의 리플로우를 방지하고, 변경된 텍스트 부분만 리페인트합니다.
6.3 리액트가 없을 때와 있을 때의 차이
리액트가 없을 때, (코드 상에서)전통적인 방식으로 DOM을 직접 조작하는 경우, 작은 변경이라도 전체 DOM을 업데이트하거나, 개발자가 수동으로 DOM의 특정 부분만 찾아서 업데이트해야 합니다. 이는 복잡하고 실수가 발생할 여지가 큽니다. 특히 대규모 애플리케이션에서는 DOM 조작이 자주 일어나면 리플로우와 리페인트가 빈번하게 발생하여 성능 문제가 생길 수 있습니다.
리액트는 가상 DOM을 통해 이러한 문제를 해결하며, 상태나 props 변경 시 최소한의 DOM 조작만으로 효율적인 업데이트를 수행합니다. 이로 인해 불필요한 리플로우와 리페인트가 줄어들고, 전체 렌더링 성능이 크게 향상됩니다.
7. React 렌더링의 두 단계: Render Phase와 Commit Phase
렌더링은 Render 단게와 Commit 단계로 나뉘어 집니다.
7.1 Render Phase: 가상 DOM 생성 및 Diffing
JSX로 된 코드가 바벨에 의해 리액트 엘리먼트들로 바뀌거나 Diffing 연산을 하는 부분을 렌더링에서는 Render Phase에 해당하는 것들입니다.
7.2 Commit Phase: 실제 DOM에 반영
초기 렌더링에서는 위 사진처럼 리액트 엘리먼트를 만들고 (Render Phase) 바로 Real DOM에 커밋(Commit Phase)을 합니다.
- 여기서 '커밋'이라는 단어는 우리가 git을 사용할 때나 데이터베이스 트랜잭션에서 사용할 때와 마찬가지의 역할을 하는 것을 알 수 있습니다.
리렌더링이 일어날 때는 렌더링 전의 리액트 엘리먼트 트리와 이후의 리액트 엘리먼트 트리를 비교하여 최소한의 연산을 계산(Render Phase)한 후에 Real DOM에 반영 (Commit Phase) 시킵니다.
이렇게 각각 렌더 단계와 커밋단계로 나누었을 때 알 수 있는 것은 컴포넌트가 렌더링 된다고 해서 Real DOM 조작(manipulation)이 무조건 일어나는 것은 아니라는 것입니다.
바뀐 부분이 존재하지 않으면 리액트는 Diffing 과정에서 Real DOM에 커밋할 사항을 만들어내지 않습니다.
또한 커밋 단계에서는 개발자가 조작할 수 있는 부분은 없습니다. ReactDOM 라이브러리에게 책임을 맡긴 것이라고 할 수 있죠. 하지만 Render Phase는 개발자가 충분히 조작할 수 있습니다.
우리는 리액트 엘리먼트의 트리를 잘 설계하여 Render Phase는 컨트롤 할 수 있습니다. 공식문서에 의하면 커밋 단계는 엄청 빠르지만 렌더 단계는 느릴 수 있다고 명시해 둔 것을 볼 수 있습니다. 그렇기 때문에 리액트 엘리먼트 트리의 depth가 깊어지고 노드들이 많아질수록 우리는 React.memo를 활용하여 하위 트리 렌더링을 막는 등의 최적화를 해줘야 합니다.
정리해보면 아래와 같습니다.
Render Phase
- Render Phase는 리액트가 컴포넌트의 JSX 코드를 바탕으로 가상 DOM을 생성하고, 기존 가상 DOM과 비교하는 Diffing 과정이 일어나는 단계입니다.
- 컴포넌트가 호출되어 JSX를 반환하고, 이 JSX는 바벨을 통해 자바스크립트 객체로 변환되는데, 이 객체가 바로 리액트 엘리먼트 트리입니다.
- 초기 렌더링이나 리렌더링이 발생할 때, 이 트리를 생성한 후 이전 가상 DOM과 비교하여 어떤 부분이 변경되었는지 계산합니다.
- 특징: Render phase에서는 브라우저의 실제 DOM을 수정하지 않으며, 순수하게 가상 DOM 내에서 일어나는 계산 과정일 뿐입니다. 컴포넌트가 렌더링된다고 해서 무조건 DOM 업데이트가 발생하는 것은 아닙니다.
Commit Phase
- Commit Phase는 Render Phase에서 변경된 가상 DOM이 실제 DOM에 반영되는 단계입니다.
- 여기서는 리액트가 변경 사항을 브라우저의 실제 DOM에 적용하고, DOM 노드에 새로운 속성을 설정하거나 스타일을 수정하는 작업이 이루어집니다.
- 특징: Commit Phase는 상대적으로 매우 빠르며, 이 단계에서 개발자가 조작할 수 있는 부분은 없습니다. ReactDOM이 DOM을 직접 업데이트하고 이때 DOM 업데이트는 필요 최소한으로 이루어집니다.
8. React.memo와 useMemo 사용법 및 주의사항
React.memo
- 역할: React.memo는 컴포넌트의 불필요한 렌더링을 방지하기 위한 고차 컴포넌트(HOC)입니다.
- 기본적으로 부모 컴포넌트가 리렌더링될 때, 자식 컴포넌트도 자동으로 리렌더링됩니다. 그러나 자식 컴포넌트의 props가 변경되지 않았다면, 굳이 자식 컴포넌트를 다시 렌더링할 필요가 없겠죠.
- React.memo는 해당 컴포넌트의 props가 변경되지 않으면, 해당 컴포넌트를 다시 렌더링하지 않고 이전 결과를 재사용합니다.
- 어느 단게에서 작동하는가: Render Phase에서 최적화됩니다.
- React.memo는 리렌더링이 발생할 때, 이전의 props와 새로운 props를 비교하여 변화가 없다면 컴포넌트가 다시 호출되지 않도록 막습니다. 즉, Render Phase에서 불필요한 컴포넌트 호출을 줄여줍니다.
- 하지만, 만약 props가 변경되었다면, 여전히 Render Phase는 진행되며, 이어서 Commit Phase로 이어지게 됩니다.
useMemo (useCallback도 유사함)
- 역할: useMemo는 특정 계산 결과를 캐싱하여, 해당 값이 다시 계산되는 것을 방지하는 훅입니다.
- 컴포넌트가 렌더링될 때, 복잡한 계산이나 무거운 연산을 매번 다시 실행하지 않기 위해, useMemo를 사용하여 그 결과를 메모이제이션할 수 있습니다.
- 예를 들어, 컴포넌트에서 연산이 많이 소요되는 로직이 있을 경우, 의존성 배열(dependencies)이 변경되지 않는 한 그 연산을 다시 하지 않도록 합니다.
- 어느 단계에서 작동하는가: Render Phase에서 작동합니다.
- useMemo는 컴포넌트가 다시 렌더링될 때, 해당 계산이 다시 실행되지 않도록 합니다. 이는 가상 DOM이 생성되고 비교되는 Render Phse 동안 불필요한 연산을 방지합니다.
React.memo와 useMemo의 차이점
- React.memo는 컴포넌트 단위에서 렌더링을 최적화하여 불필요한 컴포넌트 리렌더링 자체를 방지합니다.
- useMemo는 컴포넌트 호출을 방지하는 차원이 아닌 컴포넌트가 호출되었을 때 그 내부에서 복잡한 계산 로직만을 최적화하여, 매번 연산이 수행되지 않도록 한다는 차이가 있습니다.
주의 사항
React.memo와 useMemo는 각각 의존성을 통해 최적화를 하는데, 그 방식에서 차이가 있습니다.
먼저 React.memo는 컴포넌트의 props를 비교하여 최적화를 합니다. 부모 컴포넌트가 리렌더링될 때 자식 컴포넌트도 리렌더링이 될 수 있는데, React.memo는 이때 props가 변경되지 않았다면 자식 컴포넌트를 다시 호출하지 않고 이전에 렌더링된 결과를 재사용합니다.
이때 기본적으로 shallow comparison(얕은 비교)를 통해 props의 변경 여부를 판단하기 때문에, props가 원시 값(숫자, 문자열)이라면 그 값 자체를 비교하고, 객체나 배열 같은 참조형 데이터 타입인 경우 그 참조 값을 가지고 비교를 하게 됩니다.
예시:
const MyComponent = React.memo(({ count }) => {
console.log('렌더링');
return <div>{count}</div>;
});
// 부모 컴포넌트에서 count 값이 동일하다면 MyComponent는 다시 렌더링되지 않음
이처럼 props가 변경되지 않으면 컴포넌트 전체를 리렌더링하지 않으므로 React.memo는 props를 의존성처럼 사용합니다.
반면 useMemo는 특정 값을 계산할 때 의존성 배열(dependencies)을 기반으로 최적화합니다.
useMemo는 주어진 의존성 배열에 있는 값들이 변경되지 않으면, 해당 메모이제이션된 값을 재사용하여 불필요한 연산을 방지며,
의존성 배열이 변경되었을 때만 해당 계산을 다시 수행합니다.
const expensiveComputation = (num) => {
sleep(10);
return num * 2;
};
function MyComponent({ num }) {
const computedValue = useMemo(() => expensiveComputation(num), [num]);
return <div>{computedValue}</div>;
}
// num 값이 변경되지 않으면 expensiveComputation 함수는 다시 실행되지 않음
useMemo는 의존성 배열을 기반으로 메모이제이션을 하여, 지정된 값들이 변경되지 않으면 다시 계산하지 않고 이전 값을 재사용합니다.
8.1 메모이제이션의 Side effect(부작용)
React.memo는 언뜻보면 컴포넌트의 불필요한 렌더링을 방지해 성능을 최적화할 수 있기 때문에 모든 상황에서 사용하는 것이 좋다고 생각이 들 수 있지만 무조건 사용한다고 좋은 것이 아니고, 심지어 오히려 성능이 떨어질 수 있는 상황도 있기에 적절하게 사용하는 것이 중요합니다.
React.memo는 기본적으로 shallow comparison를 사용하여 props가 변경되었는지 판단합니다. 얕은 비교는 원시 값의 경우는 빠르게 비교할 수 있겠지만, 참조형 데이터(배열, 객체, 함수 등)의 경우에는 해당 참조만을 비교하므로, 구조가 복잡한 객체나 배열을 가지고 있을 때 그 내용을 정확히 비교하지는 못한다는 단점이 있습니다.
만약, 참조형 데이터가 리렌더링 될 때마다 새로운 참조값을 가진다면, React.memo는 얕은 비교를 통해 "변경됨"으로 판단하게 되는데, 이 경우 실제로를 값이 동일하더라고 React.memo가 예상한 효과를 거두지 못하고 컴포넌트가 여전히 리렌더링될 수 있음을 의미하게 됩니다.
이 때 React.memo가 props의 변경 여부를 확인하기 위한 비교 비용이 발생하는데, 비교 연산 자체는 빠르게 수행되지만, 컴포넌트의 구조가 매우 간단하고 재렌더링 비용이 낮은 경우, 굳이 props를 비교하는 것보다 그냥 컴포넌트를 다시 렌더링하는 것이 빠를 수 있는 것입니다.
또한 상태나 props가 자주 변동되는 컴포넌트는 React.memo를 적용해도 의미가 없습니다. React.memo는 props가 변경되지 않았을 때만 렌더링을 방지하는데, props가 자주 바뀌는 컴포넌트는 어차피 매번 리렌더링이 필요하기 때문에 memoization의 효과를 얻기 어렵습니다.
8.2 함수와 객체를 props로 넘길 때 주의
React.memo는 얕은 비교를 사용하기 때문에 객체나 함수 같은 참조형 데이터가 props로 전달되면 매번 새로운 참조값을 가지기 때문에 매번 리렌더링이 발생할 수 있습니다.
예를 들어, 부모 컴포넌트에서 함수를 자식 컴포넌트의 props로 전달할 때, 부모 컴포넌트가 리렌더링되면 해당 함수가 새로 호출이 되기 때문에 React.memo는 자식 컴포넌트가 props가 변경되었다고 판단하여 다시 렌더링하게 됩니다. 이를 해결하기 위해선, useCallback과 같은 훅을 사용해 함수의 참조를 유지할 필요가 있습니다.
const Parent = () => {
const handleClick = () => {
console.log('clicked');
};
return <Child onClick={handleClick} />;
};
// 위의 예시에서는 매번 Parent가 리렌더링될 때 handleClick 함수가 새로 생성되기 때문에
// React.memo로 Child를 감싸도 리렌더링이 발생할 수 있음.
8.2 React.memo 사용에 적합한 경우
React.memo는 모든 컴포넌트에 무조건적으로 사용하는 것이 아니라, 리렌더링 비용이 높은 경우에 선택적으로 사용하는 것이 좋습니다. 다음과 같은 경우에는 React.memo 사용이 유리합니다.
- 컴포넌트가 복잡하고 리렌더링 비용이 큰 경우: 복잡한 자식 컴포넌트가 여러가지 렌더링 로직을 가지고 있고, props가 자주 변경되지 않으면 React.memo를 적용하여 성능을 개선할 수 있습니다.
- props 변경이 적은 컴포넌트: 부모 컴포넌트가 리렌더링되더라도, props가 거의 변경되지 않는 자식 컴포넌트에 React.memo를 적용하면 불필요한 리렌더링을 방지할 수 있습니다.
- 비동기 데이터나 API 호출을 많이 사용하는 컴포넌트: 외부에서 데이터를 받아와 화면에 렌더링할 때, 데이터가 자주 변경되지 않는다면 React.memo를 통해 데이터를 최적화할 수 있습니다.
9. 정리
다시 리액트의 렌더링으로 돌아와 내용들을 정리해보겠습니다.
리액트의 렌더링이란 컴포넌트가 props와 state를 통해 UI를 어떻게 구성할지 컴포넌트에게 요청하는 작업을 일컫는 말입니다.
즉, props와 state가 변화해서 트리거를 건드리면 리액트가 컴포넌트에게 현재 UI를 어떻게 재구성할지를 요청하는 과정이 일어나는 것입니다. 이 과정에서 가상 DOM이 새로 생성되거나 가상 DOM이 업데이트가 됩니다. 그러고 나서 브라우저 렌더링이 일어나 사용자가 첫 화면을 보게 되는 것입니다.
가상 DOM이란 리액트가 UI 상태를 메모리상에서 관리하는 가상적인 구조(즉, 자바스크립트 객체)를 의미합니다.
가상 DOM에서 state가 변화하면 그에 맞는 변경사항들을 루트 노드로부터 차례대로 계산을 진행하고 이를 실제 DOM에 변경된 부분만을 업데이트를 요청합니다.
앞서 가상 DOM을 메모리 상에서 관리하는 가상적인 구조라고 설명했는데 이것들은 모두 자바스크립트 객체로 표현된 것들입니다.
그렇다면 실제 DOM은 무엇으로 구성되었을까요? 실제 DOM 또한 자바스크립트 객체입니다.
- 실제 DOM
- 브라우저가 화면을 렌더링하기 위해 사용하는 자바스크립트 객체
- 접근/수정 시 DOM API를 사용해서 직접적으로 화면을 다시 그림
- 실제 DOM이 변경될 때마다 화면을 다시 그리면 브라우저의 작업량이 늘면서 성능이 낮아짐
- 가상 DOM
- 실제 DOM을 추상화한 상태
- 브라우저 화면과는 부리된 자바스크립트 객체
- 접근/수정 시 실제 브라우저 화면에 영향이 가지 않음
앞서 살펴보았던 리액트의 렌더링 프로세스 중 리액트 렌더링은 크게 두 부분으로 나눌 수 있었습니다.
렌더 단계는 실제 DOM에 반영할 변경 사항만을 파악하는 단계이고, 커밋 단계는 실제 DOM에 반영하는 단계입니다.
최초 렌더링과 리렌더링은 그 프로세스가 살짝 다릅니다.
최초 렌더링
<트리거>: 처음 애플리케이션에 진입했을 때
<렌더 단계>:
- 컴포넌트를 루트부터 차례대로 호출하면서 ReactElement를 생성
(JSX -> React.creatElement 호출 -> React Element 객체) - 이렇게 만들어진 수많은 객체 집합들은 트리 형태로 결합되어 가상 DOM을 생성
- 이 경우(최초 렌더링), 이전에 비교할 가상 DOM이 없기 때문에 모든 컴포넌트들이 렌더링 대상이 되어 생성된 가상 DOM을 실제 DOM에 그대로 업데이트시킴.
리렌더링
<트리거>:
- useState의 setter가 실행되는 경우
- useReducer의 dispatch가 실행되는 경우
- key props가 변경되는 경우
<렌더 단계>:
- 최초 렌더링에서 진행된 과정이 똑같이 진행됨(컴포넌트 호출)
- 가상 DOM 비교 작업.
- React Element 객체의 집합들로 새로운 next 가상 DOM을 만든다. next 가상 DOM과 prev 가상 DOM은 diffing 알고리즘을 통해 비교.
- diffing: 두 가상 DOM 트리 간의 차이점을 효율적으로 찾아내어, 실제로 변경된 부분만을 선택적으로 업데이트하는 과정
- 이때 diffing 알고리즘은 tag, props, key 등을 비교하여 차이점을 효율적을 찾아낼 수 있음
<커밋 단계>:
새로 생성된 next 가상 DOM이 실제 DOM에 변경 사항이 있는 부분만 업데이트가 됨. 이때 batch update가 진행됨.
- batch update: 집단이라는 뜻을 가진 batch에 따라 일괄적으로 실제 DOM을 한 번만 업데이트 하는 것을 의미.
여기서 등장한 diffing 알고리즘과 batch update 과정을 재조정(reconciliation)이라고 하는 것입니다.
Q1. 그렇다면 리액트의 렌더링이 곧 DOM 업데이트일까요?
- 트리거가 발생하여 리액트의 렌더링이 일어났는데 prev 가상 DOM과 next 가상 DOM 간의 diffing 알고리즘으로 계산한 결과 변경사항이 없었다면 커밋 단계가 생략이 되는데 커밋 단계가 곧 DOM 업데이트를 의미하기 때문에, 이 경우에는 리액트의 렌더링은 발생했지만 DOM 업데이트는 일어나지 않은 경우라고 말할 수 있겠습니다.
Q2. 리렌더링 시마다 가상 DOM을 매번 생성해줄까요?
- 아닙니다. 메모리 효율을 위한 테크닉을 사용합니다.
- next 가상 DOM은 실제 DOM에 반영이 되는 그 순간부터 prev 가상 DOM이 되며, prev 가상 DOM이었던 것이 next 가상 DOM이 되는 식으로 두 개의 객체로만 운용이 됩니다.
Q3. 리액트는 왜 이런 복잡한 프로세스를 가지는 것일까요?
- 리액트는 UI 업데이트만을 도와주는 것이 아니라 이를 더욱 효율적으로 실행하기 위한 동작들이 구현되어 있기 때문입니다.
- DOM 변경이 발생되면 웹 브라우저는 스타일과 레이아웃을 다시 계산하고 페이지를 다시 그려야 합니다. 이때 가상 DOM을 사용하여 실제 DOM에 대한 직접 변경 수를 줄일 수 있습니다.
- 가상 DOM을 통해 변경된 부분만 선택적으로 업데이트하여 사용자에게 빠르고 매끄러운 UI 경험을 제공합니다.
'Front-end' 카테고리의 다른 글
[kakao tech bootcamp] React 컴포넌트를 PDF로 만들기(react-pdf(O), react-to-pdf(X)) (10) | 2024.11.12 |
---|---|
OpenAI(ChatGPT)가 Next.js에서 Remix로 전환한 이유 (1) | 2024.11.11 |
[React] Redux가 좋은 경우 vs Zustand가 좋은 경우 (0) | 2024.11.10 |
[kakao tech bootcamp] React에서 드래그한 텍스트를 관리 하는 방법 (text selection, getSelection API) (8) | 2024.11.08 |
[Front-End] 웹 브라우저 동작 방식 이해 (feat. 렌더링) (1) | 2024.09.17 |
소중한 공감 감사합니다