RSS듀오랩스
React

React 리렌더링은 언제 일어날까?

작성자
듀오랩스 대표·10분 읽기

검색창 하나와 긴 목록이 있는 화면을 생각해 보겠습니다.

function ProductPage() {
  const [query, setQuery] = useState("");
  return (
    <>
      <input value={query} onChange={(e) => setQuery(e.target.value)} />
      <ProductList />
    </>
  );
}

function ProductList() {
  console.log("ProductList 렌더링");
  return <ul>{/* 상품 1,000개 */}</ul>;
}

ProductListquery 를 받지도 쓰지도 않습니다. 그런데 검색창에 글자를 하나 칠 때마다 콘솔에 「ProductList 렌더링」이 찍힙니다.

state 가 바뀐 컴포넌트만 다시 그린다는 생각

React 를 처음 배울 때 흔히 이렇게 이해합니다. state 가 바뀌면 그 state 를 가진 컴포넌트가 다시 그려지고, 그 state 와 관계없는 컴포넌트는 그대로 있다. 조금 더 나아가면, props 가 바뀐 자식만 다시 그려진다고 생각합니다.

이 이해로는 앞의 콘솔 출력이 설명되지 않습니다. ProductList 는 state 도 없고 props 도 없습니다. 바뀐 것이 하나도 없는데 다시 호출됐습니다.

어긋나는 곳은 두 군데입니다. 「렌더링」이 무엇을 뜻하는지, 그리고 렌더링이 어디까지 퍼지는지입니다.

렌더링은 화면을 그리는 일이 아니라 함수를 부르는 일

React 문서는 렌더링을 이렇게 정의합니다.

"Rendering" is React calling your components.

렌더링은 React 가 컴포넌트 함수를 호출하는 것입니다. 화면의 DOM 을 바꾸는 일은 그다음 단계인 커밋에서 따로 일어납니다. 그래서 문서는 세 단계로 나눠 설명합니다. 렌더링을 일으키는 계기(trigger), 컴포넌트를 호출하는 렌더링(render), DOM 에 반영하는 커밋(commit)입니다.

그리고 커밋은 필요한 만큼만 합니다.

React only changes the DOM nodes if there's a difference between renders.

앞의 예에서 ProductList 는 호출됐지만, 돌려준 결과가 지난번과 같으므로 목록의 DOM 은 건드리지 않습니다. 「다시 렌더링됐다」와 「화면이 다시 그려졌다」는 다른 말입니다. 이 둘을 섞으면 리렌더링을 실제보다 무서운 일로 여기게 됩니다.

부모가 렌더링되면 자식도 렌더링되는 이유

같은 문서는 렌더링이 퍼지는 방식을 이렇게 적습니다.

For subsequent renders, React will call the function component whose state update triggered the render. This process is recursive: if the updated component returns some other component, React will render that component next, and if that component also returns something, it will render that component next, and so on.

state 가 바뀐 ProductPage 를 호출하면, 그 함수가 돌려준 JSX 안에 <ProductList /> 가 있습니다. React 는 이어서 ProductList 도 호출합니다. props 가 바뀌었는지는 묻지 않습니다. memo 문서도 기본 동작을 한 문장으로 적습니다.

React normally re-renders a component whenever its parent re-renders.

그래서 기준을 바꿔 기억하는 편이 정확합니다. 렌더링은 state 가 바뀐 컴포넌트에서 시작해 그 아래 전체로 내려간다. props 가 같은지는 기본적으로 따지지 않습니다.

이 동작이 기본값인 이유를 저는 이렇게 이해합니다. 컴포넌트가 props 말고도 렌더링 중에 읽는 값이 있을 수 있습니다. 모든 자식의 props 를 매번 비교하는 비용도 공짜가 아닙니다. 그래서 React 는 「다시 호출해서 결과를 비교한다」를 기본으로 두고, 건너뛰기는 필요한 곳에서 명시하게 했다고 봅니다.

렌더링을 건너뛰게 하는 memo

자식의 렌더링을 건너뛰고 싶으면 memo 로 감쌉니다.

const ProductList = memo(function ProductList() {
  return <ul>{/* 상품 1,000개 */}</ul>;
});

이제 부모가 렌더링돼도 props 가 지난번과 같으면 ProductList 를 호출하지 않습니다. 같은지는 props 하나하나를 Object.is 로 비교해서 판단합니다.

여기서 흔한 함정이 생깁니다. Object.is 는 객체와 함수를 내용이 아니라 같은 객체인지로 비교합니다.

<ProductList filter={{ category: "roller" }} onSelect={() => select(id)} />

부모가 렌더링될 때마다 { category: "roller" } 와 화살표 함수는 새로 만들어집니다. 내용은 같아도 다른 객체라서 memo 는 매번 「props 가 바뀌었다」고 판단합니다. memo 를 붙였는데도 계속 렌더링된다면 대개 이 경우입니다. 이 값들을 useMemouseCallback 으로 고정하는 이유가 여기 있습니다.

memo 로도 막지 못하는 것이 있습니다. 문서에 따르면 memo 는 부모에게서 받는 props 에만 관여합니다. 컴포넌트가 쓰는 context 값이 바뀌면 memo 로 감싸도 다시 렌더링됩니다.

그리고 문서는 memo 를 보장이 아니라 최적화로 설명합니다.

memoization is a performance optimization, not a guarantee.

React 가 필요하다고 판단하면 memo 로 감싼 컴포넌트도 렌더링할 수 있다는 뜻입니다. 렌더링이 일어나지 않는 것에 기대서 동작이 맞게 짜인 코드라면, 그 코드가 이미 틀린 것입니다.

memo 보다 먼저 볼 구조

memo 는 쓸 곳이 분명할 때 좋지만, 구조를 바꾸면 필요 없어지는 경우가 많습니다. 같은 문서는 메모이제이션을 덜 필요하게 만드는 원칙 몇 가지를 먼저 소개하는데, 그중 둘이 이 문제에 바로 닿습니다.

첫째, state 를 필요한 곳 가까이 둡니다. 앞의 예에서 query 를 쓰는 것은 검색창뿐입니다. 검색창을 따로 컴포넌트로 빼서 state 를 그 안에 두면, 글자를 칠 때 렌더링은 검색창 컴포넌트에서 시작해 거기서 끝납니다. ProductList 는 형제라서 영향을 받지 않습니다.

function ProductPage() {
  return (
    <>
      <SearchBox />      {/* query state 는 여기 안에 */}
      <ProductList />
    </>
  );
}

둘째, 감싸는 컴포넌트는 JSX 를 children 으로 받습니다. 문서의 표현은 이렇습니다.

When a component visually wraps other components, let it accept JSX as children. This way, when the wrapper component updates its own state, React knows that its children don't need to re-render.

function Panel({ children }) {
  const [open, setOpen] = useState(true);
  return <section>{open && children}</section>;
}

<Panel>
  <ProductList />
</Panel>

Panelopen 이 바뀌어 Panel 이 렌더링돼도 ProductList 는 다시 호출되지 않습니다. <ProductList /> 라는 JSX 는 Panel 이 만든 것이 아니라 Panel 을 쓴 바깥 컴포넌트가 만들어서 넘겨준 것이기 때문입니다. Panel 입장에서 children 은 지난번과 같은 값입니다. 렌더링이 「JSX 를 만든 컴포넌트」를 따라 퍼진다는 점을 보여 주는 예라서, 앞의 규칙을 이해했는지 확인하기에도 좋습니다.

리렌더링을 줄이는 것이 목표가 아닌 이유

여기까지 읽으면 모든 리렌더링을 없애고 싶어집니다. 저는 그 방향을 권하지 않습니다.

렌더링은 함수 호출이고, 결과가 같으면 DOM 은 바뀌지 않습니다. 대부분의 컴포넌트는 호출이 가볍습니다. 모든 곳에 memo, useMemo, useCallback 을 붙이면 비교 비용이 생기고, 의존성 배열을 틀리는 버그가 새로 생기고, 코드를 읽기 어려워집니다. 줄일 가치가 있는 것은 실제로 느린 렌더링입니다. 목록이 수천 줄이거나, 렌더링 중에 무거운 계산을 하거나, 입력할 때마다 화면이 끊기는 경우입니다. 그 판단은 React DevTools 의 Profiler 로 어느 컴포넌트가 얼마나 걸리는지 보고 합니다.

React Compiler 는 이 부담을 줄이는 방향으로 가고 있습니다. memo 문서에는 컴파일러가 모든 컴포넌트에 memo 에 해당하는 최적화를 자동으로 적용해 손으로 하는 메모이제이션이 덜 필요해진다는 안내가 있습니다. 다만 컴파일러를 쓰더라도 렌더링이 부모에서 자식으로 퍼진다는 모델 자체는 그대로입니다. 컴파일러가 무엇을 건너뛰는지 이해하려면 이 모델이 먼저 필요합니다.

여기까지가 확실한 부분

렌더링의 정의, 렌더링이 자식으로 퍼지는 방식, memoObject.is 비교와 context 예외, children 원칙은 react.dev 문서 기준입니다. 「렌더링이 가볍다」는 말은 일반적인 경우에 대한 판단이고, 특정 화면에서 얼마나 걸리는지는 Profiler 로 재 봐야 알 수 있습니다. React Compiler 가 어떤 코드에 최적화를 적용하고 어떤 코드는 건너뛰는지는 컴파일러 문서에서 따로 확인해야 합니다.

마지막 수정:

공유하실 때는 출처(Duolabs)와 원문 주소를 표시해 주세요.