[리액트] 깊이알아보는 useState, 리렌더링핵심 작동원리

Date:     Updated:

카테고리:

태그:

주제

그렇다. 오늘은 useState에 대해서 딥다이브 해보도록 하겠다. “이런걸 왜 알아야해요?” 라고 물어보는 사람들도 있고, 삽질하는게 아니냐라는 의견도 있을 것 같다. 이유는 궁금해서이다. 이글을 쓰는 시점을 기준으로 필자는 리액트를 1달밖에 써보지 않았다. 리액트의 수많은 기능 중 극히 일부분을 사용했음에도 불구하고, 가장 기본적인 Hook, useState가 어떻게 동작하는지 그려지지 않는다는것을 알았다.

가장 간단한 질문으로부터 useState를 파고들어가보도록 하겠다. 큰 그림을 먼저 잡고 그 맥락에서 설명하는것이 베스트 시나리오일 것 같지만, 아직은 모르는게 너무 많아, 지엽적인 부분부터 궁금증을 해결해 나가며 확장해 나가고자 한다. (리액트를 잘 모른다는 뜻🤣)

물음표살인마마냥 질문하는 본인의 질문에 자문자답 해가며 이야기를 풀어나가도록 하겠다.


⭐️질문1. setState()를 호출할 때 마다, 컴포넌트가 리렌더링되나요?

const [myState, setState] = useState(0);

위와같은 state가 하나 있다고 하자, setState()를 부르면 컴포넌트가 리렌더링 될까?

정답은… 아니다! 값이 변경되지 않으면 리렌더링이 일어나지 않는다.


💁🏻‍♂️꼬리질문1. 그럼 다른값을 넣으면 컴포넌트가 리렌더링되나요?

const [myState, setState] = useState(0);
setState(1);

return(
  <div>{myState}</div>
)

위와같이 state가 0에서 1로 변경되면 컴포넌트가 리렌더링이 일어날까?

그렇다!! 값을 바꾸니까 리렌더링이 일어난다.


💁🏻‍♂️꼬리질문2. 그럼 두번 호출하면 두번 리렌더링 일어나나요?

const [myState, setState] = useState(0);
setState(1);
setState(2);

return(
  <div>{myState}</div>
)

위와같이 state를 0 => 1 => 2 로 두번 값을 바꾸면 리렌더링이 두번 일어날까?

아니다!! 값을 여러번 바꾸더라도 리렌더링은 한번 일어난다.


💁🏻‍♂️꼬리질문3. 리렌더링이 한 번 일어난다고 하셨는데 왜 한번 일어나나요?

setState는 한데묶어, 한 번만 컴포넌트를 재호출한다. useState훅을 통해 만들어진 setState는 결국 상태를 바꿔주는 함수이다. 이 함수는 dispatchAction을 통해 state를 업데이트해준다.

그!리!고! dispatchAction은 하나의 객체에 대한 업데이트들을 모아 한번에 처리해준다.


💁🏻‍♂️꼬리질문4. 어떻게 업데이트들을 한번에 처리해주나요?

일단 dispatchAction에대해 조금 더 설명하자면, 상태를 업데이트 해주는 함수이다.

앞서 말한것처럼 useState도 이친구를 사용하고, useReducer, useContext, useCallback 훅들 역시 내부적으로 dispatchAction을 통해 상태를 업데이트한다.

dispatchAction의 작동 순서를 알면, 어떻게 업데이트들을 한번에 처리해주는지 이해할 수 있다. 그러므로 dispatchAction의 작동순서에 대해 알아보도록 하겠다.

알아보려고 했는데, 리액트 18로 넘어오면서 `dispatchAction`은 `dispatchSetState`로 변경 되었다. 코드를 보면 다음과 같다(리액트 18.2 소스코드)

function dispatchSetState(fiber, queue, action){

const lane = requestUpdateLane(fiber);
const update = {
  lane,
  action,
  hasEagerState,
  eagerState,
  next,
}

if (isRenderPhaseUpdate(fiber)){
  //큐에 업데이트 집어넣음
}
else{
  //렌더링 단계 아님? 이전에 업데이트가 없음?
  //내가 업데이트해줄테니, 대기하셈

  //비동기 시작
  enqueueConcurrentHookUpdateAndEagerlyBailout(fiber, queue, update)
  //비동기 끝
}
scheduleUpdateOnFiber(root, fiber, lane, eventTime);
}

80줄짜리 코드를 아주 짧게 요약해 보았다. 큰 흐름은 다음과 같다.

  1. requestUpdateLane함수를 이용하여, 우선순위(lane)을 결정한다.
  2. update 객체 만든다.
  3. 렌더링페이즈인지 확인하고 업데이트한다.
  4. 지금업데이트 못하면, 큐에 넣어서 이따 처리한다.

따라서 dispatchSetState()함수에서 큐에넣어서 한방에 처리해주는 모습을 보면, 업데이트를 한번에 처리해주는걸 볼 수 있다.


💁🏻‍♂️꼬리질문5. 업데이트를 한번에 처리해주는군요. 그렇다고 렌더링이 한번만 일어난다고 보장할 수 있나요?

렌더링이란 무엇일까? 화면에 무언가를 그리는것이 렌더링이다.

그렇다면 리액트에서 렌더링이 일어난다의 정의는 무엇일까? 다음은 여기저기 리액트관련글에 빠지지 않고 등장하는 그림이다. 혹시 이 그림이 익숙하지 않다면, 귀찮더라도 이 기회에 한번 들여다 보는게 좋을 것 같다.

렌더링 도식도

코드를 작성할 때, lifeCycle에 따라, Mounting, Updating, Unmounting을 고려하여 훅을 걸곤 했을 것이다. 하지만, 세로축을 보면, Render phaseCommit phase가 있다.

필자가 위에서, “업데이트가 한번에 일어나므로 렌더링이 한번 일어난다” 라고 말한것은 이 부분인 것이다.

비동기적으로 업데이트들이 일어나더라도, Render Phase Commit Phase로 batching형태로 한번에 업데이트가 일어난다. 이말인 즉슨, 렌더링을 batch 단위로 한번에 처리하는것이기 때문에 렌더링, 더 정확히는 리-렌더링이 단 한번만 일어난다고 할 수 있는 것이다.


💁🏻‍♂️꼬리질문6. 그럼 한번에 처리하기 위한 batching작업은 어떻게 일어나나요?

위의 코드에서 설명했듯, update에 넣어서 한번에 처리한다.


💁🏻‍♂️꼬리질문7. 그 과정을 자세히 들어보고싶습니다만?

Linked List로 구현된 Circular-queue에 들어가게 됩니다. 이 원형큐(Circular-queue)에는 어떤 업데이트들이 이루어져야되는지 쌓이게 되고, 추후 컴포넌트들이 해당 내용들을 소비하며 상태를 바꾸어 나가게 된다.

이를 이해하기 위해서 Hook의 구현체를 참조해 보겠다. (react 18.2기준 다음과 같다)

export type Hook = {
  memoizedState: any,
  baseState: any,
  baseQueue: Update<any, any> | null,
  queue: any,
  next: Hook | null,
};

Hook은 Linked List로 연결된 queue(batch에 쌓여있는 업데이트 해야하는 작업들)를 참조하여 업데이트를 진행한다. 컴포넌트가 소비 할 때, 매번 Liked List의 head부터 찾아가면 비효율적이다. 따라서, Hook은 baseQueue에 어떤 업데이트가 마지막 업데이트인지를 기록해 둔다 알 수 있다.

그렇다면 본래의 질문과 필자의 대답인 Circular Queue에 들어간다는 부분을 보도록 하겠다.

export type UpdateQueue<S, A> = {
  pending: Update<S, A> | null,
  lanes: Lanes,
  dispatch: (A => mixed) | null,
  lastRenderedReducer: ((S, A) => S) | null,
  lastRenderedState: S | null,
};

//queue는 UpdateQueue 타입
const pending = queue.pending;
if (pending === null) {
  update.next = update; //꼬리가 머리를 물어버림 => 원형큐 생성
} else {
  update.next = pending.next;
  pending.next = update;
}
queue.pending = update;

pending === null일 때, 이제 업데이트를 실행시키고자 한다. 이때, tail이 head를 물어버리게 된다. 해당 부분은 코드상 update.next = update이다. 그렇다, Liked List에서 꼬리가 머리를 물어버림으로써 원형큐를 형성하고 이제 기다리고 기다리던 업데이트를 하러 가는 것이다. 그리고 그 전에는

update.next = pending.next;
pending.next = update;

이처럼, 업데이트 할 내용들을 Liked List에 하나씩 추가해줌으로써 batch queue를 형성하는 것이다.


💁🏻‍♂️꼬리질문8. 왜 원형큐를 이용하죠?

Tail의 위치는 알고 있지만, Head의 위치는 모르기 때문이다. update.next를 가져오면, 꼬리가 머리를 물고있기 때문에 head를 가져올 수 있다. 업데이트들이 줄줄이 소세지로 연결되어있는데, 어디서부터 업데이트를 하면 되는지에 대한 정보를 가져올 수 있기 때문이다.


💁🏻‍♂️꼬리질문9. 왜 원형큐를 이용하죠? Hook은 baseQueue에 어떤 업데이트까지 진행했는지에 대한 정보를 가지고 있는데요?

일단 Hook이 업데이트들을 소비해야 baseQueue에 기록되기 때문이다. 일단 소비되어야, 어디까지 업데이트 되었는지에 대한 정보들이 baseQueue에 기록된다는 뜻이다. 즉, 첫 업데이트를 하지 않으면 어디서부터 업데이트를 진행해야 하는지 알길이 없다.

반대로, 첫 업데이트가 이루어지고 나면 원형큐(Circular-que)가 필요 없습니다. 따라서, 어떤 업데이트들이 이루어져야 하는지 batch를 만들어 낼 때는 원형큐로 만들어서 전달되지만, 일단 업데이트가 시작되면 더이상 원형큐일 필요가 없다는 이다.

실제로도, 업데이트가 이루어지면 꼬리는 더이상 머리를 물고있지 않게된다. 소비를 시작하며 머리-꼬리의 연결을 끊어준다.


💁🏻‍♂️꼬리질문10. 자꾸 “소비”를 이야기하시는데 어떤 뜻인가요?

무엇을 업데이트 할지에 대한 정보들은 다음과 같이 소비(consume) 된다.

디파컨슘

  1. 적용할 update 리스트의 head를 가지고 옴
  2. head부터 tail까지 뒤져가면서 리듀서에게 action을 던짐
  3. update가 소비됨? => 최종 상태값 저장


💁🏻‍♂️꼬리질문11. “소비”에 대해 좀더 자세히 알려주실 수 있나요?

function updateState<S>(
  initialState: (() => S) | S,
): [S, Dispatch<BasicStateAction<S>>] {
  return updateReducer(basicStateReducer, (initialState: any));
}

updateState는 updateReducer의 최종값을 리턴해주는 것이다. 우리가 상태를 변경해준다고 느껴지는 로직은 updateReducer에서 해주는 것이다.

해당 코드 보기(클릭)
function updateReducer<S, I, A>(
  reducer: (S, A) => S,
  initialArg: I,
  init?: I => S,
): [S, Dispatch<A>] {
  const hook = updateWorkInProgressHook();
  const queue = hook.queue;

  if (queue === null) {
    throw new Error(
      'Should have a queue. This is likely a bug in React. Please file an issue.',
    );
  }

  queue.lastRenderedReducer = reducer;

  const current: Hook = (currentHook: any);

  // The last rebase update that is NOT part of the base state.
  let baseQueue = current.baseQueue;

  // The last pending update that hasn't been processed yet.
  const pendingQueue = queue.pending;
  if (pendingQueue !== null) {
    // We have new updates that haven't been processed yet.
    // We'll add them to the base queue.
    if (baseQueue !== null) {
      // Merge the pending queue and the base queue.
      const baseFirst = baseQueue.next;
      const pendingFirst = pendingQueue.next;
      baseQueue.next = pendingFirst;
      pendingQueue.next = baseFirst;
    }
    if (__DEV__) {
      if (current.baseQueue !== baseQueue) {
        // Internal invariant that should never happen, but feasibly could in
        // the future if we implement resuming, or some form of that.
        console.error(
          'Internal error: Expected work-in-progress queue to be a clone. ' +
            'This is a bug in React.',
        );
      }
    }
    current.baseQueue = baseQueue = pendingQueue;
    queue.pending = null;
  }

  if (baseQueue !== null) {
    // We have a queue to process.
    const first = baseQueue.next;
    let newState = current.baseState;

    let newBaseState = null;
    let newBaseQueueFirst = null;
    let newBaseQueueLast: Update<S, A> | null = null;
    let update = first;
    do {
      // An extra OffscreenLane bit is added to updates that were made to
      // a hidden tree, so that we can distinguish them from updates that were
      // already there when the tree was hidden.
      const updateLane = removeLanes(update.lane, OffscreenLane);
      const isHiddenUpdate = updateLane !== update.lane;

      // Check if this update was made while the tree was hidden. If so, then
      // it's not a "base" update and we should disregard the extra base lanes
      // that were added to renderLanes when we entered the Offscreen tree.
      const shouldSkipUpdate = isHiddenUpdate
        ? !isSubsetOfLanes(getWorkInProgressRootRenderLanes(), updateLane)
        : !isSubsetOfLanes(renderLanes, updateLane);

      if (shouldSkipUpdate) {
        // Priority is insufficient. Skip this update. If this is the first
        // skipped update, the previous update/state is the new base
        // update/state.
        const clone: Update<S, A> = {
          lane: updateLane,
          action: update.action,
          hasEagerState: update.hasEagerState,
          eagerState: update.eagerState,
          next: (null: any),
        };
        if (newBaseQueueLast === null) {
          newBaseQueueFirst = newBaseQueueLast = clone;
          newBaseState = newState;
        } else {
          newBaseQueueLast = newBaseQueueLast.next = clone;
        }
        // Update the remaining priority in the queue.
        // TODO: Don't need to accumulate this. Instead, we can remove
        // renderLanes from the original lanes.
        currentlyRenderingFiber.lanes = mergeLanes(
          currentlyRenderingFiber.lanes,
          updateLane,
        );
        markSkippedUpdateLanes(updateLane);
      } else {
        // This update does have sufficient priority.

        if (newBaseQueueLast !== null) {
          const clone: Update<S, A> = {
            // This update is going to be committed so we never want uncommit
            // it. Using NoLane works because 0 is a subset of all bitmasks, so
            // this will never be skipped by the check above.
            lane: NoLane,
            action: update.action,
            hasEagerState: update.hasEagerState,
            eagerState: update.eagerState,
            next: (null: any),
          };
          newBaseQueueLast = newBaseQueueLast.next = clone;
        }

        // Process this update.
        const action = update.action;
        if (shouldDoubleInvokeUserFnsInHooksDEV) {
          reducer(newState, action);
        }
        if (update.hasEagerState) {
          // If this update is a state update (not a reducer) and was processed eagerly,
          // we can use the eagerly computed state
          newState = ((update.eagerState: any): S);
        } else {
          newState = reducer(newState, action);
        }
      }
      update = update.next;
    } while (update !== null && update !== first);

    if (newBaseQueueLast === null) {
      newBaseState = newState;
    } else {
      newBaseQueueLast.next = (newBaseQueueFirst: any);
    }

    // Mark that the fiber performed work, but only if the new state is
    // different from the current state.
    if (!is(newState, hook.memoizedState)) {
      markWorkInProgressReceivedUpdate();
    }

    hook.memoizedState = newState;
    hook.baseState = newBaseState;
    hook.baseQueue = newBaseQueueLast;

    queue.lastRenderedState = newState;
  }

  if (baseQueue === null) {
    // `queue.lanes` is used for entangling transitions. We can set it back to
    // zero once the queue is empty.
    queue.lanes = NoLanes;
  }

  const dispatch: Dispatch<A> = (queue.dispatch: any);
  return [hook.memoizedState, dispatch];
}

렌더링을 최소화 시키기 위해, batch단위로 업데이트해주고자 하였다. batch단위로 업데이트하기 위해 어떤 작업들이 “한번에” 이루어져야 하는지 queue를 만들었다. 그리고 만들어진 queue를 하나씩 소비해나간다. 하나씩 뒤져가며 상태를 업데이트 시켜주고 최종적인 상태를 리턴해 준다. 이로써 작업목록에 있는 모든 업데이트들을 소비했고, 최종적인 결과물을 리턴해주고, 해당 값이 화면에 렌더링 되는 것이다.


💁🏻‍♂️꼬리질문11. 설명을 잘 못하시는군요. 오늘 하신 말씀을 정리좀 해주시겠어요?

꼬리질문2에서 다음과 같은 코드를 보았다.

const [myState, setState] = useState(0);
setState(1);
setState(2);

return(
  <div>{myState}</div>
)

0을 1로 바꾸고 2로 바꾸라는 코드이다.

1로바꿔주고 렌더링되고, 2로바꿔주고 또 렌더링되는게 아니였다. 우리는 1로 바꾸는 작업과 2로바꾸는 작업을 queue에 넣어주었다. circular que로 넣어주었고 업데이트가 시작된다.

“1로바꿔줘!” 라는 upadate를 찾고, 원형큐를 끊어준다. 그리고 update.next가 가리키는 “2로바꿔줘!!”라는 update를 찾는다. 그리고 더이상 진행할것이 없기에, updateReducer는 최종 결과를 리턴해준다. 이를 받아 updateState는 해당 state의 최종값을 리턴해준다. 이로써 updateState를 거치고 난 최종 결과물을 알게되고 해당 값으로 화면에 렌더링 된다.

따라서, 화면에서는 0 => 1 => 2로 업데이트되는게 아니다. 0 => 2로 한번에 업데이트된다.

그렇다. 한번만 렌더링된다.



맨 위로 이동하기

react 카테고리 내 다른 글 보러가기

댓글 남기기