김춘식의 짱쎈 블로그.

React는 어떻게 동작할까?

2022년 07월 16일
#react

1. 배경

몇몇 사람들은 굳이 이런 라이브러리(혹은 프레임워크)의 내부 동작 원리를 굳이 알아야하냐는 말을 한다.

나는 그럴때마다 꼭은 아니지만 몇가지의 이유로 알면 좋다고 말한다.

  1. 버그 방지
    • 내부 동작 원리를 알면, 예측 가능한 문제를 더 쉽게 회피 할 수 있다.
  2. 도구의 이해도 상승
    • 내부가 어떻게 동작하는지 알기 때문에 사용하는 도구의 동작을 예측하기 쉬워지고, 다른 비슷한 도구를 다룰 때 더 빠르게 이해할 수 있게된다.
  3. 기술적 영감 획득
    • 서비스를 만들면서 만날 수 있는 코드와 라이브러리를 만들면서 만날 수 있는 코드는 다르다.
    • 언젠가 라이브러리, 프레임워크등을 제작할 때 도움이 될 만한 기술적 영감을 얻을 수 있다.
    • 또한 해당 기술내의 최적화 기법등을 보고 배울 수 있다.

위의 이유로 나도 React의 동작 원리를 공부하게 되었고 이 경험을 공유하기 위해 글을 작성하게 되었다.

추가로, 이 글은 React 17.0.2 버전을 기준으로 설명하고 있다.

2. React Element, Component, Instance의 차이

사실 나도 이러한 개념이 있는지 이 글을 작성하면서 알았다. 각각의 용어는 매우 미세한 차이를 가지고 있기 때문에 집중해서 읽는 것을 권장한다.

읽어봐도 모르겠을 경우 공식문서를 참고해보는 것을 권장한다.

이 개념을 이해해야 이후에 나오는 개념들을 헷갈림 없이 이해할 수 있다.


최대한 쉬운 이해를 위해 log를 찍어보며 살펴보겠다.

function HelloWorld() {
  return <strong>HELLO WORLD!</strong>
}

function App() {
  console.log(HelloWorld())
  return (
    <div>
      <HelloWorld />
    </div>
  )
}

export default App

위 코드는 HelloWorld 컴포넌트를 활용해 화면에 bold체의 HELLO WORLD!를 출력하는 코드이다.

이 간단한 코드에서 핵심은 바로 console.log(HelloWorld())이다. 과연 React 컴포넌트의 반환값은 console 상에서 어떻게 나타날까?

React Element

React element

오.. 처음 보는 객체가 나타났다. 우리는 jsx(혹은 tsx)로 코드를 작성했지만 저런 녀석을 작성한 기억은 없다.

이 객체가 나타난 원인은 바로 babel이다.

babel transpile react

위 이미지처럼 우리가 작성한 jsx, tsx 파일의 컴포넌트들은 빌드 단계에서 React.createElement라는 함수로 치환된다.

이 객체가 바로 React Element이다. 이유는 createElement로 만들었기 때문이다.

그럼 한번 중요한 값들만 하나씩 살펴보자.

  • $$ typeof * 이 props는 자세히 설명하려면 매우 길기 때문에 이 글을 참고하길 바란다. $$
  • key
    • element를 map등으로 생성할 때 사용하는 그 key props다.
  • props
    • 컴포넌트에 넘기는 그 props가 맞다.
  • ref
    • 컴포넌트에 넘기는 그 ref가 맞다.
  • type
    • HTML element이다.

근데 이 내용을 보면 하나 이상한게 있다. ref와 key는 왜 props에 속해있지 않고 분리되어 있을까?

그 이유는 key와 ref는 React 내부 동작에 영향을 주는 중요한 props기 때문이다.

따라서 컴포넌트의 props에 ref, key를 정의해뒀을 경우 에러가 발생하는 것이다!

React Component

바로 위의 예시를 약간 수정해보자

function HelloWorld() {
  return <strong>HELLO WORLD!</strong>
}

function App() {
  console.log(<HelloWorld />)
  return (
    <div>
      <HelloWorld />
    </div>
  )
}

export default App

log 부분을 HelloWorld()에서 <HelloWorld />로 변경하였다.

React component 결과는 위 처럼 나온다.

보면 다 얼추 비슷하지만 한가지 다른게 있다. type 프로퍼티가 "strong"에서 HelloWorld()로 변경되었다.

즉, React Component는 컴포넌트의 return 값이 된다. (함수 컴포넌트의 경우만, class 컴포넌트는 render의 반환값이다.)

위의 HelloWorld 컴포넌트에서는

return <strong>HELLO WORLD!</strong>

이 부분이 React Component가 된다.

React Instance

사실 React InstanceReact Component는 매우매우매우매우매우 비슷하다.

그 이유는 둘의 차이점은 라이프 사이클과 this의 여부 때문이다.

아마 자연스럽게 * "엥 그건 이미 있는건데" *라는 생각이 들 수 있다.

맞다. 함수 컴포넌트의 경우 원래 있다. React가 알아서 처리해두었기 때문이다. 하지만 클래스 컴포넌트의 경우에는 없다.

따라서 React Instance는 클래스 컴포넌트로 구성되고 instance화된 React Component를 가르킨다고 이해하면 된다.

정리

  1. React ElementReact.createElement의 반환값이다.
  2. React Component는 컴포넌트의 DOM Node를 나타내는 반환 값이다.
  3. React Instance는 클래스 컴포넌트로 구성되고 instance화 된,React Component를 의미한다.

3. Reconciliation과 렌더링 여정

Reconciliation에 대해서 알아보기 전에 React가 Element들을 어떻게 렌더링 하는지 간단하게 알아보자.

  1. 어딘가에서 render등, 렌더링 함수를 호출한다.
  2. 최상위 노드에서 부터 React Element를 생성한다.
    • 이 과정은 극도로 빠르다. 위에서 보았듯 그냥 JS Object기 때문이다.
  3. 2번에서 생성된 노드 트리를 메모리에 저장한다.
    • 이게 VDOM이다.
  4. VDOM과 실제 DOM을 동기화한다.
    • 초기 렌더링에서는 모든 노드를 그린다.
    • 이후에는 변경된 노드만 그린다.

여기서 중요한 부분은 이후에는 변경된 노드만 그린다. 이 부분이다.

렌더링을 변경된 부분만 할 경우에도 위의 1~3번을 그대로 진행한다.

이후 첫 렌더링과는 다르게 메모리에는 이전(old) VDOM과 변경되어야할(new) VDOM이 동시에 존재한다. (나는 VDOM 자체가 구현체인줄 알았다. 하지만 일종의 패턴에 가깝다고 한다.)

이제 두 트리를 비교해서 다른점을 찾아내어 반영하면 되는데... 문제가 있다. 최신 알고리즘을 사용해도 두 트리를 완벽하게 비교할 경우 O(n³)의 복잡성을 가진다고 한다.

어드민등의 복잡한 페이지의 경우 DOM Node가 1000개는 가뿐히 넘어가는데 이런 경우 엄청난 성능 하락을 겪게 될것 이다.

따라서 React팀은 약간의 잔머리(가설에 기반한 휴리스틱한 해결 방법)를 써서 이를 O(n)으로 만들었다. 그 잔머리의 결과물, 즉 DOM을 다시 렌더링 하기 위해 결정하는 방법이 바로 Reconciliation이다. 그리고 Reconciliation의 동작 알고리즘을 Diffing 알고리즘이라고 부른다.

먼저 이 알고리즘을 이해하려면 잔머리(휴리스틱한 가설)이 무엇인지 명확히 알아야한다.

  1. 매우 비슷한 타입의 컴포넌트라도, 각각 별도의 트리를 생성한다.
  2. 키(그 key prop이 맞다.)는 안정적이고 예측 가능해야한다.

이해를 돕기 위해 가설별로 코드와 함께 봐보자.

1. 매우 비슷한 타입의 컴포넌트라도, 각각 별도의 트리를 생성한다.

첫 번째 상황은 컴포넌트가 아예 변경되는 상황이다.

function HelloWorld() {
  return (
    <div>
      <strong>HelloWorld</strong>
    </div>
  )
}

function GoodbyeWorld() {
  return (
    <div>
      <strong>GoodbyeWorld</strong>
    </div>
  )
}

function App() {
  const [hello, setHello] = useState(true)

  return (
    <main>
      {hello ? <HelloWorld /> : <GoodbyeWorld />}
      <button onClick={() => setHello(!hello)} />
    </main>
  )
}

위 상황에서 hello변수의 값이 바뀐다면, 1번의 가정으로 인해 아예 컴포넌트가 새로 mount 된다. React Element의 type이 달라졌기 때문이다.

즉, <HelloWorld/> 컴포넌트가 unmount 되고, <GoodbyeWorld/> 컴포넌트가 mount 된다.

두 번째는 같은 컴포넌트지만 속성이 변경되는 상황이다.

function LunchDisplay({ menu }) {
  return <h1>{menu}</h1>
}

function App() {
  const lunchMenus = ['부추밥', '순두부찌개', '선지국밥', '콩나물국밥', '롯데리아']
  const [menuIndex, setMenuIndex] = useState(0)
  return (
    <main>
      <LunchDisplay menu={lunchMenus[menuIndex]} />
      <button onClick={() => setLunch((menuIndex + 1) % lunchMenus.length)} />
    </main>
  )
}

menuIndex의 값이 바뀔 때 LaunchDisplaymenu prop의 값이 변경될 것 이다.

이때는 조금 다르게 동작한다. React Element의 type은 같기 때문에 컴포넌트가 unmount 되지는 않는다.

즉, React Component가 그대로 남아있기 때문에 컴포넌트의 상태가 유지되며, 상태에 따라 DOM이 업데이트 된다.

2. 키는 안정적이고 예측 가능해야한다.

function App() {
  const lunchMenus = ['부추밥', '순두부찌개', '선지국밥', '콩나물국밥']
  return (
    <ol>
      {lunchMenus.map((menu) => {
        return <li key={menu}>{menu}</li>
      })}
    </ol>
  )
}

위의 형태의 코드가 있다고 했을 때 DOM Node들은 이렇게 나타날 것이다.

<ol>
  <li>부추밥</li>
  <li>순두부찌개</li>
  <li>선지국밥</li>
  <li>콩나물국밥</li>
</ol>

여기서 lunchMenus에 롯데리아라는 값을 추가하면 다음과 같이 변화할 것이다.

<ol>
  <li>부추밥</li>
  <li>순두부찌개</li>
  <li>선지국밥</li>
  <li>콩나물국밥</li>
  <li>롯데리아</li>
</ol>

이런 상황에서 React는 자식 Element들을 위에서 아래로 하나씩 비교한다.

이후 롯데리아의 차례가 되었을 때 기존과 달라졌다는 것을 알고 새롭게 Element를 삽입한다.

여기까지는 나름 당연해보인다. 하지만, 롯데리아를 리스트의 제일 앞에 넣게 된다면 어떻게 될까?

<ol>
  <li>롯데리아</li>
  <li>부추밥</li>
  <li>순두부찌개</li>
  <li>선지국밥</li>
  <li>콩나물국밥</li>
</ol>

리스트의 제일 앞에 넣게 될 경우 이런식으로 DOM Node가 나타나게 될 것이다.

문제는, 이렇게 되었을 경우 자식 Element를 위에서 아래로 비교할 때 첫 번재 자식 부터 달라졌기 때문에 자식들을 모두 리렌더링 하게 된다.

하지만 key 값을 올바르게 제공했다면, React는 자식들이 모두 바뀐 것이 아닌 순서가 변경되었다는 것을 알고 컴포넌트의 위치만 변경한다.

이게 바로 key 값에 index와 같이 예측 불가능한 값을 넣으면 안되는 이유이다.

정리

  1. React가 렌더링 하는 순서는 다음과 같다.
    1. 렌더링 함수 호출
    2. 최상위 노드에서 부터 React Element를 생성
    3. 현재 VDOM과 변경 된 VDOM을 비교
    4. VDOM과 DOM을 동기화
  2. 두 개의 DOM 트리를 비교하는 방법이 바로 Reconciliation
  3. Reconciliation의 Diffing 알고리즘은 휴리스틱한 가설 아래에 탄생
    • 따라서 해당 가설에 맞지 않게 코드를 작성할 경우 퍼포먼스 측면에서 문제가 발생할 수 있음

4. 이전 Reconciliation의 문제점과 Fiber Architecture

React 15까지는 Reconciliation으로만 리렌더링을 진행했었다.

하지만 React가 점차 많이 사용되어 가며 Reconciliation만으로는 해결이 안되는 문제들이 발견되었다.

대표적인 문제가 동기적으로 동작하는 점이었다.

이전 버전의 Reconciler(React의 Reconciliation 패키지 이름)는 stack 처럼 구성되어 있었고, stack이 빌때까지 동기적으로 작업이 진행되었다. (그래서 구버전의 Reconciler를 흔히 Stack Reconciler라고 부른다.)

따라서 오래 걸리는 네트워크 작업을 먼저 요청하고, input등에 텍스트를 적게 되면 네트워킹 작업이 완료될 때 까지 입력이 불가능했다. (여기에 누군가 예시 페이지를 만들어 둔 것이 있다. Async Mode와 Sync Mode에서 각각 텍스트를 입력해보자.)

이 문제를 해결하기 위해 React팀이 16 버전에 들고나온 것이 Fiber Architecture다.

Fiber Architecture

Fiber Architecture의 큰 특징은 아래와 같다.

  1. 대부분 비동기적으로 이루어진다.
  2. 작업에 따른 우선순위가 존재하고, 상황에 따라 변경될 수 있는 등 유연하게 동작한다.
  3. 작업을 중단할 수 있고, 일시적으로 멈출 수 있으며 재시작할 수도 있다.
  4. 가능한 경우, 이전에 완료해두었던 작업을 재사용한다.
  5. Error Boundaries에 대한 처리가 명확해졌다. (이전에는 React 내부에서 예외 발생시 내부 루프가 완전히 망가졌다고 한다.)

이 특징만 살펴보더라도, 굉장한 진보가 이루어졌음을 짐작할 수 있다.

자 그렇다면, Fiber란 무엇일까?

Fiber는 React에서 처리되는 일종의 작업의 단위이다. FiberNode라는 함수로 구현되어 있으며, 그저 JS Object이다. Component, DOM Node, Text 등에 1:1로 대응되게 생성된다.

생성된 Fiber는 크게 2가지의 단계를 거쳐 동작한다.

  1. Render 단계
    • 사용자에게 직접적으로 보이지 않는 작업들을 비동기적으로 처리
    • 작업들의 우선순위가 결정되고, 일부 작업들을 중단, 일시중지, 재시작등을 진행
    • beginWork, completeWork등의 함수가 호출되며 동작함
  2. Commit 단계
    • 렌더링 단계에서 결정된 비교 결과를 화면에 렌더링함
    • 이 단계는 동기적으로 진행되며, 중단, 일시중지, 재시작등이 불가능함

위 내용을 보면 알 수 있듯이, 이전 Reconciler에서 있던 문제를 크게 2가지 단계로 구분하여 해결한 것을 볼 수 있다.

이 단계들을 조금 더 자세히 알아보자.

1. Render 단계

먼저 renderRootSync라는 함수에서 시작해서 workLoopSync라는 함수를 호출한다.

WorkLoop는 while문으로 이루어져 있으며 Fiber 객체를 돌며 업데이트 하는 역할을 한다. 업데이트에는 beginWork, completeWork라는 함수를 사용한다.

이 두 함수에 대해 설명하기전에 먼저 Fiber 객체들이 어떻게 서로를 참조하고 있는지에 대해서 알아야한다.

<ol>
  <li>롯데리아</li>
  <li>
    부추밥
    <p>설명: 부추와 밥이다. 이것저것 섞어서 먹으면 된다.</p>
    <span>인기도: ★★★★☆</span>
  </li>
  <li>선지국밥</li>
</ol>

위와 같은 DOM Node들이 있을 때, Fiber의 구조는 아래와 같이 생겼다.

fiber structure

Fiber 구조의 특징은 아래와 같다.

  1. child는 1개만 존재할 수 있다.
  2. child가 여러개일 경우, 제일 첫번째 자식이 child가 된다.
  3. 같은 부모의 자식들은 sibling(형제)로 묶인다.

이렇게 생성된 객체들은 먼저 beginWork함수를 통해 Fiber를 생성하며 순회한다.

beginWork는 child가 없을 때 까지 탐색하다, child가 없다면 해당 child의 sibling으로 넘어간다. 해당 sibling의 child가 없을 때 까지 반복한다.

말로는 어려우니, 위의 예시로 설명을 해보겠다.

  1. ol 태그에서 시작, child가 존재함으로 작업중으로 표시
  2. 롯데리아로 이동, child가 존재하지 않음으로 작업완료로 표시
  3. 부추밥으로 이동, child가 존재함으로 작업중으로 표시
  4. 부추밥 - 설명으로 이동, child가 존재하지 않음으로 작업완료로 표시
  5. 부추밥 - 인기도로 이동, child가 존재하지 않음으로 작업완료로 표시

이 과정을 거치며 child와 sibling이 모두 없을 경우 beginWork가 종료되고 completeWork 함수가 실행된다.

completeWork의 경우는 beginWork와 반대로 실행된다.

  1. 부추밥으로 이동 (부추밥 - 설명, 부추밥 - 인기도가 모두 작업 완료 상태이기 때문에 넘어간다.), child가 모두 작업 완료 상태이므로 작업완료로 표시
  2. 선지국밥으로 이동, child가 존재하지 않음으로 작업완료로 표시
  3. 롯데리아, 부추밥, 선지국밥 모두 작업완료 상태이므로, ol 태그로 돌아가 작업완료로 표시

즉, beginWork는 아래 방향으로 순회하고 completeWork는 위 방향으로 순회한다.

이렇게 순회를 마치면 Fiber로 구성된 Fiber Tree가 생성된다.

이미 생성된 경우, 변경된 부분만 업데이트 된다.

2. Commit 단계

Commit 단계는 화면에 보이는 Tree를 교체하는 작업을 진행한다.

Fiber Tree는 2가지가 존재하는데, 현재 화면에 보이는 Current Tree, 비동기적으로 업데이트를 진행중인 WorkInProgress Tree가 존재한다.

이후 WorkInProgress Tree가 완성되면 단순히 포인터를 Current Tree에서 WorkInProgress Tree로 옮기며 전환하면 끝이난다.

이미지로 설명하면 다음과 같다.

fiber-tree-1

fiber-tree-2

fiber-tree-3

게임을 만들어봤던 개발자라면, 이와 비슷한 개념이 바로 떠올랐을 것이다. 이름하여 Double Buffering이다. 읽어보면 나름 재밌을 수 있다.

정리

  1. React 16부터 적용된 새로운 Reconciler 아키텍쳐의 이름이 Fiber Architecture이다. 이 아키텍쳐로 Reconciliation의 구현체가 변경되었다.
  2. Fiber ArchitectureFiberNode 들로 구성되어 있으며 여러 요소와 1:1 대응되어 생성된다.
  3. FiberNode는 하나의 자식과 여러 개의 형제를 가질 수 있다.
  4. WorkLoop는 while문으로 되어있고, FiberNode들을 생성, 업데이트 하는 역할을 한다.
  5. 동작은 크게 Render, Commit 단계로 이루어져 있다.
    • Render 단계는 비동기적으로 동작하고, Commit 단계는 동기적으로 동작한다.

개인적인 목표로는, 여기에 추가로 React가 어떻게 Side Effect를 감지하는지까지 적으려고 했으나.. 여기까지 적는데 너무 많은 에너지를 소모하여 다음글에 적도록 하겠다.

Reference

  1. React Components, Elements, and Instances
  2. Reconciliation
  3. acdlite의 React Fiber Architecture 저장소
  4. React 17.0.2 저장소
  5. A deep dive into React Fiber
  6. Inside Fiber: in-depth overview of the new reconciliation algorithm in React

Copyright ⓒ 2022 김춘식 All rights reserved.

Powered By Gatsby