[이제와서 시작하는 React 마스터하기 #11] 성능 최적화 - 똑똑하게 렌더링하기
[이제와서 시작하는 React 마스터하기 #11] 성능 최적화 - 똑똑하게 렌더링하기
100일 챌린지 Day 11 - 리액트가 똑똑하게 일하도록 만들기! 불필요한 작업을 줄여서 앱을 빠르게 만들어봐요. 🚀
배울 내용
이번 시간에는 React 앱을 더 빠르게 만드는 방법을 배워봐요.
- 렌더링이 언제 일어나는지 이해하기
- React.memo로 불필요한 렌더링 막기
- useMemo로 계산 결과 저장하기
- useCallback으로 함수 재사용하기
시작하기 전에
이번 내용을 더 잘 이해하려면 아래 내용을 먼저 보고 오세요:
일상 비유로 이해하기
성능 최적화를 일상으로 비유하면 이렇습니다:
📱 스마트폰 배터리 절약 모드
- 문제: 모든 앱이 항상 실행되면 배터리가 빨리 닳아요
- 해결: 필요 없는 앱은 백그라운드에서 멈춰요
- React: 변경되지 않은 컴포넌트는 다시 그리지 않아요
🍳 주방에서 요리하기
- memo: “이거 어제 만든 것과 똑같네? 다시 안 만들어도 돼!”
- useMemo: “이 소스는 한 번 만들어서 냉장고에 보관해두자”
- useCallback: “이 레시피는 메모해두고 계속 쓰자”
1️⃣ 렌더링 이해하기
렌더링이 뭔가요?
렌더링 = 컴포넌트를 다시 그리는 것
1
2
3
4
5
6
7
8
9
10
11
12
function Counter() {
const [count, setCount] = useState(0);
console.log('Counter 렌더링됨!'); // count가 바뀔 때마다 출력
return (
<div>
<p>카운트: {count}</p>
<button onClick={() => setCount(count + 1)}>증가</button>
</div>
);
}
언제 렌더링이 일어나나요?
- State가 변경될 때
- Props가 변경될 때
- 부모 컴포넌트가 렌더링될 때
문제 상황 보기
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
function App() {
const [count, setCount] = useState(0);
return (
<div>
<h1>카운트: {count}</h1>
<button onClick={() => setCount(count + 1)}>증가</button>
{/* UserList는 변경되지 않았는데도 계속 렌더링됨! */}
<UserList />
</div>
);
}
function UserList() {
console.log('UserList 렌더링됨'); // count가 바뀔 때마다 출력
const users = ['김철수', '이영희', '박민수'];
return (
<ul>
{users.map((user, index) => (
<li key={index}>{user}</li>
))}
</ul>
);
}
문제점:
- count가 바뀔 때 App이 렌더링돼요
- App이 렌더링되면 UserList도 렌더링돼요
- 하지만 UserList는 변경된 게 없어요! 불필요한 작업이에요
2️⃣ React.memo - 똑똑한 렌더링
React.memo란?
컴포넌트를 “기억”해두고, props가 바뀌지 않으면 다시 렌더링하지 않아요.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import { memo } from 'react';
// memo로 감싸기
const UserList = memo(function UserList() {
console.log('UserList 렌더링됨');
const users = ['김철수', '이영희', '박민수'];
return (
<ul>
{users.map((user, index) => (
<li key={index}>{user}</li>
))}
</ul>
);
});
function App() {
const [count, setCount] = useState(0);
return (
<div>
<h1>카운트: {count}</h1>
<button onClick={() => setCount(count + 1)}>증가</button>
{/* 이제 count가 바뀌어도 UserList는 렌더링 안 됨! */}
<UserList />
</div>
);
}
실습: 사용자 카드 최적화하기
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
import { useState, memo } from 'react';
// memo로 최적화
const UserCard = memo(function UserCard({ name, age }) {
console.log(`${name} 카드 렌더링됨`);
return (
<div style=>
<h3>{name}</h3>
<p>나이: {age}세</p>
</div>
);
});
function App() {
const [count, setCount] = useState(0);
return (
<div>
<h1>카운트: {count}</h1>
<button onClick={() => setCount(count + 1)}>증가</button>
<div>
<h2>사용자 목록</h2>
<UserCard name="김철수" age={25} />
<UserCard name="이영희" age={30} />
<UserCard name="박민수" age={28} />
</div>
</div>
);
}
결과: count를 아무리 증가시켜도 UserCard는 다시 렌더링되지 않아요!
3️⃣ useMemo - 계산 결과 저장하기
useMemo란?
비용이 많이 드는 계산 결과를 “메모”해두는 Hook이에요.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import { useState, useMemo } from 'react';
function TodoList() {
const [todos, setTodos] = useState([
{ id: 1, text: '리액트 공부', completed: false },
{ id: 2, text: '운동하기', completed: true },
{ id: 3, text: '책 읽기', completed: false }
]);
const [count, setCount] = useState(0);
// useMemo 없이: count가 바뀔 때마다 계산됨 (비효율적)
const incompleteTodos = todos.filter(todo => !todo.completed);
// useMemo 사용: todos가 바뀔 때만 계산됨 (효율적)
const incompleteTodos = useMemo(() => {
console.log('미완료 할 일 계산 중...');
return todos.filter(todo => !todo.completed);
}, [todos]); // todos가 바뀔 때만 다시 계산
return (
<div>
<h2>미완료: {incompleteTodos.length}개</h2>
<button onClick={() => setCount(count + 1)}>
카운트 증가: {count}
</button>
<ul>
{todos.map(todo => (
<li key={todo.id}>{todo.text}</li>
))}
</ul>
</div>
);
}
실습: 검색 기능 최적화하기
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
import { useState, useMemo } from 'react';
function SearchableList() {
const [searchTerm, setSearchTerm] = useState('');
const [items] = useState([
'사과', '바나나', '체리', '딸기', '포도',
'수박', '멜론', '복숭아', '자두', '키위'
]);
// 검색 결과를 메모이제이션
const filteredItems = useMemo(() => {
console.log('검색 실행 중...');
return items.filter(item =>
item.toLowerCase().includes(searchTerm.toLowerCase())
);
}, [searchTerm, items]); // searchTerm이나 items가 바뀔 때만 재계산
return (
<div>
<input
type="text"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="과일 이름을 검색하세요"
/>
<p>검색 결과: {filteredItems.length}개</p>
<ul>
{filteredItems.map((item, index) => (
<li key={index}>{item}</li>
))}
</ul>
</div>
);
}
4️⃣ useCallback - 함수 재사용하기
useCallback이란?
함수를 “기억”해두고 재사용하는 Hook이에요.
왜 필요한가요?
1
2
3
4
5
6
7
8
9
10
function App() {
const [count, setCount] = useState(0);
// ❌ App이 렌더링될 때마다 새로운 함수가 만들어져요
const handleClick = () => {
console.log('클릭!');
};
return <Button onClick={handleClick} />;
}
1
2
3
4
5
6
7
8
9
10
11
12
import { useCallback } from 'react';
function App() {
const [count, setCount] = useState(0);
// ✅ 한 번 만든 함수를 계속 재사용해요
const handleClick = useCallback(() => {
console.log('클릭!');
}, []); // 의존성 배열이 비어있으면 한 번만 생성
return <Button onClick={handleClick} />;
}
실습: Todo 앱 최적화하기
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
import { useState, useCallback, memo } from 'react';
// memo로 최적화된 TodoItem
const TodoItem = memo(function TodoItem({ todo, onToggle, onDelete }) {
console.log(`TodoItem ${todo.id} 렌더링됨`);
return (
<div style=>
<input
type="checkbox"
checked={todo.completed}
onChange={() => onToggle(todo.id)}
/>
<span style=>
{todo.text}
</span>
<button
onClick={() => onDelete(todo.id)}
style=
>
삭제
</button>
</div>
);
});
function TodoApp() {
const [todos, setTodos] = useState([
{ id: 1, text: '리액트 공부', completed: false },
{ id: 2, text: '운동하기', completed: false }
]);
const [input, setInput] = useState('');
// useCallback으로 함수 최적화
const handleToggle = useCallback((id) => {
setTodos(prevTodos =>
prevTodos.map(todo =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
)
);
}, []); // 빈 배열: 함수는 한 번만 생성됨
const handleDelete = useCallback((id) => {
setTodos(prevTodos => prevTodos.filter(todo => todo.id !== id));
}, []);
const handleAdd = () => {
if (input.trim()) {
setTodos([...todos, {
id: Date.now(),
text: input,
completed: false
}]);
setInput('');
}
};
return (
<div style=>
<h1>할 일 목록</h1>
<div>
<input
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="할 일을 입력하세요"
/>
<button onClick={handleAdd}>추가</button>
</div>
<div>
{todos.map(todo => (
<TodoItem
key={todo.id}
todo={todo}
onToggle={handleToggle}
onDelete={handleDelete}
/>
))}
</div>
</div>
);
}
포인트:
TodoItem을memo로 감쌌어요handleToggle과handleDelete를useCallback으로 최적화했어요- todo 하나를 토글해도 다른 TodoItem들은 렌더링되지 않아요!
5️⃣ 종합 실습: 게시판 앱
모든 최적화 기법을 사용한 게시판을 만들어봐요.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
import { useState, useMemo, useCallback, memo } from 'react';
// 게시글 카드 컴포넌트 (memo로 최적화)
const PostCard = memo(function PostCard({ post, onLike }) {
console.log(`PostCard ${post.id} 렌더링됨`);
return (
<div style=>
<h3>{post.title}</h3>
<p style=>{post.content}</p>
<div style=>
<span>작성자: {post.author}</span>
<button
onClick={() => onLike(post.id)}
style=
>
👍 좋아요 {post.likes}
</button>
</div>
</div>
);
});
function PostBoard() {
const [posts, setPosts] = useState([
{ id: 1, title: 'React 시작하기', content: 'React는 재미있어요', author: '김철수', likes: 5 },
{ id: 2, title: 'Hook 사용법', content: 'Hook은 강력해요', author: '이영희', likes: 3 },
{ id: 3, title: '성능 최적화', content: '최적화는 중요해요', author: '박민수', likes: 8 }
]);
const [searchTerm, setSearchTerm] = useState('');
const [sortBy, setSortBy] = useState('latest'); // latest 또는 likes
// 검색 결과를 useMemo로 최적화
const filteredPosts = useMemo(() => {
console.log('게시글 필터링 중...');
return posts.filter(post =>
post.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
post.content.toLowerCase().includes(searchTerm.toLowerCase())
);
}, [posts, searchTerm]);
// 정렬 결과를 useMemo로 최적화
const sortedPosts = useMemo(() => {
console.log('게시글 정렬 중...');
const sorted = [...filteredPosts];
if (sortBy === 'likes') {
sorted.sort((a, b) => b.likes - a.likes);
}
return sorted;
}, [filteredPosts, sortBy]);
// 좋아요 핸들러를 useCallback으로 최적화
const handleLike = useCallback((postId) => {
setPosts(prevPosts =>
prevPosts.map(post =>
post.id === postId
? { ...post, likes: post.likes + 1 }
: post
)
);
}, []);
return (
<div style=>
<h1>게시판</h1>
<div style=>
<input
type="text"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="제목이나 내용으로 검색..."
style=
/>
<select
value={sortBy}
onChange={(e) => setSortBy(e.target.value)}
style=
>
<option value="latest">최신순</option>
<option value="likes">좋아요순</option>
</select>
</div>
<p>검색 결과: {sortedPosts.length}개</p>
<div>
{sortedPosts.map(post => (
<PostCard
key={post.id}
post={post}
onLike={handleLike}
/>
))}
</div>
</div>
);
}
export default PostBoard;
사용된 최적화:
PostCard를memo로 감싸서 불필요한 렌더링 방지filteredPosts를useMemo로 검색 최적화sortedPosts를useMemo로 정렬 최적화handleLike를useCallback으로 함수 재사용
결과:
- 검색어를 입력해도 변경된 게시글만 렌더링돼요
- 정렬을 바꿔도 필요한 계산만 다시 해요
- 좋아요를 눌러도 해당 게시글만 업데이트돼요
흔한 실수와 해결 방법
실수 1: 모든 곳에 최적화 적용하기
1
2
3
4
// ❌ 불필요한 최적화
const SimpleText = memo(function SimpleText({ text }) {
return <p>{text}</p>; // 너무 간단한 컴포넌트
});
해결:
1
2
3
4
// ✅ 간단한 컴포넌트는 최적화 불필요
function SimpleText({ text }) {
return <p>{text}</p>;
}
언제 최적화할까요?
- 렌더링이 느린 컴포넌트
- 큰 리스트를 렌더링하는 경우
- 복잡한 계산이 있는 경우
실수 2: 의존성 배열 잘못 설정하기
1
2
3
4
// ❌ 의존성 배열이 잘못됨
const filtered = useMemo(() => {
return items.filter(item => item.name.includes(searchTerm));
}, []); // searchTerm이 바뀌어도 재계산 안 됨!
해결:
1
2
3
4
// ✅ 필요한 모든 의존성 포함
const filtered = useMemo(() => {
return items.filter(item => item.name.includes(searchTerm));
}, [items, searchTerm]); // items나 searchTerm이 바뀌면 재계산
실수 3: 객체를 Props로 전달하기
1
2
3
4
5
// ❌ 매번 새로운 객체 생성
function App() {
return <UserCard user= />;
// 렌더링마다 새 객체가 만들어져서 memo가 소용없음
}
해결:
1
2
3
4
5
6
7
8
9
10
11
12
// ✅ useMemo로 객체 메모이제이션
function App() {
const user = useMemo(() => ({ name: '김철수', age: 25 }), []);
return <UserCard user={user} />;
}
// 또는 상수로 선언
const USER = { name: '김철수', age: 25 };
function App() {
return <UserCard user={USER} />;
}
정리
오늘 배운 내용
- 렌더링이 언제 일어나는지 이해했어요
- React.memo로 컴포넌트 최적화를 할 수 있어요
- useMemo로 계산 결과를 저장할 수 있어요
- useCallback으로 함수를 재사용할 수 있어요
- 언제 최적화해야 하는지 알아요
최적화 3원칙
- 측정 먼저: 느린지 확인하고 최적화하기
- 필요한 곳만: 모든 곳에 최적화하지 않기
- 의존성 배열: 정확하게 설정하기
다음 단계
- React DevTools Profiler로 성능 측정하기
- 복잡한 앱에서 최적화 적용하기
- 가상 스크롤링(Virtual Scrolling) 알아보기
숙제
필수 과제
- 카운터 + 리스트 앱 만들기
- 카운터 버튼이 있어요
- 사용자 리스트가 있어요
- memo를 사용해서 카운터를 눌러도 리스트는 렌더링되지 않게 만들기
- 검색 가능한 상품 목록 만들기
- 상품 배열을 만들어요 (최소 10개)
- 검색 기능을 추가해요
- useMemo로 검색 결과를 최적화하기
선택 과제
- Todo 앱 완전체 만들기
- 할 일 추가/삭제/완료 기능
- memo, useMemo, useCallback 모두 사용하기
- 콘솔에서 렌더링 확인하기
- 성능 비교하기
- 최적화 전/후 버전 만들기
- 콘솔 로그로 렌더링 횟수 비교하기
- 어떤 차이가 있는지 기록하기
“최적화는 필요할 때만, 정확하게! 🎯”
모든 코드를 최적화할 필요는 없어요. 느린 부분을 찾아서 똑똑하게 최적화하세요.
React 완벽 가이드 시리즈
- React란 무엇인가? 시작하기 전 알아야 할 모든 것
- useState와 useEffect - 상태와 생명주기 기초
- 컴포넌트 이해하기 - 레고 블록으로 만드는 웹
- Props - 컴포넌트끼리 대화하기
- useEffect - 생명주기 이해하기
- 조건부/리스트 렌더링 - 똑똑하게 보여주기
- Context API - Props 지옥 탈출하기
- Custom Hooks - 나만의 Hook 만들기
- React Router - 페이지 이동하기
- 폼 처리 - 사용자 입력 받기
- 성능 최적화 - 똑똑하게 렌더링하기 ← 현재 글
- 에러 바운더리와 Suspense - 에러 처리와 로딩 상태 관리
- 상태 관리 라이브러리 - Redux, Zustand, Recoil 비교
- 서버 상태 관리 - React Query/TanStack Query 활용
- React와 TypeScript - 타입 안전한 React 개발
- Next.js 입문 - SSR/SSG와 풀스택 React
- React 테스팅 전략 - Jest, React Testing Library, E2E
- 고급 패턴과 베스트 프랙티스 - HOC, Render Props, Compound Components
- 실전 프로젝트 - Todo 앱에서 쇼핑몰까지
- React 배포와 DevOps - 빌드 최적화와 CI/CD
관련 자료
이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.
