About→

← Studies

07. React 성능 — Render → Re-render → memo → useMemo → useCallback

React를 사용하다 보면 한 번쯤 이런 말을 듣게 됩니다.

“불필요한 리렌더링을 줄여야 합니다.”

그래서 React.memo, useMemo, useCallback을 사용하고,

text
memo 써야 하나?
useMemo 써야 하나?
useCallback 써야 하나?

를 고민하게 됩니다.

그런데 React 성능을 제대로 이해하려면 먼저 리렌더링이 정확히 무엇인지부터 이해해야 합니다.

앞에서 React의 동작을 다음과 같이 살펴봤습니다.

text
State 변경
    ↓
React Render
    ↓
Reconciliation
    ↓
Commit
    ↓
DOM 변경
    ↓
Browser Rendering
    ↓
화면

여기서 중요한 것은

React가 다시 계산하는 것과 브라우저가 실제 화면을 다시 그리는 것은 서로 다른 일이라는 점입니다.

React 성능을 이해하는 출발점도 여기서 시작합니다.

1. React에서 Render란 무엇인가?

먼저 React에서 말하는 Render를 다시 생각해봅시다.

jsx
function Counter() {
  const [count, setCount] = useState(0);

  return (
    <button onClick={() => setCount(count + 1)}>
      {count}
    </button>
  );
}

처음 컴포넌트가 실행되면 React는

jsx
<button>
  0
</button>

이라는 UI 결과를 계산합니다.

그리고 setCount가 실행되면 상태가 변경됩니다.

text
count: 0
   ↓
setCount(1)
   ↓
React가 다시 컴포넌트를 실행
   ↓
<button>
  1
</button>

이 과정을 Re-render라고 합니다.

즉,

Re-render는 컴포넌트가 다시 실행되어 새로운 UI 결과를 계산하는 과정

이라고 이해하면 됩니다.

2. Re-render가 발생하는 대표적인 이유

React 컴포넌트가 다시 실행되는 대표적인 경우는 크게 세 가지로 볼 수 있습니다.

① 자신의 State가 변경되는 경우

jsx
const [count, setCount] = useState(0);

setCount(1);

State가 변경되면 해당 컴포넌트가 다시 렌더링됩니다.

② 부모 컴포넌트가 다시 렌더링되는 경우

이 부분이 React 성능에서 상당히 중요합니다.

jsx
function Parent() {
  const [count, setCount] = useState(0);

  return (
    <>
      <button onClick={() => setCount(count + 1)}>
        {count}
      </button>

      <Child />
    </>
  );
}

그리고

jsx
function Child() {
  console.log("Child render");

  return <div>Child</div>;
}

가 있다고 해봅시다.

Parent의 count가 변경되면

text
Parent State 변경
      ↓
Parent Re-render
      ↓
Child도 다시 평가될 수 있음

따라서 콘솔에는

text
Child render

가 다시 출력될 수 있습니다.

그런데 여기서 이상한 점이 있습니다.

Child는 아무것도 변경되지 않았습니다.

화면도 똑같습니다.

그런데 왜 다시 실행될까요?

3. Re-render와 DOM 변경은 다르다

이것을 구분하는 것이 React 성능의 핵심입니다.

예를 들어

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

가 다시 실행되었다고 해서 브라우저의

html
<div>Hello</div>

가 무조건 다시 만들어지는 것은 아닙니다.

React는 이전 결과와 새로운 결과를 비교합니다.

text
이전 결과
<div>Hello</div>

       ↓ 비교

새로운 결과
<div>Hello</div>

변경할 필요가 없다면 실제 DOM 변경을 하지 않을 수 있습니다.

따라서 다음 세 가지를 구분해야 합니다.

text
Component Re-render
        ↓
React가 컴포넌트를 다시 실행

React Reconciliation
        ↓
이전 UI 결과와 새로운 UI 결과를 비교

DOM Update
        ↓
실제로 DOM에 변경사항을 적용

그리고 그 이후 브라우저가 필요한 렌더링 작업을 수행합니다.

text
DOM Update
   ↓
Layout
   ↓
Paint
   ↓
Composite

따라서

리렌더링이 발생했다 = 화면을 다시 전부 그렸다

가 아닙니다.

4. 그렇다면 불필요한 Re-render는 왜 문제가 될까?

여기서 의문이 생깁니다.

“DOM이 변경되지 않는다면 다시 실행되는 것이 왜 문제인가?”

간단한 컴포넌트라면 거의 문제가 되지 않습니다.

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

이 정도 컴포넌트를 몇 번 다시 실행하는 것은 큰 부담이 아닙니다.

문제는 컴포넌트 내부에서 비싼 계산을 하고 있을 때입니다.

예를 들어

jsx
function WorkList({ data }) {
  const filteredData = data
    .filter(...)
    .sort(...)
    .map(...);

  return <Table data={filteredData} />;
}

데이터가 수천~수만 건이고 필터링과 정렬이 무겁다면 이야기가 달라집니다.

text
State 변경
    ↓
WorkList Re-render
    ↓
filter
    ↓
sort
    ↓
map
    ↓
Table 계산

사용자가 단순히 버튼 하나를 클릭했을 뿐인데 이런 계산이 반복될 수 있습니다.

그래서 React 성능에서는 단순히

“리렌더링을 없애자.”

가 아니라

“어떤 컴포넌트가 왜 다시 실행되고 있으며, 그 비용이 실제로 큰가?”

를 봐야 합니다.

5. Props가 변경되면?

이번에는 부모가 자식에게 Props를 전달하는 경우를 생각해봅시다.

jsx
function Parent() {
  const [count, setCount] = useState(0);

  return (
    <>
      <button onClick={() => setCount(count + 1)}>
        {count}
      </button>

      <Child name="Yonghee" />
    </>
  );
}

Parent가 다시 렌더링되더라도

jsx
name="Yonghee"

라는 값은 동일합니다.

하지만 React 입장에서는 부모가 다시 렌더링되면서 자식도 다시 평가할 수 있습니다.

여기서 사용할 수 있는 것이 React.memo입니다.

6. React.memo

jsx
const Child = memo(function Child({ name }) {
  console.log("Child render");

  return <div>{name}</div>;
});

이렇게 하면 React는 부모가 다시 렌더링되었을 때 자식의 Props를 확인합니다.

text
Parent Re-render
      ↓
Child Props 비교
      ↓
Props가 동일
      ↓
Child Re-render 생략 가능

즉 memo는

Props가 동일하다면 컴포넌트를 다시 렌더링하지 않도록 최적화하는 도구

입니다.

기본적으로 Props를 얕게 비교하며, 각 Props 값은 Object.is 기준으로 비교됩니다.

7. 그런데 객체를 Props로 전달하면?

여기서 React 성능에서 자주 등장하는 문제가 하나 있습니다.

jsx
function Parent() {
  const user = {
    name: "Yonghee"
  };

  return <Child user={user} />;
}

겉으로 보면 매번 같은 객체처럼 보입니다.

하지만 Parent가 다시 실행될 때마다

jsx
const user = {
  name: "Yonghee"
};

새로운 객체가 만들어집니다.

즉,

text
첫 번째 Render

user → 객체 A

두 번째 Render

user → 객체 B

입니다.

내용은 같습니다.

text
A.name === B.name

하지만 객체 자체는 서로 다릅니다.

text
A !== B

따라서 memo가 적용되어 있어도 Props가 변경되었다고 판단할 수 있습니다.

8. 함수도 마찬가지다

함수도 객체와 비슷하게 생각할 수 있습니다.

jsx
function Parent() {
  const handleClick = () => {
    console.log("click");
  };

  return <Child onClick={handleClick} />;
}

Parent가 다시 렌더링될 때마다 새로운 함수가 만들어집니다.

text
첫 번째 Render

handleClick → 함수 A

두 번째 Render

handleClick → 함수 B

코드는 똑같지만 함수의 참조는 다릅니다.

text
A !== B

따라서

jsx
const Child = memo(function Child({ onClick }) {
  ...
});

라고 하더라도 onClick이 매번 새로운 함수라면 memo의 효과를 얻지 못할 수 있습니다.

여기서 등장하는 것이 useCallback입니다.

9. useCallback — 함수의 참조를 기억한다

jsx
const handleClick = useCallback(() => {
  console.log("click");
}, []);

이렇게 하면 React는 의존성이 동일한 동안 같은 함수 참조를 유지할 수 있습니다.

text
첫 번째 Render
      ↓
함수 A 생성

두 번째 Render
      ↓
같은 함수 A 사용

세 번째 Render
      ↓
같은 함수 A 사용

그래서

jsx
const Child = memo(function Child({ onClick }) {
  ...
});

와 함께 사용하면 의미가 생깁니다.

text
Parent Re-render
      ↓
handleClick 참조 유지
      ↓
Child Props 동일
      ↓
memo가 Child Re-render를 생략할 수 있음

중요한 것은 useCallback이 함수 실행을 빠르게 만들어주는 것이 아니라는 것입니다.

핵심은

함수의 참조(identity)를 유지하는 것

입니다.

10. useMemo — 계산 결과를 기억한다

이번에는 함수 자체가 아니라 계산 결과를 생각해봅시다.

jsx
const filteredData = useMemo(() => {
  return data
    .filter(item => item.name.includes(keyword))
    .sort(...);
}, [data, keyword]);

이 경우 React는

text
data 또는 keyword 변경
        ↓
계산 다시 수행

둘 다 동일
        ↓
이전에 계산한 결과 재사용

할 수 있습니다.

즉 useMemo는

비용이 있는 계산의 결과값을 기억하는 도구

라고 이해하면 됩니다.

11. useMemo와 useCallback의 차이

둘의 차이는 간단하게 기억할 수 있습니다.

text
useMemo
→ 계산한 "값"을 기억

useCallback
→ "함수"를 기억

예를 들어

jsx
const result = useMemo(
  () => expensiveCalculation(data),
  [data]
);

는 계산 결과를 기억합니다.

반면

jsx
const handleClick = useCallback(
  () => handleSelect(id),
  [id]
);

는 함수 참조를 기억합니다.

둘 다 결국 목적은 불필요한 작업을 줄이는 것이지만 대상이 다릅니다.

12. 그렇다면 모든 곳에 memo를 사용하면 될까?

그렇지는 않습니다.

예를 들어

jsx
const value = useMemo(() => 1 + 1, []);

이렇게 하는 것은 거의 의미가 없습니다.

계산 자체가 너무 간단하기 때문입니다.

오히려 useMemo를 사용하는 것 자체에도 관리 비용이 있습니다.

마찬가지로

jsx
const handleClick = useCallback(() => {
  console.log("click");
}, []);

를 모든 함수에 무조건 붙이는 것도 좋은 최적화 전략은 아닙니다.

React 성능에서 중요한 것은

text
리렌더링 발생
    ↓
왜 발생했는가?
    ↓
비용이 큰가?
    ↓
어디에서 비용이 발생하는가?
    ↓
그 비용을 줄일 수 있는가?

입니다.

13. React 성능을 볼 때 세 가지를 구분하자

React 성능 문제를 볼 때는 다음 세 단계를 분리해서 생각하면 좋습니다.

① Component Render

컴포넌트 함수가 다시 실행됩니다.

jsx
function Component() {
  console.log("render");

  return <div>Hello</div>;
}

② DOM Update

React가 이전 결과와 비교한 뒤 실제 DOM 변경이 필요한지 판단합니다.

text
Render
  ↓
Reconciliation
  ↓
변경사항 발견
  ↓
DOM Update

변경사항이 없다면 실제 DOM 변경이 발생하지 않을 수도 있습니다.

③ Browser Rendering

DOM이나 스타일 등에 변화가 생기면 브라우저가 필요한 렌더링 작업을 수행합니다.

text
DOM / Style 변경
      ↓
Layout
      ↓
Paint
      ↓
Composite

따라서 성능 문제가 있다고 해서 무조건 memo부터 찾으면 안 됩니다.

14. 실제 개발에서는 어떻게 접근할까?

예를 들어 의료 영상 WorkList에서 이런 상황이 있다고 해봅시다.

text
WorkList
 ├── Search
 ├── Filter
 ├── Sort
 ├── Table
 │    ├── Row
 │    ├── Row
 │    ├── Row
 │    └── ...
 └── Pagination

검색 조건이 변경될 때마다 전체 WorkList가 다시 렌더링됩니다.

그런데 데이터가 많고 각 Row에서 여러 계산을 하고 있다면 비용이 커질 수 있습니다.

이때 무작정

jsx
useMemo
useCallback
memo

를 전부 붙이는 것이 아니라 먼저 확인합니다.

text
1. 어떤 컴포넌트가 다시 렌더링되는가?

2. 왜 다시 렌더링되는가?

3. 실제로 렌더링 비용이 큰 컴포넌트는 어디인가?

4. Props가 매번 새로운 참조가 되고 있는가?

5. 계산 자체가 비싼가?

6. DOM 변경이 많이 발생하는가?

그리고 문제에 맞는 방법을 선택합니다.

text
컴포넌트 자체의 불필요한 실행
        ↓
React.memo

비싼 계산 반복
        ↓
useMemo

함수 참조가 계속 변경
        ↓
useCallback

렌더링해야 하는 데이터 자체가 너무 많음
        ↓
Virtualization / Pagination 등 구조적 해결

마지막 부분이 특히 중요합니다.

데이터가 30,000개인데

jsx
memo
useMemo
useCallback

만 적용한다고 해서 문제가 근본적으로 해결되는 것은 아닙니다.

렌더링해야 하는 양 자체가 너무 많다면 렌더링 구조를 바꾸는 것이 더 중요할 수 있습니다.

15. React 성능 최적화의 핵심은 도구가 아니다

결국 memo, useMemo, useCallback은 목적이 아니라 도구입니다.

text
❌ "리렌더링이 발생하니까 memo를 사용한다."

⭕ "이 컴포넌트의 렌더링 비용이 크고,
    Props가 자주 변경되지 않기 때문에 memo를 사용한다."

이 차이가 중요합니다.

성능 최적화는

text
문제 발견
   ↓
원인 분석
   ↓
비용 확인
   ↓
최적화 방법 선택
   ↓
개선 결과 확인

의 순서로 접근하는 것이 좋습니다.

특히 React에서는 React DevTools Profiler 같은 도구를 이용해 실제로 어떤 컴포넌트가 렌더링되고 얼마나 시간이 걸리는지 확인한 뒤 최적화하는 것이 좋습니다.

16. 지금까지 배운 내용을 하나로 연결하면

여기까지의 내용을 모두 연결하면 React가 브라우저에서 어떻게 동작하는지 조금 더 선명해집니다.

text
사용자 이벤트
      ↓
JavaScript 실행
      ↓
State 변경
      ↓
React Re-render
      ↓
Reconciliation
      ↓
Commit
      ↓
필요한 DOM 변경
      ↓
Browser Rendering
      ↓
Layout
      ↓
Paint
      ↓
Composite
      ↓
화면

그리고 성능 문제가 발생하면 중간 어디에서 비용이 발생하는지 찾습니다.

text
JavaScript 계산이 무거운가?
        ↓
Main Thread 비용

React Render가 많은가?
        ↓
memo / 컴포넌트 구조 검토

계산이 반복되는가?
        ↓
useMemo 검토

함수 참조가 계속 변경되는가?
        ↓
useCallback 검토

DOM에 그리는 양이 너무 많은가?
        ↓
렌더링 구조 / Virtualization 검토

브라우저 렌더링 비용이 큰가?
        ↓
Layout / Paint / Composite 분석

이렇게 보면 memo, useMemo, useCallback은 서로 별개의 기술이 아닙니다.

모두 React가 불필요한 일을 반복하지 않도록 하기 위한 방법 중 하나입니다.

마무리

React 성능을 공부할 때 가장 먼저 외워야 하는 것은

text
memo
useMemo
useCallback

의 사용법이 아닙니다.

먼저 다음 관계를 이해하는 것이 중요합니다.

text
State / Props 변경
       ↓
Re-render
       ↓
Reconciliation
       ↓
Commit
       ↓
DOM 변경
       ↓
Browser Rendering

그리고 문제가 생겼을 때

“무엇이 다시 실행되고 있고, 왜 실행되고 있으며, 실제 비용은 어디에서 발생하는가?”

를 찾는 것이 React 성능 최적화의 출발점입니다.

이 관점이 잡히면 memo, useMemo, useCallback은 단순히 외워서 사용하는 Hook이 아니라 필요한 상황에서 선택하는 최적화 도구로 보이기 시작합니다.

다음으로는 React의 초기 로딩 성능을 살펴볼 수 있습니다.

지금까지는 애플리케이션이 실행된 이후의 React 동작과 성능을 다뤘다면, 다음 글에서는

text
React 애플리케이션이 처음 실행될 때
JavaScript를 전부 받아야 하는가?

라는 문제에서 시작해

text
Code Splitting
    ↓
Dynamic Import
    ↓
React.lazy
    ↓
Suspense
    ↓
초기 로딩 최적화

로 이어가겠습니다.