Published on

Build your own React - Concurrent Rendering과 Fiber

Authors

이 포스팅은 AUSG 5기 Frontend Deep Dive 스터디에서 Build your own React 포스팅을 읽고 학습한 내용을 기록한 것입니다. 원문 내용에 더해 React 동작 원리를 이해하는데 도움이 되는 내용이 포함되어 있습니다. 글의 내용과 관련한 피드백은 언제나 환영합니다.

지난 포스팅에서는 JSX가 Babel과 같은 빌드 도구를 통해 어떻게 트랜스파일되고, 트랜스파일 된 코드가 어떻게 DOM element로 변환되는지 살펴보았다. 다시 정리해보면 JSX를 Babel로 트랜스파일하면 React.createElement() 함수를 호출하도록 변환되고, 해당 함수가 실행되면 type, props 키를 갖는 오브젝트를 반환한다. 이 오브젝트는 ReactDOM.render() 함수에 의해 실제 DOM 노드로 만들어지고 최종적으로 DOM 트리가 만들어진다.

그런데 지난 포스팅에서 만든 결과물에는 한 가지 문제가 존재한다. 그 문제는 아래 코드에 있다.

// ...render() 함수
element.props.children.forEach((child) => render(child, dom))

render() 함수의 인자로 전달한 element의 children을 순회하면서 render 함수를 호출하고 있는데, render할 element의 수가 많을 경우 main thread를 block하게 된다. 즉 사용자 입력 처리, 애니메이션 등 보다 중요한 작업들이 element tree를 모두 렌더링 할 때까지 기다리게 된다.

브라우저 렌더링 과정

위 문제를 제대로 이해하기 위해선 브라우저의 렌더러 프로세스의 동작을 이해해야 한다. 렌더러 프로세스의 main thread는 HTML, CSS, JS를 파싱하여 DOM tree, CSSOM tree를 생성하고 이를 합쳐 Layout tree를 만든다. Layout tree는 페이지의 기하학적 정보와 실제로 화면에 보여질 DOM 노드 정보를 포함한다. Layout tree가 만들어지면 Layout tree를 각 레이어로 구분한 Layer tree를 생성한 뒤 각 요소를 어떠한 순서로 그릴지에 관한 Paint record를 생성한다. 마지막으로 main thread는 이를 commit하여 compositor thread에 전달한다.

Browser rendering path

Browser rendering path(출처: https://aerotwist.com/blog/the-anatomy-of-a-frame/)

Compositor thread는 레이어 타일을 계산하는 Composite작업을 수행하고 Raster(Comopsitor Tile Worker) thread는 paint record를 바탕으로 실제 paint를 수행하는 Rasterize작업을 수행한다. 마지막으로 GPU 프로세스의 GPU 스레드가 레이어 및 타일을 화면에 표시하면 비로소 HTML이 픽셀로 나타나게 된다.

위 과정을 일반적으로 다음과 같이 5단계로 구분하여 표현한다,

Pixel Pipeline

Pixel Pipeline(출처: https://web.dev/rendering-performance/#the-pixel-pipeline)

중요한 점은 각 단계에서 무엇인가 변한다면 그 다음 단계들이 갱신된다는 점이다. 예를 들어, 레이아웃 트리에서 무엇인가 변한다면 문서에서 영향 받은 부분에 대하여 페인트하는 순서가 갱신될 필요가 있다. 그렇기 때문에 흔히 렌더링 비용이 비싸다고 말하고, 이를 해결하기 위해 React와 같이 JSON 기반의 virtual DOM을 사용한다(물론 virtual DOM이 빛을 발하기 위해선 몇 가지 가정이 필요하다).

Animation Frame

브라우저는 일반적으로 초당 60번 스크린을 refresh한다. 각 refresh 사이의 간격을 frame이라고 한다. Composite을 제외한 렌더링 과정은 main thread에서 이뤄지기 때문에, main thread가 무거운 javascript를 실행하고 있을 경우 해당 frame에 렌더링하지 못해 page jank가 발생해서 사용자가 버벅임을 느끼게 된다.

page jank

Page jank(출처: https://developer.chrome.com/blog/inside-browser-part3/)

다시 위에서 언급한 문제로 돌아와보자. render() 함수가 처리할 element가 매우 많다면 위 처럼 page jank가 발생할 수 있다. 만약 애니메이션이 돌아가고 있었다면 해당 애니메이션은 멈춰 보일 것이고, 사용자가 input에 텍스트를 입력해도 반응하지 않을 수 있다.

이 문제를 해결하기 위해, 긴 javascript 작업을 작은 단위로 쪼개고 requestAnimationFrame() 또는 requestIdleCallback() 함수를 사용하여 각 프레임마다 실행하게 할 수 있다. 또한 Web Worker를 활용하여 main thread가 아닌 다른 thread가 javascript를 실행하도록 할 수 있다.

이번 포스팅에서는 requestIdleCallback() 함수를 활용하여 render() 함수를 작은 단위로 나누어 위 문제를 해결해보자.

Note: React는 requestIdleCallback() 함수를 활용하였다가 requestAnimationFrame() 을 활용하는 방식으로 변경하였고, 현재 scheduler 패키지를 사용한다. 작업을 작은 단위로 쪼개는 개념은 기존과 동일하기 때문에 여기서는 requestIdleCallback() 함수를 활용한다.

Step III: Concurrent Mode

먼저 Element를 렌더링하는 작업을 작은 단위로 나누자. 그래서 각 작업이 끝난 다음 브라우저의 상태를 확인해보고, 더 중요한 작업이 필요하다면 렌더링 작업을 잠시 멈추고 해당 작업을 수행하도록 하자.

// 다음 렌더링 작업
let nextUnitOfWork = null

function workLoop(deadline) {
  let shouldYield = false
  while (nextUnitOfWork && !shouldYield) {
    // 다음 렌더링 작업을 수행하고, 다시 다음 렌더링 작업을 지정한다.
    nextUnitOfWork = performUnitOfWork(nextUnitOfWork)
    // 주어진 idle 시간이 1ms 보다 적다면 loop를 빠져나오고 다음 frame을 기다린다
    shouldYield = deadline.timeRemaining() < 1
  }
  requestIdleCallback(workLoop)
}
// 브라우저가 idle 상태일 때 workLoop를 호출하도록 한다
requestIdleCallback(workLoop)

function performUnitOfWork(nextUnitOfWork) {
  // TODO: add dom node
  // TODO: create new fibers
  // TODO: return next unit of work
}

nextUnitOfWork 는 다음 렌더링할 작업을 저장하고, performUnitOfWork() 함수에 전달하여 렌더링한 뒤 다음 렌더링 작업을 선정하여 반환하도록 하자. 이 함수의 본문은 조금 뒤에 작성하자.

requestIdleCallback() 함수는 매 frame마다 main thread가 idle한 때에 callback 함수를 실행할 수 있도록 한다. argument로 받은 함수에 deadline 파라미터를 전달한다. 이 파라미터의 메서드를 통해 현재 프레임에서 남은 idle 시간이 얼마인지 계산할 수 있는데, 여기서는 1ms 보다 적으면 더 이상 렌더링 작업을 하지 않고 다음 frame에서 진행하도록 루프를 빠져나오게 하자.

이제 뼈대를 갖추었으니, 실제로 렌더링 작업을 작은 단위로 나눠야 하는데, 이를 위해 작업 단위를 표현하는 자료구조 Fiber가 등장한다.

Step IV: Fibers

Fiber를 도입하는 목적은 단순하다. 언제든지 렌더링 작업을 중지했다 다시 작업할 수 있도록 하는 것이다. 즉, 렌더링 작업이 다른 우선순위가 높은 작업에 의해 뒤로 밀려나더라도 브라우저가 다음 렌더링 작업을 쉽게 찾을 수 있도록 하는 것이다.

하나의 element에 대해 하나의 fiber가 대응되고, 이 fiber가 바로 위에서 정의한 nextUnitOfWork가 된다. JSX를 render할 때 제일 먼저 root element에 대한 fiber를 생성하고, nextUnitOfWork로 지정한다. 그런 다음 performUnitOfWork() 함수를 호출하여 다음 작업들을 처리해나간다. performUnitOfWork() 함수는 다음 순서로 동작한다.

  1. 주어진 element에 대한 DOM을 생성하고 DOM tree에 붙인다.
  2. 주어진 element의 children에 대해 fiber를 생성한다.
  3. 새롭게 nextUnitOfWork를 지정한다.

이해를 돕기 위해 다음과 같은 DOM tree가 있다고 생각해보자.

Fiber tree

Fiber tree(출처: https://pomb.us/build-your-own-react/)

위 트리에서 <div> fiber를 performUnitOfWork() 로 전달하면 해당 element에 맞는 DOM 노드를 생성하고 root에 붙인다. 그런 다음 children인 <h1>, <h2> element에 대응되는 fiber를 생성한다. 마지막으로 <h1> fiber를 nextUnitOfWork로 선정하고 종료한다. nextUnitOfWork를 선정하는 과정은 먼저 child가 있는지, sibling이 있는지 확인하고 없다면 parent로 올라가 root에 도달할 때 까지 이를 반복한다. 즉 위 트리에서 nextUnitOfWork로 선정되는 순서는 <div> -> <h1> -> <p> -> <a> -> <h2> 가 된다.

위 과정을 코드로 옮겨보자. 우선 우리가 만든 render 함수에서 dom을 생성하는 부분만 함수로 추출하자.

// render 함수 안에서 DOM 노드를 생성하는 로직만 분리한다
function createDom(fiber) {
  const dom =
    fiber.type == 'TEXT_ELEMENT' ? document.createTextNode('') : document.createElement(fiber.type)

  const isProperty = (key) => key !== 'children'
  Object.keys(fiber.props)
    .filter(isProperty)
    .forEach((name) => {
      dom[name] = fiber.props[name]
    })

  return dom
}

그리고 render 함수는 root element에 대한 fiber를 생성하고 nextUnitOfWork로 지정한다.

let nextUnitOfWork = null
function render(element, container) {
  // element를 next unit of work로 선정한다.
  nextUnitOfWork = {
    dom: container,
    props: {
      children: [element],
    },
  }
}

다음으로 performUnitOfWork() 함수를 단계별로 작성하자. 먼저 fiber에 대한 DOM을 생성하고 dom 프로퍼티에 저장한다. 현재 fiber의 parent가 있다면 .appendChild() 하여 DOM tree에 붙인다.

function performUnitOfWork(fiber) {
  // add dom node
  if (!fiber.dom) {
    fiber.dom = createDom(fiber)
  }
  if (fiber.parent) {
    fiber.parent.dom.appendChild(fiber.dom)
  }
  // TODO: create new fibers
  // TODO: return next unit of work
}

다음으로 현재 fiber의 children을 순회하면서 각 child element에 대한 fiber를 생성한다. Fiber tree를 만들기 위해 각 fiber의 child, sibling 필드에 이를 저장한다.

function performUnitOfWork(fiber) {
  // ...add dom node(생략)
  // create new fibers
  const elements = fiber.props.children
  let index = 0
  let prevSibling = null

  while (index < elements.length) {
    const element = elements[index]
    const newFiber = {
      type: element.type,
      props: element.props,
      parent: fiber,
      dom: null,
    }

    if (index === 0) {
      fiber.child = newFiber
    } else {
      prevSibling.sibling = newFiber
    }

    prevSibling = newFiber
    index++
  }
  // TODO: return next unit of work
}

마지막으로 nextUnitOfWork를 선정하여 반환한다. 이때 child, sibling, parent 순서대로 찾으면서 root fiber에 도달하여 더 이상 parent가 존재하지 않을 떄 까지 반복한다.

function performUnitOfWork(fiber) {
  // ...add dom node(생략)
  // ...create new fibers(생략)
  if (fiber.child) {
    return fiber.child
  }
  let nextFiber = fiber
  while (nextFiber) {
    if (nextFiber.sibling) {
      return nextFiber.sibling
    }
    nextFiber = nextFiber.parent
  }
}

자, 여기까지 하면 렌더링 작업을 작은 단위로 나눌 수 있게 된다. 그리하여 많은 element를 렌더링하느라 main thread를 block하여 page jank가 발생하는 것을 예방할 수 있게 되었다.

그런데 여기서 한 가지 문제가 있다. 작업을 작은 단위로 나눈 것 까진 좋은데, 렌더링 작업을 브라우저가 언제든지 중단할 수 있기 때문에 DOM 노드를 tree에 붙이는 작업이 끊길 수 있고, 그로인해 DOM tree가 완전히 갖춰지지 않은 상태를 사용자가 볼 수 있다. 이는 사용자 경험을 해칠 수 있다.

CPU x6 throttling 한 채로 새로고침 하는 과정. 불완전한 UI가 그려지는게 보인다.

Step V: Render and Commit Phases

이 문제를 해결하기 위해 DOM 노드를 tree에 붙이는 것을 나중으로 미룰 수 있다. performUnitOfWork() 함수에서 parent fiber에 현재 fiber의 DOM 노드를 append 하는 로직을 제거하자.

function performUnitOfWork(fiber) {
  // add dom node
  if (!fiber.dom) {
    fiber.dom = createDom(fiber)
  }
  // DOM 노드를 append 하는 로직 제거
  // if (fiber.parent) {
  //   fiber.parent.dom.appendChild(fiber.dom)
  // }
  // ...
}

대신 현재 root fiber tree의 현황을 파악하는 wipRoot(Working In Progress Root)를 만들고 렌더링 작업이 완료되었을 때, 즉 nextUnitOfWork 가 존재하지 않을 때 이를 commit하여 DOM tree를 만들도록 한다. 우선 render 함수를 다음과 같이 수정하자.

function render(element, container) {
  // wipRoot는 현재 렌더링중인 fiber tree의 root를 참조한다
  wipRoot = {
    dom: container,
    props: {
      children: [element],
    },
  }
  nextUnitOfWork = wipRoot
}

let nextUnitOfWork = null
let wipRoot = null

마지막으로 workLoop 에서 nextUnitOfWork 가 존재하지 않으면 commitRoot() 함수를 호출하여 재귀적으로 DOM tree를 만든다.

function commitRoot() {
  commitWork(wipRoot.child)
  wipRoot = null
}
function commitWork(fiber) {
  if (!fiber) {
    return
  }
  const domParent = fiber.parent.dom
  domParent.appendChild(fiber.dom)
  commitWork(fiber.child)
  commitWork(fiber.sibling)
}
function workLoop(deadline) {
  // ...
  // nextUnitOfWork가 존재하지 않으면 현재 wipRoot를 commit 한다
  if (!nextUnitOfWork && wipRoot) {
    commitRoot()
  }
  requestIdleCallback(workLoop)
}

자, 이렇게해서 불완전한 UI가 화면에 보여지는 것도 막을 수 있게 되었다.

CPU x6 throttling 한 채로 새로고침 하는 과정. 불완전한 UI는 보이지 않는다.

지금까지 만든 동작하는 소스코드는 Github 레포지토리에서 확인할 수 있다.

Recap

이번 포스팅에서는 지난 시간 작성한 라이브러리가 main thread를 block하는 문제를 다루었다. 먼저 브라우저의 동작 원리를 바탕으로 해결 방법을 살펴보았고, 렌더링 작업을 작은 단위로 나누는 방법으로 이를 해결하였다. 또 렌더링 작업을 작게 나누기 위한 자료구조인 Fiber에 대해서도 살펴보았다. 이번 포스팅에서 다룬 내용을 정리해보면 다음과 같다.

  • 브라우저는 렌더러 프로세스와 GPU 프로세스를 활용하여 HTML 페이지를 픽셀로 나타낸다.
  • 브라우저의 렌더러 프로세스의 main thread는 크게 5가지 과정(JS > Style > Layout > Paint > Composite)을 거친다.
  • 브라우저는 초당 60회 스크린을 refresh하고 각 refresh 사이를 frame이라 한다.
  • 무거운 javascript를 실행하여 해당 frame에 렌더링이 이뤄지지 못 할 경우 page jank가 발생하여 뚝뚝 끊기는 현상이 나타난다.
  • Page jank를 해결하기 위해 javascript작업을 작은 단위로 나누거나 워커 스레드를 사용할 수 있다.
  • requestIdleCallback() 함수는 매 frame마다 main thread가 idle한 때에 callback 함수를 실행할 수 있도록 한다.

References