📢 어렵고 정석적인 개념 설명보다는 저같은 초보자도 이해하기 쉽게 정리하는 것을 원칙으로 포스팅하고 있습니다. 😄

[React] 성능 최적화 : useMemo, useCallback, React.memo

성능 최적화란?

함수형 컴포넌트를 사용하는 리액트의 특성상 컴포넌트의 상태가 바뀌거나, 전달받은 Props가 업데이트되거나, 자식 컴포넌트일 때 부모 컴포넌트가 렌더링 되는 경우 컴포넌트가 리렌더링 되는 현상이 발생한다.

(컴포넌트가 리렌더링 되면 그 안에 있는 함수, 변수, 컴포넌트 모두 재생성되게 된다.)

 

안에 들어 있는 정보가 바뀌었으니 새로 렌더링이 되는 것은 어찌 보면 당연한 현상이다. 하지만, 관점을 바꿔 생각해 보면 하나의 컴포넌트 안에 무수한 상태, 변수들과 전달받은 다양한 Props들이 있을 텐데 수많은 요소 중에서 하나 바뀌었다고 전체가 리렌더링 되는 것은 정말 비효율적인 것이다. (한 명이 잘못했다고 모두가 손들어야 하는 건 아니다.)

 

따라서, 우리는 컴포넌트가 렌더링 될 때 굳이 같이 렌더링 될 필요가 없는 요소들은 렌더링 되지 않도록 방지해 주는 우산을 씌워줘야 한다. 그 우산 역할이 바로 이번에 소개할 useMemo, useCallback, React.memo이다.

 

🤔 메모이제이션(Memoization)?
이 글에서는 메모이제이션을 컴포넌트가 렌더링 될 때 함수가 재생성되는 것을 '방지'하는 것이라고 설명하고 있습니다. 이것은 이해를 위한 설명으로 실제로는 메모리에 저장한 뒤 특정 조건일 때만 '사용'하는 것이 올바른 정의입니다.

 

useMemo()

function App() {
    const [count, setCount] = useState(1);
    const [text, setText] = useState("");

    const countUp = useMemo(() => {
        const num = count;
        return num;
        console.log("CountUp Rendering");
    }, [count]);

    const num = countUp; // 함수가 아니기 때문에 ()를 붙이지 않는다.

    useEffect(() => {
        console.log("Component Rendering");
    });

    return (
        <div>
            <h1>{num}</h1>
            <button onclick={() => setCount(count + 1)}>+</button>
            <input type="text" onChange={(e) => setText(e.target.value)}>
        </div>
    )
}

useMemo()'결괏값(함수가 리턴하는 값)을 기준으로' 컴포넌트가 렌더링 될 때 재생성되는 것을 방지하는 도구이다. 컴포넌트가 렌더링 되면 값을 리턴하는 함수 또한 재생성되게 된다. 나와 상관없는 요소가 변했는데, 나도 같이 재생성되면 얼마나 억울하겠는가?

useMemo는 시도 때도 없이 재생성되는 함수의 억울함을 풀어주기 위해 사용되는 리액트 Hook으로서 의존성 배열(Depth) 안에 있는 값이 변할 때만 렌더링 되는 기능을 가진다.

 

위의 예시 코드는 count와 text라는 상태가 있고, 상태가 변할 때마다 App 컴포넌트가 렌더링 되는 구조이다.

countUp() 함수는 count 상태를 그대로 받아와서 num이라는 값으로 리턴해주는 함수로서 오로지 예시를 위해 만든 함수이다. 만약 useMemo로 countUp() 함수를 감싸주지 않았다면, countUp()과 전혀 상관없는 text 상태가 변화했을 때도 같이 재생성됐을 것이다. 하지만, 위 코드처럼 useMemo로 감싸주고 의존성 배열에 count를 넣어주면 count 상태가 변할 때만 초기화된다.

 

useMemo는 처음 리턴된 값을 메모리에 저장시켜 놓기 때문에 컴포넌트가 렌더링 돼도 다시 호출되지 않는다. 이런 걸 Memoization이라고 하는데, 이와 관련된 기능 모두 같은 원리이다. 단지, 이 기능은 메모리를 사용하는 것이기 때문에 너무 과다하게 쓸 경우 오히려 성능이 더 떨어질 수 있으니 이 점만 주의하자.

 

useCallback()

const App = () => {
    const [count, setCount] = useState(1);
    const [text, setText] = useState("");

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

    return (
        <div>
            <p>{count}</p>
            <button onClick={countUp}></button>
            <input type="text" onChange={(e) => setText(e.target.value)} />
        </div>
    )
}

useMemo()가 값을 기준으로 했다면, useCallback()은 함수를 기준으로 렌더링 시 재생성되는 것을 방지해 준다.

위의 예시를 보면, App 컴포넌트 안에는 count와 text라는 상태가 있다. countUp() 함수는 text 상태와 아무런 관계가 없다.

하지만, input 태그에 텍스트를 입력하면서 setText 상태 변화 함수로 인해 text 상태가 변하게 되고, 그때마다 컴포넌트는 렌더링 되면서 countUp() 함수도 동시에 재생성되게 된다. (단지, 같은 컴포넌트 안에 있었다는 이유만으로..)

 

countUp() 함수 입장에서는 짜증 날 수 있다. text랑 말 한마디 나눠본 적 없는데, text가 변할 때마다 재생성되기 때문이다.

이때, 필요한 게 useCallback이다. countUp() 함수를 useCallback으로 감싸주고, 의존성 배열 안에 count를 넣어주었다.

이제부터 countUp() 함수는 count 상태가 변할 때 빼고는 재생성되지 않는다.

 

useCallback은 useMemo와 원리도 같고 사용하는 이유도 같다. 단지, 함수가 리턴하는 게 무엇이냐에 따라 차이가 있는 것이다. (함수가 값을 리턴한다면 useMemo, 함수 자체는 useCallback)

 

React.memo()

const CountView = React.memo(({ count }) => {
    return (
        <div>
            <h1>{count}</h1>
        </div>
    )
});

const TextView = React.memo(({ text }) => {
    return (
        <div>
            <h1>{text}</h1>
        </div>
    )
});

const App = () => {
    const [count, setCount] = useState(1);
    const [text, setText] = useState("");

    return (
        <div>
            <CountView count={count} />
            <TextView text={text} />
        </div>
    )
}

React.memo()는 자식 컴포넌트 입장에서 부모 컴포넌트가 렌더링 됐다고 자기도 똑같이 렌더링 되는 것을 막고자 고안된 기능이다. 컴포넌트가 리렌더링 되는 조건 중 하나가 바로 부모 컴포넌트의 렌더링이다. (자식이 부모님의 말씀을 잘 듣는 것은 자식으로서의 도리이다. 하지만, 그렇다고 첫째가 잘못해서 혼난 걸 둘째, 셋째도 같이 혼날 필요는 없다.)

 

위의 예시 코드에는 App 컴포넌트가 부모 컴포넌트, CountView와 TextView 컴포넌트가 자식 컴포넌트로 구성되어 있다.

만약, React.memo로 자식 컴포넌트들을 감싸주지 않았다면 App 컴포넌트가 렌더링 될 때마다 CountView, TextView 컴포넌트도 같이 렌더링 됐었을 것이다. 하지만, 이것은 자식 컴포넌트 입장에서 억울할 수 있다.

 

예를 들어, TextView 컴포넌트는 count 상태가 업데이트될 때마다 App 컴포넌트가 렌더링 되므로 같이 렌더링 된다.

TextView 입장에서는 전혀 상관없는 count 때문에 자기가 재생성되는 게 불만일 수 있다.

이때, React.memo로 자식 컴포넌트를 감싸주면 부모 컴포넌트가 렌더링 되더라도 자기가 전달받은 Props가 업데이트된 게 아니라면 가볍게 무시할 수 있게 된다. (리액트 한정 부모님의 말씀을 무조건 다 들을 필요는 없다.)

 

React.memo로 감싼 컴포넌트의 전달받은 Props가 객체(배열) 일 때

const TextView = React.memo(({ text }) => {
    return (
        <div>
            <h1>{text.name}</h1>
        </div>
    )
}, areEqual)

function areEqual(prevProps, nextProps) {
    return prevProps.text.name === nextProps.text.name;
    // 인자명-상태이름-key이름 순으로 작성한다
}

const App = () => {
    const [text, setText] = useState({
        name: 'Shawn'
    })

    return (
        <div>
            <TextView text={text} />
            <input type="text" onChange={() => setText({ name: 'Shawn' })} />
        </div>
    )
}

React.memo로 감싼 컴포넌트는 자기가 전달받은 Props가 업데이트될 때만 렌더링 된다고 했다. 만약, 전달받은 Props가 객체(배열) 형식의 상태라면 주의할 점이 있다. 바로 객체(배열)끼리의 '얕은 비교'를 피해야 한다.

 

객체 A와 객체 B가 있다고 하자. 둘 다 name에 'shawn'이라는 값이 있다고 하면, A와 B는 같다고 봐야 할까?

문자라면 같겠지만, 객체는 다르다. 객체와 배열은 기본적으로 주소값이란 것을 가지고 있다. 따라서 A와 B는 같은 값을 가지고 있지만 주소값이 서로 다르기 때문에 서로 다른 객체인 것이다.

 

🤔 얕은 비교?
1) 1 === 1  //  true
2) 'hello' === 'hello'  //  true
3) [1, 2, 3] === [1, 2, 3]  //  false (얕은 비교)
4) obj1 === obj2  //  false (얕은 비교)
* obj1 = {a: 1}, obj2 = {a: 1}
3, 4번처럼 값이 아닌 주소값으로 비교되는 것을 얕은 비교라고 한다.

 

위의 예시 코드에서 TextView 컴포넌트는 text 상태를 Props로 전달받고 있고, text의 초깃값과 setText 상태 변화 함수의 결괏값은 서로 같은 상황이다.

만약, text가 문자였다면 서로 같은 값으로 인식해서 컴포넌트의 렌더링이 되지 않았을 것이다. 하지만, 객체이기 때문에 값이 {name: 'Shawn'}으로 같더라도 서로 다른 값으로 인식해서 컴포넌트의 렌더링이 발생하게 된다.

 

이를 방지하기 위해서 우리는 React.memo의 두 번째 인자로 areEqual() 함수를 넣어줘야 한다. areEqual()은 이전의 Props의 값과 상태 변화 함수로 변환된 Props의 값이 서로 같은 지를 비교해 주는 함수로 객체나 배열의 경우에 주소값이 아닌 해당하는 key의 value 값으로써 서로 비교하게 된다. 즉, 얕은 비교를 방지해 주는 함수인 셈이다.

 

이런 식으로 React.memo를 사용할 때는 항상 전달받은 Props가 어떤 형식인지 잘 따져보고, Props의 타입에 따라 얕은 비교가 되는 경우 위의 방법대로 렌더링 되는 것을 방지해줘야 한다.

 

1. Memoization : 이전에 사용한 함수의 결과를 메모리에 저장해 놓고, 필요할 때 다시 재사용하는 것
2. useMemo() : 함수의 결괏값을 메모리에 저장 -> 특정 조건일 때 함수를 호출하여 새로운 결과값 생성
3. useCallback() : 함수 자체를 메모리에 저장 -> 특정 조건일 때 함수를 호출하여 새로운 함수를 생성
4. React.memo() : 전달받은 Props가 업데이트될 때만 렌더링 -> 부모 컴포넌트가 렌더링 된다고 렌더링 되지 않음
4-1. React.memo일 때 Props가 얕은 비교를 하는 객체/배열일 경우 두 번째 인자로 areEqual 함수 넣어주기