React 톺아보기 - 02. Intro

모든 설명은 v16.12.0 버전 함수형 컴포넌트와 브라우저 환경을 기준으로 합니다. 버전에 따라 코드는 변경될 수 있으며 클래스 컴포넌트는 설명에서 제외됨을 알려 드립니다.

이번 포스트에서는 사전지식을 좀 더 자세하게 다루도록 하겠습니다.

Intro에서 언급되는 부분들은 추후에 주제에 맞는 포스트에서 본격적으로 다루므로 가볍게 읽고 지나가시면 됩니다.

react lifescycle
React lifecycle

위 표는 클래스 컴포넌트의 라이프 사이클 실행 시점을 나타내는 표로 오다가다 많이 보셨을 겁니다.
표 왼쪽을 보면 리액트를 개발하면서 접해보지 못한 단어들이 보입니다. “Render phase”, “Commit phase”
표의 설명만 봐서는 무엇이 중지되고 다시 시작될 수 있는지, side effect는 무엇인지 이해하기가 쉽지 않습니다.

Render phase

Render phase는 쉽게 말해 VDOM 조작 단계라고 생각하시면 됩니다.
리액트는 변경점이 생겼을 때(element의 추가, 수정, 삭제) 이를 VDOM에 반영하기 위해 Work를 담당하는 함수를 scheduler를 통해 실행시킵니다.

Work

Work는 고유명사로 추후에 다루게 되는 reconciler컴포넌트의 변경을 DOM에 적용하기 위해 행하는 작업Work로 통칭한다고 생각하시면 됩니다. 이 Work를 통해 Render phase, Commit phase가 진행됩니다.

Render phase는 VDOM을 재조정하는 일련의 과정입니다. 재조정을 담당하는 reconciler의 설계가 스택 기반에서 fiber architecture로 넘어오면서 이 과정을 abort, stop, restart 할 수 있게 되었습니다.
이 기능은 concurrent mode 에서만 비동기와 함께 이루어지며 legacy mode(현재 우리가 일반적으로 사용하는 ReactDOM.render())에서는 위 기능 없이 동기적으로 Render phase가 동작합니다.

컴포넌트 호출은 Render phase에서 실행되며 호출이 곧 화면 반영을 나타내는 것은 아님을 알려 드렸습니다. 이전 포스트에서 언급한 용어인 렌더링에 빗대어 보자면 컴포넌트가 리-렌더링 된다는 말은 컴포넌트가 호출되고 그 결과가 VDOM에 반영된다는 것이지 DOM에 마운트되어 페인트 된다는 뜻은 아닙니다.

Commit phase

Commit phase는 Render phase에서 재조정된 VDOM을 DOM에 적용하고 라이프 사이클을 실행하는 단계입니다. 여기서도 마찬가지로 DOM에 마운트된다는 것이지 페인트 된다는 건 아닙니다.

이 단계는 모드와는 상관없이 항상 일관적인 화면 업데이트를 위해 동기적으로 실행 됩니다. 동기적으로 실행된다는 건 콜 스택을 한 번도 비우지 않고 DOM 조작을 일괄처리한다는 뜻입니다. 그러므로 Commit phase 중간에 페인트 되지 않습니다. 이 단계가 끝나고 리액트에서 콜 스택을 비워줘야지만 브라우저에서 화면을 페인트 할 수 있게 됩니다.

그 다음은 자주 언급되는 VDOM을 알아보겠습니다.

VDOM

vDOM
Virtual DOM

current 트리와 workInProgress 트리의 최상단 노드를 current, workInProgress로 명시하였지만 더 자세히는 Host root라는 이름의 노드입니다.

  • 리액트는 VDOM을 더블 버퍼링 형태로 관리합니다. DOM에 마운트된 current 트리와 Render phase에서 작업 중인 workInProgress 트리로 나뉘어 있습니다. 이 workInProgress 트리는 Commit phase를 지나면 current 트리로 관리됩니다.

    function commitRootImpl(...) {
      /*...*/
      root.current = finishedWork
      /*...*/
    }

    이렇듯 더블 버퍼링 형태이기 때문에 리액트는 workInProgress에 작업을 하다가도 언제든지 버리고 처음부터 다시 작업하거나 또는 중지시켰다가 다시 시작하는 등 작업 우선순위에 맞게 유연하게 대처할 수 있기에 사용자 경험을 최우선적으로 고려할 수 있습니다.

  • workInProgress가 만들어지는 방식은 current에서 자기 복제하여 서로 alternate로 참조하는 방식입니다.
  • VDOM 노드fiber는 자식을 child로 참조하는데 first child만 참조합니다. 나머지 자식들은 이전 형제가 sibling으로 참조하고 있습니다.
  • 모든 자식은 부모를 return으로 참조합니다.

React element

React element는 컴포넌트의 정보를 담고 있는 모델 객체입니다.
아래의 예시를 통해 React element가 어떻게 생겼는지 확인해보세요.

const App = ({ content }) => <div>{content}</div>
ReactDOM.render(<App key="1" content="Deep dive react" />, container)

const element = {
  // This tag allows us to uniquely identify this as a React Element
  $$typeof: REACT_ELEMENT_TYPE,

  // Built-in properties that belong on the element
  type: type, // function App()
  key: key, // 1
  props: props, // { content: 'deep dive react' }
  ref: ref, // undefined
}
  • 우리가 작성하는 JSX는 babel을 통해 react.createElement(…)로 변환되어 React element로 생성됩니다.
  • type에는 컴포넌트 종류에 따라 다른 값을 사용합니다. 호스트 컴포넌트는 tag 이름이 함수형 컴포넌트는 함수 자체를 저장합니다.
  • JSX를 통해 태그의 attribute로 넘겨주는 값중 key, ref를 제외한 나머지는 props로 관리합니다.

Fiber

React element를 VDOM에 올려놓아야 합니다. 그 확장을 fiber가 해줍니다. fiber는 VDOM 노드이며 컴포넌트가 살아 숨쉬기 위해 필요한 모든 정보를 관리하고 있습니다.

react-reconciler > ReactFiber.js

  
function FiberNode(tag, pendingProps, key){
  // Instance
  this.tag = tag; // fiber의 종류를 나타냄
  this.key = key;
  this.type = null; // 추후에 React element의 type을 저장
  this.stateNode = null; // 호스트 컴포넌트에 대응되는 HTML element를 저장

  // Fiber
  this.return = null; // 부모 fiber
  this.child = null; // 자식 fiber
  this.sibling = null; // 형제 fiber
  this.index = 0; // 형제들 사이에서의 자신의 위치

  this.pendingProps = pendingProps; // workInProgress는 아직 작업이 끝난 상태가 아니므로 props를 pending으로 관리
  this.memoizedProps = null; // Render phase가 끝나면 pendingProps는 memoizedProps로 관리
  this.updateQueue = null; // 컴포넌트 종류에 따라 element의 변경점 또는 라이프사이클을 저장
  this.memoizedState = null; // 함수형 컴포넌트는 훅을 통해 상태를 관리하므로 hook 리스트가 저장된다.

  // Effects
  this.effectTag = NoEffect; // fiber가 가지고 있는 side effect를 기록
  this.nextEffect = null; // side effect list 
  this.firstEffect = null; // side effect list
  this.lastEffect = null; // side effect list 

  this.expirationTime = NoWork; // 컴포넌트 업데이트 발생 시간을 기록
  this.childExpirationTime = NoWork; // 서브 트리에서 업데이트가 발생할 경우 기록

  this.alternate = null; // 반대편 fiber를 참조
}

fiber에는 너무 많은 정보가 있기 때문에 이런 게 있구나 하고 훑어만 보시면 됩니다. 추후에 하나도 빠짐없이 다루게 될 것입니다.

Side effect

VDOM에 변경점을 만들거나(추가, 수정, 삭제..) 혹은 변경점을 만들어낼 수도 있는 작업(라이프 사이클)을 side effect라고 합니다. 아래는 리액트에서 사용되는 side effect tag입니다.

shared > ReactSideEffectTags.js

  
export const NoEffect = /                / 0b0000000000000;
export const PerformedWork = /           / 0b0000000000001;
export const Placement = /               / 0b0000000000010;
export const Update = /                  / 0b0000000000100;
export const PlacementAndUpdate = /      / 0b0000000000110;
export const Deletion = /                / 0b0000000001000;
export const ContentReset = /            / 0b0000000010000;
export const Passive = /                 / 0b0001000000000;
/*...*/

이 tag는 fiber의 effectTag에 저장됩니다. 해당 tag가 달린 fiber는 effect로서 취급되며 연결 리스트로 상위로 엮어져 올라갑니다. 최종적으로 최상위 fiber가 하위 모든 effect를 가지고 있게 되며 이는 Commit phase에서 소비됩니다.

// 자식의 side effect를 부모로 올린다.
if (returnFiber.firstEffect === null) {
  returnFiber.firstEffect = workInProgress.firstEffect
}
if (workInProgress.lastEffect !== null) {
  if (returnFiber.lastEffect !== null) {
    returnFiber.lastEffect.nextEffect = workInProgress.firstEffect
  }
  returnFiber.lastEffect = workInProgress.lastEffect
}

// 자신에게도 side effect가 있다면 자기 자신도 effect list에 추가해준다.
if (effectTag > PerformedWork) {
  if (returnFiber.lastEffect !== null) {
    returnFiber.lastEffect.nextEffect = workInProgress
  } else {
    returnFiber.firstEffect = workInProgress
  }
  returnFiber.lastEffect = workInProgress
}

로직 순서를 보면 부모로 리스트를 올릴 때 자식을 먼저 연결하고 자신은 맨 마지막에 추가됩니다.
중요한 부분은 아니지만 coomit phase에서는 리스트의 순서대로 소비되기 때문에 DFS의 순서로 effect가 적용됨을 알 수 있습니다.

Bit Masking

리액트에서는 effect tag나 여러 상태 값을 Bit Masking으로 관리하므로 본격적으로 시작하기에 앞서 모르시는 분들을 위해 한번 짚고 넘어가겠습니다.

현재 실행되고 있는 환경을 나타내는 context 또한 bit masking으로 관리하므로 이 context를 다루어 보면서 알아보겠습니다.

비트 연산자에 대한 설명은 생략합니다.

render context는 16, commit context는 32로 정의되어 있습니다. 이를 2진수로 변환하면 각각 10000, 100000 입니다. 즉 2진수로 5번째 자리가 1이면 Render phase란 뜻입니다.

상태값을 담고 있는 2진수를 비트 연산자를 통해서 다음과 같이 관리할 수 있습니다. 확인은 and(&)연산자, 추가는 or(|)연산자, 삭제는 xor(~)연산자를 사용하면 됩니다.

예를 들어 다음과 같이 정의되어 있다고 가정하겠습니다.

const NoContext = 0b00
const RenderContext = 0b01
const CommitContext = 0b10
const executionContext = NoContext // 0

Render phase에 접어들어 현재 context에 render context를 추가한다면 다음과 같습니다

executionContext |= RenderContext // 00 | 01 => 01

현재 context에 따라서 분기 처리를 해야 한다면 다음과 같습니다.

if (executionContext & RenderContext !== NoContext) // 01 & 01 !== 00

Render phase가 끝나고 Commit phase에 접어들 때는 이전 context를 지우고 다음 context를 추가해야 합니다.

executionContext &= ~RenderContext // 01 &  10 => 00
executionContext |= CommitContext // 00 | 10 => 10

리액트 실제 사용 사례

if ((executionContext & (RenderContext | CommitContext)) !== NoContext) {
  //현재 context가 Render phase이거나 Commit phase이면
  return msToExpirationTime(now())
}

사전 지식은 이 정도면 충분합니다.
이해되지 않는다고 좌절할 필요는 없습니다. 그냥 이런 게 있구나 하고 기억만 해두시면 됩니다. 우리는 개발자이니 백번 말로 설명하는 것보단 한 번 코드로 보는 게 더 이해하기 쉬울 거라 생각합니다.

본격적으로 코드와 함께 리액트 분석을 시작해 봅시다.


오픈소스를 톺아보며 매직 코드라 생각했던 부분들의 동작 원리와 의미, 의도를 파악해보고 서로의 생각을 나누기 위한 블로그