도리쓰에러쓰

[React] 리액트 렌더링 성능 최적화 (Performance Optimize) 본문

React/React

[React] 리액트 렌더링 성능 최적화 (Performance Optimize)

강도리 2022. 7. 15. 13:36

리액트 성능 최적화하는 방법을 알아보기 전에 컴포넌트가 리렌더링 되는 조건을 알고 지나가야 할 것 같다.

 

📍 컴포넌트 리렌더링 조건

1️⃣ 부모에서 전달받은 props가 변경될 때

2️⃣ 부모 컴포넌트가 재렌더링될 때

3️⃣ 자신의 state가 변경될 때

 

리액트의 성능 최적화를 위해선 불필요한 렌더링을 막을 필요가 있다.

지금부터 리액트의 성능 최적화 7가지에 대해 정리하겠다.


1. useMemo()

useMemo()는 컴포넌트의 성능을 최적화시킬 수 있는 대표적인 React Hooks 중 하나다.

useMemo()는 메모이제이션(Memoization)된 값을 반환한다.

💡 메모이제이션(Memoization) :: 연산의 결과값을 메모리에 저장해두고 동일한 입력이 들어오면 재사용하는 기법

 

만약 컴포넌트 내의 어떤 함수가 값을 리턴하는데 많은 시간이 소요된다면 이 컴포넌트가 재렌더링될 때마다 함수가 호출되면서 많은 시간을 소요하게 될 것이다. 또 이 함수가 리턴되는 값이 자식 컴포넌트에도 사용된다면, 자식 컴포넌트도 함수가 호출될 때마다 새로운 값을 받아 재렌더링 하게 된다.

 

아래 코드는 아직 useMemo()가 사용되지 않은 코드이다.

import { useState, useMemo useRef } from "react";
import Item from "./Item";
import Average from "./Average";

function UserList() {
  let numberRef = useRef(2);

  const [users, setUsers] = useState([
    {
      id: 0,
      name: "sewon",
      age: 30,
      score: 100
    },
    {
      id: 1,
      name: "kongil",
      age: 50,
      score: 10
    }
  ]);

  const average = (() => {
    return users.reduce((acc, cur) => {
      return acc + cur.age / users.length;
    }, 0);
  })();

  return (
      <div>
       <Average average={average} />
      </div>
  );
}

export default UserList;

위 코드는 Average 컴포넌트가 실행되어 함수 average에서 리턴된 값이 props로 전달된다.

함수 average가 리턴값이 나오기까지 많은 시간을 소요하는 가정한다면, UserList 컴포넌트가 재렌더링될 때마다 함수 average가 실행될 것이다.

 

불필요한 연산을 줄이려면 useMemo()를 통해 해결할 수 있다. useMemo() 사용 방법은 아래와 같다.

 

💡 useMemo() 사용 방법

useMemo(()=> func, [input_dependency])

1️⃣ func : 캐시하고 싶은 함수

2️⃣ input_dependency : 해당 값들이 변경되면 func이 호출

 

위 예시에서 불필요한 연산을 줄이기 위해 아래 코드처럼 useMemo()를 사용하여 최적화시킬 수 있다. 

const average = useMemo(() => {
    return users.reduce((acc, cur) => {
      return acc + cur.score / users.length;
    }, 0);
  }, [users]);

useMemo()는 배열 안에 작성한 값(input_dependency)이 변하지 않으면 함수를 다시 호출하지 않고 이전에 반환된 결과값을 재사용한다. 이렇게 되면 함수 호출 시간도 줄이고, props로 받는 하위 컴포넌트의 재렌더링도 방지할 수 있다.


2. React.memo

React.memo는 hook이 아니기 때문에 클래스 컴포넌트에서도 사용이 가능하다.

함수 컴포넌트에서는 shouldComponentUpdate를 사용할 수 없어서 그 대안으로 React.memo를 사용하고 있다.

React.memo를 통해 컴포넌트의 props가 바뀌지 않았다면 재렌더링되지 않도록하여 성능을 최적화할 수 있다.

또 React.memo는 콜백함수를 이용하여 메모이제이션(Memoization)을 적용할지의 여부도 판단할 수 있다.

 

아래 코드는 1.useMemo() 코드 예시에서 몇가지가 추가된 예시이다.

Item 컴포넌트로 리스트를 만들고 버튼을 클릭할 때마다 addUserClickHandler() 함수가 실행되어 리스트가 추가되는 예시이다.

import { useState, useRef } from "react";
import Item from "./Item";
import Average from "./Average";

function UserList() {
  const numberRef = useRef(2);
  const [text, setText] = useState("");
  const [users, setUsers] = useState([
    {
      id: 0,
      name: "sewon",
      age: 30,
      score: 100
    },
    {
      id: 1,
      name: "kongil",
      age: 50,
      score: 10
    }
  ]);

  const average = useMemo(() => {
    return users.reduce((acc, cur) => {
      return acc + cur.score / users.length;
    }, 0);
  }, [users]);

  const textChangeHandler = (e) => {setText(e.currentTarget.value)}
  
   const addUserClickHandler = () => {
    setUsers([
      ...users,
      {
        id: (numberRef.current += 1),
        name: "yeonkor",
        age: 30,
        score: 90
      }
    ]);
  }

  return (
      <div>
       <input
         type="text"
         value={text}
         onChange={textChangeHandler}
        />
       <Average average={average} />
       <button type='button' className='button' onClick={addUserClickHandler}>
        새 유저 생성
       </button>
      {users.map((user) => {
        return (
          <Item key={user.id} user={user} />
        );
      })}
      </div>
  );
}

export default UserList;

 

//Item.jsx
import React,{ memo } from "react";

function Item({user}) {

  return (
    <div className="item">
      <div>이름: {user.name}</div>
      <div>나이: {user.age}</div>
      <div>점수: {user.score}</div>
      <div>등급: {result.grade}</div>
    </div>
  );
}

export default memo(Item);

Item 컴포넌트에 React.memo를 적용하였으므로 버튼을 클릭하여 users를 추가할 때마다 UserList 컴포넌트가 재렌더링 되더라도 새로 추가된 Item 컴포넌트만 새로 렌더되고 이미 렌더된 Item 컴포넌트들은 재렌더링되지 않는다. 


❓ React.memo는 언제 사용하면 좋을까

React.memo는 무분별한 사용은 지양해야 한다.

이를 사용하는 코드와 메모이제이션(Memoization)용 메모리가 추가로 필요하게 되고, 최적화를 위한 연산이 불필요한 경우엔 비용만 발생시키기 때문이다.

 

🙆🏻‍♀️ React.memo를 사용해야 하는 경우

1️⃣ 함수 컴포넌트에 같은 props, 같은 렌더링 결과를 제공할 경우

2️⃣ UI Element 양이 많은 컴포넌트의 경우

3️⃣ Pure Functional 컴포넌트의 경우


3. useCallback()

useCallback()은 useMemo()와 비슷하지만

useMemo()는 리턴값을 memoize하고, useCallback()은 함수 선언을 memoize하는데 사용한다.

 

위 예시에서 UserList 컴포넌트 안에 있는 button 태그를 아래 Button 컴포넌트로 변경하여 예시를 들어보려고 한다.

import React.{memo} from "react";

function Button({onClick}) {
  return (
    <button type="button" onClick={onClick}>
      버튼
    </button>
  );
}

export default memo(Button);

onClick() 함수는 UserList에서 전달받고 있는 함수이다.

UserList 컴포넌트는 사용자가 input에 입력할 때마다 자식 컴포넌트를 포함하여 재렌더링된다.

그런데 재렌더링될 때마다 addUserClickHandler() 함수를 새로 생성하여 Button 컴포넌트에 props를 전달하고 있다.

Button 컴포넌트는 불필요한 렌더링을 막기 위해 memo를 통해 이전 props와 같다면 컴포넌트를 재렌더링하지 않고 있어야 한다.

 

하지만 이 경우에 Button 컴포넌트도 같이 재렌더링 되는 문제가 발생한다.

그 이유는 함수는 객체이고, 새로 생성된 함수는 다른 참조 값을 가지기 때문에 Button 컴포넌트 입장에서는 새로 생성된 함수를 받을 때 props가 변한 것이라고 인지하기 때문이다.

 

상위 컴포넌트가 재렌더링될 때마다 상위 컴포넌트 안에 선언된 함수를 새로 생성하기 때문에 그때마다 새로운 참조 함수를 하위 컴포넌트로 넘겨주게 된다. 그래서 하위 컴포넌트도 props가 달라졌기 때문에 컴포넌트를 memo로 감싼다 하더라도 재렌더링이 일어나게 되는 것이다.

 

이런 경우엔 useCallback()을 사용하여 종속 변수들이 변하지 않는 이상 함수를 재생성하지 않고,

이전에 사용했던 참조 변수를 그대로 하위 컴포넌트에 props로 전달한다.

그리하여 하위 컴포넌트도 props가 변경되지 않는다고 인지하여 하위 컴포넌트의 재렌더링을 방지할 수 있다.


4. 자식 컴포넌트의 props로 객체를 넘겨줄 경우 변형하지 않고 넘기기

흔히 props의 값으로 객체를 넘겨줄 때가 많은데 이 때 props로 전달하는 형태에 주의해야 한다.

 

아래 예시같은 경우 새로 생성된 객체가 props로 들어가기 때문에 컴포넌트가 재렌더링될 때마다 새로운 객체가 생성되어 자식 컴포넌트로 전달된다. (props로 전달한 객체가 동일한 값이어도 새로 생성된 객체는 이전 객체와 다른 참조 주소를 가진 객체이기 때문에 자식 컴포넌트는 메모이제이션(memoization)이 되지 않는다.)

// 생성자 함수
<Component prop={new Obj("x")} />

// 객체 리터럴
<Component prop={{property: "x"}} />

 

🙅🏻‍♀️ 아래 예시는 메모이제이션(memoization)이 되지 않는 옳지 않은 예시이다.

function UserList() {
{...}

 const getResult = useCallback((score) => {
    if (score <= 70) {
      return { grade: "D" };
    } else if (score <= 80) {
      return { grade: "C" };
    } else if (score <= 90) {
      return { grade: "B" };
    } else {
      return { grade: "A" };
    }
  }, []);

return(
 <div>
 {users.map((user) => {
    return (
      <Item key={user.id} user={user} result={getResult(user.score)} />
        );
      })}
 </div> 
  
)
export default memo(UserList);


// Item.jsx  
function Item({user, result}) {
  return (
    <div className="item">
      <div>이름: {user.name}</div>
      <div>나이: {user.age}</div>
      <div>점수: {user.score}</div>
      <div>등급: {result.grade}</div>
    </div>
  );
}

export default Item;

위 예시는 Item 컴포넌트에 객체가 props로 넘겨지고 있어 UserList 컴포넌트가 재렌더링될 때 Item 컴포넌트도 재렌더링이 된다. 이럴 때는 부모 컴포넌트에 함수를 작성하는 것보단 하위 컴포넌트에 작성하는 것이 좋다.

 

🙆🏻‍♀️ 좋은 예시

// UserList.jsx  
function UserList() {
{...}

return(
 <div>
 {users.map((user) => {
    return (
      <Item key={user.id} user={user} />
        );
      })}
 </div> 
  
)
export default memo(UserList);



// Item.jsx  
function Item({user}) {
  const getResult = useCallback((score) => {
    if (score <= 70) {
      return { grade: "D" };
    }
    if (score <= 80) {
      return { grade: "C" };
    }
    if (score <= 90) {
      return { grade: "B" };
    } else {
      return { grade: "A" };
    }
  }, []);

  const { grade } = getResult(user.score);

  return (
    <div className="item">
      <div>이름: {user.name}</div>
      <div>나이: {user.age}</div>
      <div>점수: {user.score}</div>
      <div>등급: {grade}</div>
    </div>
  );
}

export default memo(Item);

5. 컴포넌트를 매핑할 때에는 key값으로 index를 사용하지 않는다.

아래 리액트 공식 문서를 확인해보면 '항목의 순서가 바뀔 수 있는 경우 key에 index를 사용하는 것은 권장하지 않는다.'라고 적혀있다.

 

리스트와 Key – React

A JavaScript library for building user interfaces

ko.reactjs.org

 

리스트의 중간에 어떤 요소가 삽입될 때 그 중간 이후에 위치한 요소들은 전부 index가 변경된다.

이로 인해 key값이 변경되어 리액트는 key가 동일한 경우, 동일한 DOM Element를 보여주기 때문에 예상치 못한 문제가 발생할 수 있다.

또한, 데이터가 key와 매치가 안되어 서로 꼬이는 부작용도 발생할 수 있다.


6. useState()의 함수형 업데이트

기존의 useState()를 사용하며 대부분 setState() 할 때 새로운 값을 파라미터로 넣었다.

하지만 setState()를 사용할 때 새로운 값을 작성하는 것이 아닌, prevState를 작성하면 useCallback()을 사용할 때 의존성 배열에 값을 넣어주지 않아도 된다.

// 기존 useState()
const onRemove = useCallback(id => {
  setTodos(todos.filter(todo => todo.id !== id));
}, [todos]);

// 업데이트된 useState()
const onRemove = useCallback(id => {
  setTodos(todos => todos.filter(todo => todo.id !== id));
}, []);

7. Input에 onChange() 최적화

보통 <input ./> 태그에 onChange 이벤트를 주면 사용자가 값을 입력할 때마다 해당 컴포넌트가 렌더링된다.

lodash라는 최적화 라이브러리를 사용하기도 하지만, 아래 예시는 라이브러리를 사용하지 않고 최적화하는 방법이다.

// 최적화 전
function UserList() {
 {...}
  return (
      <div>
       <input
         type="text"
         value={text}
         onChange={(event) => setText(event.currentTarget.value)}
        />
   {...}
      </div>
  );
}

export default UserList;


// 최적화 후
function UserList() {
 {...}
  return (
      <div>
       <input
          ref={searchRef}
          type="text"
          onKeyUp={() => {
            let searchQuery = searchRef.current.value.toLowerCase();
            setTimeout(() => {
              if (searchQuery === searchRef.current.value.toLowerCase()) {
                setText(searchQuery);
              }
            }, 400);
          }}
        />
   {...}
      </div>
  );
}

export default UserList;
Comments