page owner's profile image
minsug
생각보다 더 섬세한 리액트 렌더링리액트의 핵심 개념인 virtual DOM을 알아보자
2024년 4월 24일
25분

개요

  • 유튜브 API가 이전 토큰에 전송한 영상을 다음 토큰에도 포함하는 경우가 있었는데 이후 다른 키워드를 입력하면 중복된 영상이 새로운 키워드의 영상들과 함께 렌더링되는 현상이 일어났다.

  • 따라서, 이 글에서는 리엑트의 렌더링을 설명하고 위와 같은 현상의 원인이 무엇인지 그리고 해결방법에는 어떠한 것이 있는지 설명하고자 한다.

리액트의 렌더링

보통 렌더링이라함은 브라우저에서 HTML과 CSS를 기반으로 웹페이지에 필요한 UI를 그리는 과정을 의미한다. 그러나, 리액트에서 렌더링은 조금 다른 의미를 지닌다. 리액트의 렌더링은 브라우저에 필요한 DOM 트리를 만드는 과정을 의미한다. 리엑트는 이 렌더링 작업을 위한 자체적인 렌더링 프로세스가 있다.

리액트의 렌더링 프로세스는 어플리케이션 트리 안에 있는 모든 컴포넌트들이 현재 자신들이 가지고 있는 props와 state의 값을 기반으로 어떻게 UI를 구성하고 이를 바탕으로 어떤 DOM 결과를 브라우저에 제공할 것인지 계산하는 일련의 과정을 의미하며 총 3 가지로 나뉜다.

첫 번째로 렌더링을 유발하는 단계로 createRoot의 실행 혹은 state를 업데이트하게 되면 발생한다.

두 번째는 렌더링으로 컴포넌트를 호출하는 단계이다. createRoot로 렌더링이 발생했다면 루트 컴포넌트를 호출하고 state의 업데이트로 인한 렌더링이라면 해당 state가 속해있는 컴포넌트를 호출한다.

마지막은 커밋 단계로 변경사항들을 실제 DOM에 적용하는 작업을 진행한다. 첫 커밋이라면 appendChild를 사용해서 스크린에 있는 모든 노드를 생성한다. 만약 첫 커밋이 아니라면 최소한의 작업을 통해 변경사항만을 실제 DOM에 적용한다. 그리고 이 변경사항은 렌더링 중에 계산된다. (이 부분을 잘 기억하자)

💡 흔히 알고있는 가상 DOM을 활용한 Reconciliation은 두 번째 단계인 렌더링에서 이뤄진다. 이에 대해서는 뒤에서 더 설명한다.

리액트의 렌더링은 언제 일어날까?

최초 렌더링: 사용자가 처음 어플리케이션에 진입했을때 보여야할 결과물을 위해 리액트는 최초 렌더링을 수행한다.
리렌더링: 리렌더링은 처음 어플리케이션에 진입했을 때 최초 렌더링이 발생한 이후로 발생하는 모든 렌더링을 의미한다.

  1. 함수형 컴포넌트의 useState()의 두 번째 배열 요소인 setter가 실행되는 경우: state의 변화는 컴포넌트의 상태 변화를 의미한다. 컴포넌트의 상태가 변화할때 렌더링을 발생한다.
  2. props가 변경되는 경우: 부모로부터 전달받는 값인 props가 달라지면 이를 사용하는 자식 컴포넌트에서 변경이 필요하므로 리렌더링이 일어난다.
  3. 컴포넌트의 Key props가 변경되는 경우: 리액트에서 key는 명시적으로 선언돼 있지 않더라도 모든 컴포넌트에서 사용이 가능한 특수한 props다. 일반적으로 key는 하위 컴포넌트를 선안할 때 사용된다.

이외에도 useReducer()의 두 번째 배열 요소가 실행되는 경우와 클래스형 컴포넌트에서 리렌더링되는 경우 등이 있지만 이 글에서는 생략하겠다.

가상 DOM VS. 실제 DOM

개발에 입문하고 나서 많은 사람들이 DOM 개념을 받아들이는데 많은 시간이 소요된다. DOM이란 쉽게 말해서 생성된 Document를 조작 가능하게 하는 API이다. 여기서 Document란 웹 페이지를 의미한다.

리액트의 렌더링 프로세스는 DOM 결과를 브라우저에게 제공할 것인지 계산하는 과정이라고 설명했다. 그렇다면 리액트는 결과를 어떻게 계산할까? 실제 DOM을 추상화하여 만들어진 가상 DOM의 변화를 감지해서 변경 결과를 도출한다. 이게 무슨 말인가 하면 컴포넌트의 상태가 변경되면 리액트는 이 변경을 감지하고 기존 가상 DOM 트리와 변경된 가상 DOM 트리을 비교한다. 이런 알고리즘을 Reconciliation(이하 “재조정”)이라고 지칭하며 가상 DOM의 탐색 성능을 최적화했다.

재조정은 다음과 같은 특징을 갖고 있다.

컴포넌트 유형이 다르면 diff를 진행하지 않고 트리 자체를 새로운 트리로 대체한다.
리스트의 diff는 key를 기준으로 수행된다. 이때 key는 안정적이고 예측 가능하며 유니크한 값이어야 한다.

💡 흔히 알고있는 가상 DOM을 활용한 Reconciliation은 두 번째 단계인 렌더링에서 이뤄진다. 이에 대해서는 뒤에서 더 설명한다.

리액트가 가상 DOM을 선택한 이유

V8 엔진에서 브라우저 렌더링 과정은 다음과 같다.

DOM 트리 생성 ⇒ CSSOM 생성 ⇒ Render 트리 생성 ⇒ Layout ⇒ Paint

이 중 브라우저 리소스가 가장 많이 소모되는 부분이 Layout과 Paint 과정이다. 그리고 이 두 과정은 Render 트리가 변경될때마다 재실행된다. 즉, Render 트리 변경의 최소화가 브라우저 성능을 최적화한다는 것이다.

그리고 리액트는 가상 DOM과 재조정을 통해 오로지 변경사항만을 실제 DOM에 적용함으로 Render 트리 변경을 최소화한다. 다시 말해서, 브라우저가 해야할 할 일을 덜어줌으로 성능을 최적화한다.

그러나, 리액트 팀은 가상 DOM과 재조정만으로 해결할 수 없는 문제를 직면했고 이를 해결하기 위해 React 16에서 리액트 파이버를 도입하기에 이른다. 이제 리액트 파이버가 무엇인지 알아보자.

💡 React 16 이전 재조정을 담당하는 Reconciler는 Stack Reconciler였지만 16 이후에 Fiber Reconciler로 변경되었다. Fiber Reconciler가 리액트 파이버를 관리한다.

최적화된 렌더링을 더 향상시키기 위해 리액트가 사용한 방법

앞서 언급했듯이 리액트는 가상 DOM 트리 변화를 감지해 브라우저에게 변화 정보를 전달한다고 했다. 그리고 브라우저는 변화 정보를 담은 노드만을 렌더링함으로서 성능을 최적화할 수 있었다. 그런데 리액트 팀은 React 16 버전부터 리액트 파이버를 추가함으로서 랜더링 프로세스 방식에 변화를 주었다.

리액트 팀은 왜 파이버를 도입했을까?

React 16 이전의 Stack Reconciler의 작동 방식은 동기적으로 이뤄졌으며 모든 작업을 스택으로 처리했다. 한번 작업이 시작되면 끝날때까지 멈추지 않는다는 말이다. 만약 작업이 오래 걸리지 않는다면 문제가 되지 않겠지만 메인 쓰레드에 과부하가 걸릴 정도의 고연산 작업이라면 유저 경험을 심각하게 저해시킬 정도의 렌더링 문제가 유발될 수 있다.

이를 해결하기 위해 리액트 파이버가 React 16에 도입되었는데 주 역할은 다음과 같다.

  • 연산을 멈추고 다시 수행할 수 있는 기능
  • 각기 역할마다 다른 우선순위를 부여할 수 있는 기능
  • 이전에 완료된 연산을 재사용할 수 있는 기능
  • 필요가 없어진 연산을 중간에 취소하는 기능

Stack Reconciler와 달리 Fiber Reconciler는 더 이상 재조정을 렌더링과 동시에 진행하지 않음으로서 리액트 파이버는 업데이트될 변경사항에 우선순위를 부여할 수 있게 되었다. 그리고 리액트는 이것을 증분 렌더링이라고 지칭한다.

증분 렌더링은 렌더링 작업을 점진적으로 처리할 수 있는 더 작고 우선순위가 지정된 청크(객체)로 가상 DOM을 업데이트하는 작업을 분할하는 방식으로 작동하는데 이를 위해서 재조정과 렌더링이 분리되었다. 그리고 이 분리된 방식을 우리는 위에서 설명한 렌더링 단계와 커밋 단계로 이미 배웠다.

그러나 파이버를 이제 알았으니 조금 다른 시각에서 이 두 단계를 다시 알아보자.

  • 렌더링 단계 1: 리액트는 UI에 나타나야할 모든 변경 사항들을 리스트로 저장한다. 이때 리액트는 언제든 작업을 중지할 수 있으며 다른 단계로 넘어갈 수도 있다.
  • 렌더링 단계 2: 변경사항 리스트가 완성됐으면 리액트는 다음 단계에서 실행되어야할 변경 사항을 예약해놓는다.
  • 커밋 단계 1: 리액트는 렌더링 단계 2에서 예약해 놓은 변경 사항 중 특정 사항만 렌더링 할 수 있게 지정할 수 있다.
  • 커밋 단계 2: 커밋되면 React는 변경 사항을 렌더링하도록 브라우저에게 알린다.

💡 렌더링 단계는 언제든 중지할 수 있지만 커밋 단계는 한번 시작되면
멈출 수 없다.

리액트 파이버의 작동 원리

증분 렌더링이 각 단계에서 어떻게 작동하는 지 한번 알아보자. 우선 파이버는 Fiber Reconciler가 관리하는 자바스크립트 객체다. 파이버를 노드로 갖는 트리를 파이버 트리라고 하는데 이것을 우리는 웹 환경에서 가상 DOM이라고 부른다. 다만 가상 DOM이란 용어는 웹 어플리에케이션에만 통용되는 언어로 파이버와 가상 DOM은 동일한 개념이 아니다.

리액트 파이트 트리는 이렇게 생겼다.리액트 파이트 트리는 이렇게 생겼다.

리액트 파이버는 여러 요소가 있지만 간소화해서 이야기하자면 type, child 그리고 sibling이 있다,

  • type: 생성된 function Component
  • child: 해당 Component의 가장 왼쪽에 있는 자식 노드
  • sibling: 해당 Component의 형제 노드

한 가지 주의해야할 점은 파이버는 모든 자식 노드를 포함하는 것이 아닌 가장 왼쪽에 있는 자식 하나만을 참조하고 있다.

어디서 많이 익숙한 것이 연결 리스트처럼 생겼다. 공식적인 이름은 Left-child right-sibling tree이다.

파이버 트리는 하나가 아닌 두개

파이버 트리는 총 두 개로 하나는 현재 모습을 담은 파이버 트리이고 다른 하나는 작업 중인 상태를 나타내는 workInProgress 트리다. 리액트 파이버의 작업이 끝나면 리액트는 단순히 포인터만 변경해 현재 트리를 workInProgress트리로 바꿔버린다. (이러한 기술을 더블 버퍼링이라고 하며 커밋 단계에서 수행된다.)

두 개의 트리가 존재하시때문에 리액트는 미처 렌더가 끝나지 않은 모습을 노출시키지 않을 수 있다. 리액트 파이버 트리의 작업 단계는 다음과 같다.

  1. 모든 작업은 현재 UI 렌더링을 위해 존재하는 current 트리를 기준으로 시작된다.
  2. 만약 업데이트가 발생할 경우 리액트에서 새로 받은 데이터로 workInProgress 트리를 빌드하기 시작한다.
  3. 이 workInProgress 트리를 빌드하는 작업이 마무리되면 다음 렌더링에 이 트리를 사용한다.
  4. 이 workInProgress 트리가 UI에 최종적으로 렌더링되어 반영이 완료되면 current가 workInProgress 트리로 변경된다.

리액트 파이버의 도입으로 얻은 효과

가장 첫 번째로 성능을 향상시켰다. 재조정하는 동안 다른 작업을 중지하지 않기 때문에 React는 필요할 때마다 작업을 일시 중지하거나 렌더링을 시작할 수 있게됐다.

두 번째로 훨씬 깔끔한 방식으로 오류를 처리할 수 있게 됐다. 자바스크립트 런타임 오류가 발생할 때마다 흰색 화면을 표시하는 대신 Error Boundary를 설정하여 문제가 발생할 경우 백업 화면을 표시할 수 있게 됐다.

💡 리액트 파이버의 도입으로 Error Boundary, Suspense, React.Lazy, Fragment 그리고 Concurrency Mode가 가능해졌다.

리액트 렌더링을 이해하기 위해 조금 먼 길을 달려왔다. 그럼 다시 문제 상황으로 돌아가서 해결해보자.

Key Props가 정확하게 뭐였더라…?

리액트를 처음 시작하고 다수의 component를 map으로 만들때 key를 꼭 넣어달라는 error와 마주했었다. 그러곤 리액트 레거시 페이지의 List and Keys 대충 훓고 key를 index 값으로 줬었던 기억이 있다.

그리고 이게 이번 과제에서 제대로 발목을 잡았다.

Key란 무엇인가…

리액트 공식 문서에서는 키를 데스크탑 파일의 이름에 비유한다.

만약 데스크탑 파일에 이름이 존재하지 않는다면 우리는 파일과 폴더를 위치 기반으로 찾으려고 할 것이다. 이는 매우 불안정하고 같은 결과를 보장하지 않는 방법으로 만약 중간에 새로운 파일이 추가된다면 우리는 같은 파일을 찾지 못한 가능성이 크다.

그러나, 파일에 이름이 있다면 우리는 이름을 기반으로 찾을 것이고 같은 이름을 갖는 상황이 아니라면 다른 파일을 찾을 일은 없다. (그래서 독립적인 키를 부여하는 것이 중요하다.)

리액트에서 키란 데스크탑의 폴더 이름과 같은 개념으로 유니크한 키가 있다면 컴포넌트를 검색할때 실수 없이 정확하게 찾을 수 있다.

리액트 파이버 트리는 키를 이용한다

위에서 설명했던 리액트 파이버 트리를 다시 떠올려보자. 리액트 파이버 트리는 총 2 개가 있으며 하나는 현재 유저에게 보이는 컴포넌트를 담은 트리이며 다른 하나는 변경 사항이 있을 경우 작업이 이뤄지며 유저에게는 보이지 않는 트리이다.

변경 사항이 있을 경우를 리액트는 키를 이용해서 구별한다. (기본적으로 type, props, key를 이용한다.) key가 존재한다면 두 트리에 있는 컴포넌트는 key 기준으로 구별한다. 만약 없다면 내부 프로퍼티인 siblings index를 기준으로 판단한다.

그래서 문제 원인은?

문제 상황을 자세히 설명하면 유튜브 API를 통해 VideoList(state)에 10개의 비디오 정보를 저장한 후 VideoList를 map을 이용하여 VideoEntry라는 컴포넌트를 만들었다.

종종 유튜브 API가 이전 요청에 포함된 비디오 정보를 다음 요청에도 담는 경우가 있었는데 이 중복된 비디오는 그 다음 요청에도 사라지지 않는 문제가 발생했다.

예를 들어, “페이커”라는 검색어로 40개의 영상을 로딩했을 때 2개의 중복된 영상이 있다. 그 다음 “손흥민”을 검색했을때 이 2 개의 중복된 영상이 사라지지 않고 남아있다. 즉, 손흥민을 검색했을때 이전 페이커 영상에 대한 정보를 담고 있는 state는 변경되어서 사라졌지만 렌더링은 계속 되고 있다는 것이다.

VideoList(state)를 기반으로 생성되는 VideoEntry의 변경 과정은 이렇다.
마지막 두 개의 블렛 포인트는 아직 확인되지 않은 의견입니다. (업데이트 예정)

  • 새로운 VideoLIst(state)로 workInProgress 트리를 빌드한다. ⇒ 이때 빌드의 기초는 current 트리이다.
  • 새로운 VideoLIst(state)에는 이전 VideoList(state)에 있는 정보들이 존재하지 않기 때문에 기존 VideoEntry를 키를 기반해서 검색 및 삭제한다.
  • 이때 리엑트는 이미 키를 기반으로 하나의 컴포넌트를 삭제했음으로 중복된 키를 가진 컴포넌트를 삭제한 것으로 인식한다.
  • 그래서 workInProgress 트리에 중복된 비디오 중 하나가 남아있어 결국 UI적으로 나타난다.

결국 중복된 키를 가진 컴포넌트가 존재했기에 발생한 문제였고 이는 리엑트가 가상 DOM을 업데이트할떄 사용하는 리엑트 파이버 방식에 따른 것이었다.

문제 해결 방법
해결 방법은 많이 있겠지만 가장 먼저 떠오른 것은 2 가지였다.

  1. 명시적으로 컴포넌트를 언마운트하는 것
    리액트 공식 문서에 따르면 컴포넌트는 key가 바뀌면 언마운트한다. 따라서 VideoLIst(Component)에 키를 키워드로 주어서 검색될때마다 언마운트시켜 중복된 영상을 모두 화면에서 지울 수 있다.

  2. 중복된 id를 가진 비디오를 상태에 추가하지 않는 것
    조금 더 근본적인 방법으로 API를 통해 VideoInfo를 가져와 VideoLIst(state)에 추가하기 전에 기존에 있는 id라면 추가하지 않는 방법(filter) 이외에도 VideoEntry의 키 값에 video.id뿐만 아니라 Data.now() 등을 추가해 더 유니크하게 주는 방식이 있을 수 있지만 그래도 가장 근본적인 대처는 중복된 id를 가진 비디오를 상태에 추가하지 않는 것이라고 생각한다.

출처

모던 리액트 Deep Dive
React-fiber-architecture
Knowing React Virtual DOM and React Fiber Tree
What is React Fiber and How It Helps You Build a High Performing React Applications