개발새발 로그

React - useCallback에 대하여 본문

React

React - useCallback에 대하여

이즈흐 2023. 12. 13. 16:40

useCallback

useCallback도 useMemo처럼 메모이제이션 기법으로 컴포넌트의 성능을 최적화해주는 것이다.

 

 

메모이제이션이란

어떠한 자주 사용되는 값을 받아오기위해 반복적으로 계산해야한다면

이전에 이미 계산한 값을 캐싱해둠으로 해당 값이 필요할 때마다 반복적으로 계산하는게 아니라

메모리에서 꺼내서 재사용하는 최적화 기법

 

 

useMemo는 인자로 받은 콜백함수의 리턴 값을 메모이제이션해서 사용하는 것이다.

 

그럼 useCallback은 무엇일까?

 

useCallback은 인자로 받은 콜백함수 자체를 메모이제이션하는 것이다.

const cachedFn = useCallback(fn, dependencies)

useCallback은 리렌더 사이에 함수 정의를 캐시할 수 있는 React Hook이다.

 

먼저 알아야할 점이 있다.

자바스크립트에서 함수는 사실 객체다.

아래 코드를 보자

const calculate = (num) = >{
	return num + 1;
}

위 코드는 

calculate라는 변수에 함수 객체가 할당된 것이다.

함수 객체는 객체 타입이므로 객체가 변수에 바로 들어가는 것이 아니라

함수 객체가 저장된 메모리 공간의 주소 값이 변수에 저장된다.

 

이제 calculate변수는 함수 객체를 참조하고 있는 것이다.

 

리액트에서 함수형 컴포는트는 함수다.

함수형 컴포넌트가 렌더링 된다는 것은 호출된다는 것이다.

함수가 호출되면 모든 내부 변수는 초기화 된다.

 

초기화되면 calculate변수 또한 초기화 될 것이고, 함수 객체가 새로 생성이 된다.

그럼 함수 객체는 또 다른 메모리 공간에 저장이 된다.

그럼 calculate변수 안에는 또 다른 메모리 주소 값이 들어가 있게 된다.

 

 

그래서 만약 함수컴포넌트가 호출되어 모든 내부변수가 초기화될 때

useCallback으로 함수 객체를 감싸서 메모이제이션한다면 다시 초기화 되는 것을 막을 수 있다.

 

그 말은 컴포넌트가 맨 처음 렌더링 될때만 이 함수 객체를 만들어서 calculate를 초기화 해주고,

이후에 렌더링이 될  때는 calculate 변수가 새로운 함수 객체를 다시 할당받는게 아니라

이전에 이미 할당받은 함수 객체를 계속해서 갖고 있으면서 재사용할 수 있게 된다.

 

 


 

useCallback은 첫 번째 인자로 콜백 함수를 받고

두 번째 인자로 의존성 배열을 받는다.

const cachedFn = useCallback(fn, dependencies)

 

의존성 배열 내부에 있는 값이 변경되지 않는 이상 다시 초기화되지 않는다.

의존성 배열안에 빈값을 넣은 상태로 인자를 준다면 맨 처음 렌더링 되었을 때만 useCallback이 실행된다.

 

이 때 주의할 점

useCallback을 감싼 함수에 의존성 배열이 빈 상태로 state를 넣는다면

함수는 메모이제이션 해줬을 당시의 값을 기억하기 때문에

이후에 state가 변경되어도 메모이제이션된 함수 안의 값은 바뀌지 않는다.

즉 state가 바뀌어도 렌더링 이전의 함수 결과가 출력된다.

 

그래서 의존성 배열에 state를 넣어줘야

함수가 다시 초기화 되고, 정상적으로 예상되는 결과가 나타나게 된다.

 

 

 

우리가 useCallbakc을 써야할 상황은 언제일까?

간단히 말하자면 

함수 객체를 갖고있는 A라는 변수가 있는 상태에서

아무 관련 없는 state가 변경되어서 렌더링이 되었을 때

A라는 함수는 다시 초기화가 된다.

 

이는 비효율적이다.

왜냐하면  A라는 변수는 state와 관련이 없는데 재 생성되어야하기 때문이다.

이를 막을 때 주로 사용된다.

 

 

하위 컴포넌트에 props으로 함수를 보내줄 때도 마찬가지다.

만약 하위 컴포넌트에 A라는 함수 객체를 보내주는 상위 컴포넌트가 있다고 가정하자

만약 상위 컴포넌트에서 어떤 동작에 의해 렌더링이 되었다면

상위 컴포넌트의 내부 변수는 초기화 될 것이고,

하위 컴포넌트도 재 호출될 것이고,

물론 하위 컴포넌트에 props로 보내주던 A라는 함수도 초기화 된다.

 

근데 여기서 A라는 함수는 눈으로 보기에 달라진 점이 없다. 

그저 똑같은 함수인데 

만약 하위 컴포넌트에서 useEffect를 통해 A 함수를 의존성 배열로 받고 있다면

A 함수이전과 다른 함수 객체이므로 

useEffect가 재 호출 될 것이다.

 

이 점이 비효율적인 것이다.

이를 위해서 useCallback을 사용하는 것이다.

 

 

useCallback 내에서 state 업데이트하는 방법

때로는 메모된 콜백의 이전 상태를 기반으로 상태를 업데이트 해야하는 경우가 있다.

그래서 아래 handleAddTodo 함수는 todos를 종속성으로 지정하는데 

그 이유는 다음 todo를 계산해야되기 때문이다.

function TodoList() {
  const [todos, setTodos] = useState([]);

  const handleAddTodo = useCallback((text) => {
    const newTodo = { id: nextId++, text };
    setTodos([...todos, newTodo]);
  }, [todos]);
  // ...

일반적으로 메모화된 함수는 가능한 한 종속성이 적어야 한다.

다음 상태를 계산하기 위해 일부 상태만 읽어야 하는 경우

업데이터 함수를 전달하여 해당 종속성을 제거할 수 있다.

function TodoList() {
  const [todos, setTodos] = useState([]);

  const handleAddTodo = useCallback((text) => {
    const newTodo = { id: nextId++, text };
    setTodos(todos => [...todos, newTodo]);
  }, []); // ✅ No need for the todos dependency
  // ...

여기서는 할 일을 종속성으로 만들고 내부에서 읽는 대신

상태를 업데이트하는 방법에 대한 명령어(todos => [...todos, newTodo])를 React에 전달한다.

 

 

이펙트가 너무 자주 발동되지 않도록 하기

때로는 useEffect 내부에서 함수를 호출하고 싶을 때가 있다.

function ChatRoom({ roomId }) {
  const [message, setMessage] = useState('');

  function createOptions() {
    return {
      serverUrl: 'https://localhost:1234',
      roomId: roomId
    };
  }

  useEffect(() => {
    const options = createOptions();
    const connection = createConnection();
    connection.connect();
    // ...

이로 인해 문제가 발생합니다.

모든 반응형 값은 Effect의 종속성으로 선언해야 합니다.

그러나 아래와 같이 createOptions을 종속성으로 선언하면 Effect가 채팅방에 계속 다시 연결하게 됩니다

  useEffect(() => {
    const options = createOptions();
    const connection = createConnection();
    connection.connect();
    return () => connection.disconnect();
  }, [createOptions]); // 🔴  문제: 이 종속성은 렌더링할 때마다 변경됩니다.
  // ...

 

이 문제를 해결하려면 Effect에서 호출해야 하는 함수를 useCallback으로 래핑하면 됩니다

function ChatRoom({ roomId }) {
  const [message, setMessage] = useState('');

  const createOptions = useCallback(() => {
    return {
      serverUrl: 'https://localhost:1234',
      roomId: roomId
    };
  }, [roomId]); // ✅ roomId가 변경될 때만 변경
  useEffect(() => {
    const options = createOptions();
    const connection = createConnection();
    connection.connect();
    return () => connection.disconnect();
  }, [createOptions]); // ✅ createOptions가 변경될 때만 변경됩니다.
  // ...

 

이렇게 하면 roomId가 동일한 경우 재렌더링 간에 createOptions 함수가 동일하게 유지됩니다.

하지만 함수 종속성을 없애는 것이 더 좋습니다.

함수를 Effect 내부로 이동하세요

function ChatRoom({ roomId }) {
  const [message, setMessage] = useState('');

  useEffect(() => {
    function createOptions() { // ✅ 사용 콜백이나 함수 종속성이 필요 없습니다!
      return {
        serverUrl: 'https://localhost:1234',
        roomId: roomId
      };
    }

    const options = createOptions();
    const connection = createConnection();
    connection.connect();
    return () => connection.disconnect();
  }, [roomId]); // ✅ roomId가 변경될 때만 변경
  // ...

 


모든 반응형 값은 Effect의 종속성으로 선언?
useEffect내부에 만약 state나 props와 관련된 값은 의존성 배열에 꼭 포함시켜야합니다.

만약 useEffect 내부에서 state나 props와 같은 값이 사용되었는데,
그 값이 변경될 때마다 효과를 다시 실행하고 싶다면 해당 값을 종속성 배열에 추가해야 합니다.
그렇지 않으면 예상치 못한 문제가 발생할 수 있습니다.
1. *클로저(Closure) 문제:
useEffect 내부에서 상위 스코프의 변수를 사용할 때, 해당 변수가 변경되지 않으면서 useEffect가 여러 번 실행될 수 있습니다. 이는 클로저 문제로 인해 발생합니다. 종속성 배열에 해당 변수를 명시함으로써 이러한 문제를 방지할 수 있습니다.
2. 무한 루프 문제:
종속성 배열에 반응형 값이 없는 경우(빈 배열 조차 없는 경우),
useEffect는 매 렌더링 시에 실행되므로 무한 루프에 빠질 수 있습니다.
예를 들어, 상태를 변경하고 해당 상태를 useEffect에서 다시 변경하는 경우가 있을 수 있습니다.
3. 성능 문제:
종속성이 변경되지 않는데도 계속해서 useEffect가 실행되면 성능에 영향을 미칠 수 있습니다.
불필요한 계산이나 API 호출이 반복되면서 성능 저하가 발생할 수 있습니다.

종속성 배열에 해당 반응형 값들을 명시함으로써 이러한 문제들을 방지하고,
useEffect가 의도대로 동작하도록 할 수 있습니다.
종속성 배열을 관리함으로써 useEffect가 특정 값들이 변경될 때만 실행되도록 조절할 수 있습니다.

 


 

 

커스텀 훅 최적화하기

사용자 정의 Hook을 작성하는 경우 반환하는 모든 함수를 useCallback으로 감싸는 것이 좋습니다

function useRouter() {
  const { dispatch } = useContext(RouterStateContext);

  const navigate = useCallback((url) => {
    dispatch({ type: 'navigate', url });
  }, [dispatch]);

  const goBack = useCallback(() => {
    dispatch({ type: 'back' });
  }, [dispatch]);

  return {
    navigate,
    goBack,
  };
}

 

 

루프에서 각 목록에 대해 useCallback을 사용했짐나 허용되지 않는다?

차트 컴포넌트가 메모로 감싸져 있다고 가정해 봅시다.

ReportList 컴포넌트가 다시 렌더링할 때 목록의 모든 차트를 다시 렌더링하는 것을 건너뛰고 싶을 수 있습니다.

그러나 루프에서 useCallback 호출할 수는 없습니다.

function ReportList({ items }) {
  return (
    <article>
      {items.map(item => {
        // 🔴 이와 같은 루프에서는 useCallback을 호출할 수 없습니다:
        const handleClick = useCallback(() => {
          sendReport(item)
        }, [item]);

        return (
          <figure key={item.id}>
            <Chart onClick={handleClick} />
          </figure>
        );
      })}
    </article>
  );
}

 

대신 아래와 같이  Report에 대한 컴포넌트를 추출하고 거기에 useCallback 넣습니다

function ReportList({ items }) {
  return (
    <article>
      {items.map(item =>
        <Report key={item.id} item={item} />
      )}
    </article>
  );
}

function Report({ item }) {
  // ✅최상위 수준에서 useCallback을 호출합니다:
  const handleClick = useCallback(() => {
    sendReport(item)
  }, [item]);

  return (
    <figure>
      <Chart onClick={handleClick} />
    </figure>
  );
}

 

또는 위 코드에서 useCallback을 제거하고 대신 Report 자체를 메모로 래핑할 수 있습니다.

Report컴포넌트의 props가 변경되지 않으면 Report는 다시 렌더링을 건너뛰므로 차트도 다시 렌더링을 건너뜁니다:

function ReportList({ items }) {
  // ...
}

const Report = memo(function Report({ item }) {
  function handleClick() {
    sendReport(item);
  }

  return (
    <figure>
      <Chart onClick={handleClick} />
    </figure>
  );
});

 

 

 

 

 

 

 

*useEffect에서 의존성을 명시하지 않는다면 클로저 문제가 생긴다?

클로저 문제는 주로 JavaScript에서 함수가 생성될 때 해당 함수가 접근할 수 있는 외부 스코프의 변수가 변경되었을 때 발생하는 문제입니다.

React의 useEffect에서도 이러한 클로저 문제가 발생할 수 있습니다.

다음은 클로저 문제의 예시입니다

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

function MyComponent() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    // 이 효과는 count가 변경될 때만 실행되어야 하는 것이 의도
    console.log(`Effect: ${count}`);

    // 클로저 문제: setCount는 렌더링 시에 생성된 함수이므로 현재 count 값을 캡처함
    const intervalId = setInterval(() => {
      console.log(`Interval: ${count}`);
    }, 1000);

    return () => {
      clearInterval(intervalId);
    };
  }, []); // 종속성 배열이 비어있어서 count의 변경을 감지하지 못함

  const handleIncrement = () => {
    setCount(count + 1);
  };

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={handleIncrement}>Increment</button>
    </div>
  );
}

위 코드에서 useEffect의 종속성 배열이 비어있기 때문에,

setCount로 상태를 업데이트할 때마다 useEffect는 항상 동일한 클로저를 참조하게 됩니다.

이로 인해 setInterval에 의해 생성된 함수는 항상 최초 렌더링 시의 count 값을 캡처하게 됩니다.

결과적으로, 버튼을 클릭해도 setInterval에서 사용되는 함수는 항상 클릭 이전의 count 값을 보여줍니다.

이것은 클로저로 인해 발생하는 문제로, useEffect의 종속성 배열에 count를 추가함으로써 이 문제를 해결할 수 있습니다:

useEffect(() => {
  console.log(`Effect: ${count}`);

  const intervalId = setInterval(() => {
    console.log(`Interval: ${count}`);
  }, 1000);

  return () => {
    clearInterval(intervalId);
  };
}, [count]); // 종속성 배열에 count 추가

 

728x90
반응형
LIST

'React' 카테고리의 다른 글

React - 컴포넌트 심화  (0) 2023.12.15
React - useReducer에 대하여  (0) 2023.12.13
React - 커스텀 훅을 만들면서  (0) 2023.12.12
React - useMemo에 대하여  (1) 2023.12.11
React - useContext에 대하여  (1) 2023.12.11