React.memo 제대로 사용하기
내 메모는 왜 이상할까?
15.3ms → 1.1ms로 약 14배 빨라진 렌더링 시간
렌더링 최적화 어떻게 해야 할까?
프로젝트에서 리스트 렌더링이 느린 것을 발견했습니다.
React DevTools Profiler로 확인해보니 불필요한 리렌더링이 많았고, "React.memo를 써야겠다"고 생각했습니다.
그런데 막상 적용하려니 코드가 복잡하게 얽혀있어서 바로 적용하기가 어려웠습니다. 그래서 이번 기회에 React.memo를 제대로 공부하기로 했습니다.
간단한 TodoList 예제를 만들어 원리를 이해하고, 실제로 어떻게 작동하는지 테스트해봤습니다.
오늘은 그 학습 과정과 깨달은 점들을 공유하겠습니다.
문제 상황
React.memo를 이해하기 위해 간단한 TodoList 예제를 만들었습니다.
50개의 할 일 목록에서 1개를 클릭하면 어떤 일이 일어날까요?
const TodoItem = ({ todo, onToggle }) => {
console.log(`렌더링: Todo ${todo.id}`);
return <div onClick={onToggle}>{todo.text}</div>;
};
function App() {
const [todos, setTodos] = useState(generateTodos(50));
const handleToggle = (id) => {
setTodos((prev) =>
prev.map((todo) =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
)
);
};
return (
<div>
{todos.map((todo) => (
<TodoItem
key={todo.id}
todo={todo}
onToggle={() => handleToggle(todo.id)}
/>
))}
</div>
);
}
- 예상: 1개만 리렌더링
- 실제: 50개 전체가 리렌더링
50개 전체가 리렌더링됩니다.
이 패턴이 바로 우리 프로젝트에서 발견한 문제와 동일했습니다.
왜 이런 일이?
React는 기본적으로 부모 컴포넌트가 리렌더링되면 모든 자식 컴포넌트도 리렌더링합니다.
todos state가 변경되어 App이 리렌더링되면, 모든 TodoItem도 따라서 리렌더링되는 것이죠.
아래는 제가 예시 코드로 만든 메모를 적용하기 전과 후 그리고 잘못 적용한 사례에 대한 결과입니다.
Memo 전
렌더링 시간
Profiler
저는 한 개의 할 일 목록을 선택했을 뿐인데, 50개의 할 일 목록이 모두 리렌더링되는 것을 확인할 수 있습니다.
거기에 더해 리렌더링에 걸린 시간은 약 15.3ms가 걸렸습니다.
이 문제가 바로 저희 프로젝트에서 발생한 문제와 동일한 패턴입니다.
장소 한 개를 수정했을 뿐인데 다른 장소들도 다시 리렌더링된 되는 것이죠.
첫 번째 시도: React.memo 적용
이 문제를 해결하기 위해 React.memo를 적용해보기로 했습니다.
React.memo란?
React.memo는 컴포넌트를 메모이제이션하는 고차 컴포넌트입니다.
// 일반 컴포넌트
function TodoItem(props) {
// 부모가 리렌더링되면 무조건 리렌더링
return {props.todo.text};
}
// React.memo 적용
const TodoItem = React.memo(function TodoItem(props) {
// 부모가 리렌더링되어도, props가 같으면 스킵!
return {props.todo.text};
});
핵심 원리: 이전 props와 새로운 props를 비교해서, 바뀌지 않았으면 리렌더링을 스킵합니다.
적용해보기
이 원리를 믿고 TodoItem에 memo를 적용했습니다.
const TodoItem = React.memo(({ todo, onToggle }) => {
console.log(`렌더링: Todo ${todo.id}`);
return <div onClick={onToggle}>{todo.text}</div>;
});
결과
렌더링 시간 측정
profiler 측정
하지만 여전히 50개의 할 일 목록이 리렌더링되는 것을 볼 수 있는데요 🥲
왜 작동하지 않을까요?
문제 원인 1 (React.memo의 비교 방식)
React.memo는 props를 얕은 비교(Shallow Comparison) 합니다.
// React.memo의 비교 로직 (의사코드)
function arePropsEqual(prevProps, nextProps) {
for (let key in prevProps) {
if (prevProps[key] !== nextProps[key]) {
// === 비교
return false;
}
}
return true;
}
핵심은 === 연산자로 비교한다는 것입니다.
언제 비교하나요?
부모 컴포넌트 리렌더링
↓
React: "자식들 렌더링해야지"
↓
자식에 memo가 있나 확인
↓
있음: props 비교 먼저
↓
props 같음? → 렌더링 스킵
props 다름? → 렌더링 진행
핵심: 자식을 렌더링하기 전에 비교합니다.
문제 원인 2 (Javascript의 참조 비교)
Javascript에는 두 가지 타입이 있습니다
원시 타입(Primitive): 값 자체를 비교
const a = 5;
const b = 5;
console.log(a === b); // true
const str1 = 'hello';
const str2 = 'hello';
console.log(str1 === str2); // true
참조 타입(Reference): 메모리 주소를 비교
const fn1 = () => console.log('hi');
const fn2 = () => console.log('hi');
console.log(fn1 === fn2); // false! (다른 메모리 주소)
const obj1 = { name: 'John' };
const obj2 = { name: 'John' };
console.log(obj1 === obj2); // false! (다른 메모리 주소)
함수와 객체는 내용이 같아도 참조가 다르면 다른 값입니다.
현재 코드의 문제
function App() {
return (
{todos.map(todo => (
<TodoItem
key={todo.id}
todo={todo}
onToggle={() => handleToggle(todo.id)} // 🚨 여기가 문제
/>
))}
);
}
이 코드가 어떻게 작동하는지 보겠습니다
// 렌더링 1
<TodoItem
onToggle={() => handleToggle(1)} // 메모리 주소 A
/>
// 렌더링 2
<TodoItem
onToggle={() => handleToggle(1)} // 메모리 주소 B (새로 생성)
/>
// React.memo의 판단
prevProps.onToggle === nextProps.onToggle
→ 메모리 A !== 메모리 B
→ false
→ "props가 바뀌었다"
→ 리렌더링 실행
매 렌더링마다 새로운 화살표 함수가 생성되므로, React.memo는 "props가 바뀌었다"고 판단합니다.
추가 문제 (컴포넌트 내부 선언)
inline 함수뿐만 아니라, 컴포넌트 내부에 선언한 모든 함수와 객체도 같은 문제가 발생합니다.
function App() {
const [todos, setTodos] = useState([]);
// 이 함수도 매 렌더링마다 새로 생성됨
const handleToggle = (id) => {
/* ... */
};
const handleDelete = (id) => {
/* ... */
};
// 이 객체도 매번 새로 생성됨
const config = { showCheckbox: true };
return <TodoItem onToggle={handleToggle} config={config} />;
}
컴포넌트 내부의 함수, 객체, 배열은 모두 매 렌더링마다 새로 생성됩니다.
올바른 최적화
React.memo + useCallback
해결 전략
문제: 함수가 매번 새로운 참조를 갖는다
해결: 같은 함수를 재사용한다
React는 이를 위한 Hook을 제공합니다. useCallback
useCallback
// ❌ 매번 새로운 함수
function Component() {
const handleClick = () => {
console.log('clicked');
};
// 렌더링마다 새 함수 생성
return <Child onClick={handleClick} />;
}
// ✅ 함수를 재사용
function Component() {
const handleClick = useCallback(() => {
console.log('clicked');
}, []); // deps 빈 배열 → 한 번만 생성, 계속 재사용
return <Child onClick={handleClick} />;
}
useCallback의 동작 원리
렌더링 1: useCallback(() => {}, [])
→ deps 확인: 없음
→ 함수 생성 (메모리 0x001)
→ 저장
렌더링 2: useCallback(() => {}, [])
→ deps 확인: 바뀌지 않음
→ 저장된 함수 재사용 (메모리 0x001)
렌더링 3: useCallback(() => {}, [])
→ deps 확인: 바뀌지 않음
→ 저장된 함수 재사용 (메모리 0x001)
같은 메모리 주소(0x001)를 유지하므로, React.memo가 "props가 안 바뀌었다"라고 판단합니다.
현재 코드에 적용하기
이제 우리 코드에 useCallback을 적용해보겠습니다.
핵심 변경사항
| 항목 | Before | After |
| -------------- | ------------------ | ---------------------- |
| handleToggle | 매번 새로 생성 | useCallback으로 재사용 |
| onToggle props | `() => toggle(id)` | `toggle` 직접 전달 |
| id 전달 방식 | inline | 별도 props (todoId) |
수정된 코드
function App() {
const [todos, setTodos] = useState(generateTodos(50));
// ✅ useCallback으로 함수 메모이제이션
const handleToggle = useCallback((id) => {
setTodos((prev) =>
prev.map((todo) =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
)
);
}, []); // deps 빈 배열 → 함수 재사용
return (
<div>
{todos.map((todo) => (
<TodoItem
key={todo.id}
todo={todo}
onToggle={handleToggle} // ✅ 같은 함수 전달
todoId={todo.id} // ✅ ID는 별도 props
/>
))}
</div>
);
}
// ✅ TodoItem도 수정
const TodoItem = React.memo(({ todo, onToggle, todoId }) => {
console.log(`렌더링: Todo ${todo.id}`);
return (
<div onClick={() => onToggle(todoId)}>
{' '}
{/* 여기서 ID 사용 */}
{todo.text}
</div>
);
});
작동 방식 시각화
렌더링 1 (초기)
handleToggle 생성 (0x001)
TodoItem 1: onToggle={0x001}, todo={...}
TodoItem 2: onToggle={0x001}, todo={...}
...
TodoItem 50: onToggle={0x001}, todo={...}
렌더링 2 (Todo 25 클릭)
handleToggle 재사용 (0x001) ✅
TodoItem 1-24:
onToggle={0x001} (안 바뀜) ✅
todo (안 바뀜) ✅
→ 리렌더링 스킵
TodoItem 25:
onToggle={0x001} (안 바뀜) ✅
todo (completed 바뀜) ❌
→ 리렌더링
TodoItem 26-50:
onToggle={0x001} (안 바뀜) ✅
todo (안 바뀜) ✅
→ 리렌더링 스킵
측정 결과
렌더링 시간
Profiler
드디어 1개만 리렌더링됩니다 😂
성능 비교
| 구분 | Before | After | 개선 |
| ----------- | ------ | ----- | ------------ |
| 렌더링 횟수 | 50개 | 1개 | 50배 감소 |
| 렌더링 시간 | 15.3ms | 1.1ms | 14배 단축 ⚡ |
많은 사람이 우려하는 부분
메모리를 많이 사용하면 성능에 부담이 있는거 아닌가?
React.memo는 이전 props와 렌더 결과를 메모리에 기록해두고, 다음 렌더링 때 shallow 비교와 캐싱된 결과 활용을 위해 소량의 메모리를 사용합니다.
실질적으로 메모리 오버헤드는 상당히 작아서, 일반적인 컴포넌트에서 거의 신경 쓸 필요가 없는 수준입니다.
React의 메모이제이션 캐싱 전략은 주로 가장 최근 렌더 결과만 기록하고, 복잡한 데이터나 대규모 트리에서만 약간의 추가적인 비용이 발생할 수 있습니다.
따라서, 일반적인 앱에서는 성능 최적화 효과에 비해 메모리 부담이 낮으므로, 지나치게 큰 컴포넌트나 복잡한 props가 아니라면 리스크는 거의 없습니다.
마치며
이렇게 기본 개념을 살펴보며 왜 저희 프로젝트에서 리렌더링이 자주 발생하는지 알아보았습니다.
다음 편에는 실제 프로젝트에 적용해서 개선된 결과를 올려보도록 하겠습니다 😁