[번역] 리액트는 언제 컴포넌트를 렌더링 하나요?

eunbinn·2022년 4월 16일
138

FrontEnd 번역

목록 보기
5/31
post-thumbnail

원문: https://www.zhenghao.io/posts/react-rerender

정확히 언제 그리고 왜 리액트는 컴포넌트를 렌더링 하는 것일까요?

이 글은 Mark Erikson의 에세이 리액트 렌더링 동작의 (거의) 완전한 가이드를 제 관점에서 다시 작성한 것으로, 리액트 커뮤니티에서 가장 자주 묻는 질문 중 하나인 "리액트는 언제 또는 왜 컴포넌트를 렌더링 하나요?"에 대해 조금의 리액트 소스 코드를 사용하여 답해보려 합니다.

저는 보통 상세한 내부 구현까지 파고드는 것을 그다지 좋아하지 않으며, 리액트를 사용하며 생산성을 높이기 위해 세부 구현까지 알 필요는 없다고 생각합니다. 그러나 렌더링 동작과 리렌더링을 피하기 위한 규칙을 이해하는 데에 있어 리액트의 문서는 만족할만큼 충분한 설명을 제공하지 않았습니다. 그렇기 때문에 소스 코드를 들여다봐야 했습니다. 그렇다고 해서 내부 코드를 상세하게 파헤치는 글은 아닙니다. 만약 상세한 내부 코드에 관심 있으시다면 Jser가 작성한 훌륭한 글들을 확인해 보세요.

Tl;dr

  • 리액트는 컴포넌트를 다음과 같은 상황일 경우 (재)렌더링 합니다:
    • 컴포넌트에 예정된 상태 업데이트가 있을 경우.
      • 컴포넌트에서 사용된 커스텀 훅의 예정된 업데이트가 있을 경우도 포함합니다.
    • 부모 컴포넌트가 렌더링 되고 리렌더링에서 제외되는 기준에 충족하지 않을 경우. 제외되는 기준은 다음의 네 가지 조건을 모두 동시에 충족해야 합니다.
      • 컴포넌트가 이전에 렌더링 되었어야 함. 즉, 이미 마운트 되었어야 함.
      • 변경된 props(참조)가 없어야 함.
      • 컴포넌트에서 사용하고 있는 context 값이 변경되지 않아야 함.
      • 컴포넌트에 예정된 상태 업데이트가 없어야 함.
  • 아마도 성능 문제가 발생할 때까지 불필요한 리렌더링에 대해서는 걱정할 필요가 없을겁니다. 성능 문제가 발생했을 때 적용할 수 있는 해결책에 대해서는 제가 작성한 흐름도를 확인하세요.

주의사항: 리액트의 concurrent 모드를 사용하지 않았기 때문에 이 글의 일부는 concurrent 리액트에 적용되지 않을 수 있습니다.

"렌더"는 무엇을 의미하나요?

눈치채셨는지 모르겠지만, 저는 "컴포넌트가 렌더링 한다"가 아닌 "리액트가 컴포넌트를 렌더링 한다"라고 계속 말하고 있었습니다. 보통 사람들은 이 둘을 구별하지 않고 제멋대로 결정하여 말하곤 합니다. 까다롭다고 생각하실 수도 있습니다만, 이 글에서는 "리액트가 컴포넌트를 렌더링 한다"만을 사용할 것입니다. 이 편이 리액트가 어떻게 동작하는지 더 정확하게 설명하기 때문입니다. 컴포넌트(UI에서 업데이트를 예약할 수 있도록 리액트에 의해 강화된 함수)는 리액트에 의해 호출되는 것입니다. 컴포넌트가 스스로의 상태를 능동적으로 변화시켰든 다른 변화 때문인지든 상관없이 리액트에 의해 호출되는 것입니다.

리액트의 핵심 디자인 원칙 중 하나에 따라 리액트는 UI 스케줄링 및 업데이트를 완전히 제어할 수 있습니다. 이는 몇 가지 의미를 가집니다.

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

▶︎ 리액트 17에서는 일부 상태 업데이트는 일괄 처리할 수 없습니다

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


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


▶︎ 맥락에 따라서 "업데이트"가 무엇을 의미하는지 정의해 봅시다

"렌더"라는 단어와 함께 "업데이트"라는 단어 또한 많이 사용될 것입니다. "업데이트"는 맥락에 따라서 의미가 달라질 수 있습니다.

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

"리액트가 UI를 업데이트한다"의 업데이트는 리액트가 기존 DOM 노드를 변환하거나 DOM 트리의 내부 표현과 일치하도록 새로운 DOM 노드를 생성한다는 의미입니다. 여기서 업데이트는 컴포넌트를 렌더링 한 결과입니다.


그래서 리액트는 정확히 언제 컴포넌트를 렌더링 하나요?

컴포넌트는 두 가지 유형의 렌더링이 발생할 수 있습니다.

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

능동적인 렌더링

"능동적인 렌더링"은 제가 만든 단어입니다. "능동적인"이란 컴포넌트 자체(또는 컴포넌트가 사용하는 커스텀 훅)가 업데이트를 예약하기 위해 다음을 통해 능동적으로 상태를 변경하는 것을 의미합니다:

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

업데이트를 능동적으로 예약하는 또 다른 방법은, ReactDOM.render를 직접 호출하는 것입니다. 다음은 리액트 공식 문서의 예입니다.

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는 무엇일까요? Fiber는 리액트 16에서 도입되었습니다. 새로운 조정 알고리즘이자 리액트 내부의 작업 단위를 나타내는 새로운 데이터 구조입니다. Reconciler에 의해 ReactElement에서 fiber 노드가 생성됩니다. 일반적으로 모든 ReactElement는 해당하는 fiber 노드가 있지만 몇 가지 예외가 있습니다. 예를 들면, Fragment 타입의 ReactElement는 해당하는 fiber 노드가 없습니다.

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

이 글은 리액트 내부 구현에 대한 글이 아닙니다. fiber 노드와 재조정(reconciliation) 과정에 대해 더 알아보고 싶으시다면 이 글을 참고해 주세요.


수동적인 렌더링

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

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

위의 예시에서 부모 컴포넌트가 리액트에 의해 렌더링 되면, 자식 컴포넌트는 props에 참조/아이덴디티가 변경된 것(이 내용은 나중에 더 자세히 알아봅시다) 외에 의미 있는 변경사항이 없더라도 렌더링 됩니다.

렌더 단계에서 리액트는 재귀적으로 컴포넌트 트리를 탐색하여 컴포넌트를 렌더링 합니다. 따라서 만약 자식 컴포넌트에게 또 다른 자식 컴포넌트가 있다면 해당 자식 컴포넌트도 렌더링 됩니다. (계속해서 얘기하지만, 렌더링 제외 기준을 충족하지 않을 경우에 한합니다.)

function Child() {
  return <GrandChild />; // 만약 `Child` 가 렌더링 되면 `GrandChild` 또한 렌더링 됩니다.
}

그러나 컴포넌트 중 하나가 렌더링 제외 기준을 충족한다면 리액트는 해당 컴포넌트를 렌더링 하지 않습니다.

그렇다면, 렌더링 제외 기준이란 무엇일까요?

이에 답하기 위해 두 가지 예시를 살펴보겠습니다.

모든 자식 컴포넌트가 동일하게 만들어지지 않았을 때

먼저 예를 살펴보겠습니다.

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 자체는 업데이트를 예약한 컴포넌트이기 때문에 리액트에 의해 리렌더링 될 것입니다. 하지만 모든 자식 컴포넌트 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>
  );
}

코드 샌드박스에서 확인해보세요.

ChildA는 리렌더링 되었습니다. 이는 부모 컴포넌트가 업데이트를 예약하고 리렌더링 되었다는 것을 알고 있기 때문에 놀라운 일은 아닙니다.

그러나 ChildA와 달리 ChildBChildC리렌더링 되지 않았습니다. 그 이유는 ChildB와 ChildC가 렌더링 제외 기준을 충족했기 때문에 리액트가 렌더링을 건너 뛴 것입니다.

새로운 소식이 아닐 수도 있습니다만, Kent C. Dodds, Dan Abramov, Mark는 모두 이 최적화 기술에 대해 포스팅한 블로그 글이 있습니다.

Context consumer는 provider가 렌더링 될 때마다 렌더링 됩니다

수동적인 렌더링은 컴포넌트가 context consumer 일 때에도 발생할 수 있습니다.

이전 예시를 조금 바꿔서 Parent 컴포넌트가 context provider로, ChildC가 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>;
}

코드 샌드박스에서 확인해보세요.

결과는 아래와 같습니다.

리액트가 Parent를 렌더링 할 때마다 이전 contextValue다른 참조 값을 갖는 새로운 contextValue를 생성합니다. 결과적으로 context consumer인 ChildC는 다른 context value를 갖게 되며, 리액트는 변경 사항을 반영하기 위해 ChildC를 리렌더링 합니다.

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


▶︎ 렌더링 제외 기준은 각 컴포넌트 레벨에서 적용됩니다

컴포넌트 중 하나가 렌더링 제외 기준을 충족할 경우 리액트는 해당 컴포넌트를 렌더링 하지 않습니다. 그러나 리액트는 해당 컴포넌트의 자식 컴포넌트에 업데이트가 필요한지 계속해서 확인합니다. 아래 예시에서 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>;
}

코드샌드박스에서 확인해보세요.


렌더링 제외 기준

충분한 예시들을 살펴봤으니 렌더링 제외 기준이 진짜로 무엇인지 알아봅시다.

진실을 알기 위해서는 내부 코드를 파헤쳐 봐야 합니다. 하지만 어디서부터 시작해야 할까요?

런타임 중에 호출 스택을 확인하기 위해 성능 탭에서 앱을 프로파일링 할 수 있습니다.

위의 스크린샷은 App이 처음 마운트 되었을 때 호출 스택의 스냅샷입니다.

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

관련된 세부 사항이 너무 많지만 공통적으로 확인할 수 있는 패턴은 리액트가 렌더링 하는 모든 컴포넌트는 beginWork를 호출해야 한다는 것입니다. 이곳이 렌더링 제외 기준의 비밀이 숨겨져 있는 곳인 것 같네요.

관련 소스 코드를 한 번 확인해 봅시다.

아주 긴 함수입니다. 이 함수는 current, workInProgressrenderLanes의 세 가지 인수를 받습니다. current는 기존 fiber 노드에 대한 포인터이며, workInProgress는 업데이트를 반영할 새로운 fiber 노드에 대한 포인터입니다. 왜 두 개의 fiber 노드가 각 업데이트에 포함되어 있을까요? 이를 이중 버퍼링(double buffering)이라고 하며, 체감 성능을 향상시키기 위한 최적화 기법입니다.

이 함수에서는 많은 일이 일어나고 있지만, 리액트의 렌더링 제외 로직의 정확한 위치를 찾는 것은 어렵지 않습니다.

// ...생략
// 보류 중인 업데이트 또는 context가 없습니다. 여기서 렌더링을 제외합니다.
  didReceiveUpdate = false;
    return attemptEarlyBailoutIfNoScheduledUpdate(
      current,
      workInProgress,
      renderLanes
);

이 라인에 도달하기 위해서는 다음 조건이 충족되어야 합니다.

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

대략적으로 다음과 같습니다.

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

첫 번째 조건과 네 번째 조건은 쉽게 이해할 수 있으니 두 번째 조건과 세 번째 조건을 좀 더 살펴보겠습니다.

props를 변경하지 않는 방법

컴포넌트의 propsReact.createElement에서 생성한 ReactElement의 속성입니다. ReactElement는 불변이므로 리액트가 컴포넌트를 렌더링(호출) 할 때마다 React.createElement가 호출되어 새 ReactElement가 생성됩니다. 따라서 컴포넌트의 props는 리렌더링할 때마다 처음부터 생성됩니다.

첫 번째 예시를 다시 살펴봅시다.

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

Parent에서 반환된 <Child />는 Babel에 의해 React.createElement(Child, null)로 컴파일되고 {type: Child, props: {}} 이러한 형태의 ReactElement가 생성됩니다.

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

하지만 만약 ChildParentprops로 전달할 수 있다면 어떻게 될까요?

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

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

리액트에 의해 Parent가 렌더링 될 때 Child에 대한 React.createElement 함수가 호출되지 않습니다. 따라서 Child새로운 props가 생성되지 않고, 이는 위에서 언급한 네 가지 렌더링 제외 기준을 모두 충족시킵니다.

이것이 이 예시에서 Parent가 업데이트를 예약할 때마다 ChildA만 리렌더링 되었던 이유입니다.

function Parent({ children, lastChild }) {
  return (
    <div className="parent">
      <ChildA /> // ChildA만 리렌더링 됩니다
      {children} // 리렌더링 제외
      {lastChild} // 리렌더링 제외
    </div>
  );
}

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

위에서 언급했던 것처럼, 기본적으로 리액트는 ===를 사용하여 이전 props와 현재 props를 비교합니다.

다행히도 리액트는 컴포넌트를 PureComponent로 만들어가 React.memo로 감쌀 경우, props 변경을 확인할 다른 방법을 제공합니다. 이런 경우에 리액트는 ===를 사용하여 참조가 변경되었는지 확인하는 대신, props의 모든 property에 얕은 비교를 수행합니다. 개념적으로 Object.keys(prevProps).some(key => prevProps[key] !== nextProps[key])와 유사합니다.

그러나 이러한 최적화는 악용되어서는 안되며, 리액트가 이를 기본 렌더링 동작으로 만들지 않은 데에는 이유가 있습니다. Dan Abramov는 props 비교에 드는 비용을 무시해서는 안 되며 더 나은 대안이 많이 있다고 거듭 언급했습니다.

context 값을 변경하지 않는 방법

컴포넌트가 어떤 context 값의 consumer인 경우, provider가 리렌더링 되고 context 값이 변경됐을 때(참조적으로만), 컴포넌트는 리렌더링 됩니다.

이러한 이유로 이 예시에서 Parent가 리렌더링 될 때마다 consumer인 ChildC도 리렌더링 됩니다.

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 컴포넌트의 리렌더 간에 참조적으로 동일하게 유지하는 것입니다.

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 값을 기억할 필요가 없습니다. 수동 렌더링이 발생할 수 없기 때문입니다. 예를 들어:

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

const ContextA = createContext(null);

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

context 소비를 최적화하기 위해 사용할 수 있는 다양한 기술들이 있습니다. 제 친구 Vladimir가 작성한 좋은 글이 있으니 한 번 확인해 보세요.

모든 것은 하나의 암묵적인 전제에 근거하고 있습니다

고백하건데, 사실 위의 렌더링 제외에 대한 이야기가 컴포넌트가 항상 컴포넌트 트리의 같은 위치에 렌더링 된다는 전제하에 이루어졌습니다. 제가 이를 미리 말하지 않은 이유는 보통 같은 위치에서 렌더링 되기 때문입니다. 하지만 다음과 같은 경우:

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

이런 경우에 리액트는 전체 하위 트리를 파괴하고 처음부터 다시 빌드합니다. 컴포넌트가 리렌더링 될 뿐만 아니라 해당 상태도 손실됩니다.

이에 대해 자세히 알아보려면 리액트 문서의 상태를 보존하고 재설정하기를 확인하세요.

어떤 교훈을 얻을 수 있나요?

렌더링 제외 규칙이 복잡하다고 생각하든 그렇지 않든 간에, 한 가지 알아갔으면 하는 포인트는 리액트가 다양한 이유로 컴포넌트를 리렌더링 한다는 것입니다.

UI 엔지니어링에서 가장 어려운 문제 중 두 가지는 앱 상태의 불일치와 부실을 피하는 것이기 때문에 리액트의 리렌더링이 필요합니다.

따라서 컴포넌트가 리렌더할 준비가 되어있도록하고 많은 컴포넌트에 대해 복원력이 있어야 합니다. 제가 만든 훅(Dan Abramov로부터 아이디어를 얻었습니다)으로 컴포넌트에 스트레스 테스트를 할 수 있습니다. 또한 컴포넌트가 멱등성을 갖게 만들어 컴포넌트를 한 번 또는 여러 번 렌더링 해도 실제 UI에 차이가 발생하지 않아야 합니다(성능 저하 제외).

그러나 과도한 리렌더링으로인해 UI 엔지니어링에서 또 다른 두 가지 가장 어려운 문제인 응답성과 지연이 발생한다면, 어디를 확인해야 할지 또 어떻게 최적화해야 할지 이미 알고 있기를 바랍니다.

흐름도

예상치 못한 리렌더링을 확인하는 데 도움이 될 수 있는 흐름도를 만들었습니다.