React

[React] useCallback을 이용한 성능 최적화

킹우현 2023. 7. 18. 15:11

원래 컴포넌트가 렌더링 될 때 그 안에 있는 함수도 다시 생성하게 됩니다. 하지만 똑같은 함수를 컴포넌트가 리렌더링 된다고 해서 계속 함수를 다시 만드는 것은 좋은 현상이 아닙니다.

그리고 이렇게 컴포넌트가 리렌더링 될 때마다 함수를 계속 다시 만든다고 하면 만약 이 함수가 자식 컴포넌트에 props로 전달되면 함수가 다시 생성될 때마다 자식 컴포넌트가 리렌더링 하게 됩니다.

 

1) useCallback 이란 ?

const memoizedCallback = useCallback(함수, 배열);

 

useCallback() 함수를 메모이제이션(Memoization)하기 위해서 사용되는 hook 함수입니다. 즉, 메모이제이션된 함수를 반환하는 함수입니다.

 

useCallback 적용은 useCallback 안에 콜백함수의존성 배열을 순서대로 넣어주시면 됩니다.

함수 내에서 참조하는 state, props가 있다면 의존성 배열에 추가해주시면 됩니다.

useCallback으로 인해서 의존성 배열에 추가해준 state 혹은 props가 변하지 않는다면 함수는 새로 생성되지 않습니다.


새로 생성되지 않기에 메모리에 새로 할당되지 않고 동일 참조 값을 사용하게 됩니다.

 

의존성 배열에 아무것도 없다면 컴포넌트가 최초 렌더링 시에만 함수가 생성되며 그 이후에는 동일한 참조 값을 사용하는 함수가 됩니다.


어떤 React 컴포넌트 함수 안에 함수가 선언이 되어 있다면 이 함수는 해당 컴포넌트가 랜더링될 때 마다 새로운 함수가 생성됩니다.

 

하지만 useCallback()을 사용하면, 해당 컴포넌트가 렌더링되더라도 그 함수가 의존하는 값들이 바뀌지 않는 한 기존 함수를 계속해서 반환합니다. 

 

사실 컴포넌트가 렌더링될 때마다 함수를 새로 선언하는 것은 자바스크립트가 브라우저에서 얼마나 빨리 실행되는지를 생각해보면 성능 상 큰 문제가 되지 않습니다. 

 

따라서 단순히 컴포넌트 내에서 함수를 반복해서 생성하지 않기 위해서 useCallback()을 사용하는 것은 큰 의미가 없거나 오히려 손해인 경우도 있습니다.

그럼 도대체 useCallback() hook 함수는 어떻게 쓸 때 의미있는 성능 향상을 기대할 수 있을까요?

 

2-1) JS 함수의 동등성

useCallback() hook 함수를 언제 사용해야하는지 제대로 이해하려면 먼저 자바스크립트에서 함수 간의 동등함이 어떻게 결정되는지 알 필요가 있습니다.

> const add1 = () => x + y;
undefined
> const add2 = () => x + y;
undefined
> add1 === add2
false


브라우저 콘솔을 열고 다음과 같이 동일한 코드의 자바스크립트 함수가 동일한지 === 연산자를 통해 비교를 해보면 false가 반환될 것입니다.

자바스크립트에서 함수도 객체로 취급이 되는데, 이때 메모리 주소에 의한 참조 비교가 일어나기 때문입니다.

이러한 자바스크립트 특성은 React 컴포넌트 함수 내에서 어떤 함수를 다른 함수의 인자로 넘기거나 자식 컴포넌트의 prop으로 넘길 때 예상치 못한 성능 문제로 이어질 수 있습니다.

 

2-2) 의존 배열로 함수를 넘길 때

import React, { useState, useEffect } from "react";

function Profile({ userId }) {
  const [user, setUser] = useState(null);

  const fetchUser = () =>
    fetch(`https://your-api.com/users/${userId}`)
      .then((response) => response.json())
      .then(({ user }) => user);

  useEffect(() => {
    fetchUser().then((user) => setUser(user));
  }, [fetchUser]);

  // ...
}

많은 React hook 함수들이 불필요한 작업을 줄이기 위해서 두 번째 인자로, 첫 번째 함수가 의존해야하는 배열을 받습니다.

예를 들어, useEffect() 함수는 두 번째 인자로 넘어온 의존 배열이 변경될 때만 첫 번째 인자로 넘어온 함수를 호출합니다.

예를 들어, 다음과 컴포넌트에서 API를 호출하는 코드는 fetchUser 함수가 변경될 때만 호출됩니다. 여기서 위에서 설명드린 자바스크립트가 함수의 동등성을 판단하는 방식 때문에 예상치 못한 무한 루프에 발생하게 됩니다. 

fetchUser는 함수이기 때문에, userId 값이 바뀌든 말든 컴포넌트가 랜더링될 때 마다 새로운 참조값으로 변경이 됩니다. 

 

그러면 useEffect() 함수가 호출되어 user 상태값이 바뀌고 그러면 다시 랜더링이 되고 그럼 또 다시 useEffect() 함수가 호출되는 악순환이 반복됩니다.

import React, { useState, useEffect } from "react";

function Profile({ userId }) {
  const [user, setUser] = useState(null);

  const fetchUser = useCallback(
    () =>
      fetch(`https://your-api.com/users/${userId}`)
        .then((response) => response.json())
        .then(({ user }) => user),
    [userId]
  );

  useEffect(() => {
    fetchUser().then((user) => setUser(user));
  }, [fetchUser]);

  // ...
}

이와 같은 상황에서 useCallback() Hook을 이용하면 컴포넌트가 다시 렌더링되더라도 fetchUser 함수의 참조값을 동일하게 유지시킬 수 있습니다.

 

 따라서 의도했던 대로, useEffect()에 넘어온 함수는 userId 값이 변경되지 않는 한 재호출 되지 않게 됩니다. 

 

2-3) React.memo와 함께 사용하기

1) 테스트 함수 생성하기

2) props로 함수 전달하기

 

3) 리렌더링 발생시키기

원래는 React.memo로 감싸줘서 리렌더링 되지 않던 컴포넌트들이 한 글자 입력 시마다 List 컴포넌트까지 다시 리렌더링 되는 걸 볼 수가 있습니다.

👉🏻 B 컴포넌트가 렌더링될 때 마다 testFunction이 새로 생성되기 때문에, List의 prop 값도 바뀌게 되어 불필요한 렌더링이 일어나게 됨 !

4) useCallback Hook을 통해 문제 해결하기

useCallback Hook을 사용하여 prop으로 전달된 함수를 메모이제이션 하였습니다.

따라서 해당 함수(testFunction)은 B 컴포넌트가 렌더링될 때마다 다시 생성되는 것이 아니라, 첫 렌더링 시에 생성된 함수를 반환(재사용)하게 되고, 따라서 List 컴포넌트의 prop 값 또한 변경되지 않기 때문에 렌더링이 일어나지 않습니다.

 

이런 방식으로 React.memouseCallback을 사용해서 렌더링 성능을 개선할 수 있습니다 :)

 

useCallback을 사용하기 좋은 경우

useCallback도 모든 함수에 다 사용하기보다는 사용하는 자체로서 비용이 들기 때문에 정말 필요한 때에 사용하는 게 좋습니다.

1. 자식 컴포넌트가 React.memo()로 최적화되어 있고
2. 그 자식 컴포넌트에게 해당 함수를 props로 넘겨줄 때

3. 또는 의존성 배열에 함수가 포함된 경우


useCallback을 사용하는 것이 유용합니다.