React 톺아보기 - 03. Hooks_2

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

3. 상태가 변경되어 리-렌더될 때 변경된 상태 값은 어떻게 가지고 오는 것일까?

3 - 1 상태를 담은 훅 객체 불러오기

컴포넌트 마운트 이후 우리가 작성한 useState()는 업데이트 구현체인 updateState()를 사용하게 됩니다.

reconciler > ReactFiberHooks.js

  
function updateState(initialState) {
  return updateReducer(basicStateReducer, initialState)
}

updateState()는 그저 updateReducer()로 포워딩하는 함수입니다. 그리고 이름에서 알 수 있듯이 updateReducer()는 useReducer()의 업데이트 구현체입니다. useState()와 useReducer()의 차이점은 내부에서 action을 소비하는 리듀서를 외부에서 주입할 수 있느냐?입니다.

업데이트 구현체가 해야 할 일은 이전에 만들어놓았던 훅을 다시 불러오는 것입니다. 그리고 훅의 queue에 담겨있는 update를 소비하여 최종 상태 값을 구해야 합니다. 이 동작은 useState(), useReducer()의 훅 객체마다 행해지는데 이 과정에서 진행 상황을 훅 객체의 baseUpdate와 baseState에 기입하게 됩니다.

문제는 여러가지 이유로 컴포넌트 렌더링을 취소해야 될 경우가 있습니다. 이를 대비하기 위해 fiber의 workInProgress 처럼 훅 객체 또한 작업용 훅을 만들어 사용합니다.

가장 먼저 마운트는 객체를 생성했다면 업데이트는 기존 훅을 작업용 훅으로 생성해야 합니다.

reconciler > ReactFiberHooks.js

  
function updateReducer(reducer, initialArg, init) {
  const hook = updateWorkInProgressHook()
  /*...*/
}

reconciler > ReactFiberHooks.js

  
function updateWorkInProgressHook() {
  if (nextWorkInProgressHook !== null) {
    // Render phase update로 인해 재호출될 경우 아래에서 만들어논 객체를 재사용..
  } else {
    currentHook = nextCurrentHook
    // 작업용 훅 객체를 만든다.
    const newHook: Hook = {
      memoizedState: currentHook.memoizedState,

      baseState: currentHook.baseState,
      queue: currentHook.queue,
      baseUpdate: currentHook.baseUpdate,

      next: null,
    }

    if (workInProgressHook === null) {
      // This is the first hook in the list.
      workInProgressHook = firstWorkInProgressHook = newHook
    } else {
      // Append to the end of the list.
      workInProgressHook = workInProgressHook.next = newHook
    }
    nextCurrentHook = currentHook.next
  }
}

nextWorkInProgressHooknextCurrentHook가 무엇을 뜻하는지 기억 안날 경우 renderWithHooks()의 Render phase update로 인한 호출 로직일반 호출 로직을 다시 확인해보고 오세요.

Render phase update는 언제든지 자주 발생할 수 있습니다. 이때 매번 작업용 객체를 생성한다면 비효율적이므로 첫 컴포넌트 호출에서 만들어놓은 것을 재사용합니다.

reconciler > ReactFiberHooks.js

  
function updateWorkInProgressHook() {
  if (nextWorkInProgressHook !== null) {
    // 재사용
    workInProgressHook = nextWorkInProgressHook
    nextWorkInProgressHook = workInProgressHook.next
    // current hook
    currentHook = nextCurrentHook
    nextCurrentHook = currentHook !== null ? currentHook.next : null
  } else {
    /* 작업용 훅 객체 생성.. */
  }
  return workInProgressHook
}

진행 상황 기록하기

훅 객체를 만들 때 설명을 생략했던 baseUpdate와 baseState, 그리고 update가 왜 Circular Linked List인지 알아볼 시점이 왔습니다. 잠시 해당 부분만 뜯어오겠습니다.

const hook = {
  memoizedState: null,
  queue: null,
  next: null,
  baseState: null,
  baseUpdate: null,
}

const last = queue.last
if (last === null) {
  update.next = update
} else {
  const first = last.next
  if (first !== null) {
    update.next = first
  }
  last.next = update
}
queue.last = update

baseUpdate, baseState

훅과 queue에 대해 좀 더 깊게 생각해 봅시다.

컴포넌트의 상태를 변경하기 위해 훅 setState()를 호출하면 update는 queue에 연결 리스트로 추가됩니다. 그리고 컴포넌트 재호출 시 queue에서 update를 꺼내와 소비합니다. 문제는 update를 소비할 때 항상 head부터 시작하게 된다면 이미 소비된 update를 중복으로 처리하게 됩니다.

그래서 적용된 부분과 아직 적용되지 않은 부분의 경계선을 정해줄 필요가 있습니다. 그래야 이 경계선을 기준으로 적용되지 않은 부분의 update들만 소비할 수 있으며 이미 적용된, 더는 사용하지 않을 update를 건너뛰고 GC를 위해 참조도 끊어줄 수 있습니다.

이 경계선에 대한 정보는 baseUpdate, baseState에 담기게 됩니다. baseUpdate는 마지막으로 적용된 update의 포인터이며 baseState는 baseUpdate를 소비한 결괏값입니다. 이제 이 baseUpdate를 기준으로 이후 노드들은 아직 적용되지 않은 update 리스트가 됩니다.

Circular Linked List

근데 queue는 last를 통해 마지막 update만 참조하고 있습니다. 이러면 tail만 알 수 있지 head는 모릅니다.
잠깐, “훅의 baseUpdate를 통해 아직 적용하지 않은 update를 알 수 있다고 하지 않았나요?”
맞습니다. 하지만 baseUpdate는 컴포넌트 재호출을 통해 한번은 update를 소비해야 훅에 기록됩니다. 그전까지는 head를 알 수 없습니다.

이러한 이유로 컴포넌트의 첫 업데이트가 발생하기 전까지는 head를 어딘가에서 물고 있어야 하며 update를 Circular Linked List로 만들어 tail update가 head 가리키도록 한 것입니다. 그리고 이 연결은 첫 업데이트 적용 시점에 끊어주게 됩니다.

update를 queue에 추가하는 코드를 보면 last.next의 존재 여부를 확인하는 코드가 바로 head를 훅의 baseUpdate로 물고 있는 것인지 아니면 아직도 tail update가 물고 있는지 확인하는 부분입니다.

3 - 2 update 소비하기

update를 소비할 때 두 가지 상황이 존재합니다. 하나는 유휴 상태에서 발생한 업데이트이며 또 다른 하나는 컴포넌트 호출 시점에서 발생한 Render phase update입니다.

reconciler > ReactFiberHooks.js

  
function updateReducer(reducer, initialArg, init) {
  // const hook = updateWorkInProgressHook();

  const queue = hook.queue
  queue.lastRenderedReducer = reducer // useState()는 기본 리듀서인 basicStateReducer를 사용
  
  // Render phase update 판단
  if (numberOfReRenders > 0) {
    /* Render phase update 소비.. */
    return ...;
  }

  /* update 소비.. */
}

Render phase update를 소비하는 부분은 마지막에 확인하도록 하겠습니다.

이제 훅을 가지고 왔으니 queue에 있는 update를 적용할 차례입니다. 로직은 간단합니다.

  1. 적용할 update 리스트의 head를 가지고 온다.

    1. 훅의 baseUpdate 또는 queue의 last.next
    2. Circular Linked List 라면 더는 head를 물고 있을 필요가 없으므로 연결을 끊어준다.
  2. update 리스트의 head부터 tail까지 차례로 리듀서에 action을 던져 결괏값을 취한다.
  3. update를 모두 소비했다면 최종 상태값을 저장한다.

reconciler > ReactFiberHooks.js

  
function updateReducer(reducer, initialArg, init) {
  /*...*/

  /*
  if (numberOfReRenders > 0) {
    Render phase update 소비..
  }
  */
 
  const last = queue.last
  const baseUpdate = hook.baseUpdate
  const baseState = hook.baseState

  // 1. 적용시킬 update의 head를 가지고 온다.
  let first
  if (baseUpdate !== null) {
    if (last !== null) {
      last.next = null // 1-2 연결을 끊는다.
    }
    first = baseUpdate.next // 1-1 baseUpdate의 head 참조
  } else {
    first = last !== null ? last.next : null // 1-1 Circular Linked List의 head 참조
  }

  // 2. head부터 tail까지 차례로 리듀서에 action을 던져 결괏값을 취한다.
  if (first !== null) {
    let newState = baseState
    let prevUpdate = baseUpdate
    let update = first
    do {
      const action = update.action
      newState = reducer(newState, action)
      prevUpdate = update
      update = update.next
    } while (update !== null && update !== first)

    // 3. update를 모두 소비했다면 최종 상태값을 저장한다.
    hook.memoizedState = newState
    hook.baseUpdate = prevUpdate // 적용된 update의 tail pointer
    hook.baseState = newState // baseUpdate의 결괏값
  }

  const dispatch = queue.dispatch
  return [hook.memoizedState, dispatch] // 최종 상태 값 반환
}

마지막으로 Render phase update 소비 로직을 확인하면서 마무리 하겠습니다.

3 - 3 Render phase update 적용 시키기

dispatchAction()은 자신이 호출될 때 Render phase가 진행 중이라면 update를 queue가 아닌 renderPhaseUpdates에 저장해두었으므로 queue가 아닌 맵에서 리스트를 꺼내 사용합니다. update 소비 로직은 이전에 본 것과 크게 다르지 않으므로 별 다른 설명 없이 넘어가겠습니다.

reconciler > ReactFiberHooks.js

  
function updateReducer(reducer, initialArg, init) {
  /*...*/
  if (numberOfReRenders > 0) {
    const dispatch = queue.dispatch

    if (renderPhaseUpdates !== null) {
      const firstRenderPhaseUpdate = renderPhaseUpdates.get(queue)

      if (firstRenderPhaseUpdate !== undefined) {
        renderPhaseUpdates.delete(queue)
        let newState = hook.memoizedState
        let update = firstRenderPhaseUpdate

        do {
          const action = update.action
          newState = reducer(newState, action)
          update = update.next
        } while (update !== null)
  
        hook.memoizedState = newState
        queue.lastRenderedState = newState
        return [newState, dispatch]
      }
    }
    return [hook.memoizedState, dispatch]
  }
  /*...*/
}
  • Render phase update는 queue에 추가되는 것이 아니므로 따로 baseUpdate에 tail update를 추가하는 작업은 필요 없습니다.

useState()가 어떻게 함수형 컴포넌트의 상태를 관리하는지 알아보았습니다. 본의 아니게 useReducer()의 update 구현체도 함께 알아보았는데 useEffect()와 useLayoutEffect()는 reconciler를 분석하면서 확인하게 될 것이지만 이를 제외한 나머지 훅들은 다루지 않으므로 따로 직접 분석해 보시길 추천해 드립니다.

다음 포스트는 dispatchAction()에게 전달받은 Work를 실행시켜 줄 scheduler입니다.


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