React - Diffing Algorithm - Key = 0

hannaxannah·2024년 6월 21일
0
post-thumbnail

React의 동작원리에 대해 공부하던 중, 두 트리 간의 비교 과정에 대해 저 자세히 공부해 보고자 글을 작성하게 되었습니다.


React빠르고 효율적인 UI 업데이트를 지원하는 자바스크립트 라이브러리입니다.

React는 가상 DOMDiffing 알고리즘을 사용하여 여러 번의 렌더링 과정을 압축하고 최소한의 렌더링 단위를 만들어 냅니다. 이러한 개념들을 자세히 살펴보며 React의 성능 최적화에 중요한 역할을 하는 keyReact Fiber에 대해서도 알아보도록 하겠습니다.


가상 DOM(Virtual DOM)이란?

DOM(Document Object Model)은 웹 페이지에 대한 인터페이스로 브라우저가 웹페이지의 콘텐츠와 구조를 어떻게 보여줄지에 대한 정보를 담고 있습니다.

브라우저가 웹사이트 접근 요청을 받으면 브라우저의 렌더링 엔진이 동작을 수행하는데, 이때 브라우저의 렌더링 엔진은 DOM Tree와 CSSOM Tree를 만들고 이를 순회하며 렌더링을 수행합니다. 이 과정은 매우 복잡하고 많은 비용이 발생하며 이러한 비용은 오롯이 브라우저와 소비자가 지불하게 됩니다.

이러한 문제점을 해결하기 위해 가상 DOM이 탄생했고 가상 DOM은 말 그대로 실제 브라우저의 DOM이 아닌 React가 관리하는 가상의 DOM을 의미합니다. 가상 DOM은 웹 페이지가 표시해야 할 DOM을 일단 메모리에 저장하고 React가 실제 변경에 대한 준비가 완료됐을 때 실제 브라우저의 DOM에 변경 사항을 반영합니다.


가상 DOM 그만!! Value UI라고 불러다오 feat. React Fiber

우리가 줄곧 가상 DOM이라고 불러왔던 것은 자바스크립트 객체로 이루어진 트리입니다. 그리고 이러한 트리를 구성하는 자바스크립트 객체를 React Fiber라고 합니다. React는 초기 렌더링 시 애플리케이션에 존재하는 모든 컴포넌트 인스턴스를 추적하고, 컴포넌트 인스턴스의 내부 데이터 구조를 Fiber라는 객체에 저장합니다.

다음은 이 Fiber 라는 것을 실제 React 코드에서 구현한 모습입니다.

function FiberNode( // 원문과는 다르게 인자에서 type 추론을 진행합니다. 
  this: $FlowFixMe,
  tag: WorkTag,
  pendingProps: mixed,
  key: null | string,
  mode: TypeOfMode,
) {
  // Instance
  this.tag = tag; // Fiber Node의 유형을 나타냅니다. 함수형 컴포넌트, 클래스 컴포넌트, 호스트 컴포넌트 등을 의미합니다. 
  this.key = key; // React에서 key 속성을 의미합니다. 리스트에서 요소들을 구별할때 사용됩니다. 재조정 과정에서 중요한 역할을 수행합니다. 
  this.elementType = null; // +) 기존과는 다르게 새롭게 추가되었습니다. React 요소의 타입입니다. 예를 들어, <MyComponent />에서 elementType은 MyComponent가 됩니다.
  this.type = null; // 컴포넌트의 구체적인 타입 정보를 담습니다. elementType과 유사하지만, 고차 컴포넌트나 다른 추상화를 다룰 때 차이가 나타날 수 있습니다.
  this.stateNode = null; // Fiber Node와 연관된 실제 DOM 노드나 React 컴포넌트 인스턴스를 가리킵니다. 클래스 컴포넌트의 경우 컴포넌트 인스턴스가 여기에 해당합니다.

  // Fiber
  this.return = null; // 부모 FiberNode
  this.child = null; // 부모입장에서 첫번째 자식노드
  this.sibling = null; // 자신의 바로 "다음" 형제 노드를 칭함
  this.index = 0; // 자신의 형제들 중에서 몇번째 형제인지

	// ...

  // Effects
  // ...
  this.alternate = null; // 더블 버퍼링 구조의 각 tree 를 관리합니다
  
  // ...
}

Fiber의 주요 속성을 살펴보도록 하겠습니다.

  • tag : 파이버 타입(컴포넌트 인스턴스, HTML의 DOM 노드 등)을 식별하기 위한 태그
  • stateNode : 파이버가 참조(reference)하는 정보
  • child, sibling, return : 파이버 간의 관계를 나타내는 속성
    • child : 첫 번째 자식
    • sibling : 첫 번째 자식의 형제
    • return : 부모
  • index : 여러 형제들 사이에서 자신의 위치를 숫자로 표현
  • alternate : 반대편 트리 파이버

우리가 알던 가상 DOM은 사실상 Fiber라는 자바스크립트 객체를 Node로 구성한 Tree라고 볼 수 있습니다. React 개발자인 Dan Abramov는 '가상 DOM'이라는 용어를 폐기할 것을 권고한 적이 있습니다. 대신에 React를 Value UI라고 표현해줄 것을 권장합니다. Fiber 객체를 보면 알 수 있듯이 React는 UI를 문자열, 숫자, 배열과 같은 ‘값(Value)’으로 관리하고 있습니다. 변수에 이러한 UI 관련 값을 보관하고, React의 자바스크립트 코드 흐름에 따라 이를 관리하고 표현하는 것이 바로 React입니다.


재조정(Reconciliation)과 Diffing 알고리즘(Diffing Algorithm)

React는 컴포넌트에서 state나 props가 변경되면 이전 UI 상태와 새로운 UI 상태를 비교하여 변경된 부분만 업데이트하는 과정을 수행합니다. 이러한 과정을 재조정이라고 합니다.

그렇다면 React는 이전 UI 상태와 새로운 UI 상태를 어떻게 비교할까요?

React 내부에는 두 개의 Fiber Tree(우리가 가상 DOM이라고 부르는 것)가 존재합니다. 하나는 현재 모습을 담은 Current Fiber Tree이고 다른 하나는 컴포넌트에 변경이 발생하여 작업 중인 모습을 담은 workInProgress Fiber Tree입니다.

workInProgress Fiber Tree의 생성이 완료되면 React는 Diffing 알고리즘을 사용하여 이전 트리 즉, Current Fiber Tree와 workInProgress Fiber Tree를 비교하고 변경된 부분을 찾습니다.

React는 Diffing 알고리즘을 사용하게 된 계기를 다음과 같이 설명합니다.

The Diffing Algorithm

하나의 트리를 가지고 다른 트리로 변환하기 위한 최소한의 연산 수를 구하는 알고리즘 문제를 풀기 위한 일반적인 해결책들이 있습니다. 하지만 이러한 최첨단의 알고리즘도 n개의 엘리먼트가 있는 트리에 대해 O(n3)의 복잡도를 가집니다.

React에 이 알고리즘을 적용한다면, 1000개의 엘리먼트를 그리기 위해 10억 번의 비교 연산을 수행해야 합니다. 너무나도 비싼 연산이죠. React는 대신, 두 가지 가정을 기반하여 O(n) 복잡도의 휴리스틱 알고리즘을 구현했습니다.

  1. 서로 다른 타입의 두 엘리먼트는 서로 다른 트리를 만들어낸다.
  2. 개발자가 key prop을 통해, 여러 렌더링 사이에서 어떤 자식 엘리먼트가 변경되지 않아야 할지 표시해 줄 수 있다.

위에서 언급한 두 가지 가정에 대해 좀 더 자세히 알아보겠습니다.

가정 1. 서로 다른 타입의 두 엘리먼트는 서로 다른 트리를 만들어낸다.
여기서 말하는 타입은 아까 Fiber 객체의 주요 속성에서 알아본 tag 속성을 떠올리면 됩니다. 만약 컴포넌트가 리턴하는 jsx 안에서 <p /><span />으로 바뀌었다거나 <Counter /><Profile />로 바뀌는 경우, UI 구조 자체가 크게 변경될 것입니다. 이러한 경우 React는 불필요한 비교를 줄이고 성능을 최적화하기 위해 기존 엘리먼트의 트리를 제거하고 새로운 트리를 생성합니다.

가정 2. 개발자가 key prop을 통해, 여러 렌더링 사이에서 어떤 자식 엘리먼트가 변경되지 않아야 할지 표시해 줄 수 있다.
다음과 같은 구조의 리스트가 있다고 생각해 보겠습니다.

// 이전 렌더링
<ul>
  <li key="1">항목 1</li>
  <li key="2">항목 2</li>
</ul>

// 다음 렌더링
<ul>
  <li key="2">항목 2</li> 
  <li key="1">항목 1</li>
</ul>

만약 <li /> 태그에 key가 없다면 React는 두 항목의 내용을 모두 변경해야 한다고 판단합니다. 하지만 key가 있으므로, React는 두 항목의 순서만 변경되었음을 알고 트리 변경을 최소화할 수 있습니다. key에 대한 내용은 아래에서 좀 더 자세히 다뤄보도록 하겠습니다.

다시 Diffing 알고리즘으로 돌아오겠습니다.

Diffing 알고리즘두 트리(Current Fiber Tree와 workInProgress Fiber Tree) 간의 변경 사항을 효율적으로 비교하여 필요한 최소한의 업데이트만 수행하는 알고리즘입니다. 이로 인해 React는 불필요한 연산을 최소화하여 빠르고 효율적인 UI 업데이트를 지원할 수 있습니다.

Diffing 알고리즘의 검색 과정에 대해서는 Gemini Advanced의 답변으로 대체하겠습니다.

리액트 디핑 알고리즘(React Diffing Algorithm)의 주요 전략 by Gemini Advanced

  • 트리 레벨 비교 (Tree Reconciliation):
    • 두 트리의 루트 노드부터 시작하여 각 노드의 타입(태그 이름)을 비교합니다.
    • 만약 타입이 다르면 해당 노드와 그 하위 트리 전체를 교체합니다.
    • 타입이 같으면 해당 노드의 속성과 자식 노드들을 비교합니다.
  • 컴포넌트 레벨 비교 (Component Reconciliation):
    • 같은 타입의 컴포넌트인 경우, props를 비교하여 변경된 props에 해당하는 부분만 업데이트합니다.
    • shouldComponentUpdate 생명주기 메서드를 통해 불필요한 리렌더링을 방지할 수 있습니다.
  • 엘리먼트 레벨 비교 (Element Reconciliation):
    • 같은 타입의 DOM 요소인 경우, 속성을 비교하여 변경된 속성만 업데이트합니다.
    • 텍스트 노드의 경우, 내용을 비교하여 변경된 내용만 업데이트합니다.
  • 리스트 비교 (List Reconciliation):
    • 리스트의 경우, 각 항목에 고유한 key prop을 부여하여 효율적으로 비교합니다.
    • key 값을 기준으로 변경된 항목만 업데이트하고, 순서 변경은 DOM 조작을 통해 처리합니다.

React에서 Key의 역할과 중요성

여기 다음과 같은 구조의 리스트가 있다고 생각해 보겠습니다.

<ul>
  <li>사과</li>
  <li>바나나</li>
</ul>

이 리스트에 <li>망고</li>를 추가하고 싶습니다.
다음과 같이 가장 마지막에 추가해 보겠습니다.

<ul>
  <li>사과</li>
  <li>바나나</li>
  <li>망고</li>   <!-- 변경 사항 -->
</ul>

React는 첫 번째 <li>사과</li>와 두 번째 <li>바나나</li>를 ‘변경 사항 없음’으로 인식하고 그대로 유지합니다. 그리고 <li>망고</li> 항목이 새로 추가된 것으로 인식하고 렌더링을 수행합니다.

이번에는 가장 상단에 추가해 보겠습니다.

<ul>
  <li>망고</li>   <!-- 변경 사항 -->
  <li>사과</li>
  <li>바나나</li>
</ul>

React는 첫 번째 <li>사과</li>와 두 번째 <li>바나나</li>가 각각 <li>망고</li><li>사과</li>로 변경되었다고 인식합니다. 그리고 <li>바나나</li> 항목이 새로 추가된 것으로 인식하고 렌더링을 수행합니다.

이와 같은 불필요한 연산을 줄이기 위해 React는 리스트와 같이 여러 개의 자식 엘리먼트를 생성할 때, 각 엘리먼트에 key 값을 부여하는 것을 권장합니다. React는 이 key 값을 기준으로 엘리먼트를 식별하고 변경 사항을 판단합니다.

그럼 다음과 같이 다시 key 값을 부여해서 다시 렌더링을 진행해 보겠습니다.

<ul>
  <li key='mango'>망고</li>
  <li key='apple'>사과</li>
  <li key='banana'>바나나</li>
</ul>

React는 새로운 <li>망고</li> 항목이 추가된 것으로 인식합니다. 그리고 <li key="사과">사과</li><li key="바나나">바나나</li>는 순서만 변경되었다고 인식하고 트리에서 위치만 변경하는 방식으로 렌더링을 수행합니다.

이처럼 key를 사용하면 변경된 항목만 정확하게 찾아 업데이트하여 React의 렌더링 성능을 크게 향상시킬 수 있습니다.

key를 사용할 때 index나 즉석에서 생성한 값의 사용은 지양해야 합니다.

리스트에 key 값을 부여하지 않는 경우 React는 Fiber의 Sibling Index만을 기준으로 판단합니다. 앞서 살펴본 것과 같이 추가하려는 엘리먼트를 최상단에 추가하는 경우 나머지 엘리먼트를 변경되었다고 인식하는 것이 그 이유입니다.
<Child key={Math.random()}>과 같이 key에 매 렌더링마다 변하는 임의의 값을 넣는 경우, 리렌더링이 일어날 때마다 Sibling 컴포넌트를 명확히 구분할 수 없을 뿐더러 key의 변화는 리렌더링을 야기합니다.

마무리

React는 초기 렌더링 시 애플리케이션에 존재하는 모든 컴포넌트 인스턴스를 추적하고, 컴포넌트 인스턴스의 내부 데이터 구조를 Fiber라는 객체에 저장합니다. 그리고 이러한 Fiber 객체를 노드로 하여 트리를 구성합니다. 이 트리가 우리가 흔히 말하는 '가상 DOM'입니다.

React는 이 트리(Current Fiber Tree)를 기준 삼아 리렌더링마다 이 트리의 노드를 재사용하고 새로운 트리(workInProgress Fiber Tree)를 생성합니다. 두 트리 간의 변경 사항을 효율적으로 비교하여 필요한 최소한의 업데이트를 실제 DOM에 적용합니다.

두 트리를 비교할 때 Diffing 알고리즘을 사용하며 이 과정에서 key는 비교 대상을 식별하는 데 도움을 줍니다.

참고

모던 리액트 Deep Dive
리액트에서 key에 index를 넣으면 안 되는 ‘진짜’ 이유
(번역) 블로그 답변: React 렌더링 동작에 대한 (거의) 완벽한 가이드
네이버 d2 | React 파이버 아키텍처 분석
React.js의 렌더링 방식 살펴보기 - 이정환 | 2023 NE(O)RDINARY CONFERENCE
React 톺아보기 - 2.1 (주석을 담아서)

profile
٩(ˊᗜˋ*)و

0개의 댓글