이 글에서는 react로 많이 넘어온 이유들 중 virtualDOM 등장 전후 배경에 대해서만 다룬다.
virtualDOM 전의 기존 배경
1. 비효율적인 브라우저의 렌더링 과정
브라우저의 렌더링 가정을 간략화하자면 다음과 같다.
- 개발자가 작성한 HTML 파일을 브라우저가 받는다.
- 웹 엔진의 HTML/XML parser가 HTML 파일을 파싱하여 DOM node로 이루어진 DOM tree를 생성한다.
- 이후 css 파일도 CSS parser가 파싱하여 CSSOM tree를 생성한다.
- 생성된 DOM tree와 CSSOM tree를 합쳐서 render tree를 생성한다.
- Layout 단계 이전에 브라우저의 자바스크립트 엔진이 JS파일을 읽어 DOM API를 사용해 render tree를 변경.
- Layout 단계에서 render tree를 토대로 화면에 요소가 정확히 어디에 그려져야 하는지 계산하여 정한다.
- 앞선 과정에서 얻은 정보들을 바탕으로 요소에 색을 입히는 painting 과정을 거친다.
- 이 모든 과정에 끝나면 애플리케이션의 UI가 화면에 display 된다.
이와같이 화면에 요소를 그릴 때 많은 자원이 소모된다.
리렌더링 : 자바스크립트가 DOM, CSSOM을 변경하는 경우 발생(렌더링 과정을 또 거치는 것이다.)
리플로우: 레이아웃 계산을 다시 하는 것
리페인트: 재결합된 렌더 트리를 기반으로 다시 페인트를 하는 것
2. 기존의 SSR 방식
예전에는 서버 쪽에서 데이터와 함께 하나의 정적인 페이지를 완전히 렌더링 해서 브라우저로 넘겨주는 ssr 방식을 많이 사용했다. 그러나 ssr에는 크게 두 가지 문제가 있었다.
첫 번째, 서버가 각 기기의 브라우저 요청에 맞게 화면을 렌더링해서 보내줘야 했기 때문에 각기 다른 여러 화면을 그려야 했다. 그래서 역할 분리의 필요성이 거론되었다.
두 번째, 작은 변화에도 페이지 (하나)전체를 처음부터 다시 렌더해서 브라우저로 보내야 했기 때문에 자원의 낭비가 불필요하게 컸다.
3. AJAX, jQuery, CSR의 등장
AJAX 기술의 등장
이 때 AJAX 기술이 등장(1999)한다. 당시에는 넓게 활용되지 못하다가 '🔗AJAX: A New Approach to Web Applications'이라는 글에서 처음 소개 되었다. 이 글은 2005년 2월에 발표되었고, 그 이후 AJAX라는 용어와 기술이 폭넓게 사용되기 시작했다. AJAX 기술 덕분에 데이터가 바뀐 화면의 일부만 업데이트 할 수 있게 되었다. 기존에 작은 변화에도 모든 것을 다시 그려야 했던 단점을 극복할 수 있게 해준 기술이다. 이 AJAX 기술을 이용한 대표적인 라이브러리가 바로 jQuery다.
jQuery
api로 받아온 데이터의 변화로 ui를 바꿔야 했을 때 처음에는 AJAX 호출(e.g XMLHttpRequest)을 쉽게 할 수 있는 jQuery를 많이 사용했다. 또한 기존의 까다로운 DOM API(e.g. 브라우저 환경마다 해당 DOM API가 있는지 확인해야 했음.)를 보다 짧은 코드로 쉽게 사용할 수 있었다. 또한 Animation 특히 Transition 효과를 보다 쉽게 구현할 수 있게 해주어 동적인 화면을 표현할 수 있게 해주었다(기존에는 transitoin 시간 중간중간의 display를 직접 구현해야 했다.). 이러한 이유들로 상당한 인기를 얻었다. 하지만 jQuery는 성능 면에서 좋지 못했다.
그 이유로 크게 두 가지를 꼽을 수 있다.
첫 번째, 브라우저 별로 DOM API가 달랐기에 크로스 브라우징 문제를 해결하고자 여러 DOM API를 내부적으로 래핑하였다는 점이다. 결과적으로 jquery 메서드를 사용하면 속도가 기존에 비해 느렸다.
두 번째, 매번 DOM을 직접적으로 접근하여 조작한다는 문제가 있었다. DOM을 조작할 수 있는 API가 다양해도 너무 다양했다. 자칫하면 명령형 프로그래밍을 짜다가 실수로 잘못된 코드를 짤 수 있는 위험도 있었고, 작은 변화 하나하나마다 브라우저의 모든 렌더링 과정을 거치는 것은 마찬가지였기에 성능에 무리가 되었다.
CSR 방식과 jQuery
한편 점점 브라우저와 Javascript가 발전하면서 브라우저(클라이언트) 단에서 렌더링하려는 방식으로 기술이 변화하기 시작했다. 서버에서는 `REST API`와 `GraphQL`같이 클라이언트가 필요한 데이터만 제공하는 형태로 기술이 변화했다.
이 때 고안된 방식이 번들링된 html, css, js 파일을 브라우저로 보내고 서버는 오로지 브라우저와 데이터베이스의 중간에서 데이터 매개자의 역할에 집중할 수 있는 CSR 방식이다. CSR 방식 덕분에 비로소 서버와 클라이언트 사이드의 명확한 분리가 이루어지게 된다. csr에서 클라이언트 브라우저는 애플리케이션 전체 페이지에 대한 html, css, js 번들 파일을 전달받고 그리는 작업을 수행한다. 페이지 전환 때는 api 호출을 통해 받아온 데이터를 껴서 렌더링하게 되었다.
`DOM`을 직접적으로 다루는 행위가 급격하게 감소했고, `상태(state)`를 기준으로 `DOM`을 렌더링 하는 형태로 발전했다. `DOM`이 변하는 경우가 `state`에 종속 되어버린 것이다. 반대로 말하면, `State`가 변하지 않을 경우 `DOM`이 변하면 안 되는 것이다.
이러한 과정 속에서 Client-Side Rendering이라는 개념과 상태관리라는 개념이 생기게 되었다.
jQuery는 이러한 웹 생태계의 변화에도 맞지 않았다. 현재의 웹 개발 트렌드에서는 모듈화와 컴포넌트 기반 아키텍쳐, 그리고 상태관리가 강조되고 있다. jQuery는 단방향 데이터 흐름의 부재와 컴포넌트 기반 아키텍쳐의 부재로 복잡한 상태 관리에 불리할 수밖에 없다. 또한 AJAX 기술의 발전(e.g. `XMLHTTPRequest`를 보다 짧게 구현할 수 있는 `fetch` 등장), 자바스크립트의 발전, 브라우저의 발전 및 표준화 등이 맞물려서 jQuery는 사실상 legacy로 남게 되었다.
이러한 웹 생태계의 변화에서 떠오른 것이 Angular, React, Vue 라이브러리 혹은 프레임워크인데 아래에서는 가장 인기를 끈 React의 virtualDOM에 대해서 다뤄보도록 한다.
virtualDOM
vitrualDOM은 기존의 DOM tree를 복제한 자바스크립트 객체이다. vitrualDOM은 DOM보다 가볍다.
더 가벼운 이유는 `style`이나 `class`등의 속성은 가지고 있지만 화면에 변화를 직접 줄 수 있는 기능인 `getElementById` 등과 같은 DOM API메서드는 갖고 있지 않기 때문이다.
virtualDOM의 동작 방식
최초에 브라우저가 실제 DOM tree를 생성하고 브라우저에 우리 애플리케이션 UI가 렌더된다.
이 때 vitrualDOM은 DOM tree를 가벼운 버전으로 복사를 한다. 그리고 DOM node에 변화가 생기면, virtualDOM은 새로운 가상의 virtualDOM을 다시 처음부터 만들게 된다.
변화가 생길 때마다 가상 돔을 다시 처음부터 만들면 비효율적인 것 아닌가?
그렇게 생각할 수도 있다. 하지만 DOM node를 조작하는 것의 비효율성은 DOM tree를 업데이트 하는 과정에서 발생하는 것이 아니라 렌더링하는 과정에서 비싼 비용이 드는 것이다. 가상 돔은 실제로 렌더링하는 것이 아니라 메모리상에서 tree를 변경하는 일이기 때문에 상당히 빠르게 작업이 진행될 수 있다.
가상 돔은 이전 가상 돔과 현재의 가상 돔을 비교하여 실제로 변경된 사항만을 전부 가상 돔에 반영한 후 변경된 부분만을 모아서 실제 DOM에 적용하여 한 번만 렌더링 함으로써 성능을 최적화한다.
diffing 알고리즘과 react key
재조정 : virtualDOM과 실제 DOM을 비교하고 일치시키는 과정
리액트는 이전 가상돔과 이후 가상돔을 비교한 후 변화된 부분만을 감지한 후에 실제 DOM에 반영하게 된다.
이 비교하는 과정에서 diffing 알고리즘을 사용한다.
`React.createElement`
React.createElement(
type, // 태그 이름 문자열 | 리액트 컴포넌트 | React.Fragment
[props], // 리액트 컴포넌트에 넣어주는 데이터 객체
[ ... children] // 자식으로 넣어주는 요소들
);
`React.createElement`로 만든 JSX객체가 자바스크립트 객체로 변환되면 `type` 프로퍼티를 갖게 된다. 변경 전의 타입과 변경 후의 타입이 같은지 비교하여(`type` === `type`?) 엘리먼트 타입이 같을 경우 변경 전 엘리먼트 속성과 변경 후 엘리먼트 속성을 비교하여 동일한 내역은 유지하고 변경된 속성들만 갱신한다.
a 태그에서 img 태그로 또는 A컴포넌트에서 B컴포넌트로의 경우처럼 타입이 달라진 경우(`type` !== `type`?) 리액트는 이전 트리를 삭제하고 완전히 새로운 트리를 만든다.
리액트에서 `key` prop을 사용하는 이유가 이 재조정과 깊은 연관이 있다.
예시를 들어서 key prop의 중요성을 보도록 하자.
// before
<ul>
<li>first</li>
<li>second</li>
</ul>
// after
<ul>
<li>first</li>
<li>second</li>
<li>third</li>
</ul>
첫 번째 노드와 두 번째 노드는 동일하고 마지막에 하나만 더 추가되었기 때문에 문제없이 추가된 노드만 새로 그려진다.
또 다른 예시를 보자.
// before
<ul>
<li>mike</li>
<li>john</li>
</ul>
// after
<ul>
<li>billy</li> // 추가된 노드
<li>mike</li>
<li>john</li>
</ul>
새로운 `<li>` 태그 엘리먼트가 첫 번째 위치에 추가 되었다.
리액트는 이 상태를 보고 모든 요소가 제자리에 위치하지 않았다고 생각하고 자식 노드를 전부 새로 그리게 된다.
이런 문제는 성능 이슈를 유발할 수 있다.
이러한 문제를 해결하기 위해서 식별자로 key prop을 사용한다.
자식 노드들이 key prop을 갖고 있으면 리액트는 key값으로 이전 트리와 변경 이후 트리를 비교한다. 그러면 두 번째 예시처럼 첫 번째에 새로운 노드가 추가되어도 문제없이 추가된 노드만 그릴 수 있게 된다. 그런데 key 값은 변경되지 않는 유일한 값을 넣어줘야 한다.
배열 index를 key 프롭에 할당하면 안 되는 이유
배열이 바뀔 때마다 key 프롭에 전달되는 배열의 인덱스가 0부터 n까지 새롭게 할당된다. 변경될 수 있는 값을 키로 주는 것이기 때문에 추가된 노드와 추가되기 전 노드 사이에서 문제를 유발할 수 있다. 예를 들어 위 예시코드에서 배열 인덱스를 `<li>`태그에 키값으로 줬다고 가정해보자. billy에 무엇인가를 하려고 했는데, mike에 그 무엇인가가 일어나는 부작용을 보게 될 것이다.
리액트만의 렌더링 과정
(원래 안 다루려고 했는데 virtualDOM이랑 밀접한 내용이라 적어두기로 했다.)
위에서 vitrualDOM은 실제로 렌더링 하는 것이 아니고 메모리 상에서 tree를 변경하는 일이라고 하였다. 그러면 브라우저에서 뜻하는 렌더링과는 그 의미와 과정에 차이가 있다는 뜻인데 리액트에서 말하는 렌더링이란 어떠한 과정일까?
(virtualDOM을 활용한) 리액트의 렌더링 과정은 크게 Trigger, Render, Commit 3 가지 단계로 이루어져 있다.
초기 렌더링 추상화

리렌더링 추상화

Trigger 단계
컴포넌트는 두가지의 상황에서 렌더링된다.
- 초기 렌더링 상황
- 루트 노드에서 createRoot() 를 호출 → 이후, 루트 컴포넌트와 함께 render 메소드 호출
- (해당 컴포넌트나 컴포넌트 부모의) state가 업데이트 된 상황
- 첫번째 랜더링 이후, set 함수를 이용해 컴포넌트의 state를 업데이트 하면 렌더링 대기열에 추가
렌더링 조건이 충족되었을 때 렌더링하라고 신호를 보내는 것이 Trigger 단계이다.
Render 단계
trigger 이후, React가 컴포넌트를 호출하여 화면에 무엇을 표시할지 파악하는 단계이다.
리액트에서의 "Rendering"은 컴포넌트를 호출하는 것이다. 브라우저 렌더링과는 다르다. 아래에서 얘기할 렌더링은 리액트에서의 렌더링을 뜻한다. 이 렌더링 절차는 재귀적이라 자식 컴포넌트들이 반환하는 모든 것들을 렌더링한다.
- Re-render 상황
- re rendering 을 하는 동안 이전 렌더링 이후 변경된 state를 계산하고 해당 정보로 commit 단계 전까지 아무 작업도 수행하지 않음.
랜더링은 언제나 🔗순수해야 한다.
컴퓨터 과학에서의 ‘순수함’ ⇒ 자신의 일만 생각함, 같은 입력에 같은 출력
Render 단계에서의 성능 최적화
업데이트 된 컴포넌트의 모든 nesting된 컴포넌트들을 렌더링하는 기본 로직은 만약 업데이트된 컴포넌트가 tree상에서 매우 높은 위치에 위치해있다면 최적화된 성능을 끌어내기는 힘들다.
만약 이러한 성능 이슈를 직면한다면, 몇가지 해결할 수 있는 최적화 방법들이 있다.
우리가 많이 들어본 `useMemo`, `useCallback`, 그리고 react 18에 추가된 `useTransition`, `useDefferedValue`가 여기에 해당한다. 자세한 사항은 🔗링크를 참고하자.
Commit 단계
실제 DOM으로 commit 하는 단계를 의미한다.
- 초기 랜더링
- `Node.appendChild()` DOM API (👉리액트만의 API가 아님)를 이용해 생성한 DOM노드를 화면에 표시
- 말 그대로 브라우저 초기 렌더링 단계를 완수하는 것
- Re-Render 상황
- virtualDOM을 통해 이전 렌더와의 차이점을 파악한 정보를 바탕으로 실제 DOM node 변경하여 UI 업데이트
Virtual DOM 과 Rendering의 관계
- Trigger → 컴포넌트의 Render method가 트리거되어 Virtual DOM 생성. 메모리상에 존재.
- Render → 변경된 부분들을 다 포함한 전체 Virtual DOM트리 생성. 이후 이전 가상 DOM비교하여 DOM 에 적용할 변경 사항 계산.
- Commit → 변경된 부분만 커밋하여 실제 돔에 반영 후 메모리에서 가상 돔 삭제
Q. 리액트의 렌더링 과정에 대해 설명해보세요.
A. 트리거 단계에서는 컴포넌트 자신 혹은 부모 컴포넌트의 상태(state)나 속성(props)이 변경된다면, 해당 컴포넌트는 렌더링이 필요하다는 신호를 받습니다. 렌더 단계에서는 트리거 된 컴포넌트의 업데이트가 필요한 부분을 가상 DOM에 적용합니다. 이후 커밋 단계에서 가상 DOM에 적용된 변경 사항만 실제 DOM에 적용하여 UI를 업데이트 합니다.
레퍼런스
1. https://react.dev/learn/render-and-commit
2. https://react.dev/reference/react/hooks#performance-hooks