개발자에게 Performance는 목표 지표 중 하나일 것입니다.
React를 사용한다면 Performance 개선을 위해 컴포넌트의 불필요한 리렌더링을 방지할 필요가 있습니다.
이번 포스팅을 통해서 불필요한 리렌더링을 방지하기 위해 React에서 제공하는 기능인 useCallback(), useMemo() 그리고 React.memo()에 대해서 알아보고자 합니다.
1. React 렌더링 과정
React 공식 문서에서 살펴보면 렌더링 과정을 총 3단계로 설명하고 있습니다.

React가 수행하는 DOM 업데이트 과정을 하나씩 나열해보면 아래와 같습니다.
Step 1: Trigger a render
- 컴포넌트의 initial Render로 인해 trigger
- 컴포넌트 혹은 그의 ancestor 컴포넌트의 상태 변화로 인해 trigger
Step 2: React renders your components
- React에서 렌더링이란 컴포넌트를 호출하는 것이다.
- 렌더링은 화면에 무엇을 보여줘야하는지를 알아내기 위한 과정이다.
- Initial Render trigger가 발생했다면 Html 태그에 맞춰서 컴포넌트를 구성하는 tag에 맞춰 DOM Node를 생성한다.
- Re-render trigger가 발생했다면 이전 렌더링과 비교해서 어느 부분이 변경되었는지 계산한다.
** 여기서 중요한 것은 "commit phase"가 진행되기 전까지는 계산된 정보로 어느 행동도 하지 않는다. (New Virtual DOM을 생성하는 과정입니다.)**
// 원문
During a re-render, React will calculate which of their properties, if any, have changed since the previous render.
It won’t do anything with that information until the next step, the commit phase.
Step 3: React commits changes to the DOM
- component 호출이 끝난 이후, 리액트는 이전에 계산된 정보를 이용하여 DOM을 modify한다.
- Initial Render의 경우 appendChild()라는 DOM API를 이용하여 이전에 생성한 DOM Node들을 화면에 생성한다.
- Re-render의 경우 최근 발생한 렌더링 Output과 DOM을 매치시킵니다.
여기서 우리의 Performance를 향상시키기 위해서는 리렌더링의 실질적 연산 과정인 Step2를 핸들링해야합니다.
이 때 사용되는 방법이 처음에 소개해드린 useCallback(), useMemo(), React.memo()입니다.
2. Render Phase
“Render Phase를 최적화한다는 것은 무엇일까요?”
Render Phase는 간단히 말해 이전 DOM Tree와 비교하여 New Virtual DOM Tree를 만드는 과정입니다.
이 과정에서 최적화를 하는 방법은 이전 DOM Tree에서 변하지 않은 데이터나 함수에 대해서 불필요한 연산을 반복하지 않는 것입니다.
조금 더 자세히 살펴보기 위해 아래의 예제 코드로 불필요한 렌더링이 발생하는 상황을 만들어봅시다!
// Parent.tsx
import { useMemo, useState } from "react";
import ChildBrother from "./ChildBrother";
import ChildSister from "./ChildSister";
import RenderWhenPropsChange from "./assets/RenderWhenPropsChange";
const Parent = () => {
const [parentItem, setparentItem] = useState("");
const [tempData, settempData] = useState("");
const onClickButton = () => {
setparentItem((curVal) => `${curVal}.`);
};
const onClickEmptyButton = () => {
settempData(`${tempData}!`);
};
const firstEx = { name: "AAAA" };
// const secondEx = () => {};
console.log("Render Parent !");
return (
<div>
<button onClick={onClickButton}>클릭</button>
<button onClick={onClickEmptyButton}>Empty Button</button>
<ChildBrother parentItem={parentItem} />
<ChildSister />
<RenderWhenPropsChange props={firstEx} />
</div>
);
};
export default Parent;
//ChildBrother.tsx
import Friends from "./Friends";
const ChildBrother = ({ parentItem }: { parentItem: string }) => {
console.log(`Render ChildBrother${parentItem}!`);
return (
<div>
{Array.from({ length: 100 }).map((_, idx) => (
<Friends key={idx} idx={idx} text="Brother" />
))}
</div>
);
};
export default ChildBrother;
//ChildSister.tsx
import Friends from "./Friends";
const ChildSister = () => {
console.log("Render ChildSister!");
return (
<div>
{Array.from({ length: 100 }).map((_, idx) => (
<Friends key={idx} idx={idx} text="Sister" />
))}
</div>
);
};
export default ChildSister;
import React from "react";
const Friends = ({ idx, text }: { idx: number, text: string }) => {
if (idx === 99) {
console.log(`${idx} ${text} Friends`);
}
return <div></div>;
};
export default Friends;
//RenderWhenPropsChange.tsx
import React from "react";
const RenderWhenPropsChange = ({
props,
}: {
props: { name: string } | (() => void),
}) => {
console.log(`Props Change Detect! (from RenderWhenPropsChange.tsx)
props : ${props}`);
return <div></div>;
};
export default RenderWhenPropsChange;
위 코드를 간단하게 설명하자면 총 4개의 컴포넌트 (Parent, ChildBrother, ChildSister, Friends)로 구성되어 있습니다.
Parent 컴포넌트는 ChildBrother, ChildSister 컴포넌트를 호출하고 있습니다.
Parent 컴포넌트에는 useState로 선언된 2개의 상태변수 parentItem, tempData를 관리합니다.
Parent 컴포넌트에는 2개의 클릭 버튼이 존재하는데 첫번째 클릭 버튼은 parentItem의 상태를 변경, Empty Button은 tempData의 상태를 변화시킵니다.
Parent 컴포넌트는 ChildBrother,ChildSister로 parentItem을 props로 전달합니다. 다만, tempData는 오직 Parent 컴포넌트에서만 사용되는 변수입니다.
다음으로 ChildBrother는 props를 전달받아서 100개의 Friends 컴포넌트를 호출합니다. 이때, Parent에서 전달받은 Props는 Friends로 전달하지 않습니다.
다음으로 ChildSister는 props를 전달받지 않습니다. 오직 Friends 컴포넌트를 100개 호출합니다. 또한, ChildSister는 별도로 관리하는 상태 변수가 없습니다.
마지막으로 RenderWhenPropsChange는 단순히 props를 넘겨받기만하는 컴포넌트입니다.
모든 컴포넌트는 렌더링 여부를 확인하기 위해 컴포넌트가 렌더링되는 시점에 콘솔창에 렌더링 여부를 출력하도록 설계합니다.
(콘솔창의 안전을 위해 100개씩 하위 컴포넌트가 렌더링되는 경우, 마지막 컴포넌트의 호출 여부만 확인하도록 합니다.)
개발자는 아래의 케이스처럼 동작하길 기대하고 있습니다.
1. 첫번째 버튼을 클릭한 경우(parentItem 상태 변화 trigger)
- 직접적으로 상태변수를 관리하는 Parent, 그리고 이를 props로 넘겨받는 ChildBrother 컴포넌트만 리렌더링 되길 바란다.
- RenderWhenPropsChange에 넘겨주는 props는 변하지 않으므로 리렌더링이 발생하지 않으면 좋겠다.
2, 두번째 버튼을 클릭한 경우(tempData 상태 변화 trigger)
- 직접적으로 상태 변수를 관리하는 Parent 컴포넌트만 리렌더링되길 바란다.
- RenderWhenPropsChange에 넘겨주는 props는 변하지 않으므로 리렌더링이 발생하지 않으면 좋겠다.
즉, props로 넘겨받는 상태가 변하는 경우에만 리렌더링되길 바란다.
우선 위처럼 코드를 구성한 상태에서 콘솔 테스트를 진행해보면 결과는 다음과 같습니다.

즉, Parent의 모든 하위 컴포넌트가 계속 리렌더링이 발생하는 문제를 확인할 수 있습니다.
자 이제부터 리렌더링 최적화 방법을 한번 살펴보도록 하겠습니다.
2-1. React.memo
렌더링 최적화 방법중 하나가 바로 React.memo입니다.
React.memo는 props의 값이 변하지 않는다면 리렌더링을 발생시키지 않기 위해 동작하는 기능입니다.
기본적으로 얕은 비교 방법을 이용하여 비교를 진행한다는 것을 기억해야 합니다.
위 코드에서 React.memo를 적용한다면 어떤 효과를 기대할 수 있을까요?
우선 React.memo를 적용해보고 테스트 결과를 살펴보도록 하겠습니다.
//ChildBrother.tsx
import React from "react";
import Friends from "./Friends";
const ChildBrother = ({ parentItem }: { parentItem: string }) => {
console.log(`Render ChildBrother${parentItem}!`);
return (
<div>
{Array.from({ length: 100 }).map((_, idx) => (
<Friends key={idx} idx={idx} text="Brother" />
))}
</div>
);
};
export default React.memo(ChildBrother);
//ChildSister.tsx
import Friends from "./Friends";
import React from "react";
const ChildSister = () => {
console.log("Render ChildSister!");
return (
<div>
{Array.from({ length: 100 }).map((_, idx) => (
<Friends key={idx} idx={idx} text="Sister" />
))}
</div>
);
};
export default React.memo(ChildSister);
//RenderWhenPropsChange.tsx
import React from "react";
const RenderWhenPropsChange = ({
props,
}: {
props: { name: string } | (() => void),
}) => {
console.log(`Props Change Detect! (from RenderWhenPropsChange.tsx)
props : ${props}`);
return <div></div>;
};
export default React.memo(RenderWhenPropsChange);

테스트를 위해 React.memo를 Parent의 하위 컴포넌트인 ChildBrother, ChildSister, 그리고 RenderWhenPropsChange에 추가하였습니다.
그런데 테스트 결과를 살펴보면 한가지 이상한 부분을 확인할 수 있습니다.
클릭 버튼을 누르면 Parent에서 관리하는 parentItem의 상태가 변화하고 이는 ChildBrother에 Props로 전달됩니다.
반면, ChildSister에는 전달되는 Props가 없으므로 기대되는 리렌더링 컴포넌트는 “Parent, ChildBrother, ChildBrother-Friends”입니다.
그런데 RenderWhenPropsChange는 왜 리렌더링이 발생하는걸까요?
다음으로 Empty Button을 누르면 Parent가 가지고 있는 상태 변수만 변화하기 때문에 다른 하위 컴포넌트들은 모두 리렌더링이 되면 안됩니다.
따라서 기대되는 리렌터링 컴포넌트는 오직 “Parent”만 있습니다.
하지만, RenderWhenPropsChange는 어느 버튼을 클릭하던 계속 리렌더링이 발생하는 모습을 볼 수 있습니다. 왜 그런걸까요?
이 부분은 Parent 컴포넌트에서 선언한 변수 & 함수와 관련이 있습니다.
이어서 살펴보도록 합시다.
2-2. useCallback(), useMemo()
우선 Parent 컴포넌트를 다시 살펴봅시다.
//Parent.tsx
import { useState } from "react";
import ChildBrother from "./ChildBrother";
import ChildSister from "./ChildSister";
import RenderWhenPropsChange from "./assets/RenderWhenPropsChange";
const Parent = () => {
const [parentItem, setparentItem] = useState("");
const [tempData, settempData] = useState("");
const onClickButton = () => {
setparentItem((curVal) => `${curVal}.`);
};
const onClickEmptyButton = () => {
settempData(`${tempData}!`);
};
const firstEx = { name: "AAAA" };
// const secondEx = () => {};
console.log("Render Parent !");
return (
<div>
<button onClick={onClickButton}>클릭</button>
<button onClick={onClickEmptyButton}>Empty Button</button>
<ChildBrother parentItem={parentItem} />
<ChildSister />
<RenderWhenPropsChange props={firstEx} />
</div>
);
};
export default Parent;
여기서 firstEx라는 변수는 Parent 컴포넌트 내부에서 선언된 객체입니다.
RenderWhenPropsChange 컴포넌트는 props로 여기서 선언된 객체를 전달받고 있습니다.
즉, Parent가 리렌더링 될때마다 firstEx가 변화하기 때문에 React.memo를 RenderWhenPropsChange에 선언했음에도 계속 리렌더링이 발생하는 것입니다.
이러한 불필요한 리렌더링을 발생시킬 수 있는 변수 & 함수에 메모제이션을 제공하는 훅이 바로 useCallback(), useMemo()입니다.
즉, 함수나 변수 (Reference 변수)가 변화하지 않는다면 렌더링 될때마다 이 변수를 새로 생성하지 않도록 도와주는 훅입니다.
Parent 컴포넌트에서 선언된 firstEx 변수에 메모제이션을 도와주는 useMemo() 훅을 적용한 예시는 다음과 같습니다.
const firstEx = useMemo(() => ({ name: "AAAA" }), []);
위처럼 firstEx를 메모제이션 시켜서 Parent가 리렌더링이 될때마다 firstEx를 새롭게 정의 하는 것이 아니라, deps가 변할때 firstEx를 선언하도록 합니다.
테스트 결과는 아래와 같습니다.

즉, 테스트를 통해 개발자가 원하는 불필요한 리렌더링 과정을 모두 제거해낸 모습을 확인할 수 있습니다.
useMemo의 한가지 주의할 지점은 얕은 비교를 통해 이전에 생성된 변수와 비교하기 때문에 참조 변수에 대해서 사용해야 합니다.
useCallback의 경우, useMemo와 동일한 방식으로 사용이 가능하며 useMemo와 달리 변수가 아니라 함수에 대해서 사용한다는 점에서 차이가 있습니다.
3. 마무리
이번 포스팅을 통해서 React 컴파일러의 3단계 그리고 그 중, Render Phase에서 렌더링을 하는 방법인 React.memo, useCallback, useMemo 기능을 살펴봤습니다.
중요한 것은 렌더링 최적화 방법도 내부 로직이 실행되는 함수의 역할을 합니다.
즉, 개발자가 불필요한 렌더링이라고 생각하는 컴포넌트가 렌더링할 때 발생하는 cost가 적다면 위 방법을 사용하지 않는 것이 오히려 더 나은 Performance를 낼 수 있습니다.
하지만, 저는 위의 경우처럼 100개의 컴포넌트를 하위에 렌더링하는 ChildSister, ChildBrother의 경우는 React.memo를 이용하여 렌더링 최적화를 시키는 것이 나은 판단이라고 생각합니다.
따라서 어느 상황에 렌더링 최적화를 적용할지는 개발자 본인이 선택하는 것이기 때문에 “Cost”에 유의해서 위 기능을 사용한다면 좋은 Performance를 얻을 수 있을 것 같습니다.
React 공식 문서 - Render and Commit
[10분 테코톡] 앨버의 리액트 렌더링 최적화