본문 바로가기
Web/React

리액트의 메모이제이션 (useMemo, useCallback, React.memo)

by 가나닩 2024. 10. 31.

리액트는 컴포넌트를 기반으로 동작한다. 컴포넌트는 props, state, context 등의 변경이 감지될때 변경 내용을 반영하기 위해서 재연산, 재성성을 진행하고 재렌더링 된다는 특징을 가지고 있다.

이러한 특징은 UI의 일관성을 유지하고 사용자 경험을 개선 하거나 코드의 간결화, 예측 가능성 향상 등등의 장점들을 가지고 있지만 작성한 코드의 내용에 따라 불필요한 재렌더링이 자주 발생하여 성능에 악영향을 줄수도 있다.

 

성능저하를 최소화하기 위해서는 메모이제이션을 활용해야한다. 아주 쉽게 말하자면 "수정할 필요가 없는 부분은 냅둬라" 이다.

 

리액트의 불필요한 재연산, 재생성, 재렌더링을 방지하기 위한 기능은 여러가지가 있지만 여기서는 메모이제이션, 그 중에서도 useMemo와 useCallback, React.memo를 다룬다. 특징을 간단히 정리하자면 아래와같다.

 

  1. useMemo
    값의 저장 및 재사용 (함수의 불필요한 재실행, 배열 객체 등의 참조값 변경을 방지)
  2. useCallback
    함수 저장 및 재사용 (함수의 불필요한 재생성을 방지)
  3. React.memo
    컴포넌트 저장 및 재사용 (컴포넌트의 불필요한 재렌더링을 방지)

 

 

1. 메모이제이션의 필요성

리액트는 앞서 말한 props, state, context 등등 변경점이 감지되면 그 변경점이 포함된 컴포넌트 전체를 다시 호출한다. 쉽게말해 해당 컴포넌트에 포함된 변수, 배열, 객체, 함수, JSX코드, 자식 컴포넌트 등등 모든 코드가 재실행되는것이다.

 

이 방식이 문제가 되는 예로는 복잡한 연산을 하는 함수가 있을때 이 함수와 전혀 관계없는 변화가 감지될때도 함수를 재생성하고 재실행하게되어 성능저하가 발생하게 되는 것이다.

 

또 하나의 예로는 자식 컴포넌트가 매우 많이 포함되어 있는 경우 부모 컴포넌트에서 state의 값 하나만 변경되더라도 수많은 컴포넌트의 리렌더링이 발생하게 된다.

 

이러한 현상을 최소화하기위해 사용하는것이 메모이제이션이다. 연산이 복잡한 함수는 필요할때만 재생성 및 재실행 하도록 지정하고 자주 리렌더링될 필요없는 자식컴포넌트도 따로 지정하여 성능저하를 최소화 한다.

 

 

 

2. useMemo

• useMemo의 사용법과 용도

간단히 설명한것처럼 useMemo는 값을 저장하고 재사용한다. 아래의 예시를 보자.

import { useState } from "react";

function Parent() {
    const [apple, setApple] = useState(0);
    const [banana, setBanana] = useState(0);

    const appleTotal = () => {
        console.log("사과 금액 계산됨.");
        return apple * 1000;
    };

    return (
        <>
            <p>사과 : {apple}</p>
            <p>바나나 : {banana}</p>
            <p>사과 총 금액 : {appleTotal()}</p>
            <button
                onClick={() => {
                    setApple((prev) => prev + 1);
                }}
            >
                사과 +1
            </button>
            <button
                onClick={() => {
                    setBanana((prev) => prev + 1);
                }}
            >
                바나나 +1
            </button>
        </>
    );
}

export default Parent;

바나나 +1 버튼을 눌러도 "사과 금액 계산됨."이 콘솔에 출력된다.

 

사과와 바나나의 개수를 추가하고, 사과의 금액을 계산하는 코드이다. 사과의 총 금액은 사과의 개수가 추가될때만 계산하면 된다. 그러나 바나나의 개수를 추가하는 버튼을 눌러도 사과의 총 금액을 계산하는 appleTotal 함수가 호출되어 콘솔에 "사과 금액 계산됨."이 출력된다.

 

이는 banana라는 state의 값이 변경되면서 컴포넌트가 리렌더링되기 때문이다. 컴포넌트가 다시 렌더링 되면서 JSX 코드도 다시 실행되고 사과 총 금액을 표시하기 위한 appleTotal 함수가 다시 호출되어 함수 내의 연산내용이 실행되는것이다.

 

이 코드에서 필요한것은 사과의 개수가 변경될때만 사과의 총 금액을 연산하도록 하는것이다. useMemo를 사용하면 아래와 같이 코드를 수정할 수 있다.

import { useMemo, useState } from "react";

function Parent() {
    const [apple, setApple] = useState(0);
    const [banana, setBanana] = useState(0);

    // useMemo가 추가되었다.
    const appleTotal = useMemo(() => {
        console.log("사과 금액 계산됨.");
        return apple * 1000;
    }, [apple]);

    return (
        <>
            <p>사과 : {apple}</p>
            <p>바나나 : {banana}</p>
            <p>사과 총 금액 : {appleTotal}</p>
            <button
                onClick={() => {
                    setApple((prev) => prev + 1);
                }}
            >
                사과 +1
            </button>
            <button
                onClick={() => {
                    setBanana((prev) => prev + 1);
                }}
            >
                바나나 +1
            </button>
        </>
    );
}

export default Parent;

사과 총 금액을 계산하는 함수에 useMemo를 추가했다.

 

useMemo가 추가된 appleTotal 함수는 결과값을 캐싱(저장)한다. 이후 의존성 배열에 있는 apple이라는 state가 변경될때만 함수 내부의 연산을 다시 시행한다. 따라서 banana의 값이 변경되더라도 결과값은 캐싱되어 사용되기때문에 내부 연산이 실행되지않고 당연히 console.log도 동작하지 않는다.

 

• useEffect와의 차이점

위 코드만 보면 동일한 의존성 배열로 연산내용을 useEffect에 추가해도 똑같은거 아닌가? 라는 생각이 들 수 있다.

사과의 금액을 또 다른 state로 관리하고 useEffect 내부에 사과값을 연산하여 state값을 변경하는 코드를 집어넣으면 사실상 같은 동작을 수행한다. 하지만 useMemo와 useEffect는 용도에서 차이점이 존재한다.

 

  • useMemo
    앞선 설명과 같이 결과값을 캐싱하여 재사용하는것이 주 목적이다. 렌더링과정에 상관없이 미리 캐시된 데이터를 사용하며 연산이 필요한 경우에도 연산을 끝마친 후 결과값을 렌더링에 사용한다.
  • useEffect
    컴포넌트 생명 주기(마운트, 업데이트, 언마운트)를 다루고 비동기 실행을 하는것이 주 목적이다. 컴포넌트의 렌더링과 DOM 업데이트는 동기적으로 수행되지만 useEffect 내의 코드는 렌더링이 완료된 이후 비동기적으로 실행되므로 사이드 이펙트(데이터 페칭, 타이머 등) 처리에 활용하는것이 좋다.

 

이러한 특징에서 발생하는 눈으로 확인할수있는 차이점은 처음 페이지 접속때의 값 표시 유무를 보면 알수 있다.

useMemo로 처리한 연산결과값을 렌더링해보면 렌더링 단계에 이미 연산이 끝났으므로(동기실행) 화면표시와 동시에 값도 함께 표시되는것을 알수있다.

하지만 useEffect는 렌더링 이후 비동기적으로 코드를 실행하므로 연산이 끝나기전에는 빈 데이터 혹은 state의 초기값이 표시된다. 이후 연산이 완료되면 연산결과가 화면에 표시된다.

 

 

• 함수에만 사용가능한가?

useMemo는 여러번 언급했듯이 값을 저장하여 사용하는것이 주 목적이다. 따라서 배열, 객체같은 참조데이터도 참조값을 저장하여 사용하면 리렌더링때마다 참조값이 변경되며 새로 생성되는 것을 방지할 수 있고 해당 배열, 객체를 사용하는 컴포넌트 등의 리렌더링을 방지해줄수도 있다.

 

• useMemo 사용 시 주의사항

  1. 성능에 이점이 있는지 확인 후 사용
    특정 값을 반환하는 모든 계산 등에 useMemo를 사용하면 오히려 성능이 저하될 수 있다. useMemo는 캐시된 값과 현재 연산하려는 값이 같은지 비교하는 과정을 거친다. 연산 자체가 무거워 연산수를 줄여하는 특수한 경우가 아닌 단순 연산에 사용하게 되면 오히려 비교 절차가 추가 되면서 성능 저하를 가져올 수 있으므로 필요한 곳에만 가져다 사용하는것이 좋다.
  2. 정확한 의존성 배열 설정
    잘못된 값이 의존성 배열에 들어가게 되면 필요하지 않을때 계속 연산을 수행하게되면서 장점을 제대로 활용할 수 업없게 된다. 업데이트가 필요한 시점을 명확히 할수 있는 항목을 의존성 배열에 추가해야한다.

 

 

3. useCallback

• useCallback의 사용법과 용도

자바스크립트에서 함수를 사용할때는 함수의 내용물을 그대로 복사해서 사용하는것이 아니다. 함수의 내용물을 메모리에 저장한뒤 저장된 주소를 통해 함수를 사용한다. 함수가 저장된 위치의 주소를 함수의 참조값이라고 한다.

 

여기서 중요한 점은 완전히 같은 내용을 가진 함수를 여러개 생성하더라도 메모리 내에서는 따로 취급되어 함수를 사용할때 다른 함수로 인식한다는것이다. banana 함수 내부에 console.log("apple") 을 작성했다 하더라도 두 함수는 다른 주소에 저장되고 다른 함수로 취급된다. 이는 대부분 프로그래밍 언어가 공유하는 특성이다.

 

이러한 특징은 컴포넌트의 재렌더링때도 똑같이 작동하여 같은 함수라도 다시 생성되면 다른 주소에 저장되어 다른 함수로 취급된다. 이런 특징으로 인해 무한루프나 컴포넌트의 무의미한 재렌더링 현상이 발생될 수 있다.

 

아래의 예시를 보자.

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

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

    const increment = () => setCount((prevCount) => prevCount + 1);
    
    // increment 함수의 참조값이 계속 변경되어 무한루프가 된다.
    useEffect(() => {
        increment();
    }, [increment]);

    return (
        <div>
            <p>Count: {count}</p>
        </div>
    );
}

export default Counter;

 

useEffect는 increment 함수의 참조값 변경이 감지되면 increment 함수를 실행한다. useEffect에 의해 무한루프가 되는과정을 정리하면 다음과 같다.

  1. 코드가 실행된다. useEffect는 첫 실행시 내부 코드를 한번 실행한다.
  2. increment 함수가 실행되어 count의 값이 한번 더해진다.
  3. state의 변경을 감지한 Counter 컴포넌트는 재렌더링을 시작한다.
  4. 재렌더링은 앞서 설명한것처럼 코드를 처음부터 다시 실행한다.
  5. 이때 increment 함수는 내용이 같지만 코드 재실행에 의해 재생성 되므로 주소(참조값)가 바뀐다.
  6. useEffect는 increment의 참조값 변경을 감지하여 increment 함수를 실행한다.
  7. 2~6이 반복되며 무한루프가 된다.

이처럼 함수의 참조값이 트리거로 작동하는 곳에서 문제가 발생할 수 있다.

위 코드는 아래와 같이 수정하여 문제를 해결할 수 있다.

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

function Counter() {
    const [count, setCount] = useState(0);
    
    // useCallback에 의해 increment 함수의 참조값이 고정된다.
    const increment = useCallback(() => {
        setCount((prevCount) => prevCount + 1);
    }, []);

    useEffect(() => {
        increment();
    }, [increment]);

    return (
        <div>
            <p>Count: {count}</p>
        </div>
    );
}

export default Counter;

 

useCallback도 useMemo와 사용법이 유사하다. useMemo가 값을 저장하고 특정조건에서만 다시 계산을 실행한것처럼 useCallback은 함수의 참조값을 저장하고 특정조건에서만 함수를 재생성한다.

increment 함수는 useCallback에 의해 초기화되었고 의존성 배열이 비어있으므로 처음 생성후 컴포넌트가 재렌더링 되더라도 참조값이 변경되지 않는다. 

 

• useCallback을 사용해야 하는 이유 (사용 예시)

앞선 설명을 보면 useEffect의 의존성 배열에서 increment 함수를 제거하고 count를 추가하는것이 가장 간단한 해결방법이다. 그럼에도 useCallback을 사용해야하는 경우는 여러가지가 있다.

 

※ props, state 등을 기준으로 useEffect를 실행시키는 대신 함수의 변경을 기준으로 useEffect 실행시키기

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

function Counter() {
    const [count, setCount] = useState(0);
    const [apple, setApple] = useState(0);
    const [banana, setBanana] = useState(0);

    const increment = useCallback(() => {
        setCount(apple + banana);
    }, [apple, banana]);

    useEffect(() => {
        increment();
    }, [increment]);

    ...
}

export default Counter;

 

 

일반적으로 increment 함수에 useCallback을 사용하지 않고 useEffect의 의존성 배열에 apple, banana를 넣어 사용하게 된다. 실제로 그렇게 하는것이 가독성도 좋고 불필요한 useCallback의 사용도 덜어준다.

하지만 특정 함수에서 여러개의 state 등이 사용되고 추후에도 수정될 가능성이 높다면 useEffect가 변경을 감지하는것 보다는 함수 자체가 변경을 감지하여 재생성되도록 하는것이 유지보수에 도움이 될 수 있다.

이러한 방식은 데이터 fetch에 주로 사용된다.

 

※ 자식 컴포넌트 재렌더링 방지

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

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

  const handleClick = useCallback(() => {
    console.log("Button clicked");
  }, []);

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>Increment Count</button>
      <ChildComponent onClick={handleClick} />
    </div>
  );
}

const ChildComponent = React.memo(({ onClick }) => {
  console.log("ChildComponent 렌더링");
  return <button onClick={onClick}>Click Me</button>;
});

export default ParentComponent;

 

 

handleClick 함수가 자식 컴포넌트 props로 전달되어 사용되고 있다. Increment Count 버튼을 누르면 count값이 변경되고 부모 컴포넌트가 재렌더링 된다. useCallback이 없을경우 handleClick 함수도 재생성되어 다른 함수가 props로 전달되었다 판단한 자식 컴포넌트도 함께 재렌더링을 진행한다.

하지만 useCallback을 handleClick 함수에 추가하여 Increment Count 버튼을 누르더라도 자식컴포넌트는 재 렌더링이 되지 않아 console.log("ChildComponent 렌더링")이 동작하지 않는다.

 

이때는 자식 컴포넌트에 React.memo를 추가해줘야한다.

 

 

 

4. React.memo

앞서 설명한 useMemo와 useCallback이 무언가를 메모이제이션 한것처럼 React.memo는 컴포넌트를 메모이제이션 한다.

 

리액트에서 자식 컴포넌트는 기본적으로 부모 컴포넌트가 재렌더링 될때 함께 재렌더링 된다. 만약 자식 컴포넌트에 복잡한 계산이나 무거운 연산, 렌더링 로직이 포함되어 있을경우 부모 컴포넌트가 렌더링 될때마다 불필요한 연산을 계속 하게된다.

 

여기서 React.memo를 사용해주면 props의 변경 유무를 감지하여 컴포넌트를 캐싱 및 사용한다.

 

더보기
import React, { useState, useCallback } from "react";

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

  const handleClick = useCallback(() => {
    console.log("Button clicked");
  }, []);

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>Increment Count</button>
      <ChildComponent onClick={handleClick} />
    </div>
  );
}

const ChildComponent = React.memo(({ onClick }) => {
  console.log("ChildComponent 렌더링");
  return <button onClick={onClick}>Click Me</button>;
});

export default ParentComponent;

 

props로 전달되는 handleClick 함수는 useCallback으로 함수 참조값이 메모이제이션 되었다. 하지만 자식 컴포넌트는 props로 전달된 함수 참조값과 무관하게 부모 컴포넌트가 재렌더링될때 함께 재렌더링 된다.

 

React.memo를 사용하면 props의 변경이 없을 경우 재렌더링을 막아준다. useCallback으로 생성된 함수는 count의 값이 변경되어 부모 컴포넌트가 재렌더링 되더라도 참조값의 변경이 없으며 이를 props로 전달받은 React.memo 자식 컴포넌트도 props의 변경이 없는것으로 간주해 재렌더링을 하지 않는다.