1. 리렌더링이 언제, 왜 일어나는가
리렌더링이 발생하는 조건은 세 가지다.
| 조건 | 설명 |
|---|---|
| ① state 변경 | setState 호출 시 |
| ② props 변경 | 부모가 새로운 값을 내려줄 때 |
| ③ 부모 리렌더링 | props가 바뀌지 않아도 부모가 렌더링되면 자식도 따라 렌더링됨 |
리렌더링 자체는 React가 UI를 최신 상태로 유지하는 정상적인 동작이다. 문제는 불필요한 리렌더링이 쌓일 때다.
- 렌더링 비용이 큰 컴포넌트(차트, 긴 리스트)가 관계없는 state 변경마다 다시 그려질 때
- 무거운 계산(필터링, 정렬)이 매 렌더링마다 재실행될 때
- 최상단 부모의 리렌더링이 모든 자식으로 연쇄될 때
반대로 단순한 텍스트, 아이콘 같은 컴포넌트의 리렌더링은 비용이 거의 없어서 굳이 최적화할 필요 없다. 오히려 memo를 붙이는 비교 비용이 더 클 수 있다.
2. memo — 컴포넌트 메모이제이션
한 줄 정의: props가 이전과 같으면 리렌더링을 건너뛰는 HOC(고차 컴포넌트)
import { memo } from 'react';
const Child = memo(function Child({ name }) {
console.log('Child 렌더링됨');
return <div>{name}</div>;
});
memo로 감싸면 React가 렌더링 전에 이전 props와 현재 props를 얕은 비교로 확인한다. 같으면 스킵, 다르면 렌더링.
얕은 비교의 함정
✓ 원시값 — 잘 작동함
// 값 자체를 비교
"슬기" === "슬기" // true → 스킵
42 === 42 // true → 스킵
<Child name="슬기" />
<Child count={42} />
✗ 객체 / 함수 — 소용없음
// 참조(메모리 주소)를 비교
{} === {} // false → 렌더링
() => {} === () => {} // false
<Child style={{color:"red"}} />
<Child onClick={() => {}} />
memo 쓸 때 vs 굳이 안 써도 될 때
| 상황 | memo 효과 |
|---|---|
| 렌더링 비용 큰 컴포넌트 (차트, 긴 리스트) | 써야 함 |
| 부모가 자주 리렌더링, 자식 props는 안 변함 | 써야 함 |
| 단순한 컴포넌트 (텍스트, 아이콘 하나) | 굳이 안 써도 됨 |
| props가 어차피 매번 바뀜 | 써도 의미 없음 |
3. useCallback — 함수 메모이제이션
한 줄 정의: 함수를 매번 새로 만들지 않고 의존성이 바뀔 때만 새로 만드는 훅
// Before — 매번 새 함수 생성
const handleClick = () => {
console.log('clicked');
};
// After — 의존성 안 바뀌면 같은 함수 유지
const handleClick = useCallback(() => {
console.log('clicked');
}, []); // 빈 배열 = 마운트 시 한 번만 생성
memo된 자식에게 함수를 props로 넘길 때 useCallback 없이는 매 렌더링마다 새 함수가 생성되어 memo가 무력화된다. 둘은 항상 세트로 써야 한다.
함정 — Stale Closure (클로저 문제)
JavaScript에서 함수는 만들어질 때의 변수를 기억한다. 이것이 클로저다. 의존성 배열을 잘못 쓰면 오래된 값을 참조하는 버그가 생긴다.
const [count, setCount] = useState(0);
// 버그 — count를 쓰는데 의존성 배열에 없음
const handleClick = useCallback(() => {
console.log(count); // 항상 0만 출력됨
}, []); // 마운트 시 count=0을 기억하고 영원히 업데이트 안 됨
// 해결 — count를 의존성 배열에 추가
const handleClick = useCallback(() => {
console.log(count); // 최신 count 출력
}, [count]); // count가 바뀔 때마다 함수 재생성
4. useMemo — 값 메모이제이션
한 줄 정의: 계산 결과값을 저장해두고 의존성이 바뀔 때만 다시 계산하는 훅
// 무거운 계산 — list가 바뀔 때만 재계산
const sortedList = useMemo(() => {
return list
.filter(item => item.active)
.sort((a, b) => b.score - a.score);
}, [list]);
// 객체를 memo된 자식에 넘길 때 — 참조 고정
const options = useMemo(() => ({
color: 'red',
size: 'large',
}), []);
return <MemoizedChild options={options} />;
useCallback vs useMemo 차이
// useCallback — 함수 자체를 저장
const fn = useCallback(() => doSomething(), []);
// useMemo — 함수 실행 결과값을 저장
const result = useMemo(() => doSomething(), []);
// 사실 useCallback(fn) === useMemo(() => fn)
| memo | useCallback | useMemo | |
|---|---|---|---|
| 대상 | 컴포넌트 | 함수 | 값 |
| 재실행 조건 | props 변경 | 의존성 변경 | 의존성 변경 |
| 쓰는 시점 | 부모 자주 렌더링, 자식 props 안변할 때 | memo 자식에 함수 넘길 때 | 무거운 계산 or 객체 넘길 때 |
count * 2, 문자열 조합)에 useMemo를 쓰면 비교 비용이 계산 비용보다 커진다. 무거운 연산이나 memo 자식에 객체를 넘길 때만 써야 한다.5. Before / After 코드 비교
Before — 최적화 없는 버전
import { useState } from 'react';
function heavyCalculation(list) {
console.log('무거운 계산 실행됨');
return list.filter(item => item.active).sort((a, b) => b.score - a.score);
}
function ExpensiveList({ items, onItemClick }) {
console.log('ExpensiveList 렌더링됨');
return (
<ul>
{items.map(item => (
<li key={item.id} onClick={() => onItemClick(item.id)}>
{item.name} - {item.score}
</li>
))}
</ul>
);
}
export default function App() {
const [count, setCount] = useState(0);
const [list] = useState([
{ id: 1, name: '사과', score: 3, active: true },
{ id: 2, name: '바나나', score: 1, active: false },
{ id: 3, name: '포도', score: 5, active: true },
]);
// 문제 1: list 안 바뀌어도 매번 재계산
const sortedList = heavyCalculation(list);
// 문제 2: 매번 새 함수 생성
const handleItemClick = (id) => console.log('클릭:', id);
return (
<div>
<h1>카운터: {count}</h1>
<button onClick={() => setCount(c => c + 1)}>+1</button>
{/* 문제 3: memo 없어서 count 바뀔 때마다 리렌더링 */}
<ExpensiveList items={sortedList} onItemClick={handleItemClick} />
</div>
);
}
After — 최적화 버전
import { useState, useMemo, useCallback, memo } from 'react';
function heavyCalculation(list) {
console.log('무거운 계산 실행됨');
return list.filter(item => item.active).sort((a, b) => b.score - a.score);
}
// 수정 1: memo로 감싸기
const ExpensiveList = memo(function ExpensiveList({ items, onItemClick }) {
console.log('ExpensiveList 렌더링됨');
return (
<ul>
{items.map(item => (
<li key={item.id} onClick={() => onItemClick(item.id)}>
{item.name} - {item.score}
</li>
))}
</ul>
);
});
export default function App() {
const [count, setCount] = useState(0);
const [list] = useState([
{ id: 1, name: '사과', score: 3, active: true },
{ id: 2, name: '바나나', score: 1, active: false },
{ id: 3, name: '포도', score: 5, active: true },
]);
// 수정 2: list 바뀔 때만 재계산
const sortedList = useMemo(() => heavyCalculation(list), [list]);
// 수정 3: 참조 고정
const handleItemClick = useCallback((id) => {
console.log('클릭:', id);
}, []);
return (
<div>
<h1>카운터: {count}</h1>
<button onClick={() => setCount(c => c + 1)}>+1</button>
<ExpensiveList items={sortedList} onItemClick={handleItemClick} />
</div>
);
}
6. Q&A 정리
Q1. 리렌더링이 언제 발생하나요?
state가 변경될 때, props가 변경될 때, 그리고 부모 컴포넌트가 리렌더링될 때 발생합니다. 특히 세 번째 경우는 props가 바뀌지 않아도 부모가 렌더링되면 자식도 따라 렌더링되기 때문에 주의해야 합니다.
Q2. memo는 무엇이고 언제 쓰나요?
memo는 props가 이전과 같을 때 리렌더링을 건너뛰는 HOC입니다. 얕은 비교를 사용하기 때문에 원시값은 잘 작동하지만, 객체나 함수를 props로 넘기면 매번 새 참조가 생겨서 효과가 없습니다. 그래서 함수 props는 useCallback, 객체 props는 useMemo와 함께 써야 제대로 동작합니다.
Q3. useCallback과 useMemo의 차이가 뭔가요?
둘 다 메모이제이션 훅인데, useCallback은 함수 자체를 저장하고 useMemo는 함수의 실행 결과값을 저장합니다. 실제로 useCallback(fn, deps)는 useMemo(() => fn, deps)와 동일하게 동작합니다.
Q4. useCallback을 항상 쓰는 게 좋은가요?
아닙니다. useCallback 자체도 의존성 비교 비용이 있어서, memo된 자식에 함수를 넘기는 것처럼 참조 유지가 실제로 필요할 때만 써야 합니다. 남발하면 의존성 배열 관리가 복잡해지고 stale closure 버그가 생길 수 있습니다.
Q5. stale closure가 뭔가요?
함수가 생성될 때의 변수를 클로저로 기억하는데, useCallback의 의존성 배열에 해당 변수를 누락하면 함수가 오래된 값을 계속 참조하는 버그입니다. 예를 들어 count를 사용하는 함수에서 의존성 배열을 빈 배열로 두면 count가 바뀌어도 함수 안에서는 항상 초기값만 보게 됩니다.
'💻 STUDY > Frontend' 카테고리의 다른 글
| Tailwind CSS가 가벼운 이유 (0) | 2026.05.05 |
|---|---|
| Three.js 정리하기 (0) | 2026.04.16 |
| React 렌더링 원리와 Virtual DOM (0) | 2026.03.13 |
| useReducer와 성능 최적화 (0) | 2026.03.13 |
| 라이프사이클과 useEffect (0) | 2026.03.13 |