리액트 useMemo

hyun·2022년 4월 14일
16

React Hooks

목록 보기
1/5
post-thumbnail

이 포스트에서 다룰 것

컴포넌트를 최적화하기 위해 사용하는 대표적인 훅 useMemo, useCallback 중에서, useMemo를 알아볼 것이다. useMemo를 사용하는 방법과, 사용하면 왜 좋은지를 예제를 통해 알아본다.

배경 지식

Memoization(메모이제이션)

useMemo라는 이름에서 알 수 있듯이 useMemo는 말 그대로 메모를 하는 방법으로 성능을 향상시킨다. 메모이제이션이란 동일한 값을 리턴하는 함수를 반복적으로 호출해야 한다면, 맨 처음 값을 계산할 때 메모리에 저장해서 필요할 때마다 또 다시 계산하지 않고, 저장한 값을 재사용하는 기법이다. 더 쉽게 말하자면, 자주 필요한 값을 처음 한 번만 계산하여 어딘가 저장해뒀다가 그 값이 필요할 때마다 꺼내 쓰는 방법이 메모이제이션이다.

useMemo를 사용하는 이유

useMemo의 사용법을 알아보기 전에, 꼭 알아둬야 할 것이 있다. 함수형 컴포넌트는 함수라는 사실이다. 너무 당연한 말 같지만, 바꿔 말하자면 함수형 컴포넌트가 렌더링이 된다는 것은 그 함수가 호출된다는 의미이다. 함수는 호출될 때마다 함수 내부에 정의되어 있는 모든 변수를 초기화한다.

function myComponent(){
	const value = calculate();
  	return <div> { value } </div>;
}


<myComponent />

위의 함수형 컴포넌트를 보면, 함수 내부에 있는 value라는 변수가 하나 있다. 이 변수는 calculate()라는 함수로부터 값을 받아 온다.

function calculate(){
  	return 1+1;
}

calculate() 함수는 이렇게 1+1의 값을 계산하여 리턴해주는 간단한 함수라고 해 보자.

대부분의 리액트 컴포넌트는 propsstate값의 변화로 수많은 리렌더링이 된다. 컴포넌트가 렌더링이 될 때마다, value 변수는 초기화되기 때문에 calculate() 함수는 반복적으로 호출이 될 것이다. 만약 calculate() 함수가 어떤 무겁고 복잡한 계산을 수행하는 함수라면, 이는 성능 저하를 초래할 것이다. 렌더링이 될 때마다 무의미한 계산을 반복하여 value 변수에 같은 값을 반복해서 전달하기 때문이다. useMemo를 사용하면 이런 상황을 해결할 수 있다.

useMemo 사용법

useMemo는 처음에 계산된 값을 메모리에 저장해서 컴포넌트가 반복적으로 렌더링이 되어도 다시 함수를 호출하지 않고, 이전의 결과값을 재사용할 수 있게 해준다.

useMemo의 구조

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

useMemo는 두 개의 인자를 받는다. 첫 번째 인자로는 콜백 함수, 두 번째 인자로는 배열을 받는다. 첫 번째 인자인 콜백 함수는 우리가 메모이제이션 해야 하는 값을 계산해서 리턴해주는 함수이다. 이 콜백 함수가 리턴하는 값이 바로 useMemo가 리턴하는 값이 된다. 두 번째 인자인 배열은 의존성 배열이라고도 부른다. useMemo는 배열의 값이 업데이트 될 때만 콜백 함수를 호출하여 메모이제이션 된 값을 업데이트해서 다시 메모이제이션 한다. 만약 빈 배열을 넘겨준다면, 맨 처음 컴포넌트가 마운트 되었을 때만 값을 계산하고, 이후에는 항상 메모이제이션된 값을 꺼내와서 사용된다.

useMemo를 언제 사용할까?

useMemo가 항상 좋은 것만은 아니다. 값을 재활용하기 위해 따로 메모리를 사용하기 때문에, 불필요한 값까지 메모이제이션 해버리면 오히려 성능이 악화될 수 있다. 뭐든지 그렇겠지만 필요한 상황에만 적절히 사용하는 것이 좋다.

예제 1. 사용법 익히기

예제코드를 통해 좀더 자세히 살펴보자.

// App.js
import React, { useState } from 'react';

const bigCaluclate = (num) => {
  console.log("big calculate 😰");
  for (let i=0; i< 999999999; i++ ) {} // 오래 걸리는 계산
  return num + 10000;
}

function App() {  
  const [bignum, setBigNum] = useState(1); 

  const bigvalue = bigCaluclate(bignum);

  return (
    <div>
        <h2> Big Caluclator 😰 </h2>
        <input type="number" value={bignum} 
			onChange={(e) => {setBigNum(parseInt(e.target.value))}} />   
        <span> + 10000 = { bigvalue } </span>
    </div>
  );
}

export default App;

App 컴포넌트 안에는 number 타입의 input 태그가 하나 있다. input value로는 bignum가 들어있다. bigvaluebigCaluclate()의 결과를 담는 변수이고, bigCaluclate() 함수는 들어온 인자 bignum에 10000을 더해서 반환해주는 함수이다. bigCaluclate() 함수 내부에는 의미 없는 for 반복문을 999999999번 반복하게끔 넣어 두었다.

App 컴포넌트는 함수형 컴포넌트이기 때문에, App이 렌더링된다는 것은 App이라는 함수가 호출이 된다는 것이다. App 내부의 bigvalue 변수도 계속해서 다시 초기화될 것이고, 그말인즉슨 calculate() 함수도 반복적으로 호출될 것이다. 또한, 브라우저에 있는 숫자를 변경하면 state가 변경되는 것이기 때문에 App 컴포넌트가 다시 렌더링될 것이다. 그러면 정말 렌더링이 될 때마다 bigCaluclate() 함수가 호출되는지 확인해 보자.

숫자를 변경시킬 때마다 실제로 bigCaluclate() 내부에 있는 console.log("calculate")가 실행되는 것을 볼 수 있다. 999999999번 반복문을 거쳐야 하기 때문에 약간의 딜레이를 가지며 콘솔이 찍힌다.

이번에는 smallnum이라는 state를 하나 더 추가해서 bignum과 비교해 볼 것이다. bignum과 똑같은 작업을 해 줄 것이다.

import React, { useState } from 'react';

const bigCaluclate = (num) => {
  console.log("big calculate 😰");
  for (let i=0; i< 999999999; i++ ) {} // 오래 걸리는 계산
  return num + 10000;
}

const smallCalculate = (num) => {
  console.log("small calculate 🤗");
  return num + 1;
}


function App() {  
  const [bignum, setBigNum] = useState(1); 
  const [smallnum, setSmallNum] = useState(1);

  const bigvalue = bigCaluclate(bignum);
  const smallvalue = smallCalculate(smallnum);

  return (
    <>
    <div>
        <h2> Big Caluclator 😰 </h2>
        <input type="number" value={bignum} 
				onChange={(e) => {setBigNum(parseInt(e.target.value))}} />   
        <span> + 10000 = { bigvalue } </span>
    </div>

    <div>
        <h2> Small Caluclator 🤗 </h2>
        <input type="number" value={smallnum} 
				onChange={(e) => {setSmallNum(parseInt(e.target.value))}} />   
        <span> + 1 = { smallvalue } </span>
    </div>
    </>

  );
}

export default App;

Small Calculator도 마찬가지로, 숫자를 증가시킬 때마다 콘솔이 찍힐 것이다. 대신 반복문을 제거했고, 더하는 값도 1로 낮췄으므로 딜레이가 없고 바로바로 콘솔이 찍힐 것이다. 그런데, 실제 숫자를 변경시켜 보면 Big Calculator와 마찬가지로 1초 정도 딜레이가 있는 것을 확인할 수 있다. 그 이유는 App 컴포넌트가 함수형 컴포넌트이기 때문이다.

우리가 Small Calculator의 숫자를 변경시키게 되면, smallnum이라는 state가 바뀌게 된다. state가 바뀐다는 말은 App 컴포넌트가 다시 렌더링된다는 뜻이다. 그러면 App 안에 정의되어 있는 bignum 또한 초기화가 될 것이고, bignum의 값을 할당하기 위해 bigCalculate함수가 호출되어 버린다. 그래서 smallnum을 바꿔도 bigCalculate안에 있는 999999999번의 반복문이 실행되어 반응이 느린 것이다.

그러면, 이 비효율적인 동작을 피하기 위해서 smallnum이라는 변수를 바꿀 때 bigCalculate가 불리지 않게 하는 방법을 찾아야 한다. 이 때 필요한 것이 useMemo이다. useMemo를 사용하면 어떤 조건이 만족되었을 때만 변수들이 초기화되게 할 수 있다. 만약 그 조건이 만족되지 않더라도 App 컴포넌트가 렌더링이 될 때 변수를 다시 초기화하는 것이 아니라 기존에 갖고있던 값을 그대로 사용하게 해준다(메모이제이션).

useMemo를 사용해보자

import React, { useState, useMemo } from 'react'; // useMemo import하기

const bigCaluclate = (num) => {
  console.log("big calculate 😰");
  for (let i=0; i< 999999999; i++ ) {} // 오래 걸리는 계산
  return num + 10000;
}

const smallCalculate = (num) => {
  console.log("small calculate 🤗");
  return num + 1;
}

function App() {  
  const [bignum, setBigNum] = useState(1); 
  const [smallnum, setSmallNum] = useState(1);

  // const bigvalue = bigCaluclate(bignum);
  const bigvalue = useMemo(()=>{
    // 콜백함수의 리턴값으로 우리가 memoize 할 값을 준다.
    return bigCaluclate(bignum);
    // 의존성 배열에 값을 넣어주면, 그 값이 바뀔 때만 콜백함수 안의
    // bigCalculate가 호출되어 bigvalue에 들어간다.
  }, [bignum]);

  const smallvalue = smallCalculate(smallnum);

  return (
    <>
    <div>
        <h2> Big Caluclator 😰 </h2>
        <input type="number" value={bignum} 
			onChange={(e) => {setBigNum(parseInt(e.target.value))}} />   
        <span> + 10000 = { bigvalue }</span>
    </div>

    <div>
        <h2> Small Caluclator 🤗 </h2>
        <input type="number" value={smallnum} 
			onChange={(e) => {setSmallNum(parseInt(e.target.value))}} />   
        <span> + 1 = { smallvalue }</span>
    </div>
    </>

  );
}

export default App;

기존의 bigvalue를 주석 처리하고, useMemo를 이용하여 코드를 작성했다. useMemo의 첫 번째 인자 콜백함수의 리턴값으로는 bigCaluclate(bignum)를 넣어주었고, 두 번째 인자의 배열에는 bignum을 넣어주었다. 이렇게 하면 bignum의 값이 변경될 때에만 콜백 안에 있는 bigCalculate가 호출되어 bigvalue을 초기화한다.


브라우저에서 확인해보면, Big Calculator를 사용했을 때는 똑같이 딜레이가 있지만 Small Calculator를 사용했을 때는 딜레이가 전혀 없는 것을 볼 수 있다.

예제 2. 객체 다루기

사실 개발을 하다 보면 변수 할당에 1초나 걸릴 일이 거의 없다. 실제 개발을 할 때 유용한, useMemo가 빛을 발하는 상황을 다른 예제로 알아보자.

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

function App() {    
  const [num, setNum] = useState(0);
  const [isFront, setIsFront] = useState(true);

  const developer = isFront ? '프론트엔드' : '백엔드';
  
  return (
    <div>
      <h2> 나의 공부시간 🙇🏻‍♀️ </h2>
      <input type="number" value={num} onChange={ (e) => {setNum(e.target.value)} }/> 

      <h2> 어떤 개발을 하나요? 👩🏻‍💻 </h2>
      <p> 분야: {developer} </p>
      <button onClick={() => setIsFront(!isFront)}> 공부하자 </button>
    </div>
  );
}

export default App;

위와 같이 간단한 페이지를 구성했다. 이 코드에 useEffect를 하나 추가해 보자.

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

function App() {    
  const [num, setNum] = useState(0);
  const [isFront, setIsFront] = useState(true);

  const developer = isFront ? '프론트엔드' : '백엔드';

  useEffect(() => {
    console.log("useEffect✨")
  },[developer]);

  return (
    <div>
      <h2> 나의 공부시간 🙇🏻‍♀️ </h2>
      <input type="number" value={num} onChange={ (e) => {setNum(e.target.value)} }/> 


      <h2> 어떤 개발을 하나요? 👩🏻‍💻 </h2>
      <p> 분야: {developer}</p>
      <button onClick={() => setIsFront(!isFront)}> 공부하자 </button>
    </div>
  );
}
export default App;

useEffect의 의존성 배열에 developer를 넣어뒀기 때문에, 맨 처음에 컴포넌트가 화면에 렌더링이 될 때 또는 developer가 바뀌었을 때만 콘솔이 찍힐 것이다. 정말 그런지 확인해 보자.

공부시간을 증가시킬 땐 반응이 없지만, 공부하자 버튼을 통해 developer를 변경할 때엔 콘솔이 찍힌다. useEffect가 불릴 지 말지 리액트가 판단하는 기준은, 의존성 배열에 들어있는 값이 렌더링 이전과 이후의 차이가 있는지이다. 그래서 developer 값이 바뀌었을 때만 useEffect가 호출된다.

그런데, 의존성 배열로 전달한 값이 string과 같은 Primitive type이 아니라 객체라면 이야기가 달라진다. 이번에는 developer에다 객체를 할당해 보자.

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

function App() {    
  const [num, setNum] = useState(0);
  const [isFront, setIsFront] = useState(true);

  // const developer = isFront ? '프론트엔드' : '백엔드';
  const developer = {
    field : isFront ? '프론트엔드' : '백엔드'
  }

  useEffect(() => {
    console.log("useEffect✨")
  },[developer]);

  return (
    <div>
      <h2> 나의 공부시간 🙇🏻‍♀️ </h2>
      <input type="number" value={num} onChange={ (e) => {setNum(e.target.value)} }/> 


      <h2> 어떤 개발을 하나요? 👩🏻‍💻 </h2>
      <p> 분야: {developer.field} </p>
      <button onClick={() => setIsFront(!isFront)}> 공부하자 </button>
    </div>
  );
}

export default App;

이번에도 브라우저로 useEffect의 콘솔을 확인해 보자.

developer를 변경할 때 뿐만 아니라, 공부시간 number를 변경할 때에도 똑같이 useEffect가 불러지고 있다. 왜 그럴까?

자바스크립트의 자료형

자바스크립트에는 원시 타입(Primitive), 객체(Objet) 타입의 자료형이 존재한다.

  • 원시 타입: String, Number, Boolean, 등등
  • 객체 타입: Object, Array, 등등 원시 타입 제외 나머지

어떤 변수에 원시 타입 값을 할당하면 그 값은 변수 상자 안에 넣어진다. 하지만 객체 타입은 바로 상자 안에 들어가지 않고, 그 객체가 담긴 메모리 주소가 상자에 들어간다.

같은 원시값을 가지고 있는 변수를 === 연산자로 비교하면 true가 나온다. 왜냐하면 변수라는 상자 안에 담긴 값이 같기 때문이다. 하지만 같아 보이는 객체를 넣어준 두 변수를 비교하면 false가 나온다. 왜냐하면 obj1과 obj2 안에 담겨 있는 것은 메모리 상의 주소이기 때문이고, 두 객체는 다른 주소를 가지고 있기 때문이다.

다시 useEffect로 돌아와서

공부시간 num state를 변경했을 때, App 컴포넌트가 리렌더링 되고 developer이라는 변수도 다른 주소를 다시 할당받을 것이다. 그래서 리액트의 관점에서는 developer 안에 들어있는 주소가 바뀌었기 때문에 useEffect가 호출된 것이다.

그러면 App 컴포넌트가 렌더링이 될 때 developer가 다시 초기화되는 것을 막아주면 문제가 해결될 것이다. developerisFront state가 변경될 때만 초기화가 되면 된다. useMemo를 다시 사용해서, developer를 메모이제이션 해 보자.

  const developer = useMemo(()=>{
    return { 
      field : isFront ? '프론트엔드' : '백엔드'
    };
  }, [isFront])

첫 번째 인자인 콜백함수에는 우리가 메모이제이션할 값을 넣어줘야 하므로 객체를 넣어준다. 그리고 두 번째 인자인 의존성 배열에는 콜백함수가 호출되는 조건을 넣어준다. 결과를 확인해보자.

공부하자 버튼을 누를 때만 useEffect가 호출되는 것이 확인된다. 원하는 결과가 나왔다.

profile
프론트엔드를 공부하고 있습니다.

5개의 댓글

comment-user-thumbnail
2022년 4월 15일

짱~

답글 달기
comment-user-thumbnail
2022년 4월 16일

예시가 너무 좋아요~

답글 달기
comment-user-thumbnail
2022년 4월 22일

예시가 쏙쏙 들어오네요~!

답글 달기
comment-user-thumbnail
2022년 4월 22일

짱짱 완짱완짱

답글 달기
comment-user-thumbnail
2022년 4월 25일

설명 너무 좋아요~

답글 달기