리액트의 리렌더링 조건을 더 쉽게 이해해보기

Herbert Lim·2022년 10월 23일
53
post-thumbnail

리액트를 한지 수년이 되었음에도 불구하고, 부끄럽게도 최근에서야 리렌더링되는 조건을 정확하게 이해하게 되었습니다. 그 전까지는 props 나 state가 변경되었을 때에만 리렌더링되고, 부모 컴포넌트가 리렌더링되더라도 props가 변경되지 않았다면 리렌더링 안된다고 생각을 했습니다. Class 컴포넌트에서와는 달리 함수 컴포넌트에서는 부모 컴포넌트가 함수를 props 로 자식 컴포넌트에게 전달하는 경우, 함수에 대한 레퍼런스가 렌더링될 때마다 달라지기 때문에 불필요한 자식 컴포넌트 리렌더링을 걱정하기도 하여 최적화? 하려는 노력을 해보기도 했습니다.

한 컴포넌트가 리렌더링되는 조건을 정확히 이해하는 것이 필요할까요?

개발 중인 프론트엔드에서 아직까지 딱히 성능저하를 경함하지 못하여서 리렌더링 최적화를 크게 고민하고 계시지 않다면, 리렌더링되는 조건을 이해하는 것은 나중에 성능저하 이슈를 경험하기 시작할 때로 미루셔도 되리라 생각합니다. 그런데, 만약 눈에 띄는 성능저하를 체감하지 않음에도 평상시 메모이제이션(useCallback, useMemo, memo)을 활용하여 리렌더링을 조금이라도 줄이기 위한 고민을 많이 하고 계시다면 리렌더링 조건을 정확히 이해하시면 좋을 것 같습니다. 저처럼 실제는 효과가 없는 최적화에 고민과 시간을 낭비하지 않을 수도 있으니까요.

언제 리렌더링되는지, 리렌더링 되는 조건을 이해하기 위해서 먼저 이해해야 할 것은 무엇인지 함께 살펴보도록 해요.

결론부터 요약해봅니다.

부모 컴포넌트가 리렌더링되면 자식 컴포넌트도 리렌더링 됩니다.
자식 컴포넌트의 props나 state에 변경 사항이 있었느냐는 무관합니다.

1. 부모/자식 관계와 형제 관계

먼저 컴포넌트의 관계에서 부모/자식 관계인 것과 형제 관계인 것을 구분해야 합니다.

Note: 사실 컴포넌트의 형제(sibling) 관계를 얘기한 문서는 보지 못했습니다. 부모/자식 관계와 형제 관계로 구분해서 본다면 React.createElement()나 React의 구현 디테일까지 언급하지 않고도 리렌더링 조건을 더 쉽게 설명할 수 있다고 판단하여, 이 글에서는 "형제" 관계를 언급하도록 하겠습니다

다음과 같은 아주 간단한 예제에서 Child1, Child2, Child3 는 모두 Parent의 자식 컴포넌트들입니다. Parent 컴포넌트가 리렌더링되면 Child1, Child2, Child3 는 모두 리렌더링 됩니다.

<예제 1>

const Parent = () => {
  return (
  	<Child1>
      <Child2>
      	<Child3 />
      </Child2>
    </Child1>
  )
}

const Child1 = ({ children }) => <div>{children}</div>;
const Child2 = ({ children }) => <div>{children}</div>;
const Child3 = () => <div>저는 막내예요</div>;

이들의 부모 자식 관계는 다음과 같은 트리(뒤에서 "렌더링 관계 트리")로 표현할 수 있겠습니다. Parent가 리턴하는 JSX 안에 <Component /> 형태로 등장하는 모든 컴포넌트들은 Parent의 자식이면서 서로는 형제입니다.
기본 렌더링 관계 트리

형제 컴포넌트들끼리는 리렌더링에 서로 영향이 없습니다. 맞인 Child1이 리렌더링된다고 둘째인 Child2가 리렌더링되지 않고, Child2가 리렌더링될 때 막내인 Child3가 리렌더링되지 않습니다. Child1, Child2, Child3가 리렌더링되는지 여부는 오로지 부모인 Parent 컴포넌트의 리렌더링에만 의존적입니다.

그렇다면, 이렇게 요약해볼 수 있겠습니다:

  • 부모 컴포넌트가 리렌더링되면 자식 컴포넌트들도 리렌더링됨
  • 자식 컴포넌트란 부모 컴포넌트의 JSX 안에서 사용된 모든 컴포넌트들
  • 형제 관계인 컴포넌트들끼리는 서로 리렌더링에 영향을 미치지 않음

약간 더 어려운 예제에 위 원칙을 적용해보록 해요. When Does React Render Your Component?에 아주 좋은 예제가 있어서 인용해봅니다. (이 원문을 아주 잘 번역한 글도 있습니다: [번역] 리액트는 언제 컴포넌트를 렌더링 하나요? )

<예제 2>

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

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

Parent 컴포넌트가 리렌더링되면 ChildA, ChildB, ChildC 중 어떤 컴포넌트가 리렌더링될까요? ChildA는 확실히 Parent의 자식인데, props로 받은 children과 lastChild가 햇갈리게 합니다.

App 컴포넌트에서 ChildB는 Parent 컴포넌트에 감싸져있으므로(nested) 웬지 ChildB도 Parent 컴포넌트와 함께 리렌더링될 것 같습니다. 즉, 다음과 같은 부모 자식 관계의 트리를 떠올리시는 분들도 계실 것 같습니다
렌더 트리 1

그러나, 정답은 오로지 ChildA 컴포넌트만 Parent 컴포넌트와 함께 리렌더링됩니다.

"자식 컴포넌트란 부모 컴포넌트의 JSX 안에 사용된 모든 컴포넌트들"이라고 정의했으므로 ChildB와 ChildC는 App의 자식 컴포넌트입니다.

App 컴포넌트가 리턴하는 JSX에는 Parent, ChildB, ChildC가 있습니다. 즉, Parent, ChildB, ChildC는 App 의 자식 컴포넌트들입니다. ChildC는 App 컴포넌트가 렌더링하여 Parent의 lastChild props으로 전달해주는 것이고, ChildB도 App 컴포넌트가 렌더링하여 Parent의 children props으로 전달해줍니다. (props.children 은 이 글에서 지칭하는 자식 컴포넌트가 아니라는 것을 유의해주세요.)

트리를 다음과 같이 그릴 수 있겠습니다. Parent 컴포넌트가 리렌더링될 때마다 함께 리렌더링되는 컴포넌트는 어떤 것인지 명확하게 알 수 있습니다.

props와 children이 있는 경우 렌더링 관계 트리

2. 컴포넌트 트리와 렌더링 관계 트리

다음은 예제 1에 대한 Codesandbox인데요. React DevTools 탭을 선택하여 컴포넌트 트리를 확인해봅니다.

컴포넌트 트리를 보면 Child2가 Child1의 자식이어서 Child1이 리렌더링되면 Child2와 Child3도 리렌더링될 것 같습니다.
React Developer Tools의 컴포넌트 트리

이 컴포넌트 트리는 렌더링 관계가 아닌 아래와 같은 중첩된(nested) DOM 트리 구조를 보여주는 것입니다.
DOM 구조

그렇다면, 리렌더링 관계는 정말 컴포넌트 트리와는 다른 것일까요? 위 Codesandbox 에서 React DevTools 탭의 컴포넌트 트리에서 Child2를 선택해봅니다. 그러면 오른쪽에 rendered by에 Child2를 리렌더링할 수 있는 컴포넌트들을 보여줍니다. App 또는 Parent가 리렌더링되면 Child2가 리렌더링된다는 의미입니다. rendered by에 Child1은 존재하지 않습니다. 따라서, DOM 트리나 컴포넌트 트리를 보고 리렌더링 관계를 판단하면 틀릴 수도 있음을 유의해야 하겠습니다.

그래서, 지금부터는 부모/자식의 렌더링 관계를 나타내는 트리를 렌더링 관계 트리라고 부르도록 하겠습니다.

예제 2도 Codesandbox의 React DevTools 에서 확인해보도록 해요.

ChildA, ChildB, ChildC가 모두 Parent의 자식인것처럼 보이지만, 하나씩 선택하여 rendered by를 확인해보면 ChildB와 ChildC는 App 에 의해서만 리렌더링되는 것을 확인할 수 있습니다.

React Dev Tools의 Profiler를 통해서도 확인해볼 수 있습니다. 아래 예를 보면 ChildA는 렌더되었으나 ChildB와 ChildC는 렌더되지 않은 것을 알 수 있죠.(회색으로 표현됨)

컴포넌트 트리와 렌더링 관계 트리가 동일한 경우도 있을 수 있으나, 예제 2에서는 컴포넌트 트리와 달리 렌더링 관계 트리는 아래와 같습니다.
렌더링 관계 트리

다시 한 번 정리하면

  • 부모 컴포넌트가 리렌더링되면 자식 컴포넌트들도 리렌더링됨
  • 자식 컴포넌트는 부모 컴포넌트의 JSX 안에서 사용된 모든 컴포넌트들
    • "JSX 안에서 사용된 컴포넌트"란 <Component /> 형태로 사용된 컴포넌트
    • props, children으로 받은 컴포넌트는 자식 컴포넌트가 아님
  • 자식 컴포넌트들끼리는 형제임
    • 형제 관계인 컴포넌트들끼리는 서로 리렌더링에 영향을 미치지 않음

리렌더링되는 조건을 이해하는 것이 조금 더 쉬워지셨길 바랍니다.

"부모 컴포넌트가 리렌더링되면 자식 컴포넌트들도 리렌더링"되는 기본 원리는 Context를 사용한 경우에도 적용되는데요. Context나 Store와 관련된 것은 이번 글에서는 제외하고 따로 정리해보도록 하겠습니다.

3. 2가지 렌더링과 2가지 단계(phase)

우리가 리액트로 웹개발을 할 때에는 리액트의 렌더링브라우저의 렌더링 두 가지로 나누어 볼 수 있겠습니다.

이 글에서는 리액트의 렌더링에 대해서만 얘기할 것이므로 위 그림의 왼쪽 부분을 더 확대해보겠습니다.

리액트의 공식 문서에 의하면 리액트의 렌더링은 2가지 렌더링으로 구분됩니다:

그리고, 리액트에는 렌더 단계(render phase)커밋 단계(commit phase)의 두 단계가 있습니다. 위 그림과 함께 보시면, 2 가지 렌더링과 2 가지 단계는 서로 일치하지 않습니다:

  • Render 단계: 컴포넌트 렌더링부터 엘리먼트 렌더링의 재조정까지입니다. 즉 이전 렌더링과 비교하여 변경된 부분을 파악까지만 합니다.
  • Commit 단계: DOM을 업데이트합니다. 변경된 부분만.

Commit 단계에서 DOM을 업데이트한 후에는 라이프사이클 메소드와 useEffect가 실행됩니다.

리액트 렌더링 관련 바이블 같은 "A (Mostly) Complete Guide to React Rendering Behavior" 의 "Render and Commit Phases" 부분이나 리액트 전반적으로 유용한 내용이 많은 React Docs beta의 "Render and Commit"을 참고하시면 좋습니다.

우리가 최적화하려는 부분이 어떤 부분인지 알기 위해서 정리해보았습니다. 이 글에서는 엘리먼트 렌더링이 아닌 컴포넌트 렌더링 최적화를 다루고 있습니다. 단계로 본다면 컴포넌트 렌더링을 포함하고 있는 렌더 단계에 해당합니다. (참고로 다음 장에서 활용할 React Dev Tools의 프로파일러는 렌더 단계를 보여줍니다.)

4. 컴포넌트 렌더링 최적화에 대한 오해

흔히 자식 컴포넌트의 리렌더링을 줄이기 위하여 자식 컴포넌트에게 props로 전달하는 이벤트 핸들러 같은 함수를 useCallback()으로 감싸주고 최적화했다고 안도하곤 합니다. 그런데, 과연 실제로 효과가 있는 것일까요? 예제를 통해 확인해보도록 하겠습니다.

4.1 기본 예제

다음과 같은 간단한 예제에서 출발해보도록 하겠습니다.

import { useEffect, useState, useCallback } from "react";
import "./styles.css";

export default function App() {
  return (
    <div className="App">
      <h2>Rerendering Example</h2>
      <Parent />
    </div>
  );
}

const useValue = () => {
  const [value, setValue] = useState(0);

  useEffect(() => {
    value < 3 &&
      setTimeout(() => {
        setValue((value) => value + 1);
      }, 1500);
  }, [value]);

  return value;
}

const Parent = () => {
  const value = useValue();
  const handleClick = () => {};

  return (
    <>
      <div>value: {value}</div>
      <ChildA />
      <ChildB value={value} />
      <ChildC onClick={handleClick} />
    </>
  );
};

const ChildA = () => <GrandChildren color="red" />;
const ChildB = ({ value }) => <GrandChildren color="blue" />;
const ChildC = ({ onClick }) => <GrandChildren color="green" />;

const GrandChildren = ({ color }) => (
  <div>
    {Array.from({ length: 3 }).map((_, i) => (
      <GrandGrandChild key={i + 1} order={i} color={color} />
    ))}
  </div>
);

const GrandGrandChild = ({ order, color }) => (
  <div style={{ color }}>GrandGrandChild {order}</div>
);

이 예제의 렌더링 관계 트리는 다음과 같습니다. ChildA는 props와 state가 없고, ChildB는 Parent의 state만 props 로 받습니다. ChildC는 Parent의 handleClick() 이벤트 핸들러를 props로 받는 컴포넌트입니다.

다음과 같이 React Dev Tools Profiler를 활용하여 분석해보도록 해요. 첫번째 커밋에서 뿐만 아니라 이후 3번의 리렌더링에서 Child{A,B,C}의 증손자들은 모두 리렌더링된 것을 확인할 수 있습니다.

프로파일 결과를 볼 때 아래와 그림에서 빨간 동그라미 친 부분처럼 GrandGrandChild가 보이지 않는 경우가 있는데요. 이건 아마도 프로파일러가 생략한 것 같습니다. 진짜 렌더링을 하지 않은 경우에는 파란 동그라미 친 것처럼 회색으로 확실하게 표시해줍니다. 따라서, 컴포넌트가 생략된 것을 리렌더링되지 않은 것으로 오해하지 않아야 하겠네요.

렌더링 단계에서는 매번 렌더링이 발생하기는 하지만, value 값을 보여주는 부분 외에 Child{A,B,C}의 DOM에는 변화가 없기 때문에 커밋 단계에서 DOM 업데이트는 발생하지 않습니다.

4.2 props 없는 ChildA 메모이제이션

ChildA를 React.memo()로 감싸고 Parent 컴포넌트의 ChildA를 MemoizedChildA로 바꿔봅니다.

const Parent = () => {
  ...
  return (
    <>
      <div>value: {value}</div>
      <MemoizedChildA /> // <---
      <ChildB value={value} />
      <ChildC onClick={handleClick} />
    </>
  );
};

const MemoizedChildA = memo(ChildA); // <---

두번째 이후 커밋부터는 ChildA는 리렌더링되지 않았습니다. 그래서 GrandGrandChild도 리렌더링되지 않았습니다. 아! 이건 충분히 예상하시던 바이죠? ^^

4.3 useCallback 으로 최적화하기

이번엔 ChildC의 리렌더링을 줄이기 위하여 handleClick() 이벤트 핸들러를 useCallback()으로 감싸보도록 하겠습니다.

const Parent = () => {
  ...
  const memoizedHandleClick = useCallback(() => handleClick, []);
  
  return (
    <>
      <div>value: {value}</div>
      <MemoizedChildA /> 
      <ChildB value={value} />
      <ChildC onClick={memoizedHandleClick} /> // <---
    </>
  );
};

const MemoizedChildA = memo(ChildA); // <---

기대와 달리 ChildC는 매번 리렌더링되었습니다.

함수에 대한 레퍼런스를 동일하게 유지하기는 했으나, ChildC는 props.onClick이 동일한지 여부는 체크하지 않고, 그저 부모 컴포넌트가 리렌더링되었기 때문에 리렌더링되는 것입니다. 이렇게 useCallback()을 사용한다면 원하는 최적화 효과가 발생하지 않을 뿐만 아니라 오히려 useCallback()의 dependency를 체크하는데 CPU를 낭비하게 되는 셈입니다. 🙀

4.4 useCallback과 memo() 함께 사용

ChildC를 React.memo()로 감싸고 ChildC를 MemoizedChildC로 변경해보겠습니다.

const Parent = () => {
  ...
  const memoizedHandleClick = useCallback(() => handleClick, []);
  
  return (
    <>
      <div>value: {value}</div>
      <MemoizedChildA /> 
      <ChildB value={value} />
      <MemoizedChildC onClick={memoizedHandleClick} /> // <---
    </>
  );
};

const MemoizedChildC = memo(ChildC); // <---

오!! MemoizedChildC는 리렌더링되지 않았네요! (렌더링되지 않은 MemoizedChildC가 너무 작게 나와서 GrandGrandChild가 30개씩 생성되록 하였습니다)

함수를 자식 컴포넌트에게 전달할 때 불필요한 리렌더링이 많이 발생하는 것을 줄이려면, useCallback()과 memo()를 함께 사용해주어야 효과가 있다는 것을 알 수 있습니다.

5. 마무리

부모 컴포넌트가 렌더링되면 자식 컴포넌트도 렌더링되는데, 부모/자식 관계는 컴포넌트 트리에서 부모 자식과는 일치하지 않을 수 있습니다. 컴포넌트 트리와 별도로 "렌더링 관계 트리"를 정의하여 렌더링에 영향을 주는 부모/자식 컴포넌트를 더 잘 이해할 수 있도록 해보았습니다.

부모 컴포넌트가 리렌더링될 때마다 자식이나 증손자 컴포넌트가 리렌더링되는 것을 피하는 방법으로는 메모제이션 외에도 자식이나 증손자 컴포넌트를 할아버지, 할머니 컴포넌트로 올리고 부모 컴포넌트에게 props 로 전달하는 방법도 있으니 활용해보시기 바랍니다.

"A (Mostly) Complete Guide to React Rendering Behavior"의 Memoize Everything? 섹션을 보면, 차라리 모든 함수 컴포넌트를 React.memo()로 감싸면 안되느냐는 의견에 대하여 (아마도 이런 이야기가 꽤나 많았던 모양입니다), Dan Abramove는 "모든 JS 함수에 대하여 Lodash의 memoize()를 사용하면 성능이 나아지겠는가?"라고 트윗했었다고 하는군요.

Dan: Why doesn’t React put memo() around every component by default? Isn’t it faster? Should we make a benchmark to check?
Ask yourself:
Why don’t you put Lodash memoize() around every function? Wouldn’t that make all functions faster? Do we need a benchmark for this? Why not?

저는 이번에 React 렌더링에 대해 다시 공부하면서 다음과 같은 결론을 내렸습니다.

체감할 수 있는 성능 저하가 발견되기 전까지 memoization 은 미루자

useCallback()이나 useMemo()를 사용해야 한다면
자식 컴포넌트를 memo()로 감싸줍시다

References

profile
Frontend Software Engineer at KTOWN4U

2개의 댓글

comment-user-thumbnail
2023년 4월 25일

Context나 Store와 관련된 것에 대한 글이 기대되네요

답글 달기
comment-user-thumbnail
2023년 8월 10일

그동안 컴포넌트 트리에 따른 부모/자식 관계로 리렌더링이 발생하는줄 알았는데, 큰 착각을 하고 있었네요.
아래 정보로 제가 알고 있던 정보를 덮어 쓰도록 하겠습니다. 감사합니다.

  • 자식 컴포넌트는 부모 컴포넌트의 JSX 안에서 사용된 모든 컴포넌트들
    • "JSX 안에서 사용된 컴포넌트"란 <Component /> 형태로 사용된 컴포넌트
    • props, children으로 받은 컴포넌트는 자식 컴포넌트가 아님
답글 달기