포스트

[이제와서 시작하는 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>
  );
}

언제 렌더링이 일어나나요?

  1. State가 변경될 때
  2. Props가 변경될 때
  3. 부모 컴포넌트가 렌더링될 때

문제 상황 보기

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>
  );
}

포인트:

  • TodoItemmemo로 감쌌어요
  • handleTogglehandleDeleteuseCallback으로 최적화했어요
  • 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;

사용된 최적화:

  1. PostCardmemo로 감싸서 불필요한 렌더링 방지
  2. filteredPostsuseMemo로 검색 최적화
  3. sortedPostsuseMemo로 정렬 최적화
  4. handleLikeuseCallback으로 함수 재사용

결과:

  • 검색어를 입력해도 변경된 게시글만 렌더링돼요
  • 정렬을 바꿔도 필요한 계산만 다시 해요
  • 좋아요를 눌러도 해당 게시글만 업데이트돼요

흔한 실수와 해결 방법

실수 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원칙

  1. 측정 먼저: 느린지 확인하고 최적화하기
  2. 필요한 곳만: 모든 곳에 최적화하지 않기
  3. 의존성 배열: 정확하게 설정하기

다음 단계

  • React DevTools Profiler로 성능 측정하기
  • 복잡한 앱에서 최적화 적용하기
  • 가상 스크롤링(Virtual Scrolling) 알아보기

숙제

필수 과제

  1. 카운터 + 리스트 앱 만들기
    • 카운터 버튼이 있어요
    • 사용자 리스트가 있어요
    • memo를 사용해서 카운터를 눌러도 리스트는 렌더링되지 않게 만들기
  2. 검색 가능한 상품 목록 만들기
    • 상품 배열을 만들어요 (최소 10개)
    • 검색 기능을 추가해요
    • useMemo로 검색 결과를 최적화하기

선택 과제

  1. Todo 앱 완전체 만들기
    • 할 일 추가/삭제/완료 기능
    • memo, useMemo, useCallback 모두 사용하기
    • 콘솔에서 렌더링 확인하기
  2. 성능 비교하기
    • 최적화 전/후 버전 만들기
    • 콘솔 로그로 렌더링 횟수 비교하기
    • 어떤 차이가 있는지 기록하기

“최적화는 필요할 때만, 정확하게! 🎯”

모든 코드를 최적화할 필요는 없어요. 느린 부분을 찾아서 똑똑하게 최적화하세요.


React 완벽 가이드 시리즈

  1. React란 무엇인가? 시작하기 전 알아야 할 모든 것
  2. useState와 useEffect - 상태와 생명주기 기초
  3. 컴포넌트 이해하기 - 레고 블록으로 만드는 웹
  4. Props - 컴포넌트끼리 대화하기
  5. useEffect - 생명주기 이해하기
  6. 조건부/리스트 렌더링 - 똑똑하게 보여주기
  7. Context API - Props 지옥 탈출하기
  8. Custom Hooks - 나만의 Hook 만들기
  9. React Router - 페이지 이동하기
  10. 폼 처리 - 사용자 입력 받기
  11. 성능 최적화 - 똑똑하게 렌더링하기 ← 현재 글
  12. 에러 바운더리와 Suspense - 에러 처리와 로딩 상태 관리
  13. 상태 관리 라이브러리 - Redux, Zustand, Recoil 비교
  14. 서버 상태 관리 - React Query/TanStack Query 활용
  15. React와 TypeScript - 타입 안전한 React 개발
  16. Next.js 입문 - SSR/SSG와 풀스택 React
  17. React 테스팅 전략 - Jest, React Testing Library, E2E
  18. 고급 패턴과 베스트 프랙티스 - HOC, Render Props, Compound Components
  19. 실전 프로젝트 - Todo 앱에서 쇼핑몰까지
  20. React 배포와 DevOps - 빌드 최적화와 CI/CD

관련 자료

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.