React 톺아보기 - 03. Hooks_1

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

각 포스트의 주제는 다음과 같이 리액트의 일반적인 흐름을 따라가도록 구성되었습니다.

포스트별 주제

  1. 훅을 통해 컴포넌트 상태를 업데이트한다.

  2. 업데이트를 반영할 Workscheduler에게 전달하고 scheduler는 스케줄링된 Task를 적절한 시기에 실행한다.
  3. Work을 통해 VDOM 재조정 작업을 진행한다.
  4. Work를 진행하며 발생한 변경점을 적용한다.
  5. 사용자의 상호작용으로 이벤트가 발생하고 등록된 핸들러가 실행되면서 다시 1번으로 되돌아간다.

이번 포스트에서는 훅의 동작 원리에 대해 알아볼 것이며 분석 순서는 아래와 같습니다.

  1. 훅 구현체 찾아가기
  2. 훅은 어떻게 생성되는가? (마운트)
  3. 훅은 어떻게 상태를 변경하고 컴포넌트를 리-렌더링시키는가?
  4. 상태가 변경되어 리-렌더될 때 변경된 상태 값은 어떻게 가지고 오는 것일까? (업데이트)

우리는 훅을 사용할 때 코어 패키지에서 불러 오지만 실제 구현체는 외부 모듈에 있습니다. 때문에 분석의 시작은 코어가 어떻게 외부 모듈에 있는 훅 구현체를 가지고와서 제공하는지? 에서 부터 출발합니다.

❗ useState()를 기준으로 진행합니다. useEffect()와 useLayoutEffect()는 reconciler에서 다루며 나머지 훅은 짧게 등장하거나 혹은 분석대상에서 제외됩니다.

1. 훅 구현체 찾아가기

1 - 1 Hook의 구현체는 어디에 있을까?

분석을 시작하기 가장 좋은 방법은 분석할 함수를 어디서 어떻게 가져오는지 먼저 확인하는 것입니다.

react > React.js

import { useState, useEffect, ... } from './ReactHooks'import ReactSharedInternals from './ReactSharedInternals' // 의존성을 주입받는 징검다리

const React = {
  useState,
  useEffect,
  __SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED: ReactSharedInternals,
  /*...*/
}

export default React

개발자가 코어를 통해 가져오는 훅은 ReactHooks.js 모듈에서 가져오고 있습니다.

react > ReactHooks.js

import ReactCurrentDispatcher from './ReactCurrentDispatcher'

function resolveDispatcher() {
  const dispatcher = ReactCurrentDispatcher.current  return dispatcher
}

export function useState(initialState) {
  const dispatcher = resolveDispatcher()
  return dispatcher.useState(initialState)
}

export function useEffect(create, inputs) {
  const dispatcher = resolveDispatcher()
  return dispatcher.useEffect(create, inputs)
}
/*...*/

우리가 사용하는 훅은 ReactCurrentDispatcher.js 모듈에서 가지고온 dispatcher의 메소드 였네요.
함수를 호출해 반환받은 결괏값을 사용하는 모습이 꼭 동적 바인딩을 위한 패턴 같습니다. 아직 원하는 결과를 얻지 못했으니 의심가는 ReactCurrentDispatcher.current를 따라가보겠습니다.

react > ReactCurrentDispatcher.js

const ReactCurrentDispatcher = {
  current: null,
}

export default ReactCurrentDispatcher

아무것도 없습니다.. 그냥 객체 하나가 끝입니다.

코어와 훅 사이의 관계에 대해서 좀만 더 생각해보자면 코어는 컴포넌트의 모델인 React element만 알고 있습니다. 이 element를 인스턴스화되기 전인 클래스라고 생각해보자면 훅은 이 클래스의 인스턴스화된 객체의 상태 값을 관리하는 역할을 합니다. 이렇게 따져보면 둘 사이의 괴리감이 존재합니다.

React element는 fiber로 확장되고 나서야 살아 숨 쉬게 됩니다. 그리고 이 역할은 reconciler가 가지고 있습니다. 그러므로 훅 또한 reconciler가 알고 있는 것이 맞습니다.

근데 위 리액트 코어를 보면 어디에도 reconciler에서 훅을 가져오는 부분을 확인하지 못했습니다. 그렇다는 말은 반대로 훅 객체를 외부에서 내부로 ReactCurrentDispatcher.current를 통해 주입해준다는 말이 됩니다.

이렇듯 코어는 다른 패키지의 기능을 개발자에게 제공해 줄 때 의존성을 자기가 만들지 않고 외부에서 주입 받습니다. 스프링의 DI(Dependency Injection) 처럼요.

그리고 한발 더 나아가 리액트는 외부에서 의존성을 주입할 때 코어에 직접 주입하지 않습니다. 중간자를 하나 더 두게 되는데 코어에서는 ReactSharedInternals.js가 이에 해당하고 리액트 프로젝트 전체로 보면 shared라는 패키지가 이 역할을 합니다.

1) 의존성을 관리하는 ReactSharedInternals.js와 shared 패키지

먼저 ReactSharedInternals.js는 외부에서 주입받길 기다리는 모듈들의 대기소 같은 곳입니다. ReactCurrentDispatcher 또한 여기에서 훅을 주입받길 기다리고 있습니다.

react > ReactSharedInternals.js

import ReactCurrentDispatcher from './ReactCurrentDispatcher'import ReactCurrentBatchConfig from './ReactCurrentBatchConfig'
import ReactCurrentOwner from './ReactCurrentOwner'
/*...*/

const ReactSharedInternals = {
  ReactCurrentDispatcher,  ReactCurrentBatchConfig,
  ReactCurrentOwner,
  /*...*/
}

export default ReactSharedInternals

shared는 모든 패키지가 공유하는 공통 폴더의 역할을 합니다. 그 중 코어의 출입구 역할을 하는 모듈이 shared의 ReactSharedInternals.js입니다. reconciler 역시 훅을 주입할 때 Shared 패키지를 통해서 동적으로 구현체를 주입합니다.

코어 모듈의 ReactSharedInternals.js와 이름이 같음을 유의하세요.

shared > ReactSharedInternals.js

import React from 'react'

const ReactSharedInternals =
  React.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED // core의 ReactSharedInternals.js

if (!ReactSharedInternals.hasOwnProperty('ReactCurrentDispatcher')) {
  ReactSharedInternals.ReactCurrentDispatcher = {
    current: null,
  }
}
/*...*/

export default ReactSharedInternals

훅이 개발자에게 도달되는 흐름은 다음과 같습니다.
reconciler -> shared/ReactSharedInternal -> react/ReactSharedInternal -> react/ReactCurrentDispatcher -> react/ReactHooks -> react -> 개발자

이제 우리는 훅의 위치를 shared/ReactSharedInternal.js를 import하고 ReactCurrentDispatcher를 사용하고 있는 곳을 찾아가면 확인할 수 있을 것이란 짐작을 할 수 있습니다.

1 - 2 Hook 구현체 주입

본격적으로 분석을 시작하기 전에 알려 드립니다.

  • 함수 내부에서 선언된 지역변수인지 모듈 최상단에 선언된 전역변수인지 잘 확인하시길 바랍니다.
  • 설명하지 않고 넘어가는 코드는 크게 신경 쓰지 않으셔도 됩니다. 복잡하니 지금은 생략하고 뒷부분에서 다룰 것입니다.
  • 실제 코드를 보면 __DEV__로 감싸진 코드들을 자주 보실 텐데 개발 모드에서만 사용됨을 뜻하므로 산뜻하게 무시해주시면 됩니다.
  • 분석하기에 타입이 있으면 좋을 부분들은 타입을 제거하지 않았습니다.

훅 주입은 reconciler/renderWithHooks()에서 이루어집니다. 함수 이름에서 느껴지시나요? 컴포넌트 호출 또한 여기서 합니다. 이 함수는 Render phase에서 실행되는데 그때 가서 자세히 다루고 지금은 훅과 관련된 코드들만 뜯어와서 보도록 하겠습니다.

reconciler > ReactFiberHooks.js

export function renderWithHooks(
  current: Fiber,
  workInProgress: Fiber,
  Component: any,
  props: any,
  refOrContext: any,
  nextRenderExpirationTime: ExpirationTime
) {
  /*...*/
  currentlyRenderingFiber = workInProgress // 현재 작업 중인 fiber를 전역으로 잡아둠
  nextCurrentHook = current !== null ? current.memoizedState : null

  ReactCurrentDispatcher.current =    nextCurrentHook === null ? HooksDispatcherOnMount : HooksDispatcherOnUpdate
  let children = Component(props, refOrContext)

  /*컴포넌트 재호출 로직..*/

  const renderedWork = currentlyRenderingFiber
  renderedWork.memoizedState = firstWorkInProgressHook

  ReactCurrentDispatcher.current = ContextOnlyDispatcher

  currentlyRenderingFiber = null
  /*...*/
}

reconciler 패키지의 여러 모듈은 자신의 컨텍스트를 현재 작업 중인 컴포넌트 전용으로 사용합니다. 이 말이 무슨 뜻이냐면 해당 모듈에서 선언되는 모든 전역 변수들(firstWorkInProgressHook, nextCurrentHook..)은 작업 중인 컴포넌트에만 국한되는 상태 값으로 관리한다는 뜻입니다.
컴포넌트의 작업이 끝나게 되면 모두 초기화시켜 다음 컴포넌트에서 사용할 수 있도록 준비시켜 놓습니다.
그렇기 때문에 전역변수인지 지역변수인지 유심히 살펴보시길 바랍니다.

  • Component는 fiber의 type에서 꺼낸 온 것인데 함수형 컴포넌트의 경우 개발자가 작성한 함수가 type이 됩니다.
    컴포넌트를 호출17할 때 해당 컴포넌트가 마운트되어야 한다면 전역변수 firstWorkInProgressHook에 훅 리스트가 생성되어 저장됩니다. 이 변수를 fiber의 memoizedState에 저장22해 놓음으로써 훅을 컴포넌트와 매핑시켜 줍니다.
  • 이제 라인 12를 이해할 수 있습니다. memoizedState가 null이 아니라면12 해당 컴포넌트는 마운트가 아닌 업데이트 상태이며 훅 리스트 또한 이미 존재함을 뜻합니다. 이를 이용하여 마운트 여부를 확인15 합니다. 그리고 거기에 맞게 훅 구현체를 다르게 사용합니다. 즉 컴포넌트가 마운트 될 때 훅은 마운트용 구현체를 사용할 것이고 그 이후에는 컴포넌트가 언마운트되지 않는 한 계속해서 업데이트용 구현체를 사용하게 됩니다.
  • 라인 24를 보면 추가적으로 ContextOnlyDispatcher 훅 구현체를 주입해주는 걸 확인할 수 있습니다. 이는 개발자의 실수로 컴포넌트 실행 시점이 지났음에도 훅 호출이 발생하는 상황을 대비하여 개발자가 올바르게 훅을 사용할 수 있도록 에러를 던져주는 장치입니다.

reconciler > ReactFiberHooks.js

// mount
const HooksDispatcherOnMount = {
  useState: mountState,
  useEffect: mountEffect,
  /*...*/
};

// update
const HooksDispatcherOnUpdate: = {
  useState: updateState,
  useEffect: updateEffect,
  /*...*/
};

// invalid hook call
export const ContextOnlyDispatcher: Dispatcher = {
  useState: throwInvalidHookError,
  useEffect: throwInvalidHookError,
  /*...*/
};
상황별 훅 구현체

훅 구현체는 찾았습니다. 그렇다면 컴포넌트와 매핑되는 훅 본체는 어떻게 생성되는 것일까요?

2. 훅은 어떻게 생성되는가?

2 - 1 훅 객체 만들기

컴포넌트가 마운트 될 때 useState()를 호출할 경우 fiber의 memoizedState는 null이므로 mount 구현체인 mountState()를 사용하게 됩니다.

reconciler > ReactFiberHooks.js

function mountState(initialState) {
  const hook = mountWorkInProgressHook() // 훅 객체를 생성한다.
  /*...*/
}

먼저 훅 객체부터 만들어 줍니다.

reconciler > ReactFiberHooks.js

function mountWorkInProgressHook(): Hook {
  // hook 객체에 대해서는 업데이트 구현체에서 자세히 다룹니다.
  const hook: Hook = {
    memoizedState: null, // 컴포넌트에 적용된 마지막 상태 값
    queue: null, // 훅이 호출될 때마다 update를 연결 리스트로 queue에 집어넣습니다.
    next: null, // 다음 훅을 가리키는 포인터

    // 업데이트 구현체에서 설명
    baseState: null,
    baseUpdate: null,
  }

  if (workInProgressHook === null) {
    // 맨 처음 실행되는 훅인 경우 연결 리스트의 head로 잡아둠
    firstWorkInProgressHook = workInProgressHook = hook
  } else {
    // 두번 째부터는 연결 리스트에 추가
    workInProgressHook = workInProgressHook.next = hook
  }
  return workInProgressHook
}

연결 리스트

리액트는 많은 곳에서 built-in collection 대신 연결 리스트를 이용하여 구현하였습니다. 지금까지 확인한 것만 짚어 보자면 fiber 그 자체가 effect 연결 리스트의 노드였으며, 컴포넌트와 여러 훅을 매핑 시키기 위해, 하나의 훅이 여러 번 호출될 때 정보를 저장하기 위한 queue 등이 있었습니다.

built-in collection이 아닌 연결리스트를 사용하여 구현한 이유는 리스트 탐색 흐름 제어나 노드 삭제 등 조작이 쉽고 리스트 병합에 많은 리소스가 필요하지 않는다는 것입니다. 또한, 랜덤 액세스가 필요한 부분이 없으므로 더욱이 연결리스트를 쓰지 않을 이유가 없습니다.

firstWorkInProgressHook은 훅 연결 리스트의 head로 renderWithHooks()에서 이미 봤듯이 컴포넌트 실행이 끝났을 때 fiber에 저장되어 컴포넌트와 훅 리스트를 연결해주고 workInProgressHook은 현재 처리되고 있는 훅을 나타내면서 동시에 리스트의 tail 포인터로 사용합니다.

여러 블로그에서 훅을 설명할 때 배열을 이용하여 설명하지만 우리는 이제 실제 코드를 통해 연결 리스트로 되어 있다는 걸 알 수 있습니다. 추후에 업데이트 구현체를 보면 왜 훅의 순서가 항상 같아야 하는지에 대한 이유도 명확하게 알 수 있습니다.

훅 객체를 생성했으니 계속해서 mountState()의 분석을 진행해봅시다.

reconciler > ReactFiberHooks.js

function mountState(initialState) {
  // const hook = mountWorkInProgressHook();
  if (typeof initialState === 'function') {
    // 생성자 함수일 경우
    initialState = initialState()
  }
  hook.memoizedState = hook.baseState = initialState
  /*...*/
}

훅을 사용할 때 인자로 넘어오는 initialState가 함수이면 바로 실행해서 결괏값을 얻어옵니다. 이 이후에는 mount 구현체가 아닌 update 구현체를 사용하므로 initialState가 실행되는 일은 없습니다.

2 - 2 update를 담을 queue 생성

훅을 이용하여 컴포넌트 상태를 변경하고자 할 때 업데이트 정보를 담고 있는 update 객체가 생성됩니다. 이 객체는 훅의 queue에 저장됩니다. 가령 한 번의 컴포넌트 호출에서 단일 훅의 setState()가 여러 번 호출되었다면 매 호출 생성된 update 객체는 이 queue에 쌓이게 되는 것입니다. 그 후 컴포넌트가 리-렌더링 될 때 queue에 저장되어 있던 update을 차례대로 실행해 최종적으로 적용될 state를 도출하게 됩니다.

reconciler > ReactFiberHooks.js

function mountState(initialState) {
  /*...*/
  // hook.memoizedState = hook.baseState = initialState;

  const queue = (hook.queue = {
    last: null, // 마지막 update
    dispatch: null, // push 함수

    lastRenderedReducer: basicStateReducer,
    lastRenderedState: initialState,
  })

  const dispatch = (queue.dispatch = dispatchAction.bind(
    null,
    currentlyRenderingFiber,
    queue
  ))
  return [hook.memoizedState, dispatch]
}

function basicStateReducer(state, action) {
  return typeof action === 'function' ? action(state) : action
}

dispatchqueue의 push 함수입니다. dispatch 할당14을 자세히 보면 bind()를 통해 인자를 잡아둔 상태로 외부에 노출19시키고 있습니다. dispatch는 홀로 외부로 노출되기 때문에 필요한 인자들을 잡아둘 필요가 있습니다. 그 중 하나는 훅과 매핑되는 컴포넌트의 fiber16이며 다른 하나는 자신의 존재 목적인 queue입니다.

마지막에 반환하는 배열19이 바로 우리가 사용하고 있는 const [foo, setFoo] = useState(0)가 되겠습니다.

훅과 update에 대해 혹시나 혼동될까 한 번 더 짚고 가자면 하나의 컴포넌트에서 여러 훅이 실행될 때는 훅 자체의 연결 리스트로 저장되고 그 중 단일 훅의 dispatchAction()이 여러 번 호출될 때는 해당 훅 객체의 queue에 update를 연결 리스트로 저장하는 것입니다.

function FC() {
  const [a, setA] = useState(0) // aHook
  const [b, setB] = useState(0) // bHook
  setA(_a => _a + 1) // firstUpdate
  setA(_a => _a + 1) // secondUpdate
  setA(_a => _a + 1) // thirdUpdate

  /*
   *  fiber의 훅 리스트
   *  fiber.memoizedState => aHook.next => bHook.next => null
   *
   *  aHook의 queue
   *  fiber.memoizedState => aHook.queue.last => thirdUpdate.next =>
   *  firstUpdate.next => secondUpdate.next => thirdUpdate.next => firstUpdate
   */
}

queue가 원형 연결 리스트(Circular Linked List)인 이유는 업데이트 구현체에서 확인합니다.

3. 훅은 어떻게 상태를 변경하고 컴포넌트를 리-렌더링시키는가?

3 - 1 훅 상태를 업데이트하는 dispatchAction

dispatchAction()은 queue에 update를 push함과 동시에 scheduler에게 Work를 예약하는 함수입니다.

reconciler > ReactFiberHooks.js

function dispatchAction(fiber, queue, action) {
  const alternate = fiber.alternate

  if (
    fiber === currentlyRenderingFiber ||    (alternate !== null && alternate === currentlyRenderingFiber)  ) {
    // Render phase update
  } else {
    // idle update
  }
}

재조정 작업과 관련된 reconciler 코드를 들여다 보면 Render phase 도중에 발생한 것인지5 아니면 유휴 상태에서 발생한 것인지10에 따른 분기 처리를 자주 확인할 수 있습니다. 두 케이스 모두 처리해야 할 방법과 최적화 방식이 조금씩 다르므로 현재 분석하고 있는 코드가 어떤 상황에 위치해 있는지 정확히 알고 있어야 이해하기 쉽습니다.

Render phase update

컴포넌트가 렌더링 되고 있는 상황에서 추가로 업데이트가 발생할 경우를 말합니다.
아래 코드에서 버튼을 클릭했을 때 컴포넌트는 변경된 상태 값을 반영하기 위해 호출되는데 setA(2)로 인해 추가적인 업데이트가 발생한 경우입니다.

function FC() {
  const [a, setA] = useState(0)
  if (a === 1) setA(2)
  return <button onClick={() => setA(1)}></button>
}

먼저 if 문의 조건이 무엇을 뜻하는지부터 알아보겠습니다.
currentlyRenderingFiber는 renderWithHooks()에서 workInProgress로 할당됩니다. 그리고 renderWithHooks()는 Render phase 진행 중에 호출되는 함수이죠. 그러니 currentlyRenderingFiber가 비어있지 않다는 건6 ~ 7 Render phase가 진행 중이라는 뜻입니다.

currentlyRenderingFiber를 이용하여 Render phase를 판단하는 건 대충 알겠는데 그렇다면 왜 fiberalternate를 동시에 비교하는 것일까요?

VDOM은 하나의 노드를 current와 workInProgress로 관리한다고 했습니다. 하지만 우리는 fiber를 bind()를 통해 고정해 놨습니다. 문제는 current와 workInProgress는 고정이 아닌 Commit phase를 지나면 서로 교체 된다는 점입니다. 그래서 현재 작업 중인 currentlyRenderingFiber가 둘 중 어떤 것인지 알 수가 없습니다. 이 때문에 fiber와 alternate를 모두 비교해야지만 올바르게 Render phase update임을 확인할 수 있습니다.

다음으로 분기 로직 중 idle update를 먼저 알아보겠습니다.

3 - 2 유휴 상태에서의 dispatchAction

dispatchAction()이 하는 일은 다음과 같습니다.

  1. 사용자의 업데이트 정보를 담은 update객체를 만든다.
  2. updatequeue에 저장한다.
  3. 불필요한 렌더링이 발생하지 않도록 최적화를 한다.
  4. 업데이트를 적용하기 위해 Work를 스케줄링한다.

위 내용은 idle update일 경우이고 Render phase update는 또 4번을 제외하고는 로직이 다릅니다.

reconciler > ReactFiberHooks.js

function dispatchAction(fiber, queue, action) {
// const alternate = fiber.alternate
  if (...) {
    // Render phase update
  } else {
    /* 시간을 구하는 부분은 잠시 생략하고 포스트 마지막에 다루겠습니다.
    const currentTime = requestCurrentTimeForUpdate()
    const suspenseConfig = requestCurrentSuspenseConfig()
    const expirationTime = computeExpirationForFiber(
      currentTime,
      fiber,
      suspenseConfig
    )
    */

    // update 생성
    const update = {
      expirationTime,
      action, // setState()의 인자
      next: null, // 노드 포인터
      // 이하 최적화에 사용되는 속성
      eagerReducer: null,
      eagerState: null,
    }

    // update를 queue에 추가
    const last = queue.last
    if (last === null) {
      // This is the first update. Create a circular list.
      update.next = update
    } else {
      const first = last.next
      if (first !== null) {
        // Still circular.
        update.next = first
      }
      last.next = update
    }

    queue.last = update
    /*...*/
  }
}

update를 queue에 추가할 때는 연결 리스트로 추가28 ~ 39합니다. 로직을 보면 Circular Linked List로 만들어 주는 부분34 ~ 37이 있는데 왜 이렇게 만들어 주는지는 업데이트 구현체에서 설명하도록 하겠습니다.

불필요한 컴포넌트 리-렌더링 방지

리액트는 이 시점에(idle 상태에서 업데이트가 발생한 상황) 아래와 같은 상황이라면 간단하게 성능 최적화를 할 수 있습니다.

  1. 현재 컴포넌트의 업데이트로 인해 Work가 스케줄링 되어있지 않은 상태이고
  2. action의 결괏값이 현재 상태 값과 같다면? 이때는 변경된 게 없으므로 더 이상 진행하지 않고 중단할 수 있습니다.

reconciler > ReactFiberHooks.js

function dispatchAction(fiber, queue, action) {
// const alternate = fiber.alternate
  if (...) {
    // Render phase update
  } else {
    /* ... */
    // queue.last = update

    // 컴포넌트에서 업데이트가 발생한 적이 있는지 확인
    if (
      fiber.expirationTime === NoWork &&
      (alternate === null || alternate.expirationTime === NoWork)
    ) {
      // 최적화 로직..
    }
    /*...*/
  }
}

이제 우리는 dispatchAction()에서 fiberalternate를 모두 비교하는 이유를 알고 있습니다. 여기서 expirationTime은 업데이트가 발생하여 Work가 스케줄링 될 경우 fiber에 발생 시간을 기록하게 되는데 그게 expirationTime입니다.

reconciler > ReactFiberHooks.js

function dispatchAction(fiber, queue, action) {
// const alternate = fiber.alternate
  if (...) {
    // Render phase update
  } else {
    /* ... */
    // queue.last = update

    // 컴포넌트에서 업데이트가 발생한 적이 있는지 확인
    if (...) {
      const lastRenderedReducer = queue.lastRenderedReducer
      if (lastRenderedReducer !== null) {
        const currentState = queue.lastRenderedState // 컴포넌트에 적용된 상태값
        const eagerState = lastRenderedReducer(currentState, action) // 리듀서를 통해 action의 결괏값을 얻는다
        update.eagerReducer = lastRenderedReducer
        update.eagerState = eagerState
        if (is(eagerState, currentState)) {
          return
        }
      }
    }
    scheduleWork(fiber, expirationTime)
  }
}

useState()의 queue를 만들 때 lastRenderedReducer12에 기본 리듀서인 basicStateReducer()를 할당했습니다. 참고로 lastRenderedReducer에는 useReducer(reducer, initialArg, init)의 reducer 함수가 할당되기도 합니다. 결론적으로 useState()는 useReducer()와 같은 업데이트 구현체를 공유합니다. 그러므로 useState() 하나를 분석하면 useReducer()도 함께 알 수 있습니다.

현재 상태와 같지 않다면18 컴포넌트 리-렌더링을 진행해야 하므로 Work를 스케줄링23합니다. scheduler와 연관이 있는 scheduleWork()는 다음 포스트인 scheduler에서 자세히 다루게 되므로 잠시 생략하고, 단지 fiber에 expirationTime을 새기고 재조정을 진행할 Work 함수를 스케줄링한다고 이해하시면 됩니다.

3 - 3 Render phase에서의 dispatchAction

유휴 상태에서 dispatchAction()이 호출되었다면 Work를 통해 Render phase가 진행될 것입니다. 그리고 컴포넌트를 재호출하는데 이 와중에 추가로 dispatchAction()가 호출되었다고 생각해봅시다.

이때는 유휴 상태에서 dispatchAction()이 해야 할 일(Work 스케줄링, 성능 최적화)을 할 필요가 없습니다. 이미 Work는 진행 중이므로 Work를 스케줄링하거나 성능 최적화를 위한 처리가 필요 없습니다. 단지 Render phase update가 더 이상 발생하지 않을 때까지 계속해서 컴포넌트를 재호출하여 action을 소비시키기만 하면 됩니다.

Render phase update를 바로 소비하기 위해서는 update를 잠깐 담아둘 임시 저장소가 필요합니다. 그래야 다음 컴포넌트 호출 때 이 저장소에서 꺼내 소비할 수 있습니다.

reconciler > ReactFiberHooks.js

function dispatchAction(fiber, queue, action) {
  // const alternate = fiber.alternate
  if (...) {
    didScheduleRenderPhaseUpdate = true; // renderWithHooks()에게 컴포넌트 재실행을 알려줄 플래그

    const update = {
      expirationTime: renderExpirationTime,
      action,
      suspenseConfig: null,
      eagerReducer: null,
      eagerState: null,
      next: null,
    };

    if (renderPhaseUpdates === null) {
      renderPhaseUpdates = new Map(); // update 임시 저장소
    }

    const firstRenderPhaseUpdate = renderPhaseUpdates.get(queue);
    if (firstRenderPhaseUpdate === undefined) {
      renderPhaseUpdates.set(queue, update);
    } else {
      // Append the update to the end of the list.
      let lastRenderPhaseUpdate = firstRenderPhaseUpdate;
      while (lastRenderPhaseUpdate.next !== null) {
        lastRenderPhaseUpdate = lastRenderPhaseUpdate.next;
      }
      lastRenderPhaseUpdate.next = update;
    }
  } else {
    /*idle update..*/
  }
}

renderPhaseUpdates를 소비하기 위한 컴포넌트 재호출 로직을 renderWithHooks()를 분석할 때 생략했었습니다. 이 부분을 마저 채우고 가도록 하겠습니다.

renderWithHooks()는 didScheduleRenderPhaseUpdate 플래그를 통해 Render phase update가 발생했는지 판단합니다.

reconciler > ReactFiberHooks.js

export function renderWithHooks(...) {
  /*...*/
  if (didScheduleRenderPhaseUpdate) {
    do {
      didScheduleRenderPhaseUpdate = false
      // 무한 루프 방지와 업데이트 구현체에게 Render phase update를 알려주는 플래그
      numberOfReRenders += 1

      //이하 훅 업데이트 구현체에서 Render phase update를 소비하는데 필요한 변수들을 설정
      nextCurrentHook = current !== null ? current.memoizedState : null
      nextWorkInProgressHook = firstWorkInProgressHook
      currentHook = null
      workInProgressHook = null

      ReactCurrentDispatcher.current = HooksDispatcherOnUpdate // 업데이트 구현체 주입

      children = Component(props, refOrContext) // 컴포넌트 재호출
    } while (didScheduleRenderPhaseUpdate)

    renderPhaseUpdates = null // Render phase update 저장소 초기화
    numberOfReRenders = 0
  }
  /*...*/
}

❗기억하세요. 컴포넌트 실행18전 훅 구현체는 업데이트용으로 갈아끼워16 졌습니다. useState()는 더 이상 마운트용 구현체가 아닙니다.

훅에 익숙하지 않다면 자주 확인하게 되는 “Too many re-renders. React limits the number of renders to prevent an infinite > loop.” 메세지는 dispatchAction()에서 numberOfReRenders을 기준으로 출력합니다.

reconciler > ReactFiberHooks.js

const RE_RENDER_LIMIT = 25;
function dispatchAction(...) {
  invariant(
    numberOfReRenders < RE_RENDER_LIMIT,
    'Too many re-renders. React limits the number of renders to prevent ' +
      'an infinite loop.'
  )
  /*...*/
}

여기까지 마운트 구현체의 코드입니다. 다음은 컴포넌트를 재호출하면서 실행될 업데이트 구현체이지만, 이는 다음 포스트로 넘기겠습니다.

번외

expirationTime

설명을 생략한 expirationTime에 대해 마저 알아보겠습니다.

expirationTime는 schedulerreconciler에서 사용하는데 살짝 다른 뜻으로 쓰이며 구현도 다릅니다. scheduler에서는 이름 그대로 Task의 만료시간을 나타내는 데 비해 reconciler에서는 이벤트를 구분하는 기준으로 쓰입니다. reconciler는 같은 expirationTime에서 발생한 연속적인 이벤트는 하나의 이벤트로 간주하고 expirationTime이 달라야지만 개별 이벤트로 판단합니다.

expirationTime의 가장 큰 수는 부호 있는 31bit 값입니다. 이 값을 Sync로 다룹니다. 시간을 이렇게 특정 고유명사로 다룰 수 있는 이유는 위에서 설명한 것처럼 reconciler는 expirationTime을 이벤트를 구분하는데 사용하기 때문입니다. 그래서 구현 또한 일반적으로 시간을 다루는 방식과는 다르게 구현되었으며 아무리 시간이 지나도 절대로 Sync 값에 도달하지 않습니다.

reconciler > ReactFiberExpirationTime.js

export const NoWork = 0
export const Never = 1
export const Idle = 2
export const Sync = MAX_SIGNED_31_BIT_INT // 1073741823
export const Batched = Sync - 1
const MAGIC_NUMBER_OFFSET = Batched - 1

이벤트 발생 시간을 구할 때 형식은 Date.now()가 아닌 performance.now() 입니다.

performance.now()는 브라우저가 시작되고 현재까지의 경과시간을 나타냅니다.

이걸 바로 expirationTime으로 사용하지 않고 다음의 계산식을 이용합니다.
MAX_SIGNED_31_BIT_INT - now()

now()가 오른쪽 피연산자에 있다는 건 나중에 발생한 작업일수록 더 작은 expirationTime 값을 가지게 된다는 뜻입니다. 이 부분은 나중에 코드에서 fiber에 새겨진 expirationTime를 가지고 대소비교를 할 때 헷갈릴 수 있으므로 기억해두시길 바랍니다.

여기서 주의할 점은 performance.now()로 시간을 구하기 때문에 이론상으로 발생 시간이 0일 수 있습니다. 이렇게 되면 SyncBatched와 값이 겹칠 수 있어 기존 상수들과 겹치지 않도록 offset 값을 추가로 사용하는데 그게 바로 MAGIC_NUMBER_OFFSET입니다. MAGICNUMBEROFFSET이 있기 때문에 더 이상 MAXSIGNED31BITINT를 시간을 구하는데 사용할 필요가 없습니다.

이제 브라우저 시작과 동시에 이벤트가 발생해도 offset을 이용한 다음의 계산식이라면 Sync, Batched와 겹칠 일은 없습니다.
MAGICNUMBEROFFSET - now()

아래의 코드가 발생시간에서 expirationTime을 expirationTime에서 다시 발생시간을 구하는 계산식입니다.

reconciler > ReactFiberExpirationTime.js

/*...*/
// const MAGIC_NUMBER_OFFSET = Batched - 1

const UNIT_SIZE = 10

// 1 unit of expiration time represents 10ms.
export function msToExpirationTime(ms: number): ExpirationTime {
  return MAGIC_NUMBER_OFFSET - ((ms / UNIT_SIZE) | 0)
}

export function expirationTimeToMs(expirationTime: ExpirationTime): number {
  return (MAGIC_NUMBER_OFFSET - expirationTime) * UNIT_SIZE
}

간략하게 정리하자면 fiber의 expirationTime이 NoWork라면 놀고 있다는 뜻이고 Sync는 작업이 동기적으로 처리될 것이며 expirationTime을 대소로 비교하는 조건문이 있을 때는 큰 숫자가 더 먼저 발생한 작업이다. 라고만 생각하고 넘어가시면 분석할 때 큰 무리 없이 이해할 수 있을 것입니다.

expirationTime은 주로 concurrent mode에서 더 넓은 의미로 사용됩니다. 우선순위와 같은 뜻으로 말이죠. legacy mode에서는 expirationTime을 Sync로 설정하여 동기적으로 처리합니다. 그러므로 시간과 관련된 부분은 concurrent mode를 일반적으로 사용하게 되는 날, 그때 깊이 있게 들어가 보고 지금은 이 정도면 충분합니다.

expirationTime을 구하는 방법이 궁금하신 분은 computeExpirationForFiber()를 참고하세요.


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