Too many re-renders 에러는 왜 발생하는 것일까?

chaaerim·2023년 6월 26일
4

회사에서 개발을 진행하다가 리액트 무한루프 렌더링 에러와 마주쳤다.

Too many re-renders. 
React limits the number of renders to prevent an infinite loop.

잊을만 하면 마주치는 에러인데, 마주칠 때 마다 어디가 문제인지는 알겠으면서 바로 해결하기는 또 어려웠다.

프로젝트가 커지면 커질 수록, 컴포넌트를 넘나들며 state을 props로 주고받기 시작하면 상태의 관리가 복잡하고 머리 아파진다.

위의 에러같은 경우도 주로 props로 전달받은 변수를 setState에 넣거나 렌더 과정에서 state을 변화하는 함수가 있다면 발생하는 에러이다.

분명 useState을 잘 알면 앞으로 이 에러를 마주할 가능성이 현저히 낮아질 것 같았다.

그렇다면 .. useState이 어떻게 동작하는지 제대로 파악하고 앞으로는 무한루프 렌더링 에러를 피해보자 ..!

useState 란?

useState은 react hook이다.
React 16.8에서 hook이 react의 새로운 요소로 추가가 되면서

  • 계층의 변화 없이 상태 관련 로직을 재사용할 수 있게 되었고,
  • hook을 통해 서로 비슷한 동작을 하는 작은 함수의 묶음으로 컴포넌트를 나눌 수 있게 되었고,
  • class component가 아닌 functional component를 적극적으로 활용할 수 있게 되었다.

useState은 state을 함수 컴포넌트 내부에서 사용할 수 있도록 한다.
그렇다면 함수 컴포넌트에서는 왜 state의 사용이 불가능했을까?
함수 컴포넌트에는 this가 없기 때문이다. 반면 클래스 컴포넌트에서는 props와 state에 this를 이용해서 접근이 가능하다.

class Example extends React.Component {
  constructor(props) {
    super(props);
    this.state = {  count: 0 };  
}

위와 같은 식으로 this를 이용해 state에 접근하여 count를 0으로 초기화 할 수 있다.
그러나 클래스 컴포넌트에서 this가 무엇을 가리키는지 정확하게 아는 것은 쉬운 일이 아니다. 메서드를 실행하는 시점마다 this가 가리키는 것이 바뀔 수 있기 때문이다.


import React, { useState } from 'react';

function Example() {
  // 새로운 state 변수를 선언하고, 이것을 count라 부르겠습니다.  
const [count, setCount] = useState(0);

useState을 이용하여 변수를 선언하면 2개의 아이템 쌍이 들어있는 배열을 생성한다.

첫번째 아이템은 현재 state을 의미하고 두 번째 아이템은 해당 state을 갱신해주는 함수를 뜻한다.

따라서 react에서 useState을 이용할 때에는 위와 같이 배열이나 객체의 속성을 해체하여 그 값을 개별 변수에 담을 수 있게 하는 구조 분해 할당(javascript 표현식)을 이용한다.


useState은 어떻게 구현되어 있을까?

아래 글은 https://goidle.github.io/react/in-depth-react-hooks_1/ 과 react 코드를 참고하여 작성하였다.

시간이 된다면 두가지 모두를 뜯어보는 것을 추천한다.
useState을 사용하기 위해서는 react module에서 import 해와야만 한다.
node_modules에서 useState을 찾아보니 useState은 아래와 같이 구현되어 있었다.

function useState(initialState) {
  var dispatcher = resolveDispatcher();
  return dispatcher.useState(initialState);
}

useState을 사용하게 되면 초기값을 넣어주게 되는데 여기서 initialState을 받아 resolveDispatcher()가 리턴하는 인스턴스의 useState에 initialState을 전달하고 이 값을 리턴한다.

한 뎁스 더 들어가서 resolveDispatcher()는 어떻게 생겼는지 보자.

resolveDispatcher()

function resolveDispatcher() {
  var dispatcher = ReactCurrentDispatcher.current;

  {
    if (dispatcher === null) {
      error('Invalid hook call. Hooks can only be called inside of the body of a function component. This could happen for' + ' one of the following reasons:\n' + '1. You might have mismatching versions of React and the renderer (such as React DOM)\n' + '2. You might be breaking the Rules of Hooks\n' + '3. You might have more than one copy of React in the same app\n' + 'See https://reactjs.org/link/invalid-hook-call for tips about how to debug and fix this problem.');
    }
  } // Will result in a null access error if accessed outside render phase. We
  // intentionally don't throw our own error because this is in a hot path.
  // Also helps ensure this is inlined.

  return dispatcher;
}

resolveDispatcher는 ReactCurrentDispatcher에서 dispatcher를 가져오고 dispatcher값이 null일 때 에러처리를 하고 있다.


마지막으로 딱 한 뎁스만 더 들어가보자 .. !

ReactCurrentDispatcher

var ReactCurrentDispatcher = {
  /**
   * @internal
   * @type {ReactComponent}
   */
  current: null
};

ReactCurrentDispatcher에는 의외로 ? 객체 하나로 이루어져 있다. useState hook은 인스턴스화된 객체의 상태 값을 관리하는 역할을 한다.
근데..? 상태가 바뀌면 이를 재조정하는 코드를 여기서 찾아볼 수 없다.
즉, hook 객체를 외부에서 내부로 주입해주고 있다. 아래 코드에서 자세히 살펴보자.


hook 객체를 주입하는 것과 관련된 코드는 react/packages/react-reconciler/src/ReactFiberHooks.js 에서 찾아볼 수 있다. (아래 코드는 DEV로 감싸진 코드를 제외한 부분이다. )

renderWithHooks()은 Render Phase가 진행 중에 호출된다.

Render phase는 쉽게 말해 Virtual DOM 조작 단계라고 생각하면 된다. Render phase는 Virtual DOM을 재조정하는 일련의 과정이다. 재조정을 담당하는 reconciler의 설계가 스택 기반에서 fiber architecture로 넘어오면서 이 과정을 abort, stop, restart 할 수 있게 되었다.

renderWithHooks()

export function renderWithHooks<Props, SecondArg>(
  current: Fiber | null,
  workInProgress: Fiber,
  Component: (p: Props, arg: SecondArg) => any,
  props: Props,
  secondArg: SecondArg,
  nextRenderLanes: Lanes,
): any {
  renderLanes = nextRenderLanes;
  currentlyRenderingFiber = workInProgress;

  workInProgress.memoizedState = null;
  workInProgress.updateQueue = null;
  workInProgress.lanes = NoLanes;

	// hook이 주입되는 부분 !! 
  ReactCurrentDispatcher.current =
      current === null || current.memoizedState === null
        ? HooksDispatcherOnMount
        : HooksDispatcherOnUpdate;
 
		if (didScheduleRenderPhaseUpdateDuringThisPass) {
			children = renderWithHooksAgain(
      workInProgress,
      Component,
      props,
      secondArg,
    );
  }

  if (shouldDoubleRenderDEV) {
    // In development, components are invoked twice to help detect side effects.
    setIsStrictModeForDevtools(true);
    try {
      children = renderWithHooksAgain(
        workInProgress,
        Component,
        props,
        secondArg,
      );
    } finally {
      setIsStrictModeForDevtools(false);
    }
  }

  finishRenderingHooks(current, workInProgress);

  return children;
}

	

current가 null이거나 memoization 된 state값이 null값이면 ReactCurrentDispatcher.current 값에 HooksDispatcherOnMount가 주입되고 둘 중 하나라도 값이 있는 경우에는 HooksDispatcherOnUpdate가 주입된다.

mount는 컴포넌트 라이프 사이클 중 하나로, DOM 객체가 생성되고 브라우저에 나타나는 것을 의미한다.


//mount
const HooksDispatcherOnMount: Dispatcher = {
  readContext,
	...
	useState: mountState,
	...
};

//update
const HooksDispatcherOnUpdate: Dispatcher = {
  readContext,
	...
	useState: updateState,
	...
};

HooksDispatcherOnMount와 HooksDispatcherOnUpdate는 위와 같이 생겼다.
HooksDispatcherOnMount에서는 useState이 mountState을, HooksDispatcherOnUpdate에서는 useState이 updateState을 뜻한다.


Hook은 어떻게 생겼을까?

mountState()

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()이 실행될 것이다.
여기서 리턴하는 [hook.memoizedState, dispatch]; 배열이 바로 우리가 const [state, setState]=useState()과 같이 구조분해 할당을 하여 사용하는 값들이다.
여기서 hook은 mountStateImpl()에 initialState을 넘겨 리턴되는 값이고, 아래 mountStateImpl()를 보면

mountStateImpl()

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;
}

여기서 hook에는 mountWorkInProgressHook()의 리턴값이 할당된다.


그럼 mountWorkInProgressHook을 살펴볼 수 밖에 ..

mountWorkInProgressHook()

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()에서는 hook에 memoizedState, queue, next값이 있는 객체를 할당한다.

  • memoizedState는 컴포넌트에 적용된 마지막 상태값으로 mountState()에서 상태값을 리턴하는데 사용되고,
  • queue는 mountStateImpl()에서 훅이 호출될 때마다 update를 연결 리스트로 queue에 집어넣는다.
  • 그리고 next는 workInProgressHook이 있을 때 다음 hook을 가리키는 포인터이다.

Hook 상태를 update하는 dispatchSetState

여기까지 hook과 useState이 어떻게 생겼는지 살펴봤다.

그럼 마지막으로 어떻게 값을 update 하는지를 보겠다.

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];
}

mountState에서 [hook.memoizedState, dispatch]를 리턴했고 이것이 곧 [state, setState]으로 사용되는 값이라 앞서 설명했다.

여기서 dispatch는 dispatchSetState에 어떤 값들을 bind한 결과이다. 그럼 dispatchSetState을 보면 어떻게 상태가 업데이트 되는지 알 수 있겠다.


아래는 dispatchSetState()이다.

dispatchSetState()

function dispatchSetState<S, A>(
  fiber: Fiber,
  queue: UpdateQueue<S, A>,
  action: A,
): void {

  const lane = requestUpdateLane(fiber);

  const update: Update<S, A> = {
    lane,
    revertLane: NoLane,
    action,
    hasEagerState: false,
    eagerState: null,
    next: (null: any),
  };

  if (isRenderPhaseUpdate(fiber)) {
    enqueueRenderPhaseUpdate(queue, update);
  } else {
    const alternate = fiber.alternate;
    if (
      fiber.lanes === NoLanes &&
      (alternate === null || alternate.lanes === NoLanes)
    ) {
 
      const lastRenderedReducer = queue.lastRenderedReducer;
      if (lastRenderedReducer !== null) {
        let prevDispatcher;
        try {
          const currentState: S = (queue.lastRenderedState: any);
          const eagerState = lastRenderedReducer(currentState, action);
          update.hasEagerState = true;
          update.eagerState = eagerState;
          if (is(eagerState, currentState)) {
          
            enqueueConcurrentHookUpdateAndEagerlyBailout(fiber, queue, update);
            return;
          }
        } catch (error) {
          // Suppress the error. It will throw again in the render phase.
        } finally {
          if (__DEV__) {
            ReactCurrentDispatcher.current = prevDispatcher;
          }
        }
      }
    }

    const root = enqueueConcurrentHookUpdate(fiber, queue, update, lane);
    if (root !== null) {
      scheduleUpdateOnFiber(root, fiber, lane);
      entangleTransitionUpdate(root, queue, lane);
    }
  }

  markUpdateInDevTools(fiber, lane, action);
}

위의 코드에서 fiber라는 개념이 나온다.

Fiber는 React v16에서 리액트의 핵심 알고리즘을 재구성한 새 재조정(Reconciliation) 엔진이다. React Fiber의 목표는 애니메이션, 레이아웃, 제스처, 중단 또는 재사용 기능과 같은 영역에 대한 적합성을 높이고 다양한 유형의 업데이트에 우선 순위를 지정하는 것이다.

isRenderPhaseUpdate에 fiber를 넘긴 값이 true라면 enqueueRenderPhaseUpdate()에서 render phase를 업데이트 한다.

isRenderPhaseUpdate()

function isRenderPhaseUpdate(fiber: Fiber): boolean {
  const alternate = fiber.alternate;
  return (
    fiber === currentlyRenderingFiber ||
    (alternate !== null && alternate === currentlyRenderingFiber)
  );
}

currentlyRenderingFiber는 renderWithHooks()에서 workInProgress를 할당받는다. 즉, workInProgress를 할당받았다는 것은 render Phase가 진행 중이라는 뜻이다. (renderWithHooks()은 Render Phase가 진행 중에 호출되기 때문에 ..! )전달된 fiber와 workInProgress인 fiber가 같다면 render phase에서 update가 일어났다는 뜻이기 때문에 queue에 update를 push하게 된다.

여기서 alternate과 currentlyRenderingFiber도 함께 비교하는 이유를 알면 좋은데 이는 추후 더 공부하고 보충해보겠다.


enqueueRenderPhaseUpdate()

function enqueueRenderPhaseUpdate<S, A>(
  queue: UpdateQueue<S, A>,
  update: Update<S, A>,
): void {
  
  didScheduleRenderPhaseUpdateDuringThisPass = didScheduleRenderPhaseUpdate =
    true;
  const pending = queue.pending;
  if (pending === null) {
    // This is the first update. Create a circular list.
    update.next = update;
  } else {
    update.next = pending.next;
    pending.next = update;
  }
  queue.pending = update;
}

didScheduleRenderPhaseUpdate는 render phase update가 발생했는지 판단하는 플래그이다.

enqueueRenderPhaseUpdate는 isRenderPhaseUpdate(fiber) 값이 true일 때 실행되는데 enqueueRenderPhaseUpdate가 실행되면 didScheduleRenderPhaseUpdate와 didScheduleRenderPhaseUpdateDuringThisPass값에 true값이 할당된다.

didScheduleRenderPhaseUpdateDuringThisPass가 true면 renderWithHooks()에서 renderWithHooksAgain()이 실행되며 컴포넌트가 재실행되면서 새로운 props를 내려주게 된다.


아래는 renderWithHooksAgain() 코드인데 didScheduleRenderPhaseUpdateDuringThisPass가 true인 동안, 즉 RenderPhase에서 update가 일어나지 않을 때까지 반복문이 실행된다.

renderWithHooksAgain()

const RE_RENDER_LIMIT = 25;

function renderWithHooksAgain<Props, SecondArg>(
  workInProgress: Fiber,
  Component: (p: Props, arg: SecondArg) => any,
  props: Props,
  secondArg: SecondArg,
): any {
  

  currentlyRenderingFiber = workInProgress;

  let numberOfReRenders: number = 0;
  let children;
  do {
    if (didScheduleRenderPhaseUpdateDuringThisPass) {
      // It's possible that a use() value depended on a state that was updated in
      // this rerender, so we need to watch for different thenables this time.
      thenableState = null;
    }
    thenableIndexCounter = 0;
    didScheduleRenderPhaseUpdateDuringThisPass = false;

    if (numberOfReRenders >= RE_RENDER_LIMIT) {
      throw new Error(
        'Too many re-renders. React limits the number of renders to prevent ' +
          'an infinite loop.',
      );
    }

    numberOfReRenders += 1;
  

    // Start over from the beginning of the list
    currentHook = null;
    workInProgressHook = null;

    workInProgress.updateQueue = null;

    ReactCurrentDispatcher.current = __DEV__
      ? HooksDispatcherOnRerenderInDEV
      : HooksDispatcherOnRerender;

    children = Component(props, secondArg);
  } while (didScheduleRenderPhaseUpdateDuringThisPass);
  return children;
}

RE_RENDER_LIMIT값이 상수 25로 제한되어 있기 때문에 renderWithHooksAgain()에서 반복문 내부에 있는 numberOfReRenders값을 기준으로 Too many re-renders 에러가 발생하게 된다. RenderPhase에서 업데이트가 한번에 25번 이상 발생하는 경우 에러가 나타나는 것이다.



아휴 길다......!!!

여기까지 useState를 뜯어보면서 어떠한 방식으로 useState이 동작하는지, Too many renders 에러는 왜 발생하는지까지 알아봤다.

앞으로는 react의 여러 hook들을 뜯어보면서 react의 동작원리를 공부하는 시간을 종종 가져야겠다고 다짐한 시간이었다. 😎

0개의 댓글