본문 바로가기

불필요한 리렌더링 이해와 방지

부모 상태 변경이 자식 렌더링으로 전파되는 조건을 추적하고 memo·useCallback·useMemo의 적용 대상을 구분합니다.

이번에는 가장 흔하고 중요한 성능 문제 중 하나인 불필요한 리렌더링(Unnecessary Re-renders)을 깊이 이해하고, 이를 방지하기 위한 리액트 내장 최적화 기법을 살펴보겠습니다.


앞의 상태·props·Context 학습을 바탕으로, 이번에는 화면에 보여야 할 값과 렌더링 비용을 따로 추적합니다. 렌더 함수가 실행되어도 모든 DOM 요소가 교체되는 것은 아닙니다. 먼저 출력이 맞는지 확인한 다음, 같은 입력에서 반복되는 비싼 작업을 찾습니다.

아래의 MemoExample.js, UseCallbackExample.js, UseMemoExample.js는 각각 독립 예제입니다. 앞서 만든 프로젝트의 src/components 폴더에 파일을 저장하고, App.jsx에서는 한 번에 하나만 선택해 실행하세요. 세 파일의 코드를 한 파일에 이어 붙이지 않습니다. 첫 예제를 실행하는 App.jsx는 다음과 같습니다. 다음 예제로 넘어갈 때는 import 경로와 화면에 배치하는 컴포넌트를 함께 바꾸고 페이지를 새로 고쳐 초기 상태에서 관찰합니다.

import ParentComponent from './components/MemoExample.js';

export default function App() {
  return <ParentComponent />;
}

리렌더링이란 무엇인가?

리액트는 UI를 업데이트하기 위해 리렌더링(re-rendering)이라는 과정을 거칩니다.

  • 렌더링(Rendering): 컴포넌트 함수를 호출하여 JSX를 반환하고, 이 JSX를 가상 DOM(Virtual DOM)으로 변환하는 과정입니다.
  • 리렌더링(Re-rendering): 컴포넌트의 프롭스(props)나 상태(state)가 변경되었을 때, 리액트가 해당 컴포넌트와 그 자식 컴포넌트들을 다시 렌더링하여 새로운 가상 DOM을 생성하는 과정입니다. 리액트는 이전 가상 DOM과 새 가상 DOM을 비교(재조정, reconciliation)하여 실제 DOM에 필요한 최소한의 변경만을 반영합니다.
리렌더링이 발생하는 주요 원인

상태(State) 변경: useState 훅으로 관리되는 컴포넌트의 상태가 변경될 때.

function Counter() {
  const [count, setCount] = useState(0); // count가 변경되면 Counter 리렌더링
  return <button onClick={() => setCount(count + 1)}>{count}</button>;
}

프롭스(Props) 변경: 부모 컴포넌트로부터 전달받은 프롭스가 변경될 때.

function Parent() {
  const [data, setData] = useState("Hello");
  return <Child message={data} />; // data가 변경되면 Child 리렌더링
}
function Child({ message }) { /* ... */ }

부모 컴포넌트 리렌더링: 부모 컴포넌트가 리렌더링되면, 기본적으로 모든 자식 컴포넌트도 함께 리렌더링됩니다.

이것이 바로 "불필요한 리렌더링"의 주요 원인입니다.

function Grandparent() {
  const [value, setValue] = useState(0);
  return (
    <div>
      <ParentA value={value} />
      <ParentB /> {/* ParentB는 value와 관련 없지만, Grandparent가 리렌더링되면 ParentB도 리렌더링됨 */}
    </div>
  );
}

컨텍스트(Context) 변경: useContext를 통해 사용되는 Context의 값이 변경될 때, 해당 Context를 구독하는 모든 컴포넌트가 리렌더링됩니다.

forceUpdate 호출: 극히 드물게 사용되며, 강제로 컴포넌트를 리렌더링합니다.

세 갱신 원인에서 렌더링이 발생한다. 병목이 확인되지 않으면 변경하지 않고, 확인되면 구조 문제 또는 계산·참조 문제에 대응한 뒤 같은 조건에서 검증한다.

RENDER CAUSE / CHOOSE A CHANGE

렌더링 원인을 찾고, 측정된 병목에 대응한다

자기 state, 부모의 렌더링, 구독한 Context가 갱신의 출발점이다. 렌더링 자체는 정상 동작이므로, 느린 범위를 측정한 뒤 구조 변경이나 참조·계산 캐시를 선택한다.

렌더링 원인을 찾고, 측정된 병목에 대응한다세 갱신 원인에서 렌더링이 발생한다. 병목이 확인되지 않으면 변경하지 않고, 확인되면 구조 문제 또는 계산·참조 문제에 대응한 뒤 같은 조건에서 검증한다.자기 state 갱신자신의 렌더링부모 렌더링일반 자식도 실행Context 값 변경구독한 자식 갱신병목을 측정했나?Profiler에서 확인아니오 → 유지렌더 자체는 오류 아님예 → 원인별 변경구조 / 계산 / 참조같은 조건 재측정효과 + 기능 확인
세 갱신 원인과 측정 뒤 분기를 나눈다자기 state, 부모 렌더링, Context 변경은 각각 측정 판단으로 연결된다. 세 입력 사이에는 간선이 없다. 병목 미확인은 유지로, 확인은 원인별 변경과 같은 조건 재측정으로 연결된다. 원 wide의 일곱 노드와 여섯 간선 의미를 유지한다.자기 state 갱신자신의 렌더링부모 렌더링일반 자식도 실행Context 값 변경구독한 자식 갱신병목을 측정했나?Profiler에서 확인아니오 → 유지렌더 자체는 오류 아님예 → 원인별 변경구조 / 계산 / 참조같은 조건 재측정효과 + 기능 확인

CAUSE / STATE + CONTEXT

자기 갱신은 memo로 막지 않는다

컴포넌트 자신의 state나 구독한 Context가 바뀌면 memo를 사용해도 다시 렌더링한다. 렌더 함수 실행이 DOM 전체 교체를 의미하지는 않는다.

CAUSE / PARENT

부모의 재실행을 구분한다

일반 자식은 부모와 함께 실행될 수 있다. 본문의 MemoExample에서는 parentCount 변경 시 RegularChild가 실행되고, count prop이 같은 MemoizedChild는 건너뛸 수 있다.

CHOICE / STRUCTURE

측정 후 구조 변경을 고른다

병목이 확인되지 않으면 현재 구현을 유지한다. 갱신 범위가 너무 넓다면 상태를 필요한 위치에 가깝게 두거나 컴포넌트·Context 경계를 나눠 무관한 하위 갱신을 줄인다.

CHOICE / CACHE

계산·참조가 원인이라면

memo는 안정적인 props를 비교한다. useCallback은 함수 참조, useMemo는 계산 결과를 재사용한다. 해당 병목에 적용한 뒤 같은 시나리오로 효과를 확인한다.

memo는 렌더 생략을 보장하는 기능이 아니다. 새 함수·객체·배열 prop 하나만 있어도 기본 비교 결과가 달라질 수 있으며, 값의 정확성을 캐시에 의존하면 안 된다.


불필요한 리렌더링이 성능에 미치는 영향

리액트의 가상 DOM 비교 알고리즘은 매우 효율적이지만, 불필요한 리렌더링 자체가 비용이 드는 작업입니다.

  • 컴포넌트 함수 재실행: 리렌더링 시 컴포넌트 함수가 다시 실행되어 JSX를 생성합니다. 이 과정에서 내부에 포함된 모든 로직(변수 선언, 함수 호출 등)이 다시 실행됩니다.
  • 가상 DOM 비교: 새로운 가상 DOM 트리를 생성하고 이전 트리와 비교하는 과정(재조정)도 비용을 발생시킵니다.
  • 메모리 사용 증가: 불필요한 객체, 배열, 함수 등의 재생성은 가비지 컬렉션을 유발하고 메모리 사용량을 늘릴 수 있습니다.
  • 느린 UI 반응: 특히 복잡한 컴포넌트나 많은 컴포넌트가 동시에 불필요하게 리렌더링되면 UI가 뚝뚝 끊기거나 반응이 느려지는 현상이 발생할 수 있습니다.
React Developer Tools의 "Highlight updates when components render." 기능을 켜고 앱을 사용해 보세요.

하이라이트는 렌더링이 발생했다는 관찰값입니다. 불필요한 작업인지와 실제 비용은 변경된 상태·프롭스 및 Profiler 측정으로 판단합니다.


불필요한 리렌더링 방지 기법 (Memoization)

리액트에서 불필요한 리렌더링을 방지하는 주요 기법은 메모이제이션(Memoization)입니다.

메모이제이션은 이전에 계산한 값을 기억해두었다가 동일한 입력이 들어오면 다시 계산하지 않고 저장된 값을 반환하는 최적화 기법입니다.

React.memo (컴포넌트 메모이제이션)

React.memo는 고차 컴포넌트(Higher-Order Component, HOC)로, 컴포넌트의 프롭스가 변경되지 않았다면 해당 컴포넌트를 리렌더링하지 않도록 지시합니다.

작동 방식

React.memo로 감싸진 컴포넌트는 새로운 프롭스를 받으면, 이전 프롭스와 새로운 프롭스를 얕게 비교합니다(shallow comparison).

기본 비교는 각 프롭스에 Object.is를 적용합니다. 모두 같으면 이전 결과를 재사용할 수 있지만, memo는 성능 최적화이며 렌더링 생략을 보장하지 않습니다. 컴포넌트 자신의 상태나 구독한 Context가 바뀌면 다시 렌더링됩니다.

사용 시점
  • 컴포넌트의 렌더링 비용이 비싸고 (내부에 복잡한 계산이나 많은 자식 컴포넌트가 있음)
  • 대부분의 경우 프롭스가 변경되지 않는 컴포넌트
예시
src/components/MemoExample.js
import React, { useState } from 'react';

// 같은 프롭스로 인한 부모발 렌더링을 생략할 수 있도록 React.memo 적용
const MemoizedChild = React.memo(function MemoizedChild({ count }) {
  console.log('MemoizedChild 렌더링됨', count); // 이 로그가 찍히는 시점을 확인!
  return <p>Memoized Child Count: {count}</p>;
});

// 자식 컴포넌트: React.memo로 감싸지 않음
const RegularChild = function RegularChild({ name }) {
    console.log('RegularChild 렌더링됨', name); // 이 로그가 찍히는 시점을 확인!
    return <p>Regular Child Name: {name}</p>;
};

function ParentComponent() {
  const [parentCount, setParentCount] = useState(0);
  const [childCount, setChildCount] = useState(0); // MemoizedChild에 전달될 값
  const [regularName, setRegularName] = useState("Alice"); // RegularChild에 전달될 값

  const incrementParent = () => setParentCount(parentCount + 1);
  const incrementChild = () => setChildCount(childCount + 1);
  const changeRegularName = () => setRegularName("Bob"); // 같은 상태 값이면 부모 업데이트가 생략될 수 있음

  return (
    <div style={{ maxWidth: '600px', margin: '30px auto', padding: '25px', border: '1px solid #ddd', borderRadius: '8px', boxShadow: '0 2px 10px rgba(0,0,0,0.05)', backgroundColor: '#fff' }}>
      <h2 style={{ textAlign: 'center', color: '#2c3e50', marginBottom: '30px' }}>`React.memo` 예시</h2>
      <p>Parent Count: {parentCount}</p>
      <button onClick={incrementParent} className="button" style={{ marginRight: '10px' }}>
        Parent Count 증가 (일반 자식도 렌더링)
      </button>
      <button onClick={incrementChild} className="button">
        Memoized Child Count 증가 (부모와 자식 렌더링)
      </button>
      <button onClick={changeRegularName} className="button secondary" style={{ marginLeft: '10px' }}>
        Regular Child Name 변경
      </button>

      <hr style={{ margin: '20px 0' }}/>

      {/* 같은 count 프롭스의 부모발 렌더링은 생략 가능 */}
      <MemoizedChild count={childCount} />

      {/* name이 변경되든 안 되든 ParentComponent 리렌더링 시 항상 리렌더링 */}
      <RegularChild name={regularName} />
    </div>
  );
}

export default ParentComponent;

ParentComponent의 parentCount를 증가시키면 ParentComponent가 리렌더링됩니다.

이때 MemoizedChild의 count 프롭스는 이전과 같습니다. 일반적인 캐시 재사용 경로에서는 해당 자식의 렌더링을 건너뛸 수 있습니다. 콘솔 로그의 정확한 횟수는 아래의 실행 조건과 함께 해석합니다.

반면 RegularChild는 프롭스가 변경되지 않았더라도 부모가 리렌더링되면 함께 리렌더링됩니다 (콘솔 로그가 계속 찍힘).

childCount도 부모가 소유한 상태이므로 증가시키면 ParentComponent와 RegularChild가 함께 렌더링되고, count 프롭스가 달라진 MemoizedChild도 렌더링됩니다.

위 로그 설명은 React Compiler의 자동 메모이제이션이 없는 예시의 일반적인 업데이트 흐름입니다. 개발 모드의 Strict Mode는 추가 호출을 할 수 있으므로 콘솔 횟수를 API 보장으로 해석하지 않습니다.

주의사항
  • 얕은 비교(Shallow Comparison): React.memo는 프롭스를 얕게 비교합니다. 객체나 배열 같은 참조 타입의 프롭스가 항상 새로운 참조로 생성되면 ({}나 [] 리터럴), 내용이 같더라도 React.memo는 다른 것으로 간주하여 리렌더링을 유발할 수 있습니다.
  • 성능 비용: React.memo 자체도 프롭스를 비교하는 오버헤드가 있습니다. 불필요한 리렌더링으로 인한 비용보다 프롭스 비교 비용이 더 커진다면 오히려 성능이 저하될 수 있습니다. 항상 프로파일링을 통해 최적화의 효과를 확인해야 합니다.

useCallback (함수 메모이제이션)

useCallback 훅은 의존성이 같을 때 이전에 캐시한 함수 참조를 반환합니다. 인수로 작성한 함수 리터럴의 생성 자체를 막지는 않습니다.

이는 특히 React.memo로 감싸진 자식 컴포넌트에 콜백 함수를 프롭스로 전달할 때 중요합니다.

문제점: 리액트에서 컴포넌트가 리렌더링될 때마다, 그 컴포넌트 내부에 선언된 함수들도 모두 새로 생성됩니다.

React.memo는 프롭스의 얕은 비교를 수행하는데, 새로 생성된 함수는 이전 함수와 다른 참조값을 가지므로 React.memo가 이를 변경되었다고 판단하여 자식 컴포넌트를 불필요하게 리렌더링하게 됩니다.

해결책: useCallback을 사용하여 함수를 메모이제이션하면, React는 의존성 배열(deps)의 각 값을 Object.is로 비교하고 같으면 캐시한 함수 참조를 반환합니다. 캐시는 성능 최적화이며 컴포넌트 수명 전체의 영구 저장 보장은 아닙니다.

예시
src/components/UseCallbackExample.js
import React, { useState, useCallback } from 'react';

// Memoized 된 자식 컴포넌트 (함수 프롭스를 받음)
const ButtonComponent = React.memo(({ onClick, label }) => {
  console.log(`${label} ButtonComponent 렌더링됨`);
  return <button onClick={onClick} className="button" style={{ marginRight: '10px' }}>{label}</button>;
});

function UseCallbackExample() {
  const [count, setCount] = useState(0);
  const [text, setText] = useState('Hello');

  // 🚨 부모 컴포넌트가 렌더링될 때마다 새 함수 참조가 생깁니다.
  const handleIncrementRegular = () => {
    setCount(prevCount => prevCount + 1);
  };

  // ✅ text가 같으면 캐시한 함수 참조를 반환합니다.
  const handleAlertText = useCallback(() => {
    alert(`Current Text: ${text}`);
  }, [text]); // text 변경 시 새 함수 참조를 반환

  // 폼 처리에서 많이 볼 수 있는 패턴:
  // 자식 컴포넌트에 onSubmit 핸들러를 넘길 때, 부모의 상태와 관련 없는 경우
  const handleFormSubmit = useCallback(() => {
    console.log("폼이 제출되었습니다!");
    // 여기에 폼 제출 로직 (API 호출 등)
  }, []); // 의존성 변경이 없으므로 캐시가 유지되는 동안 같은 참조 반환

  return (
    <div style={{ maxWidth: '600px', margin: '30px auto', padding: '25px', border: '1px solid #ddd', borderRadius: '8px', boxShadow: '0 2px 10px rgba(0,0,0,0.05)', backgroundColor: '#fff' }}>
      <h2 style={{ textAlign: 'center', color: '#2c3e50', marginBottom: '30px' }}>`useCallback` 예시</h2>
      <p>Count: {count}</p>
      <p>Text: {text}</p>
      <input
        type="text"
        value={text}
        onChange={(e) => setText(e.target.value)}
        placeholder="텍스트 입력"
        style={{ width: '100%', padding: '8px', marginBottom: '20px', border: '1px solid #ccc', borderRadius: '4px' }}
      />

      <ButtonComponent onClick={handleIncrementRegular} label="Regular Increment" />
      <ButtonComponent onClick={handleAlertText} label="Alert Text (useCallback)" />
      <ButtonComponent onClick={handleFormSubmit} label="Submit Form (useCallback)" />

      <p style={{ marginTop: '20px', fontSize: '0.9em', color: '#555' }}>
        `Regular Increment` 버튼을 클릭할 때마다 `handleIncrementRegular` 함수가 재생성되어<br/>
        `Regular Increment` 버튼 컴포넌트도 함께 리렌더링 되는 것을 콘솔에서 확인해보세요.<br/>
        `Alert Text` 버튼은 `text`가 변경될 때만 리렌더링 됩니다.
      </p>
    </div>
  );
}

export default UseCallbackExample;

handleIncrementRegular는 count 상태에 의존하지 않지만, UseCallbackExample이 리렌더링될 때마다 재생성되어 ButtonComponent의 onClick 프롭스가 변경된 것으로 간주됩니다.

반면 handleAlertText는 useCallback으로 감싸져 text가 같고 캐시가 유지되는 동안 이전 함수 참조를 반환합니다. 다른 props도 같다면 ButtonComponent가 부모의 렌더링에서 비롯된 자식 렌더링을 건너뛸 수 있습니다. 콜백을 호출했을 때 최신 텍스트를 보여주는지는 별도로 확인합니다.

useMemo (값 메모이제이션)

useMemo 훅은 비용이 많이 드는 계산의 결과를 메모이제이션합니다.

의존성 배열(deps)에 있는 값이 변경되지 않는 한, 이전에 계산된 값을 재사용합니다.

사용 시점
  • 컴포넌트 렌더링 시마다 다시 계산되는 복잡한 계산 결과 (예: 큰 배열 필터링, 정렬, 데이터 변환 등)
  • React.memo로 감싸진 자식 컴포넌트에 객체나 배열을 프롭스로 전달할 때, 해당 객체/배열이 불필요하게 재생성되어 자식 리렌더링을 유발하는 것을 방지할 때
예시
src/components/UseMemoExample.js
import React, { useState, useMemo } from 'react';

const allProducts = [
  { id: 1, name: 'Apple', price: 1.0 },
  { id: 2, name: 'Banana', price: 0.5 },
  { id: 3, name: 'Cherry', price: 2.0 },
  { id: 4, name: 'Date', price: 1.5 },
];

// Memoized된 자식 컴포넌트
const ProductList = React.memo(({ products }) => {
  console.log('ProductList 렌더링됨');
  return (
    <ul>
      {products.map(product => (
        <li key={product.id}>{product.name} - ${product.price}</li>
      ))}
    </ul>
  );
});

function UseMemoExample() {
  const [filterText, setFilterText] = useState('');
  const [count, setCount] = useState(0); // 다른 상태 변화를 유도하여 리렌더링 테스트

  // 🚨 useMemo를 사용하지 않으면, filterText나 count가 변경될 때마다 이 배열이 재생성됨
  // const filteredProducts = allProducts.filter(product =>
  //   product.name.toLowerCase().includes(filterText.toLowerCase())
  // );

  // ✅ useMemo를 사용하여 filterText가 변경될 때만 계산 결과를 재생성
  const filteredProducts = useMemo(() => {
    console.log('filteredProducts 계산됨'); // 이 로그가 찍히는 시점을 확인!
    return allProducts.filter(product =>
      product.name.toLowerCase().includes(filterText.toLowerCase())
    );
  }, [filterText]); // allProducts는 컴포넌트 밖 상수라 새 참조가 되지 않음

  return (
    <div style={{ maxWidth: '600px', margin: '30px auto', padding: '25px', border: '1px solid #ddd', borderRadius: '8px', boxShadow: '0 2px 10px rgba(0,0,0,0.05)', backgroundColor: '#fff' }}>
      <h2 style={{ textAlign: 'center', color: '#2c3e50', marginBottom: '30px' }}>`useMemo` 예시</h2>
      <input
        type="text"
        value={filterText}
        onChange={(e) => setFilterText(e.target.value)}
        placeholder="제품 필터링..."
        style={{ width: '100%', padding: '8px', marginBottom: '15px', border: '1px solid #ccc', borderRadius: '4px' }}
      />
      <button onClick={() => setCount(count + 1)} className="button">
        Count 증가 (UseMemoExample 리렌더링) - Current Count: {count}
      </button>

      <hr style={{ margin: '20px 0' }}/>

      {/* filteredProducts가 변경될 때만 ProductList가 리렌더링됨 */}
      <ProductList products={filteredProducts} />

      <p style={{ marginTop: '20px', fontSize: '0.9em', color: '#555' }}>
        `Count 증가` 버튼을 클릭할 때 `UseMemoExample` 컴포넌트가 리렌더링되지만,<br/>
        `filteredProducts`는 `filterText`가 변경되지 않는 한 다시 계산되지 않고,<br/>
        따라서 `ProductList` 컴포넌트도 불필요하게 리렌더링되지 않습니다.
      </p>
    </div>
  );
}

export default UseMemoExample;

Count 증가 버튼을 클릭하여 UseMemoExample 컴포넌트가 리렌더링될 때 filteredProducts 계산됨 로그가 찍히는지 확인해 보세요.

useMemo의 캐시가 재사용되는 일반적인 경로에서는 filterText가 같을 때 다시 필터링하지 않고 같은 배열을 전달하므로 부모가 다시 렌더링되어도 ProductList의 렌더링을 건너뛸 수 있습니다. 개발 모드의 추가 계산이나 캐시 폐기 뒤 재계산도 허용되므로, 로그 횟수를 화면 정확성의 조건으로 삼지는 않습니다.


최적화 적용 시 고려사항

  • 남용 금지: React.memo, useCallback, useMemo는 성능 향상을 위한 도구이지만, 이들을 사용하는 것 자체도 오버헤드가 있습니다. 항상 최적화 전 프로파일링, 최적화 후 프로파일링 원칙을 지켜서 실제 성능 향상이 있는지 확인해야 합니다.
  • 얕은 비교의 한계: 참조 타입(객체, 배열)을 프롭스나 의존성으로 사용할 때 얕은 비교의 한계를 이해해야 합니다. 불필요한 재생성을 막기 위해 useCallback이나 useMemo를 적절히 사용해야 합니다.
  • 읽기 쉬운 코드 우선: 가독성 좋고 유지보수하기 쉬운 코드가 가장 중요합니다. 성능 문제가 명확하게 드러나기 전까지는 과도한 최적화를 피하는 것이 좋습니다.
  • 컴포넌트 구조 개선: 때로는 최적화 훅을 사용하는 것보다 컴포넌트의 책임을 분리하고 상태를 더 낮은 계층으로 끌어내리는 것이 더 근본적인 해결책이 될 수 있습니다.

세 예제를 다른 동작으로 추적하기

각 예제는 새로 고친 초기 화면에서 시작합니다. 클릭 사이에는 갱신된 화면을 확인한 뒤 다음 버튼을 누릅니다. 다음 문제는 시간 측정이 아니라 값과 참조의 관계를 예측하는 연습입니다.

  1. MemoExample에서 Parent Count 버튼을 두 번, Memoized Child Count 버튼을 한 번, 이름 변경 버튼을 두 번 누릅니다. 부모가 소유한 세 상태와 두 자식의 최종 props를 적으세요. 첫 번째와 두 번째 이름 변경이 같은 새 값을 요청하는지도 확인합니다.
  2. UseCallbackExample에서 텍스트를 React로 바꾼 뒤 Alert Text 버튼을 누릅니다. 어떤 안내가 나와야 할까요? 이 콜백의 의존성을 잘못 빈 배열로 바꾸면, 첫 렌더에서 만들어진 콜백이 유지되는 경로에서 왜 Hello가 나타날 수 있는지 설명하세요. 원 코드는 수정하지 않고 두 의존성의 뜻을 비교합니다.
  3. UseMemoExample에서 필터에 a를 입력하고 Count를 세 번 증가시킵니다. 남는 상품 id와 count를 적고, 필터 입력과 Count 변경 중 어느 동작이 계산 의존성을 바꾸는지 설명하세요. 원 코드의 allProducts가 컴포넌트 밖 상수 대신 변경될 수 있는 prop으로 들어온다면 계산의 의존성에 무엇을 더해야 할까요?
값, 참조, 계산 의존성을 구분한 풀이

첫 예제의 parentCount는 2, childCount는 1, regularName은 Bob입니다. 두 자식에게 전달되는 값은 각각 count 1과 name Bob입니다. childCount라는 이름에도 불구하고 해당 state는 ParentComponent 안에 선언되어 있습니다. 자식에게는 그 값만 전달합니다. 첫 이름 변경은 Alice에서 Bob으로 바꾸지만, 두 번째는 이미 Bob인 상태에 Bob을 다시 요청합니다. 같은 상태 값이면 React가 업데이트를 건너뛸 수 있으며, 렌더 함수가 추가 호출되어도 그 결과가 버려질 수 있습니다. 그래서 두 이름 클릭을 반드시 두 번의 화면 변경이나 두 번의 콘솔 출력으로 세지 않습니다.

두 번째 예제의 정상 안내는 Current Text: React입니다. 콜백은 렌더 당시의 text를 읽으므로 text를 의존성에 넣어 새 텍스트에 대응하는 함수를 받아야 합니다. 빈 의존성을 쓰고 초기 콜백이 재사용되면 그 콜백은 Hello를 기억합니다. 버튼의 함수 참조를 유지한다는 성능 목표보다 올바른 값을 읽는 조건이 먼저입니다. 원 handleFormSubmit은 컴포넌트의 state나 props를 읽지 않으므로 그 코드에서는 빈 의존성과 모순되지 않습니다. 나중에 text를 제출하도록 변경한다면 의존성도 다시 검토합니다.

세 번째 예제의 상품은 id 1 Apple, id 2 Banana, id 4 Date이고 count는 3입니다. 소문자로 바꾼 상품 이름에 a가 있는지 판정하므로 Cherry는 제외됩니다. 필터 입력은 filterText를 바꾸어 다시 계산하게 하지만 Count 변경은 그 의존성을 바꾸지 않습니다. 캐시가 재사용되는 경로에서는 같은 결과 배열을 자식에게 전달할 수 있습니다. allProducts가 변경 가능한 prop으로 바뀐다면 계산은 그 prop도 읽으므로 의존성에 함께 넣어야 합니다. 배열 내용을 직접 바꾸고 참조를 유지하는 방식 대신, 상품 목록의 변경도 새 배열로 전달해야 해당 변화가 비교에 드러납니다.

이 예제는 상품이 네 개이므로 계산 시간의 개선을 체감하지 못해도 정상입니다. 출력과 의존성을 이해한 뒤, 실제 앱에서 측정된 비용이 있을 때 최적화를 적용합니다. React Compiler가 활성화된 환경에서는 자동 최적화가 개입할 수 있으므로 수동 memo 유무만으로 관측 차이를 단정하지 않습니다.

정확한 캐시 계약과 실행 조건은 memo 공식 설명, useCallback 공식 설명, useMemo 공식 설명에서 확인할 수 있습니다.

불필요한 리렌더링을 줄일 때는 모든 컴포넌트에 memo를 붙이기보다, 어떤 값이 바뀌었고 그 변화가 어느 범위까지 퍼졌는지를 먼저 나눠 보는 편이 안전합니다.


10장 2절 불필요한 리렌더링 이해와 방지는 여기까지입니다.

이 장에서는 리액트의 리렌더링 메커니즘을 이해하고, 불필요한 리렌더링이 성능에 미치는 영향을 알아보았습니다.

또한, 이를 방지하기 위한 핵심적인 메모이제이션 기법인 React.memo, useCallback, useMemo의 사용법과 주의사항을 자세히 살펴보았습니다.