[React] 성능 최적화 방법 : React.memo, useCallback, useMemo
프론트엔드 신입 개발자 기술 면접

🤔 Q. React에서 성능 최적화를 하기 위한 방법은 어떤 것이 있나요?
💡 A. 답변
리액트에서 성능 최적하기 위한 방법으로는, 대표적으로 메모이제이션이 있습니다
- React.memo를 사용하여 컴포넌트를 메모이제이션하여 props가 변경되지 않았을 때 리렌더링을 방지할 수 있습니다.
- useCallback을 이용하여 함수를 메모이제이션하여 불필요한 함수의 재생성을 방지할 수 있습니다.
- useMemo를 이용해 복잡한 값의 재계산을 방지해 성능을 최적화 할 수 있습니다.
+ 코드 스플리팅을 활용해 애플리케이션 JS를 여러 개의 작은 부분으로 나누어서 필요한 부분만 로드하게 해 초기 로드 시간을 줄일 수 있습니다.
01. 리액트에서 렌더링이란?
export default function App() {
console.log("App 컴포넌트 렌더링!");
return (
<div>
안녕하세요
</div>
);
}
- 리액트가 함수를 호출하는 것
- 컴포넌트가 렌더링 된다는 것은, 리액트가 해당 컴포넌트 함수를 호출하고, 그 내부 로직을 실행한 뒤 return 문에서 JSX(React Element)를 반환하는 과정을 말합니다.
- 즉 부모가 리렌더링 되면, 내부의 자식 컴포넌트도 다시 호출되는데, 이때 새로운 React Element 객체를 생성합니다.
- 여기서 렌더링은(rendering) "어떤 UI를 그릴지 계산하는 과정"입니다. 렌더링 이후에는 Virtual DOM 비교(diffing) -> 실제 DOM 업데이트(commit) 순서로 화면이 갱신됩니다.
1.1. 리렌더링 되는 조건?
리액트에서 리렌더링 되는 조건은 크게 두가지가 있습니다.
1. state가 바뀌었을 때
import { useState } from "react";
export default function App() {
const [count, setCount] = useState(0);
console.log("App 컴포넌트 렌더링!");
return (
<div>
<p>현재 count: {count}</p>
<button onClick={() => setCount(count + 1)}>+1</button>
</div>
);
}
- setCount로 상태를 바꾸면 React는 UI를 다시 그려야 하므로 컴포넌트를 다시 호출합니다. 이에 호출 과정에서 리렌더링이 일어나게 됩니다.
2. props가 바뀌었을 때
function Child({ value }) {
console.log("Child 컴포넌트 렌더링!");
return <p>값: {value}</p>;
}
export default function App() {
const [num, setNum] = useState(0);
return (
<div>
<Child value={num} />
<button onClick={() => setNum(num + 1)}>변경</button>
</div>
);
}
- 부모(App)의 state가 바뀌면 자식에게 전달되는 props가 바뀌므로, 자식(Child)도 리렌더링 됩니다.
02. React 성능 최적화 함수 - React.memo
2.1. React.memo란?
- 컴포넌트를 메모이제이션할 수 있습니다.
- 부모가 리렌더링 될 때 props가 바뀌지 않았다면, 자식 컴포넌트를 다시 렌더링 하지 않음으로써 최적화가 가능합니다.
2.2. 사용 예시
2.2.1. React.Memo 사용 전 코드
import { useEffect, useState } from 'react'
import './App.css'
const Child = ({count}) => {
console.log(`자식 컴포넌트 : ${count}`)
return <div>자식 컴포넌트 : {count}</div>
}
function App() {
const [count, setCount] = useState(0);
const [name, setName] = useState("김1");
console.log(`부모 컴포넌트 : ${count}`)
return (
<>
<div className="card">
<button onClick={() => setCount((count) => count + 1)}>
부모 컴포넌트 : count is {count}
</button>
</div>
<div className="card">
<button onClick={() => setName((name) => "김2")}>
부모 컴포넌트 : 이름은 {name}
</button>
</div>
<Child count={count}/>
</>
)
}
export default App


위의 App() 코드는 부모 컴포넌트이고, 부모의 count 상태가 자식인 Child 컴포넌트의 props로 넘겨주고 있습니다.
이때, [count] state는 부모-> 자식으로 props를 넘겨주지만, [name] state는 넘겨주지 않는 것을 알 수 있습니다.
또한 초기 상태로는 부모, 자식 컴포넌트가 1번씩 호출된 것을 알 수 있습니다.


하지만, 만약 두번째 사진과 같이 부모 컴포넌트의 이름 변경 버튼을 눌러서, name을 '김1' -> '김2'으로 변경해 보겠습니다.
그 결과 3번째 사진과 같이, 부모 컴포넌트, 자식 컴포넌트들이 또 한 번씩 호출된 것을 알 수 있습니다.
이는 부모가 리렌더링 되면 자식도 함께 리렌더링 되기 때문입니다. (다만 React는 이전 렌더와 동일한 결과면 DOM 업데이트는 생략합니다)
2.2.2. React.Memo 사용 후 코드
이제 여기서 React.Memo를 이용하여 Child 함수를 메모이제이션 해보겠습니다.
const Child = React.memo(({count}) => {
console.log(`자식 컴포넌트 : ${count}`)
return <div>자식 컴포넌트 : {count}</div>
});
Child 컴포넌트를 React.memo로 감싸주면 됩니다.


그렇게 해서 실행해서 부모 컴포넌트의 'name' state를 변경하게 되면, 아까는 부모->자식 순으로 컴포넌트가 리렌더링 되었지만
현재는 부모 컴포넌트만 리렌더링 되게 됩니다. 이는 React.memo에 의해 자식 컴포넌트의 Child가 메모이제이션 되었기 때문입니다.
Chlid 컴포넌트의 props인 'count' state가 변경되지 않았기 때문에 Child 컴포넌트는 리렌더링 되지 않게 됩니다.
2.3. React.memo의 특징
- props가 바뀌지 않으면 렌더링을 건너뜁니다.
- *얕은 비교(Shallow comparision, ===)을 사용하므로, 객체나 배열 props는 내용이 같아도 참조가 바뀌면 다시 렌더링 됩니다.
- 부모 컴포넌트가 리렌더링 돼도, 자식 props가 바뀌지 않으면 렌더링을 최소화할 수 있습니다.
※ *얕은 비교
- 숫자, 문자열 등 원시 자료형은 값을 비교하고, 배열/객체 등 참조형의 경우는 값이나 속성은 비교하지 않고, 참조되는 위치를 비교하는 것
03. React 성능 최적화 함수 - useMemo
3.1. useMemo란?
- 값을 메모이제이션할 수 있는 react hook입니다.
- 컴포넌트 내부에서 계산량이 큰 함수의 결괏값을 의존성 배열이 바뀔 때만 다시 계산하도록 합니다.
- 즉, 렌더링 최적화 및 불필요한 계산 방지를 위해 사용합니다.
3.2. 사용 예시
3.2.1. useMemo 사용 코드
useMemo는 값을 계산할 때 캐싱하고자 하는 계산 과정을 useMemo()를 사용하여 콜백함수로 넘겨줍니다.
또한, 의존성 배열에 값을 넣어서 특정 값이 바뀔 때만 다시 계산하도록 설정할 수 있습니다.
function MemoTestComponent({ items }) {
const total = useMemo(() => {
console.log("💡 총합 계산됨");
return items.reduce((sum, item) => sum + item, 0);
}, [items]);
return <div>총합: {total}</div>;
}
이 코드의 경우는, 렌더링 "도중"에 실행되면 total 값을 즉시 리턴합니다.
React가 이 값을 캐싱했다가, items가 바뀌면 다시 계산하게 됩니다.
즉 렌더링에 필요한 계산을 최적화하게 됩니다.
3.2.2. useMemo 전체 예시 코드
import React, { useMemo, useState } from "react";
import "./App.css";
function MemoTestComponent({ items }) {
const total = useMemo(() => {
console.log("💡 총합 계산됨");
return items.reduce((sum, item) => sum + item, 0);
}, [items]);
console.log("자식 렌더링")
return <div>총합: {total}</div>;
}
function App() {
const [items, setItems] = useState([1, 2, 3, 4, 5, 6]);
const [name, setName] = useState("김1");
return (
<>
<MemoTestComponent items={items} />
<div className="card">
<button onClick={() => setName(name === "김1" ? "김2" : "김1")}>
이름 변경: {name}
</button>
</div>
<div className="card">
<button onClick={() => setItems([...items, items.length + 1])}>
아이템 추가
</button>
</div>
</>
);
}
export default App;
1) 초기 상태


초기 상태의 경우는, 제일 처음에 부모 컴포넌트 (App)가 실행되고, 그 안에 자식 컴포넌트도 호출되어 렌더링 되게 됩니다.
또한 useMemo() 안의 값 계산도 진행됩니다.
2) 이름 변경 (name state 변경) 버튼 클릭
부모 컴포넌트(App)에 있는 'name' state의 값을 변경해 보겠습니다.


이 경우 '자식 렌더링'만 한번 더 나타납니다. 부모 컴포넌트의 App이 실행되었으니 자식컴포넌트로 리렌더링 되는 것입니다. (ui 변화가 없으니 자식 컴포넌트(MemoTestComponent)의 리렌더링은 DOM에는 적용 x)
하지만 자식 컴포넌트 내 useMemo의 의존성 배열 안에 있는 'items'의 값은 변경되지 않았으므로 '총합'이 다시 계산되지는 않습니다.
3) 리스트 아이템 추가 버튼 클릭


이번에는 'items'에 아이템을 추가하는 버튼을 눌렀습니다.
그럼 itesm가 기존 [1,2,3,4,5,6]에서 [1,2,3,4,5,6,7]로 변경이 됩니다.
- 그러면 자식 컴포넌트(MemoiTestComponent) 안의 useMemo의 의존성 배열에 있는 items의 값이 변경되었으므로,
값의 계산(reduce)이 다시 진행됩니다. 이에 "총합 계산됨"이라는 console 이 다시 나타나게 되는 것입니다.
- 또한, 부모(app)에서 리렌더링이 일어났으므로 자식 컴포넌트 (MemoTestComponent)에서도 리렌더링이 일어나게 됩니다. = 자식 렌더링
3.3. useMemo의 특징
- 컴포넌트는 매번 렌더링 되지만, 계산량이 큰 값을 캐싱하여 재사용할 수 있습니다.
- useMemo는 값을 메모이제이션 하는 것입니다.
- 잘못 쓴다면, 오히려 메모리 사용량이 늘 수 있습니다.
04. React 성능 최적화 함수 - useCallback
4.1. useCallback이란?
const memoizedFn = useCallback(() => {
console.log("함수 실행됨!");
}, [temp1, temp2]);
- 함수를 메모이제이션하여 재사용할 수 있습니다. 즉 렌더링 최적화를 위해 함수 참조를 유지시킵니다.
- 의존성 배열 안의 값이 바뀌지 않는다면, 이전 함수를 그대로 사용합니다.
- 의존성 배열 안이 바뀌게 되면 새로운 함수를 생성합니다.
- React는 렌더링 될 때마다 컴포넌트 내부의 함수를 새로 만들기 때문에, 불필요한 리렌더링을 막고 싶을 때 useCallback이 필요합니다.
4.2. 사용 예시
4.2.1. useCallback 사용 코드
useCallback은 메모이제이션하고자하는 함수에 useCallback을 붙이고, 콜백함수와 의존성 배열을 추가하여 사용할 수 있습니다.
// count가 바뀔 때만 새 함수 생성
const handleClick = useCallback(() => {
console.log(count);
}, [count]);
4.2.2. useCallback전체 예시 코드
import { useState, useCallback } from "react";
function Child({ onClick }) {
console.log("자식 렌더링!");
return <button onClick={onClick}>클릭</button>;
}
export default function App() {
const [count, setCount] = useState(0);
// ✅ count가 바뀔 때만 새 함수 생성
const handleClick = useCallback(() => {
console.log("현재 count:", count);
}, [count]);
console.log("부모 렌더링!");
return (
<>
<button onClick={() => setCount((c) => c + 1)}>부모 count +1</button>
<Child onClick={handleClick} />
</>
);
}
1) 초기 상태 ( + handleClick 호출)


최초 렌더링 시, useCallback안의 함수가 한번 실행되고, count = 0일 때의 handleClick이 메모이제이션됩니다.
또한 handleClick 호출 시 , '현재 count : 0"으로 나오게 됩니다.
2) count 값 변경 시 + (handleClick 호출)


setCount로 값을 변경하게 된다면, useCallback의 의존성 배열인 'count'가 변경되므로, 새로운 handleClick 함수가 다시 만들어지게 됩니다.
(count가 바뀌지 않는 한은 같은 handleClick 함수 객체(참조)가 유지됩니다.)
4.3. useCallback의 특징
- useCallback의 경우는 props로 함수를 넘기거나, 매 렌더링마다 새로운 함수를 생성하는 것을 방지할 때 사용합니다.
- 또한 의존성 배열이 바뀔 때만 새로운 함수를 생성합니다.
- useCallback 비교 로직으로는, 의존성 배열을 얕은 비교를 진행(shallow compare)합니다.
05. 참고 : 얕은 비교 vs 깊은 비교
1. 얕은 비교
- 숫자, 문자열, null, boolean, undefined, Symbol 등 원시 타입은 값을 비교합니다.
- 배열(array), 객체(Object), 함수(function) 등 참조 타입의 경우 값/속성을 비교하지 않고, 참조되는 위치를 비교합니다.
2. 깊은 비교
- 깊은 비교는 객체의 경우도 값으로 비교합니다.
- Object depth가 깊지 않은 경우 : JSON.stringify 사용
- Object depth가 깊은 경우 : lodash라이브러리의 isEqual() 사용
06. Reference
https://www.maeil-mail.kr/question/18
https://www.youtube.com/watch?v=1YAWshEGU6g
https://ssdragon.tistory.com/106

읽어주셔서 감사합니다~
도움이 되셨다면 공감 부탁드립니다 😊
직접 공부하며 정리한 내용이어서 틀릴 수도 있습니다. 피드백 환영입니다!
댓글