[React] 컴포넌트 성능 최적화? useMemo, useCallback, React.Memo

Soozynn·2022년 7월 4일
0

React

목록 보기
3/4

📝 한번 더 짚고 넘어가야할 것들

  • React의 개념과 장점, 컴포넌트가 무엇인지.
  • 재사용성있는 컴포넌트를 만드는 것이 중요한 이유, +) 아토믹 디자인
  • React 내부 작동 원리(virtual dom)
  • React 라이프사이클
  • 리렌더링이 일어나는 조건
  • 나의 관점에서 리액트란?

⛔️ 리액트를 사용하면서 자꾸 중요한 부분을 까먹고 코드를 작성할 때가 많은데 이를 한 번 더 되짚어보면,

컴포넌트를 여러 개의 작은 컴포넌트로 나누어 재사용성있게 만들고, props나 내부 네이밍 또한 일반화 시켜서 작성을 해야한다. props의 이름은 사용될 context가 아닌 컴포넌트 자체의 관점에서 짓는 것을 권장한다.


오늘은 사전과제를 하다 리액트 컴포넌트 성능 최적화를 어떻게 해줄 수 있을까? 라는 물음이 생겨 이에 대해 공부해보고 정리해보고자 한다.


먼저 리액트에서 함수형 컴포넌트는 말 그대로 "함수"이다.

함수형 컴포넌트가 "렌더링"이 된다는 건 -> 그 컴포넌트를 나타내는 함수가 다시 호출된다는 것 -> 컴포넌트 내부에 있는 모든 변수들이 초기화 되어짐.


이미지 출처) 별코딩

위 사진처럼 컴포넌트라는 함수가 렌더링이 될 때마다 calculate라는 변수가 다시 초기화가 되게 되므로 "새로 만들어진 함수 객체의 주소 값을 다시 할당받게 되어진다".

즉, 이전의 calculate와 다시 초기화가 된 calculate는 다른 주소 값을 갖게 되므로 두 변수는 다른 값이 되는 것이다.

(자바스크립트에서 같은 값을 가진 객체를 트리플 이퀄(===)을 사용해서 비교해보면 false가 나오는 원리와 동일, 값은 동일할지라도 주소 값이 다른, 서로 전혀 다른 객체이기 때문.)

마찬가지로 반대의 경우인 일반 원시 값일 경우는 해당이 안된다. 객체처럼 주소 값이 아닌 할당 받은 값 자체를 비교하기 때문.(ex 3 === 3 true)

이러한 불필요한 초기화 또는 렌더링을 막아주기 위해 아래와 같은 React hooks를 이용하여 더 성능을 최적화 시켜줄 수 있다.

리액트에서 컴포넌트 성능을 어떻게 개선할 수 있을까?

  • useMemo
  • useCallback
  • React.Memo


useMemo

  • "Memoization": 동일한 값을 리턴하는 함수를 반복적으로 호출해야한다면, 맨 처음 값을 메모리에 미리 저장해놓고 값이 동일할 경우 미리 캐싱해둔 값을 사용하는 기법.

구조useEffect와 동일하게 첫 번째 인자로 callback,을 두 번째 인자로 의존성 배열을 받는다.

useCallback(memoization할 callback, dependency(의존성 배열))

function Component() {
	const value = calculate();

	return <div>{value}</div>
}

function calculate() {
	return 10;
}

위와 같은 코드가 있다고 가정해보자.
컴포넌트가 렌더링이 될 때마다 value라는 변수가 초기화되기 때문에, calculate 함수는 반복적으로 호출될 것이다. 만약 calculate함수가 무거운 일을 하는, 시간이 오래걸리는 함수라면 굉장히 비효율적일 것이다.

하지만 useMemo를 사용하면 이러한 비효율적인 행동을 간편하게 막아줄 수 있다.
useMemo는 처음에 계산된 결과 값을 메모리에 저장해서 컴포넌트가 반복적으로 렌더링이 되어도 계속 calculate를 다시 호출하지 않고 이전에 이미 계산되었던 값을 메모리에서 꺼내와서 "재사용"할 수 있게 해준다.

아래 코드처럼 사용해주면 된다.

function Component() {
	const value = useMemo(() => calculate(), []);
    
    return <div>{value}</div>
}

useMemo 구조

const value = useMemo(() => {
	return calculate();
}. [item]);

위 코드처럼 첫 번째 인자로는 () => { ~ } -> callback 함수 / 메모이제이션해 줄 값을 계산해서 리턴해주는 함수 -> 콜백 함수가 리턴하는 값이 바로 useMemo가 리턴하는 값임(value 값)
두 번째 인자로는 [item] 의존성 배열 / 디펜던시 (배열안에 요소의 값이 업데이트 될 때만 콜백함수를 호출하여 메모이제이션된 값을 업데이트해서 다시 메모이제이션을 해줌)
-> 빈 배열일 경우는, 맨 처음 컴포넌트가 마운트 되었을 때만 값을 계산, 이후에는 항상 메모이제이션된 값을 꺼내와서 사용하겠죵?

⛔️ 이러한 성능을 가진 useMemo도 무분별하게 남용하면 성능에 무리가 갈 수 있다!
useMemo를 사용한다는 것은 값을 재활용하기 위해 따로 "메모리를 소비해서 저장을 해놓는다"는 것이기 때문에 불필요한 값까지 모두 메모이제이션을 해버리면 오히려 성능이 악화될 수 있다.

import React, { useState } from "react";

const hardCalculate = (number) => {
	console.log("어려운 계산!");
	for (let i = 0; i < 999999999; i++) {} // 생각하는 시간
    return number + 10000;
};

function App() {
	const [hardNumber, setHardNumber] = useState(1);

	const hardSum = hardCalculate(hardNumber);
    
    return (
    	<div>
        	<h3>어려운 계산기</h3>
            <input
            	type="number"
                value={hardNumber}
                onChange={(e) => setHardNumber(parseInt(e.target.value))}
            />
            <span> +. 10000 = {hardSum}</span>
        </div>
    );
    
export default App;

위 코드를 동작해보면 number를 업데이트 시켜줄 때마다 즉각적으로 숫자가 변하지 않고 조금 시간이 경과한 후에 바뀌는 것을 볼 수 있다. 이는 hardCalculate라는 함수 내부의 의미없는 for loop에 의해 시간이 조금 걸리게 되는 것이다. 때문에 증가/감소 버튼을 눌러도 즉각적으로 변경되지 않는 것..

-> 컴포넌트가 렌더링 될 때 hardSum이 다시 초기화가 되려면 hardCalculate 함수에서 리턴 받은 값이 담기게 되는데 여기서 시간이 소요되는 것. 시간이 소요되어 리턴받은 값은 그제서야 span 태그에 보여지게 되는 것.

그렇다면 아래의 코드를 한번 봐보자.

import React, { useState } from "react";

const hardCalculate = (number) => {
	console.log("어려운 계산!");
	for (let i = 0; i < 999999999; i++) {} // 생각하는 시간
    return number + 10000;
};

const easyCalculate = (number) => {
	console.log("짱 쉬운 계산!");
    return number + 1;
};

function App() {
	const [hardNumber, setHardNumber] = useState(1);
	const [easyNumber, setEasyNumber] = useState(1);
    
	const hardSum = hardCalculate(hardNumber);
    const easySum = easyCalculate(easyNumber);
    
    return (
    	<div>
        	<h3>어려운 계산기</h3>
            <input
            	type="number"
                value={hardNumber}
                onChange={(e) => setHardNumber(parseInt(e.target.value))}
            />
            <span> +. 10000 = {hardSum}</span>
        </div>
        
        <div>
        	<h3>쉬운 계산기</h3>
            <input
            	type="number"
                value={easyNumber}
                onChange={(e) => setEasyNumber(parseInt(e.target.value))}
            />
            <span> +. 10000 = {easySum}</span>
        </div>
    );
    
export default App;

위에 있는 코드를 실행시켜 쉬운 계산기의 number를 수정해보자. 무거운 함수를 담고있지 않기에 숫자를 업데이트 시켜주면 어려운 계산기와는 반대로 값이 빠르게 업데이트 되어 화면에 보여지게 될 줄 알았지만, 실제로는 어려운 계산기와 동일하게 즉각적으로 업데이트가 되지 않았다.

❗️ 그 비밀은 우리의 App 컴포넌트가 함수형 컴포넌트이기 때문이다.
우리가 쉬운 계산기의 넘버를 업데이트 시켜주면 easyNumberstate가 변하게된다. -> state가 바뀐다는 것은 곧 우리의 App 컴포넌트가 리렌더링된다는 것. -> 그러면 App이라는 함수안에 정의되어 있는 변수인 hardSumeasySum은 다시 초기화가 된다.

그렇다면 hardSum의 값으로 담기는 hardCalculate도 다시 불리기 때문에 동일하게 시간이 딜레이 되는 것이다!!

그럼 여기서, easyCalculate가 불릴 때에 hardCalculate가 불리지 않게 하는 방법은 없을까? 🤔 -> "useMemo" 사용

import React, { useState, useMemo } from "react"; // ✅

const hardCalculate = (number) => {
	console.log("어려운 계산!");
	for (let i = 0; i < 999999999; i++) {} // 생각하는 시간
    return number + 10000;
};

const easyCalculate = (number) => {
	console.log("짱 쉬운 계산!");
    return number + 1;
};

function App() {
	const [hardNumber, setHardNumber] = useState(1);
	const [easyNumber, setEasyNumber] = useState(1);
    
	const hardSum = useMemo(() => { // ✅
		return hardCalculate(hardNumber);
	}, [hardNumber]);
    const easySum = easyCalculate(easyNumber);
    
    return (
    	<div>
        	<h3>어려운 계산기</h3>
            <input
            	type="number"
                value={hardNumber}
                onChange={(e) => setHardNumber(parseInt(e.target.value))}
            />
            <span> +. 10000 = {hardSum}</span>
        </div>
        
        <div>
        	<h3>쉬운 계산기</h3>
            <input
            	type="number"
                value={easyNumber}
                onChange={(e) => setEasyNumber(parseInt(e.target.value))}
            />
            <span> +. 10000 = {easySum}</span>
        </div>
    );
    
export default App;

이제 쉬운 계산기의 숫자를 업데이트 해주면 더 이상 어려운 계산기의 함수가 실행되지 않는 것을 볼 수 있다!!

useCallback

  • useMemo 와 원리는 동일.
  • useCallback(memoization할 callback, dependency(의존성 배열))
    의존성 배열의 값이 초기화되지 않는 이상 바뀌지 않는 특징.

⭐️ 유의해야할 점은 리액트에서 리렌더링이 일어나는 조건은 state 혹은 props가 변경 되었을 때 또는 부모 컴포넌트가 리렌더링이 되었을 때의 조건이 있는데,
컴포넌트 내부에서 어떠한 동작을 위해 작성한 함수가 있을 경우, 이를 보통 변수에 다시 담아주는 방법으로 작성한다(함수 또한 객체이므로 주소 값이 담김).

이 후 리액트 훅 중 하나인 useEffect의 디펜던시에는 내가 작성해 준 함수를 담아주게 되면 해당 함수가 변경되어야지만 초기화가 될 줄 알았다.
하지만 콘솔을 찍어보며 확인을 해보니 내 생각과는 달랐다. 렌더링이 될 때마다 변수에 담아준 함수는 다른 주소 값을 변수에 계속 할당하기 때문에 디펜던시의 담은 함수가 변경될 때 외에도 해당 컴포넌트가 렌더링이 될 때마다 useEffect내부의 코드가 계속 동작을 하게 되었던 것.

코드를 보면서 다시 정리해보자면

import { useEffect, useState } from "react";

function App() {
	const [number, setNumber] = useState(0);
    
    const someFunction = () => {
    	console.log(`someFunc: number: ${number}`);
		return;
    };
    
    useEffect(() => {
		console.log("someFunction이 변경되었습니다.");
	}, [someFunction]);
    
    return (
    	<div>
        	<input
			  type="number"
              value={number}
			  onChange=((e) => setNumber(e.target.value)}
			/>
            <br />
            <button onClick={someFunction}>Call someFunc</button>
    	</div>
    );
}

export default App;

객체라는 것은 원시 값과는 다르게 사이즈가 크기 때문에 어떤 다른 메모리 공간에 저장이되고, 그 메모리 공간의 "주소 값"이 변수에 담기게 되는 것이다.(해당 메모리 공간 안에 있는 값을 참조한다라고 표현한다)
때문에 위 코드에서 someFunction 변수 또한 해당 함수(객체)의 메모리 주소 값이 담겨있는 것이다.

❗️ number state가 바껴서 App 컴포넌트가 렌더링이 된다면 someFunction 의 함수 객체가 다시 새로 생성이 돼서 또 다른 메모리 공간 안에 저장이 된다. 그렇게 되면 someFunction 이라는 메모리 안에는 이전과는 다른 주소 값이 할당되어지게 되는 것이다. 그렇기 때문에 useEffect의 입장에서는 이전 렌더링과 다음 렌더링 때 디펜던시 안에 someFunction 의 주소 값을 비교하면 두 개의 값이 "다르다"라고 인식하게 되는 것이다. 사실은 동일한 값을 가진 객체임에도.

그럼 이제 useCallback을 사용해서 App 컴포넌트가 렌더링이 되더라도 someFunction이 바뀌지 않도록 코드를 다시 작성해보자.

import { useEffect, useState, useCallback } from "react"; // ✅ useCallback import 시켜주기

function App() {
	const [number, setNumber] = useState(0);
    
    const someFunction = useCallback(() => { // ✅ useCallback으로 감싸주어 메모이제이션
    	console.log(`someFunc: number: ${number}`);
		return;
    }, [number]); // ✅ 디펜던시 안에 어떠한 값이 변경되었을 때 업데이트 해주고 싶은 것인지 생각!
    
    useEffect(() => {
		console.log("someFunction이 변경되었습니다.");
	}, [someFunction]);
    
    return (
    	<div>
        	<input
			  type="number"
              value={number}
			  onChange=((e) => setNumber(e.target.value)}
			/>
            <br />
            <button onClick={someFunction}>Call someFunc</button>
    	</div>
    );
}

export default App;

위와 같이 코드를 변경해주면 number 값이 바뀌게 되더라도 더 이상 useEffect가 리렌더링 되지 않는 것을 확인할 수 있다.

정리를 해보면서 두 개의 hooks 전부 객체인 값을 다룰 때에 유의해야한다는 느낌을 받았다.

useMemo, useCallback hooks의 차이?

  • useMemo는 메모이제이션된 "값"을 반환한다.
  • useCallback은 메모이제이션된 "함수"를 반환한다.

둘의 정확한 차이를 아직은 제대로 인지하지 못하여서 조금 더 공부가 필요할 듯 하다..😭

참고 영상) 별코딩

0개의 댓글