TIL 98 - React의 렌더링 단계와 reconciliation, key props

김영현·2024년 6월 3일
0

TIL

목록 보기
109/129

서론

리액트 내부 동작과정을 알아볼 것이다. 구현수준까지 자세하게 들여다 보진 않을 것임.
But, 동작원리의 기반을 이해하는 데 초점을 맞추어보자.
상태를 업데이트하거나, useMemo, useCallback등을 이용하여 캐싱할때 실제 DOM이 어떻게 업데이트되고 V-DOM이 어떻게 동작하는지 알고있으면 개발할때 많이 도움 될 것 같음!


Render and Commit

setState로 상태를 변화시키면, 현재 컴포넌트 + 모든 자식 컴포넌트가 Re-rendering된다.
여기서 말하는 render란 정확히 무엇일까? 이를 알려면 React공식문서에서 말하는 세가지 주요 UI 프로세스를 알아야한다.

step1) Triggering

컴포넌트 렌더링이 일어나는 이유는 단 두가지 다.

  • initial render : index.js에서 createRoot(...).render()로 유발하는 초기 렌더링
  • re-render : setState

초기 렌더링은 지극히 당연하게 컴포넌트를 렌더링한다. 이때의 렌더링을 render라고 표현한다.
그리고 setState를 이용하여 state가 업데이트되면 리렌더링(re-render)된다.

이때 render들은 대기열에 추가된다.

step2) Render

renderingtriggering하였다. 그러면 이제 컴포넌트가 실제로 렌더링 될 차례다.

  • Initial render : root컴포넌트를 호출.
  • re-render : re-render가 발동된 컴포넌트를 호출한다.

=> 컴포넌트는 함수다. 함수를 그냥 호출하는 것이다.

또한 자식컴포넌트도 존재하기에 재귀적으로 호출한다.
그렇기에 부모 컴포넌트가 re-render되면 자식 컴포넌트도 re-render되는 것이다.

이때 바로 DOM이 업데이트되는 것은 아니다.
initial render시에만 DOM노드가 생성되고, re-render시에는 이전 렌더링 이후 변경된 properties를 계산하여 다음 페이즈인 commit phase까지 이 정보를 들고만 있는다.

=> 이래서 사람들이 setState의 호출을 비동기라고 하나보다. 바로 상태가 업데이트되지 않고 commit phase를 기다린다!

step3) Commit

step2에서 render가 일어났다면, 이전 렌더이후 변경된 properties들을 저장하고 있을 것이다.
변경된 정보를 갖고 이제 DOM에 적용 할 차례다.

  • initial render : 초기 렌더링이기에 바로DOM에 반영된다. appendChild()를 사용하여 바로 업데이트한다.
  • re-render : 필요한 최소한의 작업(렌더링 중 계산된 내역)을 적용하여 실제 DOM과 최신 render버전을 일치하게 만든다. 실제 DOM과 다른 부분만 업데이트 한다.

여기서 필요한 최소한의 작업을 계산하는 일이 바로 reconciliation이다.

last) Browser Paint

이제 render단계와 commit단계를 지나 DOM이 업데이트가 되어 브라우저까 실제로 화면을 그린다.
본래 이를 Browser rendering이라 하지만, 공식문서에서는 용어의 혼동을 피하기 위해 Painting이라 부른다.

요약

  1. 상태를 변경하거나 초기 렌더링을 하여 렌더링을 촉발시킨다.
  2. 렌더단계에서 이전 렌더버전과 비교하여 바뀐 정보를 저장한다.
  3. 커밋단계에서 바뀐 정보를 이용하여 바뀐 부분만 DOM을 업데이트한다.
  4. 브라우저가 화면을 페인트한다.

reconciliation(재조정)

커밋단계에서 필요한 최소한의 작업을 계산하는일을 reconciliation이라고 하였다.
리액트 내부에서 변경사항의 최적화는 어떻게 이루어지는 걸까?

DOM은 트리구조를 가진다. 또한 Virtual DOM도 트리구조를 가진다.
따라서 Virtual DOM이 갖고있는 트리를 DOM트리로 변환하는 작업을 뜻한다.
이 과정은 최악의 경우 O(n^3)의 시간복잡도를 갖는다. 만약 1000개의 엘리먼트를 그리려 한다면, 10억번의 비교 연산을 수행해야한다. 대충 1억번에 1초가량이니, 10초가 걸리는 셈이다.

React측에서는 이러한 비싼 연산을 피하기 위하여 두가지 가정을 채택했다.

  1. 서로 다른 타입의 엘리먼트는 서로 다른 트리를 만들어 낸다
  2. 개발자는 key Prop을 통해 어떤 자식 엘리먼트가 변경되지 않아야 할지 표시해 줄 수 있다.

1번의 경우는 지극히 당연하다. <ul>에서 <div>로 바뀐다거나 <div>에서 <img>로 바뀐다거나. 이러면 새로운 트리가 생성 될 것이다.
2번의 경우는 감이오질 않는다. 아래 Diffing 알고리즘을 설명하면서 한번 알아보자.

Diffing Algorithm

React는 두개의 트리를 비교할때 root부터 비교한다. 이후 동작은 엘리먼트 타입에 따라 달라진다.

  1. DOM 엘리먼트 타입이 다를 때 : 위 목차에서 설명했듯 새로운 트리를 구축한다.
  2. DOM 엘리먼트 타입이 같을 때 : 이 때는 보통 attribute가 변경됐을 때다. 따라서 동일한 attribute는 남기고 변경된 부분만 갱신한다.
  3. 컴포넌트가 같을 때 : 인스턴스는 동일하게 유지되어 렌더링 간 state가 유지된다.

key pros(index를 key props로 사용하면 안되는 이유)

비교 알고리즘 순회는 재귀적으로 실행된다. 따라서 DOM노드의 자식들 또한 재귀적으로 처리된다.
이때 기본적으로 동시에 두 리스트를 순회하며 차이점이 있으면 변경을 생성한다.

예를들어 자식의 끝에 새로운 엘리먼트를 추가한다면, 트리 간 변경은 잘 작동할 것이다.

//첫 번째 렌더
<ul>
  <li>first</li>
  <li>second</li>
</ul>

//리 렌더 이후
<ul>
  <li>first</li>
  <li>second</li>
  <li>third</li>
</ul>

위의 예시 같은 경우, <li>first</li><li>second</li>가 일치하는 것을 확인 후 <li>third</li>가 존재하지 않으므로 트리에 추가할 것이다.

하지만 아래와 같은 경우를 생각해보자.

//첫 번째 렌더
<ul>
  <li>Duke</li>
  <li>Villanova</li>
</ul>

//리 렌더 이후
<ul>
  <li>Connecticut</li>
  <li>Duke</li>
  <li>Villanova</li>
</ul>

순서와 상관 없는 엘리먼트기에 첫 번째에 새로운 엘리먼트가 추가됐다. 따라서 첫 번째 자식부터 달라졌기에, 새로운 트리를 구축하게된다.
이러한 비효율을 해결하기위해 나온 게 key props다.

//첫 번째 렌더
<ul>
  <li key="2015">Duke</li>
  <li key="2016">Villanova</li>
</ul>

//리 렌더 이후
<ul>
  <li key="2014">Connecticut</li>
  <li key="2015">Duke</li>
  <li key="2016">Villanova</li>
</ul>

2014 키를 가진 엘리먼트를 추가 한 뒤 2015, 2016키를 가진 엘리먼트의 위치만 이동하면 되는 것을 알 수 있다.

이러한 패턴은 보통 반복문을 이용하여 컴포넌트를 렌더링하게된다.
이때 생각없이 배열의 인덱스key로 지정하면 어떻게 될까?

<ul>
  {items.map((item, index) => <li key={index}>`${item}`</li>)}
</ul>

배열 내부 순서가 변경되지 않는다면 상관 없다. 그러나 아래와 같은 예시를 생각해보자.

{todos.map((todo,index) => <Todo key={index} text={todo.text}/>)}


배열 내부 데이터가 하나만 있을땐 별 문제가 없는 것 처럼 보인다. 하지만 새로운 텍스트를 추가하게 되면 문제가 생긴다.


왜 이런일이 발생할까?

컴포넌트 인스턴스는 key를 기반으로 갱신되고 재사용된다. 따라서 인덱스를 key로 사용하면 state에도 영향을 끼친다.

  1. 첫 번째 텍스트가 입력된 input태그의 인덱스는 0이다
  2. 배열에 새로운 노드를 맨 앞에(unshift()) 추가한 뒤 다시 인덱스**를 이용하여 key를 업데이트한다.
  3. 이때 첫 번째 텍스트라는 상태를 인덱스 0이 가지고 있었으므로, 새로 추가되었지만 배열 인덱스 0에 존재하는 노드가 첫 번째 텍스트상태를 갖게된다.

그러니까 index를 key props로 사용하는건 왠만하면 지양하자 (순서가 보장된다면 상관없지만...)


느낀점

setState가 왜 바로 업데이트되지 않는건지, key props에 index를 왜 사용하면안되는 건지 막연하게만 알고있던 지식이 구체화 되는건 더할나위 없는 기쁨이다. 앞으로 부끄러운 실수를 더 줄일수 있을 듯 하다.😉

다음에는 virtual domFiber아키텍처에 대해 알아보겠습니다.


출처

출처는 리액트 공식문서 입니다.

https://ko.legacy.reactjs.org/docs/reconciliation.html
https://react.dev/learn/render-and-commit

profile
모르는 것을 모른다고 하기

0개의 댓글