React 최적화

taehyung·2024년 7월 30일

React.js

목록 보기
24/24

최적화란?

최적화란 웹 서비스의 성능을 개선하는 모든 행위를 일컫습니다. 즉, 간단한 연산에서부터 어떤 라이브러리를 사용할지와 프로젝트의 아키텍처까지 모두 포함됩니다.

  1. 서버 응답속도 개선
  2. 이미지, 폰트 등 정적 파일 로딩의 개선
  3. 불필요한 네트워크 통신 횟수 제한

등등 최적화는 모든 곳에 적용할 수 있습니다.

리액트는 위와 같은 기능을 제공하지 않지만 컴포넌트의 리렌더링과 관련된 최적화 Hook을 제공합니다.

리액트에서 제공하는 최적화 Hook

리액트(React)에서는 성능 최적화를 위해 메모이제이션(Memoization)할 수 있는 여러 Hook을 제공합니다. 이를 통해 컴포넌트의 불필요한 재생성과 리렌더링을 방지할 수 있습니다. 세 가지 주요 Hook은 다음과 같습니다:

최적화 Hook의 종류

  • useMemo ( 연산 메모이제이션 )
  • useCallback ( 함수 메모이제이션 )
  • React.memo ( 컴포넌트 메모이제이션 )

🙋🏻‍♂️ 메모이제이션이 뭐에요?
메모이제이션(Memoization)이란, 동일한 계산의 반복을 피하기 위해 이전에 계산된 값을 저장하고 재사용하는 최적화 기법입니다. 이는 컴퓨팅 성능을 향상시키고, 특히 연산 비용이 높은 함수나 반복적인 작업에서 큰 효과를 발휘합니다. 메모이제이션의 핵심 개념은 "이미 계산한 값은 다시 계산하지 않는다"는 것입니다.


useMemo

컴포넌트 내부에서 불필요한 연산을 다시 수행하지 않도록 연산을 메모이제이션 하는 Hook 입니다.

const memo = useMemo(calculateValue, dependencies)
const memo = useMemo(()=>{}, [])

useMemo의 사용 방법은 위와 같습니다.

calculateValue

메모이제이션 하려는 연산을 가진 콜백 함수로 순수 함수여야 합니다.

dependencies

calculateValue에 사용된 모든 상태와 속성 배열입니다. 리액트는 이 배열에 담긴 종속성들을 이전 값과 비교하여 변화를 감지합니다.

Returns

calculateValue가 반환한 값을 메모하고, 첫 렌더링이라면 인자 없이 함수에서 계산된 값을 반환합니다. 그다음 렌더링부터는 저장되어 있던 값을 반환하거나 함수를 호출하여 새로 계산된 값을 반환합니다.

useMemo는 React에서 성능 최적화를 위해 사용하는 훅입니다. 이 훅은 특정 값이 변경되지 않으면 함수를 재실행하지 않고 이전에 계산된 값을 재사용합니다.

  1. 종속성 비교: useMemo는 먼저 현재 종속성과 이전 종속성을 비교하여 종속성이 변경되었는지 확인합니다.
  2. 변경 여부에 따른 처리:
    2-1. 변경되지 않은 경우: 종속성이 변경되지 않았다면, 함수를 다시 실행하지 않고 이전에 메모된 값을 반환합니다.
    2-2. 변경된 경우: 종속성이 변경되었다면, 함수를 실행하여 새로운 값을 계산하고 이를 메모합니다.
  3. 값 반환: 최종적으로 이 메모된 값을 반환합니다.

이 과정은 불필요한 재연산을 방지하여 컴포넌트의 성능을 향상시킵니다.

예제

function App() {
  const [count, setCount] = useState(0);
  const heavy = () => {
    let number = 0;
    console.log("heavy 실행");

    for (let i = 0; i < 1000; i++) {
      number = i;
    }
    return number;
  };

  return (
    <>
      <div>연산 결과 : {heavy()}</div>
      <div>{count}</div>
      <div>
        <button
          onClick={() => {
            setCount((prev) => prev + 1);
          }}
        >
          +
        </button>
      </div>
    </>
  );
}
  • count 상태가 변경되면 컴포넌트가 리렌더링 된다.
  • heavy 함수도 새로 생성된다. ( 함수 재생성 )
  • heavy 함수가 다시 연산하여 연산 결과를 화면에 표시한다.

함수의 재생성은 useMemo와 무관합니다. 함수 재생성은 useCallback으로 제어할 수 있습니다. useMemo는 함수의 연산 결과와 밀접한 관계가 있습니다.

만약 heavy 함수의 연산 비용이 크다면 어떻게 할까요? 실제로 count 상태와 아무런 관련없는 연산으로 보여집니다. 이럴때 useMemo를 사용하여 연산 결과를 메모이제이션 할 수 있습니다.

  const heavy = useMemo(() => {
    let number = 0;
    console.log("heavy 실행");
    for (let i = 0; i < 1000; i++) {
      number = i;
    }
    return number;
  }, []);

의존성배열이 비어있다면 이 함수는 마운트시에 단 한번만 결과를 메모이제이션 합니다. 상태와 속성에따라 return 하는 값이 변하는 함수라면 의존성 배열에 종속성을 추가하면 상태와 속성에 반응하여 값을 새롭게 메모이제이션 합니다.

  const heavy = useMemo(() => {
    let number = 0;
    console.log("heavy 실행");
    for (let i = 0; i < 1000; i++) {
      number = i;
    }
    return count + number;
  }, [count]);

이렇게 수정할 수 있습니다.


React.memo

컴포넌트의 자체를 메모이제이션하여 불필요한 리렌더링을 방지하는 Hook 입니다.

🙋🏻‍♂️ 불필요한 리렌더링이요?
네, 불필요한 리렌더링이란 리렌더링 트리거 4종류 중 "부모컴포넌트가 리렌더링 되었을 때" 를 의미합니다. 부모컴포넌트의 리렌더링에 반응해야하는 컴포넌트라면 상관 없지만 전혀 상관없는 컴포넌트라면 불필요한 리렌더링으로 간주할 수 있습니다.

예제

const Child = () => {
  console.log("Child 리렌더링");

  return <>child</>;
};

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

  return (
    <>
    <>{count}</>
      <button
        onClick={() => {
          setCount((prev) => prev + 1);
        }}
      >
        + 1
      </button>
      <Child></Child>
    </>
  );
}

App 컴포넌트가 리렌더링 될 때, Child 컴포넌트가 리렌더링 되면서 console이 계속 작동하는것을 확인할 수 있습니다. 실제로 App 컴포넌트와 아무런 관련이 없는 컴포넌트도 불필요하게 리렌더링이 발생하고 있습니다.

const Child = React.memo(() => {
  console.log("Child 리렌더링");

  return <>child</>;
});

Child 컴포넌트를 메모이제이션 하고나니 부모컴포넌트에 리렌더링에 반응하지 않게되었습니다. 하지만 React.memo를 단순히 부모컴포넌트의 리렌더링에만 반응하지 않게하려고 사용하는것은 아닙니다. 부모가 전달해주는 Props가 변경되어야만 리렌더링이 발생하게 만드는겁니다. 즉, 실질적으로 부모에게서 전달받고 사용하는 데이터가 변경되었을 때 입니다.

그럼 props를 전달해볼까요?

const Child = React.memo(({ onClick }: { onClick: () => void }) => {
  console.log("Child 리렌더링");

  return (
    <>
      <button onClick={onClick}>+ 1</button>
    </>
  );
});

function App() {
  const [count, setCount] = useState(0);
  const onClick = () => {
    setCount((prev) => prev + 1);
  };

  return (
    <>
      <>{count}</>
      <Child onClick={onClick}></Child>
    </>
  );
}


이번에 구조는 App컴포넌트는 단순히 보여주기만하고 자식 컴포넌트에서 상태변경 함수를 전달받아 렌더링 하고있습니다. 하지만 메모이제이션 했음에도 불구하고 계속 리렌더링이 되는것으로 확인이 됩니다.. 왜그럴까요?

자바스크립트에서 함수는 1급 객체입니다.
count 상태를 변경하면 App 컴포넌트가 리렌더링되면서 onClick 함수를 새로 만들게 됩니다. 그럼 이전에 만들어둔 onClick 함수와 새로 만들어진 onClink 함수는 같은 함수일까요?

네! 기능은 같은 함수입니다. 하지만 저장된 메모리의 주소가 다르기때문에 비교 연산을 했을 때 다른 함수라고 판별됩니다. 그렇다면 지금 상황에서 Props의 값이 변했다고 Child 컴포넌트는 판단하게되고 리렌더링이 발생합니다.

이 상황에서 방법은 두가지입니다.

첫번째 방법 : React.memo 커스터마이징

const Child = React.memo(
  ({ count, onClick }: { count: number; onClick: () => void }) => {
    console.log("Child 리렌더링");

    return (
      <>
        <>{count}</>
        <button onClick={onClick}>+ 1</button>
      </>
    );
  },
  (prevProps, nextProps) => {
    // 반환값에 따라, Props가 바뀌었는지 안바뀌었는지 판단
    // true 반환 : Props가 바뀌지 않음 -> 리렌더링 X
    // false 반환 : Props가 바뀌었음 -> 리렌더링 O
    
    if (prevProps.count !== nextProps.count) false;

    return true;
  }
);

child 컴포넌트에서 count 상태와 set 함수를 props로 받고있습니다. React.memo의 두번째 인자로 콜백함수를 전달하면 그 콜백함수는 첫번째 인자로 이전의 Props와 새로운 Props를 받게 됩니다. 두번째 콜백함수에서 count상태가 변경되면 리렌더링을하고 그렇지 않으면 리렌더링을 하지않습니다.

즉, Props로 전달받은 함수는 아예 고려대상이 아니게 되는것입니다.

두번째 방법 : useCallback 사용

function App() {
  const [count, setCount] = useState(0);
  const onClick = useCallback(() => {
    setCount((prev) => prev + 1);
  },[]);

  
  return (
    <>
      <Child count={count} onClick={onClick}></Child>
    </>
  );
}

App 컴포넌트에서 Props로 전달하는 함수에 useCallback를 사용하여 함수를 메모이제이션 하는 방법입니다. 이 방법은 함수가 계속 재생성되는것을 방지해주는데 아래에서 다시 설명하겠습니다.

HOC ( Higher Order Component ), 고차컴포넌트

고차컴포넌트란?
고차컴포넌트란 React.memo와 같이 컴포넌트를 인수로받아서 새로운 기능이 추가된 컴포넌트를 리턴해주는 컴포넌트입니다.
복잡한 리액트앱을 구축할 때 꽤나 자주 사용되는 아키텍처입니다.


useCallback

useCallback 훅은 함수 그 자체를 메모이제이션하며, 컴포넌트가 리렌더링 되어도 의존성 배열에 전달한 상태와 속성이 변경되지 않으면 함수를 다시 생성하지 않습니다.

const Child = React.memo(({ onClick }: { onClick: () => void }) => {
  console.log("Child 리렌더링");

  return (
    <>
      <button onClick={onClick}>+ 1</button>
    </>
  );
});

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

  const onClick = useCallback(() => {
    setCount((prev) => prev + 1);
  }, []);

  return (
    <>
      <>{count}</>
      <Child onClick={onClick}></Child>
    </>
  );
}

React.memo 에서 사용한 예제를 useCallback 훅을 사용한 예제로 변경했습니다. count를 증가시켜서 부모 컴포넌트가 리렌더링 되어도 onClick 함수가 재생성되지 않아 child 컴포넌트또한 리렌더링되지 않는것을 확인할 수 있습니다.

의존성배열

useCallback의 의존성 배열을 빈 배열로 두게되면 컴포넌트의 첫 마운트에 단 한번만 생성합니다.

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

  const onClick = useCallback(() => {
    setCount((prev) => prev + 1);
  }, []);
  
  const onToggle = useCallback(() => {
    setCount((prev) => prev + 1);
  }, [count]);

  return (
    <>
      <>{count}</>
      <Child onClick={onClick}></Child>
    </>
  );
}
  • onClick : 컴포넌트 마운트시 단 한번 생성
  • onToggle : count의 값이 변경될때마다 새로생성

이상으로 리액트의 최적화 훅 3가지에 대해 알아보았습니다.

리액트의 메모이제이션 3가지

  • useMemo : 불필요한 연산을 방지하기위해 함수의 연산 결과를 메모이제이션
  • useCallback : 불필요한 함수 재생성을 방지하기위해 함수 자체를 메모이제이션
  • React.memo : 불필요한 컴포넌트 리렌더링을 방지하기위해 컴포넌트를 메모이제이션
profile
Front End

0개의 댓글