SIU
article thumbnail

 

리액트의 렌더링 순서를 결정짓기 위해서 리액트 내부동작과 아키텍쳐를 이해해야합니다.

리액트의 최신 버전은 18버전이지만, 핵심 아키텍쳐가 변경된 버젼은 16버젼의 fiber 입니다.

16버전 이전의 리액트는 stack 아키텍쳐를 사용하고 있었습니다.

 

 

스택 자료구조는 LIFO 방식으로 먼저 삽입된 데이터가 가장 마지막으로 접근할 수 있습니다. 맨 아래에 필요한 데이터가 있다면 위에 데이터까지 꺼내야하니(= 렌더링) 상당히 비효율적입니다. 

 

그래서 도입된 것이 fiber[파이버] 아키텍처입니다. 새로 도입된 fiber 아키텍처는 hook이 LinkedList 형태로 연결된 자료구조입니다. 

 

setState()는 어떻게 state를 변경할까? 

 

https://github.dev/facebook/react/blob/main/packages/react-reconciler/src/ReactFiberHooks.js

 

https://github.dev/facebook/react/blob/main/packages/react-reconciler/src/ReactFiberHooks.js

Setting up your web editor eyJzZXJ2ZXJDb3JyZWxhdGlvbklkIjoiYTFkZmQ2YjMtOTE0Ny00ZDgyLThkOTgtMDg4NmYzNDEwY2I4Iiwid29ya2JlbmNoVHlwZSI6ImVkaXRvciIsIndvcmtiZW5jaENvbmZpZyI6eyJ2c2NvZGVWZXJzaW9uSW5mbyI6eyJpbnNpZGVyIjp7ImNvbW1pdCI6IjI2OGYzNDk5MmM1ZDgyNmEwOGU0YjcyM

github.dev

useState() 호출 시 [state, setState] 얻게 됩니다. 그럼 useState() 호출시 반환되는 state와 setState 함수는 어떤 값들 일까요? facebook/react 오픈 소스를 열어보겠습니다.

react/packages/react-reconciler/src/ReactFiberHooks.js 경로로 이동하겠습니다. 리액트 18버전의 코드로 설명드리겠습니다.

 

ReactFiberHooks.js (Dispatcher)

const HooksDispatcherOnMount: Dispatcher = {
  readContext,

  use,
  useCallback: mountCallback,
  useContext: readContext,
  useEffect: mountEffect,
  useImperativeHandle: mountImperativeHandle,
  useLayoutEffect: mountLayoutEffect,
  useInsertionEffect: mountInsertionEffect,
  useMemo: mountMemo,
  useReducer: mountReducer,
  useRef: mountRef,
  useState: mountState,
  useDebugValue: mountDebugValue,
  useDeferredValue: mountDeferredValue,
  useTransition: mountTransition,
  useSyncExternalStore: mountSyncExternalStore,
  useId: mountId,
};

React 패키지의 ReactFiberHooks.js의 Dispatcher 함수 코드입니다.

 

useState: mountState,

그 중에서 살펴 볼 useState 훅입니다.

 

 

mountState 함수

function mountStateImpl<S>(initialState: (() => S) | S): Hook {
  const hook = mountWorkInProgressHook();
  if (typeof initialState === 'function') {
    // $FlowFixMe[incompatible-use]: Flow doesn't like mixed types
    initialState = initialState();
  }
  hook.memoizedState = hook.baseState = initialState;
  const queue: UpdateQueue<S, BasicStateAction<S>> = {
    pending: null,
    lanes: NoLanes,
    dispatch: null,
    lastRenderedReducer: basicStateReducer,
    lastRenderedState: (initialState: any),
  };
  hook.queue = queue;
  return hook;
}

function mountState<S>(
  initialState: (() => S) | S,
): [S, Dispatch<BasicStateAction<S>>] {
  const hook = mountStateImpl(initialState);
  const queue = hook.queue;
  const dispatch: Dispatch<BasicStateAction<S>> = (dispatchSetState.bind(
    null,
    currentlyRenderingFiber,
    queue,
  ): any);
  queue.dispatch = dispatch;
  return [hook.memoizedState, dispatch];
}

useState에 할당하는 mountState 함수를 살펴보겠습니다.

 

const hook = mountWorkInProgressHook();

2번 째 줄에서 mountworkInProgressHooke()을 호출하여 return 값을 할당해줍니다.

 

hook 객체

function mountWorkInProgressHook(): Hook {
  const hook: Hook = {
    memoizedState: null,

    baseState: null,
    baseQueue: null,
    queue: null,

    next: null,
  };

  if (workInProgressHook === null) {
    // This is the first hook in the list
    currentlyRenderingFiber.memoizedState = workInProgressHook = hook;
  } else {
    // Append to the end of the list
    workInProgressHook = workInProgressHook.next = hook;
  }
  return workInProgressHook;
}

mountWorkInProgressHook 함수로 이동해보겠습니다.

 

  const hook: Hook = {
    memoizedState: null,

    baseState: null,
    baseQueue: null,
    queue: null,

    next: null,
  };

hook 객체가 등장합니다. 객체에서 중요한 property(key) 값이 의미하는 것이 뭘까요?

페이스북이 친절하게 네이밍명에서 유추 가능합니다.  

useState hook을 호출하면 생성하는 hook 객체 안에는 memoizedState를 통해 기억해야 할 state 값을 저장합니다.

 

어떤 객체에서 next가 나타나는 자료구조는 LinkedList 입니다. 다음 노드를 next로 위치 정보를 저장합니다. 하나의 node 안에 next 값이 포인터 역할을 수행합니다.

Linked List : 연결된 목록(item = node )

Linked List 구조

 

제가 처음에 생각했던 setState() 원리

저는 내부원리를 보기 전까지는 그동안 setState가 교체(swap) 알고리즘으로 알고 있었습니다. 변수(메모리) 2개에 기존 값을 저장해 놓고 setState()를 호출하면 기존 값을 swap2 변수에 저장하여 복사본을 만들고, swap1을 바꾸는 원리로 알고 있었습니다. 하지만 아니였습니다. 훅은 객체로 이루어져 있었고. Linked Listed로 next로 서로 연결되어 있었습니다.

 

 

그럼 여기서 드는 궁금 점이,

1. 훅 객체는 왜 next라는 key값을 가지고 있을까?

2. 다음으로 연결된 노드는 무엇일까요? node로 연결 되려면 어떤 조건을 충족(=기준)해야 할까요?

3. LinkedList는 여러개의 node가 연결되어 있는데, 연결된 node끼리 어떤 상호작용을 할까요?

 

여기서 next는 다음 hook의 key 값이 저장됩니다. hook 객체는 next 키 값을 통해 링크드리스트 형태로 이루어져 있습니다.

이 hook이 서로 연결되어 있는 링크드 리스트는 React의 fiber에 저장됩니다. 

hook 들이 LinkedList로 저장된 곳이 fiber입니다.

 

  const hook: Hook = {
    memoizedState: null,

    baseState: null,
    baseQueue: null,
    queue: null,

    next: null,
  };

아까 hook 객체에서 queue 는 무엇을 의미할까요? 일반적인 queue 자료 구조입니다. 하나의 hook은 하나의 queue 객체를 가지게 됩니다. 자바스크립트의 이벤트 루프처럼 뭔가 이유가 있겠지만, 이건 다음에 정리하겠습니다.

 

 

function mountWorkInProgressHook(): Hook {
  const hook: Hook = {
    memoizedState: null,

    baseState: null,
    baseQueue: null,
    queue: null,

    next: null,
  };

  if (workInProgressHook === null) {
    // This is the first hook in the list
    currentlyRenderingFiber.memoizedState = workInProgressHook = hook;
  } else {
    // Append to the end of the list
    workInProgressHook = workInProgressHook.next = hook;
  }
  return workInProgressHook;
}

workInProgressHook 이 null이라면 작업 중인 hook이 없다라는 의미입니다.

작업중인 hook이 없다면 

 currentlyRenderingFiber.memoizedState = workInProgressHook = hook;

 

현재 렌더링되는 Fiber의 memorizedState에 훅을 저장합니다.

 

 

만약(else) workInProgressHook이 null 이 아닐 때에는 (= 현재 작업 중인 hook이 있다)
새로운 hook을 추가하고 싶기 때문에,

 workInProgressHook = workInProgressHook.next = hook;

아까 설명드린 next와 연결지어 설명드리면, 링크드 리스트에 추가하는 방법이 현재 작업중인 훅의 next 키 값에 다음 훅을 저장합니다.  

 

return workInProgressHook;

이렇게 새로 만든 hook 객체를 반환합니다.

 

그럼 이 hook 객체를 받는 곳을 다시 가보면, 재귀형태로 올라간다고 생각해보세요.

 

function mountStateImpl<S>(initialState: (() => S) | S): Hook {
  const hook = mountWorkInProgressHook();
  if (typeof initialState === 'function') {
    // $FlowFixMe[incompatible-use]: Flow doesn't like mixed types
    initialState = initialState();
  }
  hook.memoizedState = hook.baseState = initialState;
  const queue: UpdateQueue<S, BasicStateAction<S>> = {
    pending: null,
    lanes: NoLanes,
    dispatch: null,
    lastRenderedReducer: basicStateReducer,
    lastRenderedState: (initialState: any),
  };
  hook.queue = queue;
  return hook;
}

function mountState<S>(
  initialState: (() => S) | S,
): [S, Dispatch<BasicStateAction<S>>] {
  const hook = mountStateImpl(initialState);
  const queue = hook.queue;
  const dispatch: Dispatch<BasicStateAction<S>> = (dispatchSetState.bind(
    null,
    currentlyRenderingFiber,
    queue,
  ): any);
  queue.dispatch = dispatch;
  return [hook.memoizedState, dispatch];
}

initialState는 인자로 넘겨 받는 것이 (() => S) | S 함수거나 값 일 때에 따라

if (typeof initialState === 'function') {
    initialState = initialState();
  }

만약 함수라면 호출을 해서 return 한 값을 다시 initialState에 할당합니다.

예를 들어, useState() 함수에서 값 뿐만 아니라 함수를 인자로 넘길 수 있습니다.

 

hook.memoizedState = hook.baseState = initialState;

memoizeState는 최종적인 렌더하고 있는 state 값을 저장하고 있기 때문에 처음에는 initialState를 할당합니다.

 

 

queue 객체

 const queue: UpdateQueue<S, BasicStateAction<S>> = {
    pending: null,
    lanes: NoLanes,
    dispatch: null,
    lastRenderedReducer: basicStateReducer,
    lastRenderedState: (initialState: any),
  };

현재 리액트18버전에서는 last 프로퍼티가 없어졌습니다. (16버전에서는 queue 객체는 last, distpatch, lastRenderedReducer, lastRendedState 4개의 key로 구성되었습니다)

 

last는 queue 안에 담겨있는 마지막 값을 할당하는 것입니다. queue 안에는 update 객체가 담겨 있습니다. update 객체는 상태를 업데이트 하기 위한 정보입니다. 16버전에서는 last 프로퍼티가 있어 queue.last는 queue의 마지막 값을 가르켰습니다. 근데 18버전에서는 사라졌네요.(이부분은 저도 다시 확인해보겠습니다)

 

dispatch 함수는 queue 객체에서 새로운 update 객체를 추가하는 역할을 합니다. push 기능입니다. 

const dispatch: Dispatch<BasicStateAction<S>> = (dispatchSetState.bind(
    null,
    currentlyRenderingFiber,
    queue,
  ): any);
  queue.dispatch = dispatch;
return [hook.memoizedState, dispatch];

16버전에서는 dispatchAction이였지만 18버전에서는 dispatchSetState로 이름이 바뀌었네요. dispatchSetState.bind는 currentlyRenderingFiber 현재 작업 중인 fiber와 dispatch가 연결되어 있는(할당되어 있는) queue 가 바인딩(연결) 되어 있습니다.

 

왜 bind를 할까?

마지막 문장의 return 구문을 보면 dispatch는 return 되면서 외부에 노출됩니다.

return [hook.memoizedState, dispatch];

dispatch가 return하는 이 코드 어딘가 익숙하지 않으신가요!!!!?

 

cont [state, setState] = useState(3)

useState가 return하는 값이 구조분해할당으로 state와 setState에 담기는데 여기서 setState가 위에 dispatch에 해당합니다.

return [hook.memoizedState, dispatch];

cont [state, setState] = useState(3)

한 눈에 보면, hook.memoizedState는 state가 되고 dispatch는 setState가 됩니다.

 

const HooksDispatcherOnMount: Dispatcher = {
  readContext,

  use,
  useCallback: mountCallback,
  useContext: readContext,
  useEffect: mountEffect,
  useImperativeHandle: mountImperativeHandle,
  useLayoutEffect: mountLayoutEffect,
  useInsertionEffect: mountInsertionEffect,
  useMemo: mountMemo,
  useReducer: mountReducer,
  useRef: mountRef,
  useState: mountState,
  useDebugValue: mountDebugValue,
  useDeferredValue: mountDeferredValue,
  useTransition: mountTransition,
  useSyncExternalStore: mountSyncExternalStore,
  useId: mountId,
};

 

맨 처음에 본 이 코드에서

useState: mountState,

14번째 줄에, mountState는 useState에 할당됩니다. 

전체적인 구조를 다시 보며 마무리하겠습니다.

 

1. useState()를 호출한다는 것은 내부적으로 mountState()를 하는 것과 같다. mountState()가 return 하는 값은 배열이고 [hook.memoizedState, dispatch] 이다. 그리고 이는 [state, setState] 와 동일하다.
const [state, setState] = useState(3);

2. state는 자바스크립트 객체이다.

3. hook들은 fiber라는 LinkedList의 node 형태로 이루어져 있고, next(포인터) 라는 프로퍼티로 서로 연결되어있다.

 

위 그림을 보시면, 리액트의 훅은 큐를 가집니다. 이 큐는 update 내용을 모와서 가장 마지막 값을 반영합니다.

- setState()에서 왜 비동기로 작동하는지? 

- state는 왜 불변성을 가져야 하는가?

- React가 추구하는 함수형 프로그래밍의 순수함수와 연관성은?

- React는 왜 batch update 구조를 가지는지?

2탄에서 더 자세히 공부해 봅시다.

profile

SIU

@웹 개발자 SIU

포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!