[React] 다시 한번 useMemo를 파헤쳐보자

sohyeon kim·2022년 8월 3일
2

React & Javascript

목록 보기
38/41

컴포넌트 최적화를 위해 사용되는 대표적인 hook은 useMemo와 useCallback이 있다. 먼저 useMemo 대해서 파헤쳐보자.

useMemo에서 Memo라는 말은 Memoization을 뜻한다.

동일한 값을 리턴하는 함수를 반복적으로 호출할 경우 맨 처음 값을 메모리에 저장해서 필요할 때마다 또다시 계산하지 않고 메모리에서 꺼내서 재사용을 하는 기법이다.
간단히 말해서 자주 필요한 값을 맨 처음 계산할 때 캐싱 해놓아서 그 값이 필요할 때마다 다시 계산을 하는 것이 아니라 캐시에서 꺼내서 사용하는 것이다.

함수형 컴포넌트는 함수다. 함수형 컴포넌트가 렌더링이 된다는 것은 그 함수가 호출된다는 것이고 그때 함수 내부에 정의해놓은 내부 변수가 초기화된다.
예시를 보면서 이해해 보자.


function calculate(){
	return 10
}

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

컴포넌트는 state 와 props의 변화로 인해 수많은 렌더링을 거친다. 컴포넌트가 렌더링이 될 때마다 위 코드의 value 변수는 초기화가 된다. 렌더링이 될 때마다 calculate() 함수는 반복적으로 호출이 될 퇸데 이는 효율적이지 못하다. 만약 calculate() 함수가 무거운 일을 하는 함수라면 굉장히 비효율적일 것이다. 왜냐하면 같은 값을 value에 할당하는 무의미한 작업을 계속해서 반복하기 때문이다.


이런 문제점을 useMemo를 사용해서 memoization을 하면 이를 간단하게 해결해 줄 수 있다.

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


useMemo의 구조에 대해서 간단하게 살펴보자.

const value = useMemo(() => {
	return calculate();
}. [item])
  • useMemo는 두 개의 인자를 받는다. 첫 번째는 콜백 함수, 두 번째는 배열을 받는다.

  • 첫 번째 인자인 콜백 함수는 메모이제이션을 해줄 값을 계산해서 리턴해 주는 함수이다. 즉 이 콜백 함수가 리턴하는 값이 바로 useMemo가 리턴하는 값이 된다.

  • 두 번째 인자인 배열은 의존성 배열이라고 부르는데 useMemo는 배열 안의 요소의 값이 업데이트될 때만 콜백 함수를 다시 호출해서 메모이제이션 된 값을 업데이트해서 다시 메모이제이션을 한다.

  • 빈 배열을 넘겨주면 컴포넌트가 처음 마운트 되었을 때만 값을 계산하고 이후에는 항상 메모이제이션된 값을 꺼내와서 사용한다.

하지만 useMemo도 무분별하게 남용하면 오히려 성능면에서 좋지 않다.

useMemo를 사용한다는 것은 값을 재활용하기 위해서 따로 메모리를 소비해서 저장하는 것인데 그렇기 때문에 불필요한 값까지 전부 메모이제이션을 하면 성능에 좋지 않기 때문이다. 그러니 필요할 때만 적절하게 사용하자


예제를 통해 더 자세히 알아보자.

간단한 계산기를 만들어보았다.

import { useState } from "react";

const hardCalculate = (number) => {
  console.log("어려운 계산");
  for (let i = 0; i < 99999999; 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;
  • App 컴포넌트가 반복되어 호출이 되면 그 안에 들어있는 변수 hardSum는 계속해서 초기화된다.

  • 이 말은 즉 hardCalculate함수가 반복적으로 불려서 hardSum에 계속 값을 할당 해준 다는 것이다.

  • 또 브라우저의 숫자를 바꾸면 hardNumber이 업데이트 되는 것이기 때문에 App는 다시 렌더링이 된다.

  • hardCalculate함수는 for (let i = 0; i < 99999999; i++) {} 이것 때문에 시간이 조금 걸린다. 그래서 증가버튼을 누르면 바로 즉각적으로 증가 되는 것이 아니라 딜레이가 있다.

  • 1초 정도의 딜레이가 있는데 컴포넌트가 렌더링이 될 때 hardSum이 다시 초기화가 되려면 hardCalculate 함수에서 값을 리턴 받아야 하는 시간이 조금 걸려서 1초 뒤에 harSum에 값이 들어오게 되고 들어오면 그제서야 <span></span> 태그에서 값이 보여지는 것이다.


자, state를 하나 더 만들어보자.

import { useState } from "react";
 
const hardCalculate = (number) => {
  console.log("어려운 계산");
  for (let i = 0; i < 99999999; 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>
      {/* 📌 */}
      <h3>쉬운 계산기</h3>
      <input
        type="number"
        value={easyNumber}
        onChange={(e) => setEasyNumber(parseInt(e.target.value))}
      />
      <span> + 1 = {easySum}</span>
    </div>
  );
}

export default App;

쉬운계산기는 숫자 증가버튼을 누르면 딜레이가 없을까?

아니다. 바로 값을 리턴해줌에도 불구하고 어려운 계산기처럼 딜레이가 1초 정도 있다.


그 이유는 App 컴포넌트가 함수형 컴포넌트라는 것에 있다.

쉬운 계산기의 숫자를 바꿔주면 easyNumber가 바뀐다. state가 바뀐다는 것은 App 컴포넌트가 다시 리렌더링이 된다는 것이다. 그럼 App 안의 hardSum과 easySum 두 변수 모두 다 초기화가 된다. 그래서 hardCalculate 함수도 다시 불리기 때문에 hardNumber 을 바꾸건 easyNumber을 바꾸건 hardCalculate 안에 있는 의미 없는 for 루프가 돌아가게 된다. 이는 너무 비효율 적이다.


그럼 여기서 easyNumber를 변경할 때는 hardCalculate 함수가 불리지 않게 하는 방법이 없을까?

useMemo를 사용하면 어떤 조건이 만족되었을 때만 hardSum과 easySum 변수가 초기화가 될 수 있게 할 수 있다. 만약 그 조건을 만족시키지 않았다면 App 컴포넌트가 다시 렌더링 되더라도 다시 초기화를 시켜주는 것이 아니라 이전에 이미 가지고 있던 값을 그대로 사용하게 해주는 것이다. 이것이 바로 메모이제이션이다.


useMemo를 사용해보자.

 // const hardSum = hardCalculate(hardNumber);
  const hardSum = useMemo(() => {
    return hardCalculate(hardNumber);
  }, [hardNumber]);
  const easySum = easyCalculate(easyNumber);
  • useMemo의 첫 번째 인자의 콜백 함수의 리턴 값으로 메모이제이션 할 값을 준다. 그 값은 바로 hardCalculate 함수를 호출해서 리턴되는 값이다.

  • 두 번째 인자로 배열을 받는데 그 배열에 안에 있는 값이 바뀔 때만 콜백 안에 있는 hardCalculate 함수를 다시 호출해서 hardSum에 할당을 해준다. 즉 [ ] 안에 조건이 들어가는 것이고, 이 조건을 만족시켜야만 다시 초기화가 되게 만들어 주는 것이다.

  • 안에 hardNumber를 넣자. 그럼 hardNumber가 변경이 되어야지만 hardCalculate 가 다시 불려서 hardSum을 초기화해주는 것이다.

  • hardNumber이 변경되지 않았다면 그전에 가지고 있던 hardSum의 값을 재사용하는 것이다.

브라우저에서 확인 해보자.

어려운 계산기를 증가해 보면 똑같이 1초 정도의 딜레이가 있다. 그리고 콘솔에서 hardCalculate와 easyCalculate 함수가 모두 불린 것을 확인할 수 있다.

하지만 쉬운 계산기의 값을 올려보면 클릭할 때마다 즉각적으로 딜레이 없이 화면에 보인다.

그리고 콘솔을 보면 더이상 hardCalculate는 불려지지 않고 easyCalculate만 실행된다.

왜냐하면 쉬운 계산기 안의 값을 증가시켜 준다는 것은 easyNumber를 바꿔준다는 것이다. useMemo 훅을 보면 hardNumber 가 바뀌었을 때만 hardCalculate를 불러와서 hardSum 초기화시켜주기 때문에 쉬운 계산기를 증가시킬 때는 hardNumber에는 변화가 없기 때문에 이미 메모이제이션 된 값을 재사용하는 것이다.


그럼 usememo가 잘 사용되는 예제를 보자.

import { useState } from "react";

export default function App() {
  const [number, setNumber] = useState(0);
  const [isKorea, setIsKorea] = useState(true);

  const location = isKorea ? "한국" : "외국";

  return (
    <div>
      <h2>하루에 몇끼 먹어요?</h2>
      <input
        type="number"
        value={number}
        onChange={(e) => setNumber(e.target.value)}
      />
      <hr />
      <h2>어느 나라에 있어요?</h2>
      <p>나라:{location}</p>
      <button onClick={() => setIsKorea(!isKorea)}>비행기 타자</button>
      <p></p>
    </div>
  );
}

위 코드에 useEffect를 추가하고 그 안에서 콘솔을 찍어보자. 의존성 배열에 location을 넣어줬기 때문에 맨 처음 컴포넌트가 화면에 렌더링이 될 때 그리고 location이 바뀔 때만 실행이 될 것이다.

import { useEffect, useState } from "react";

export default function App() {
  const [number, setNumber] = useState(0);
  const [isKorea, setIsKorea] = useState(true);

  const location = isKorea ? "한국" : "외국";

  // 📌
  useEffect(() => {
    console.log("useEffect 호출");
  }, [location]);

  return (
    <div>
      <h2>하루에 몇끼 먹어요?</h2>
      <input
        type="number"
        value={number}
        onChange={(e) => setNumber(e.target.value)}
      />
      <hr />
      <h2>어느 나라에 있어요?</h2>
      <p>나라:{location}</p>
      <button onClick={() => setIsKorea(!isKorea)}>비행기 타자</button>
      <p></p>
    </div>
  );
}

새로고침하면 useEffect가 호출된 것을 확인할 수 있고, '하루에 몇 끼 먹어요'를 아무리 변경을 해도 useEffect가 호출되지 않고 '비행기 타자' 버튼만 눌렀을 때 호출되는 것을 확인할 수 있다.

이는 '하루에 몇 끼 먹어요'의 number를 업데이트해주면 App 함수가 다시 렌더링이 된다. 아무리 렌더링이 많이 돼도 const location = isKorea ? "한국" : "외국";의 location이라는 값은 계속해서 다시 초기화가 되지만 number만 바꿔줬기 때문에 location 안에 들어있는 값은 바뀌지 않을 것이다. 계속해서 똑같은 string을 할당받기 때문이다. 지금의 경우에는 한국이 들어있다.

useEffect가 불릴지 말지 리액트가 판단할 때는 의존성 배열 안에 있는 location의 값이 렌더링 이전과 이후에 차이가 있는지 확인을 한다. 그래서 location 안의 값이 바뀌었을 대만 useEffect가 호출이 되는 것이다.

그래서 비행기 타자를 클릭하면 location의 값이 한국이라는 string에서 외국이라는 string으로 바뀌었으니까 useEffect가 실행되는 것이다.

그런데 만약 의존성으로 전달해 준 location이 string 과 같은 원시 타입이 아니라 객체 타입이면 이야기가 달라진다.

import { useEffect, useState } from "react";

export default function App() {
  const [number, setNumber] = useState(0);
  const [isKorea, setIsKorea] = useState(true);

  // string 할당
  // const location = isKorea ? "한국" : "외국";
  
  // 📌Object 할당
  const location = {
    country: isKorea ? "한국" : "외국",
  };

  useEffect(() => {
    console.log("useEffect 호출");
  }, [location]);

  return (
    <div>
      <h2>하루에 몇끼 먹어요?</h2>
      <input
        type="number"
        value={number}
        onChange={(e) => setNumber(e.target.value)}
      />
      <hr />
      <h2>어느 나라에 있어요?</h2>
      {/* 📌 location.country 할당 */}
      <p>나라:{location.country}</p>
      <button onClick={() => setIsKorea(!isKorea)}>비행기 타자</button>
      <p></p>
    </div>
  );
}

비행기 타자 버튼을 누르면 아까랑 동일하게 useEffect가 호출된 것을 볼 수 있다. 근데 하루에 몇 끼 먹어요를 업데이트를 시키면 똑같이 useEffect가 호출이 된다.

location은 변경이되지 않고 number 만 바뀌었는데 왜? useEffect가 불리는 것일까 ?

잠깐 여기서 자바스크립트 타입에 대해서 알 필요가 있다. 자바스크립트는 원시 타입과 객체 타입으로 나뉘는데 변수라는 상자에 원시 타입의 값을 할당하면 그 값은 상자에 바로 들어가지만 객체 타입은 조금 다르다.
어떤 변수에 객체 타입을 값을 할당하면 객체는 엄청 크기 때문에 바로 변수에 담기지 않고 어떤 메모리 상에 공간이 할당되어 그 메모리 안에 보관이 된다. 그리고 그 변수 안에는 객체가 담긴 메모리의 주소가 할당이 된다.

같은 원시 값을 가진 두 가지 변수를 비교 연산 자료 비교하면 트루가 나온다. 왜냐면 변수라는 상자 안에 있느 값이 동일하기 때문이다. 하지만 똑같아 보이는 객체 타입의 두 가지 변수를 비교하면 false가 나온다. 왜냐하면 변수라는 상자 안에는 메모리상의 주소가 들어가 있고, 두 객체는 다른 주소에 저장되어 있기 때문이다.

위에서 다시 '하루에 몇 끼 먹어요'를 증가시키면 number가 바뀌기 때문에 App 함수가 다시 호출이 된다. 그럼 location 변수도 겉이 똑같이 생긴 객체를 또 할당을 받을 것이다.

  const location = {
    country: isKorea ? "한국" : "외국",
  };

아무리 이 오브젝트가 눈으로 보기에 똑같아 보여도 사실은 다른 오브젝트이다. 이 오브젝트는 이전의 있던 오브젝트라는 다른 메모리상 공간에 저장이 된다. location이라는 변수는 또 생성된 이 오브젝트의 주소를 참조하게 되고 그래서 useEffect 리액트의 관점에서는 Location 변수 안의 주소가 바뀌었기 때문에 아무리 오브젝트가 똑같이 생겨 보여도 location 은 변경이 된 것이다. 즉 location이 참조하는 주소가 바뀌었기 때문!

자 그럼 이것을 해결해 주려면 App 컴포넌트가 렌더링이 되었을 때 location 변수가 다시 초기화되는 것을 막아주면 된다. location 변수는 isKorea가 바뀌었을 때만 초기화 되도록 해준다.

useMemo를 사용해서 location 메모이제이션 해주자.

  const location = useMemo(() => {
    return { country: isKorea ? "한국" : "외국" };
  }, [isKorea]);

다시 확인을 해보자.
업로드중..

'비행기 타기'를 누르면 똑같이 useEffect가 호출되지만 '하루에 몇 끼'를 증가시키면 useEffect가 호출이 되지 않는 것을 확인할 수 있다.
이렇게 하면 컴포넌트를 효과적으로 최적화시킬 수 있다. useEffect에서 뭔가 오래 걸리는 작업을 해야 한다면 꼭 필요할 때만 호출되는 것이 좋기 때문이다.



참고
리액트 공식 홈페이지
별코딩-리액트훅스시리즈

profile
slow but sure

0개의 댓글