React는 언제 컴포넌트를 렌더링할까?

컴포넌트 렌더링 이해하기

이 글은 hr][When Does React Render Your Component?
라는 기사를 참고했습니다.


컴포넌트 렌더링

React는 컴포넌트를 정확히 언제, 왜 렌더링하는 걸까?

React는 우선 다음과 같은 상황일 경우에 컴포넌트를 렌더링하게 됩니다.

컴포넌트에 예정된 상태 업데이트가 있는 경우
컴포넌트에서 사용된 커스텀 훅의 예정된 업데이트가 있는 경우도 포함
부모 컴포넌트가 렌더링되고, 리렌더링에서 제외되는 기준에 충족하지 않을 경우(단, 제외되는 기준은 다음의 네 가지 조건을 모두 동시에 충족해야 함)
컴포넌트가 이전에 렌더링, 즉 이미 마운트 되어있어야 한다.
변경된 props(참조)가 없어야 한다.
컴포넌트에서 사용하고 있는 context 값이 변경되지 않아야 한다.
컴포넌트에 예정된 상태 업데이트가 없어야 한다.

리렌더링 확인을 위한 흐름도

React는 성능 문제가 발생할 때까지 불필요한 리렌더링에 대해서는 걱정할 필요가 없습니다. 하지만 만약 성능 문제가 발생한 경우라면 다음의 흐름도를 참고하여 어떤 해결책을 적용해야 할지 선택할 수 있습니다.

예상치 못한 리렌더링 확인을 위한 흐름도

렌더링과 업데이트

React의 컴포넌트란 “UI에서 업데이트를 예약할 수 있도록 React에 의해 강화된 함수”를 의미하는데, 컴포넌트는 스스로의 상태를 능동적으로 변화시킨 것이든, 다른 변화로 인한 것이든 상관없이 React에 의해 호출됩니다.

React의 핵심 디자인 원칙 중 하나로서, React는 UI 스케줄링 및 업데이트를 완전히 제어할 수 있는데, 이는 몇 가지 의미를 갖습니다.

컴포넌트가 만든 하나의 상태 업데이트가 반드시 하나의 렌더링, 즉 React에 의한 한 번의 컴포넌트 호출로 변환되는 것은 아니며, 그 이유는 다음과 같다.
React는 컴포넌트의 상태에 의미 있는 변화가 없다고 생각할 수 있다.(object.is에 의해 결정)
React는 상태 업데이트를 하나의 렌더 패스로 일괄 처리하려고 하지만, React는 promiseresolve되는 타이밍을 제어할 수 없기 때문에 promise에서의 상태 업데이트를 일괄 처리할 수 없으며, setTimeout, setIntervalrequestAnimatonFrame과 같이 별도의 이벤트 루프 콜 스택에서 실행되는 네이티브 이벤트 핸들러도 마찬가지이다.
React는 여러 렌더 패스로 작업을 분할할 수도 있다.
React는 다양한 이유로 컴포넌트를 렌더링(함수 호출) 할 수 있기 때문에 컴포넌트를 한 번 렌더링 하는 것이 UI의 시각적 업데이트로 반드시 변환되지는 않는다.

React 17에서 일부 상태 업데이트는 일괄 처리를 할 수 없습니다.

React 17에서는 promise에서의 업데이트 같은 일부 상태 업데이트는 일괄 처리할 수 없는데, React는 promiseresolve 되는 타이밍을 제어할 수 없기 때문입니다. 같은 맥락에서, 훨씬 나중에 완전히 별도의 이벤트 루프 호출 스택에서 실행되는 네이티브 이벤트 핸들러인 setTimeout, setInterval, requestAnimatonFrame도 마찬가지입니다. 하지만 React 18에서는 모든 상태 업데이트를 자동 일괄 처리할 수 있습니다.

물론 React가 렌더링을 제어할 수 있다고 해서 컴포넌트를 렌더링 하는 시기나 이유에 대해 신경 쓰지 않아도 된다는 것은 아닙니다. React에 모든 것을 의존하는 대신 React가 컴포넌트를 렌더링 하는데 사용하는 기본 메커니즘을 이해한다면 성능 문제에 직면했을 때 쉽게 해결할 수 있습니다.

업데이트란?

“렌더링”이라는 용어와 함게 “업데이트”라는 용어 또한 많이 사용되는데, 여기서의 “업데이트”는 맥락에 따라서 의미가 달라질 수 있습니다.

예를 들어, “컴포넌트가 업데이트를 예약한다”라고 했을 때의 업데이트는 컴포넌트가 자체의 상태를 변경하고 React에 변경 내용을 UI에 반영하도록 요청함을 의미합니다. 여기서 업데이트는 React가 컴포넌트를 렌더링(호출) 하는 이유인데, React가 컴포넌트를 렌더링 할지 여부, React가 컴포넌트를 렌더링 하기로 결정한 횟수 그리고 렌더링 하기로 결정하는 데 발생한 지연 시간은 다양한 요인에 따라 달라질 수 있습니다.

또 다른 예로, “React가 UI를 업데이트한다”라고 했을 때의 업데이트는 React가 기존 DOM 노드를 변환하거나 DOM 트리의 내부 표현과 일치하도록 새로운 DOM 노드를 생성한다는 의미인데, 여기서의 업데이트는 컴포넌트를 렌더링 한 결과를 의미합니다.

React의 렌더링 프로세스

컴포넌트는 두 가지 유형의 렌더링이 발생할 수 있는데, 바로 능동적 렌더링과 수동적 렌더링으로 다음과 같이 정의할 수 있습니다.

  • 능동적 렌더링은 컴포넌트 혹은 컴포넌트에서 사용한 커스텀 훅이 능동적으로 상태를 변경하기 위한 업데이트를 예약하고, ReactDOM.render를 직접 호출한다.
  • 수동적 렌더링은 부모 컴포넌트가 상태 업데이트를 예약하는데, 컴포넌트가 렌더링 제외 기준을 충족하지 않는다.

능동적 렌더링

능동적 렌더링이란 컴포넌트 자체 또는 컴포넌트가 사용하는 커스텀 훅이 업데이트를 예약하기 위해 능동적으로 상태를 변경하는 것을 의미합니다.

  • 클래스 컴포넌트를 사용하는 경우, Component.prototype.setState(즉, this.setState)
  • 함수형 컴포넌트를 사용하는 경우, 훅에 의해 발생한 dispatchAction으로, useReducer 훅의 dispatch 함수와 useState 훅의 상태 업데이트 함수는 모두 dispatchAction을 사용한다.

업데이트를 능동적으로 예약하는 또 다른 방법은, ReactDOM.render를 직접 호출하는 것인데, React 공식 문서에서 다음과 같은 예제를 볼 수 있습니다.

Hello World in React
function tick() {
  const element = (
    <div>
      <h1>Hello, world!</h1>
      <h2>It is {new Date().toLocaleTimeString()}.</h2>
    </div>
  );
  ReactDOM.render(element, document.getElementById("root"));
}
setInterval(tick, 1000);
렌더링 단계에 대한 구현 상세

업데이트를 예약하기 위해 어떤 함수를 사용했는지에 관계없이 모든 함수는 재조정을 담당하는 reconciler에서 scheduleUpdateOnFiber를 사용하는데, 이름을 통해 알 수 있듯이 Fiber의 업데이트를 예약합니다.

Fiber는 React 16에서 도입되었는데, 새로운 조정 알고리즘이자 React 내부의 작업 단위를 나타내는 새로운 데이터 구조입니다. Reconciler에 의해 ReactElement에서 fiber 노드가 생성되는데, 일반적으로 모든 ReactElement는 해당하는 fiber 노드가 있지만 몇 가지 예외가 있습니다. 예를 들면, Fragment 타입의 ReactElement는 해당하는 fiber 노드가 없습니다.

Fiber 노드와 ReactElement의 한 가지 중요한 차이점은 ReactElement는 변경이 불가능하기 때문에 항상 다시 생성되는 반면, fiber 노드는 변경이 가능하고 재사용할 수 있습니다. 즉 React는 컴포넌트를 렌더링에서 제외할 때 새로운 노드를 만드는 대신 구성하고 있는 fiber 트리에서 현재 해당하는 fiber 노드를 재사용할 수 있는 것이죠.

수동적 렌더링

수동적 렌더링은 React가 일부 부모 컴포넌트를 렌더링 하고 컴포넌트가 렌더링 제외 기준을 충족하지 않았을 때 발생합니다.

function Parent() {
  return (
    <div>
      <Child />
    </div>
  );
}

위 코드에서 부모 컴포넌트가 React에 의해 렌더링 되면, 자식 컴포넌트는 props에 참조 또는 아이덴디티가 변경된 것 외에 의미 있는 변경사항이 없더라도 렌더링이 되는데, 렌더링 단계에서 React는 재귀적으로 컴포넌트 트리를 탐색하여 컴포넌트를 렌더링하기 때문에, 만약 자식 컴포넌트에게 또 다른 자식 컴포넌트가 있다면 해당 자식 컴포넌트도 렌더링됩니다.

function Child() {
  return <GrandChild />; // 만약 `Child` 가 렌더링 되면 `GrandChild`도 렌더링 됨
}

그렇지만 컴포넌트 중 하나가 렌더링 제외 기준을 충족하는 경우 React는 해당 컴포넌트를 렌더링 하지 않습니다.

렌더링 제외 기준 예시

모든 자식 컴포너트가 동일하게 만들어지지 않은 경우
default function App() {
  return (
    <Parent lastChild={<ChildC />}>
      <ChildB />
    </Parent>
  );
}

function Parent({ children, lastChild }) {
  return (
    <div className="parent">
      <ChildA />
      {children}
      {lastChild}
    </div>
  );
}

function ChildA() {
  return <div className="childA"></div>;
}

function ChildB() {
  return <div className="childB"></div>;
}

function ChildC() {
  return <div className="childC"></div>;
}

위와 같은 코드에서 만약 Parent의 업데이트가 예정되어 있다면, 어떤 컴포넌트가 리렌더링 될까요? 당연히 Parent 자체는 업데이트를 예약한 컴포넌트이기 때문에 React에 의해 리렌더링 되겠지만, 자식 컴포넌트들인 ChildA, ChildB, ChildC도 리렌더링 되는 걸까요?

이를 확인하기 위해 우선 다음과 같이 setInterval을 통해 일정 간격으로 리렌더링을 예약하는 useForceRender 훅을 만들고,

일정 간격으로 리렌더링을 예약하는 훅
function useForceRender(interval) {
  const render = useReducer(() => ({}))[1];
  useEffect(() => {
    const id = setInterval(render, interval);
    return () => clearInterval(id);
  }, [interval]);
}

이 훅을 부모 컴포넌트 안에서 사용했을 때, 어떤 자식 컴포넌트가 리렌더링 되는지 확인해 볼 수 있습니다.

부모 컴포넌트에서 훅 사용
function Parent({ children, lastChild }) {
  useForceRender(2000);
  console.log("Parent is rendered");
  return (
    <div className="parent">
      <ChildA />
      {children}
      {lastChild}
    </div>
  );
}
When Does React Render Your Component?

ChildA는 리렌더링 되었는데, 이는 부모 컴포넌트가 업데이트를 예약하고 리렌더링 되었다는 것을 알고 있기 때문입니다. 그렇지만 ChildA와 달리 ChildBChildC는 리렌더링 되지 않습니다. 그 이유는 ChildBChildC가 렌더링 제외 기준을 충족해서 React가 렌더링을 건너 뛰었기 때문입니다.

provider가 렌더링 될 때마다 렌더링 되는 context consumer

수동적 렌더링은 컴포넌트가 context consumer 일 때에도 발생할 수 있는데, 앞의 예제 코드를 조금 바꿔서 다음과 같이 Parent 컴포넌트를 context provider로, ChildC를 context consumer로 만들어 보면,

context provider와 context consumer
const Context = createContext();

export default function App() {
  return (
    <Parent lastChild={<ChildC />}>
      <ChildB />
    </Parent>
  );
}

function Parent({ children, lastChild }) {
  useForceRender(2000);
  const contextValue = {};
  console.log("Parent is rendered");
  return (
    <div className="parent">
      <Context.Provider value={contextValue}>
        <ChildA />
        {children}
        {lastChild}
      </Context.Provider>
    </div>
  );
}

function ChildA() {
  console.log("ChildA is rendered");
  return <div className="childA"></div>;
}

function ChildB() {
  console.log("ChildB is rendered");
  return <div className="childB"></div>;
}

function ChildC() {
  console.log("ChildC is rendered");
  const value = useContext(Context);
  return <div className="childC"></div>;
}

다음과 같은 결과를 확인할 수 있습니다.

When Does React Render Your Component?

위와 같이 React는 Parent를 렌더링 할 때마다 이전 contextValue와 다른 참조 값을 갖는 새로운 contextValue를 생성하는데, 결과적으로 context consumerChildC는 다른 context value를 갖게 되고, React는 변경 사항을 반영하기 위해 ChildC를 리렌더링하게 됩니다.

만약 contextValue가 숫자나 문자열 같은 원시 값이면 리렌더링 시 동등성이 변경되지 않기 때문에 ChildC가 리렌더링 되지 않는다는 것에 주의해야 합니다.

각 컴포넌트 레벨에서 적용되는 렌더링 제외 기준

컴포넌트 중 하나가 렌더링 제외 기준을 충족할 경우 React는 해당 컴포넌트를 렌더링 하지 않습니다. 그렇지만 React는 해당 컴포넌트의 자식 컴포넌트에 업데이트가 필요한지 계속해서 확인을 하는데, 다음 코드의 경우 ChildAChildB는 렌더링에서 제외되지만, 이들의 자손 컴포넌트인 ChildCParent가 리렌더링 될 때마다 여전히 리렌더링되는 것을 확인할 수 있습니다.

function useForceRender(interval) {
  const render = useReducer(() => ({}))[1];
  useEffect(() => {
    const id = setInterval(render, interval);
    return () => clearInterval(id);
  }, [interval]);
}

function App() {
  return (
    <Parent>
      <ChildA />
    </Parent>
  );
}

function Parent({ children }) {
  useForceRender(1000);
  const contextValue = {};
  console.log("Parent is rendered");
  return (
    <div className="parent">
      <Context.Provider value={contextValue}>{children}</Context.Provider>
    </div>
  );
}

function ChildA() {
  console.log("ChildA is rendered");
  return (
    <div className="childA">
      <ChildB />
    </div>
  );
}

function ChildB() {
  console.log("ChildB is rendered");
  return (
    <div className="childB">
      <ChildC />
    </div>
  );
}

function ChildC() {
  console.log("ChildC is rendered");
  const value = useContext(Context);
  return <div className="childC"></div>;
}
When Does React Render Your Component?

렌더링 제외 기준

실제 렌더링 제외 기준을 알아보고 전에, 런타임 중에 호출 스택을 확인하기 위해 성능 탭에서 앱을 프로파일링 해보는 것이 도움이 될 수 있습니다. 다음의 이미지는 APP이 처음 마운트 되었을 때의 호출 스택을 캡쳐한 스크린샷입니다.

When Does React Render Your Component?

앱은 ReactDOM.render에 의해 마운트 되어 scheduleUpdateOnFiber를 통해 업데이트가 예약되었는데, 이것은 React가 컴포넌트를 처음 렌더링 하는지에 관계 없이 fiber 노드를 업데이트하는 시작 지점입니다.

관련된 세부 사항은 무척 많지만 공통적으로 확인할 수 있는 패턴은 React가 렌더링 하는 모든 컴포넌트는 beginWork를 호출해야 한다는 겁니다.

관련 소스 코드는 4049라인으로 짜여진 아주 긴 함수인데, 이 함수는 current, workInProgress, renderLanes의 세 가지 인수를 받습니다.

current는 기존 fiber 노드에 대한 포인터이고, workInProgress는 업데이트를 반영할 새로운 fiber 노드에 대한 포인터입니다. 이 두 개의 fiber 노드가 각 업데이트에 포함되어 있는 것을 이중 버퍼링이라고 하는데, 이는 체감 성능을 향상시키기 위한 최적화 기법입니다.

이 함수에는 많은 작업이 작성되어 있지만, React의 렌더링 제외 로직을 구현하고 있는 것은 다음 부분이야.

렌더링 제외 규칙을 작성하고 있는 600~620 라인
if (!checkScheduledUpdateOrContext(current, renderLanes)) {
  // 보류 중인 업데이트 또는 context가 없음. 여기서 렌더링을 제외함
  workInProgress.lanes = current.lanes;
  return bailoutOnAlreadyFinishedWork(
    current,
    workInProgress,
    renderLanes,
  );
}

함수에서 이 라인에 도달하기 위해서는 다음의 조건이 충족되어야 하는데,

  • current !== null
  • oldProps === newProps
  • hasLegacyContextChanged() === false
  • hasScheduledUpdateOrContext === false

대략적으로 풀이하면 다음과 같다고 볼 수 있습니다.

  • 컴포넌트가 이전에 렌더링, 즉 이미 마운트 됨
  • 변경된 props가 없음
  • 컴포넌트에서 사용되는 context 값 중 변경된 것이 없음
  • 컴포넌트 자체에서 업데이트를 예약하지 않음

위의 4가지 중 첫 번째와 네 번째는 비교적 명확하기 때무에 쉽게 이해할 수 있을 겁니다. 하지만 두 번째와 세 번째 조건에 대해서는 조금 더 살펴보겠습니다.

props를 변경하지 않는 방법

컴포넌트의 propsReact.createElement에서 생성한 ReactElement의 속성으로, ReactElement는 불변이기 때문에 React가 컴포넌트를 렌더링(호출) 할 때마다 React.createElement가 호출되어 새 ReactElement가 생성됩니다.

function Parent() {
  return (
    <div>
      <Child />
    </div>
  );
}

따라서 컴포넌트의 props는 리렌더링할 때마다 처음부터 생성되는 것이라고 할 수 있는데, 위 코드의 경우 Parent에서 반환된 <Child />Babel에 의해 React.createElement(Child, null)로 컴파일되고 {type: Child, props: {}}과 같은 형태의 ReactElement가 생성됩니다.

props는 자바스크립트 객체이기 때문에 다시 생성될 때마다 참조가 변경되는데, React는 기본적으로 ===를 사용하여 이전의 props와 현재의 props를 비교하기 때문에, props가 리렌더링 되면 다른 값으로 간주됩니다. 그래서 Childprops의 일부로 Parent로부터 아무것도 받지 않지만, Parent가 리렌더링 될 때마다 여전히 리렌더링 되고, React.createElementChild를 위해 호출되어 새로운 props 객체를 만들게 됩니다.

Child를 Parent의 props로 전달
function App() {
  return (
    <Parent>
      <Child />
    </Parent>
  );
}

function Parent({ children }) {
  return <div>{children}</div>;
}

그런데 만약 위와 같이 ChildParentprops로 전달할 수 있다면 어떻게 될까요? 이 경우 React에 의해 Parent가 렌더링 될 때 Child에 대한 React.createElement 함수가 호출되지 않습니다. 즉 Child의 새로운 props가 생성되지 않고, 이는 위에서 언급한 네 가지 렌더링 제외 기준을 모두 충족시키게 됩니다.

즉 이것이 앞의 예시에서 살펴보았듯이 Parent가 업데이트를 예약할 때마다 ChildA만 리렌더링이 된 이유라고 할 수 있습니다.

ChildA만 리렌더링되는 예
function Parent({ children, lastChild }) {
  return (
    <div className="parent">
      <ChildA /> // ChildA만 리렌더링 됨
      {children} // 리렌더링 제외
      {lastChild} // 리렌더링 제외
    </div>
  );
}

React가 props 변경을 탐지하는 데 사용하는 규칙 변경 방법

위에서 말했듯이, React는 기본적으로 ===를 사용하여 이전의 props와 현재의 props를 비교하는데, 다행히 React는 컴포넌트를 PureComponent로 만들어 React.memo로 감쌀 경우, props 변경을 확인할 수 있는 다른 방법을 제공합니다.

이 경우 React는 ===를 사용하여 참조가 변경되었는지 확인하는 대신, props의 모든 property에 얕은 비교를 수행하는데, 개념적으로는 Object.keys(prevProps).some(key => prevProps[key] !== nextProps[key])와 유사하다고 볼 수 있습니다.

context 값을 변경하지 않는 방법

컴포넌트가 어떤 context 값의 consumer인 경우, provider가 리렌더링 되고 context 값이 변경됐을 때(참조적으로만), 컴포넌트는 리렌더링되기 때문에, 다음의 코드에서도 Parent가 리렌더링 될 때마다 consumerChildC도 리렌더링된다는 것을 알 수 있습니다.

consumer 리렌더링 테스트
const Context = createContext();

export default function App() {
  return (
    <Parent lastChild={<ChildC />}>
      <ChildB />
    </Parent>
  );
}

function Parent({ children, lastChild }) {
  useForceRender(2000);
  const contextValue = {};
  console.log("Parent is rendered");
  return (
    <div className="parent">
      <Context.Provider value={contextValue}>
        <ChildA />
        {children}
        {lastChild}
      </Context.Provider>
    </div>
  );
}

function ChildC() {
  console.log("ChildC is rendered");
  const value = useContext(Context);
  return <div className="childC"></div>;
}

물론 위의 코드와 같은 형태도 나쁜 것은 아닙니다. 복합 컴포넌트 패턴은 context consumer의 렌더링 동작에 의존하는데, 만약 provider가 너무 많은 consumer나 리렌더링 하기에 너무 무거운 consumer가 있는 경우라면 성능 문제가 생길 수 있습니다.

이런 경우 가장 쉬운 해결 방법은 다음과 같이 비-원시 context 값을 useMemo로 래핑하여 provider 컴포넌트의 리렌더 간에 참조적으로 동일하게 유지하는 겁니다.

useMemo로 래핑하여 성능 문제 피하기
function Parent({ children, lastChild }) {
  const contextValue = {};
  const memoizedCxtValue = useMemo(contextValue);
  return (
    <div className="parent">
      <Context.Provider value={memoizedCxtValue}>
        <ChildA />
        {children}
        {lastChild}
      </Context.Provider>
    </div>
  );
}

useMemo를 사용하여 context 값을 래핑할 필요가 없는 예외가 하나 있는데, consumer 하위 트리가 큰 경우에 성능 최적화 기법으로 useMemo 내부에 context 값을 래핑할 수 있습니다.

그렇지만 여기에는 또 한 가지 예외가 있는데, context provider 컴포넌트가 컴포넌트 트리의 최상단에 있으면 수동 렌더링이 발생할 수 없기 때문에 context 값을 기억할 필요가 없습니다.

Parent가 컴포넌트 트리의 최상단에 있는 경우
const ContextA = createContext(null);

const Parent = () => {
  const [state, dispatch] = useReducer(reducer, initialState);
  const value = useMemo(() => [state, dispatch], [state]);
  return (
    <ContextA.Provider value={value}>
      <Child1 />
    </ContextA.Provider>
  );
};

Parent가 컴포넌트 트리의 최상단에 있는 경우, 즉 다른 부모 컴포넌트가 없는 경우에 React가 Parent를 리렌더링 하는 유일한 이유는 dispatch가 호출되었을 때뿐입니다. 이 경우 useMemo를 통해 적용한 메모이제이션은 어차피 소용이 없는데, 결과적으로 하위 트리가 리렌더링되기 때문에, 따라서 다음과 같이 값을 직접 전달하는 것이 좋습니다.

값 직접 전달하기
const ContextA = createContext(null);

const Parent = () => {
  const [state, dispatch] = useReducer(reducerA, initialStateA);
  return (
    <ContextA.Provider value={[state, dispatch]}>
      <Child1 />
    </ContextA.Provider>
  );
};

마무리

앞에서 살펴본 렌더링 제외는 컴포넌트가 항상 컴포넌트 트리의 같은 위치에 렌더링 된다는 것을 전제로 하고 있는데, 다음과 같은 경우에 React는 전체 하위 트리를 파괴하고 처음부터 다시 빌드를 하게 됩니다. 즉 다음과 같은 경우에는 컴포넌트가 리렌더링 될 뿐만 아니라 해당 상태도 손실될 수 있습니다.

  • 동일한 위치에서 다른 컴포넌트 간에 전환
  • 같은 컴포넌트를 다른 위치에 렌더링
  • 의도적으로 key를 변경

React는 다양한 이유로 컴포넌트를 리렌더링 하는데, UI 엔지니어링에서 가장 어려운 문제 중 두 가지는 앱 상태의 불일치와 부실을 피하는 것이라고 합니다. 결국 React의 리렌더링은 꼭 필요하지만, 과도한 리렌더링으로인해 응답성과 지연이 발생하는 경우에는 어디에서 불필요한 리렌더링이 일어나고 있는지 확인하고 최적화를 해야 할 필요가 있습니다.

참고 기사

답글 남기기