들어가며

최근 회사에서 리액트로 개발하는 도중 무분별한 리렌더링이 발생하는 이슈를 겪었다.

대표적으로, 사용자가 문의사항을 남기는 페이지에서 문의 내용을 입력할 때마다 다른 인풋이 깜빡거리는 현상이 발생했다.

이 깜빡거리는 현상의 원인은 문의 내용 입력 시 content state 의 업데이트로 컴포넌트가 리렌더링되었기 때문이었다.

리액트는 상태의 변경에 따라 상태가 변경되는 컴포넌트와 그 이하의 모든 자식 컴포넌트가 렌더링의 대상이 된다. 문제는 자식 컴포넌트의 상태가 변경되지 않아도(갱신될 필요가 없어도) 불필요한 렌더링이 일어난다고 한다.

리액트의 특성상 content state 가 업데이트되었는데 다른 인풋 또한 불필요하게 렌더링이 발생한 것으로 예상된다.

위와 같은 리렌더링 이슈를 막기 위해 해결책을 찾던 도중 useMemo 훅에 대해 알게 되었고, 내용을 잊어버리지 않고자 글로 정리하게 되었다.

게시글의 내용은 레퍼런스의 영상을 학습하며 정리한 내용이니, 자세한 내용은 영상을 참고해주시기 바란다.

알고 넘어가야 할 개념 : 메모이제이션(memoization)

메모이제이션(memoization) 이란 계산된 값을 메모리 상에 저장하고, 이후 같은 계산을 반복하지 않고 메모리 상에서 꺼내 재사용하는 것을 의미한다.

useMemo 훅

useMemo 는 전달된 함수가 실행되고 반환된 결과를 캐싱한다.

useMemo 의 구조는 다음과 같다.

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

useMemo 는 2개의 인자를 받는데, 1번째 인자로는 콜백 함수, 2번째 인자로는 배열을 받는다.

1번째 인자인 콜백 함수는 우리가 메모이제이션(캐싱)해 줄 값을 계산해서 리턴해주는 함수이다.

이 콜백 함수가 리턴해주는 값이 바로 useMemo 가 리턴해주는 값이 된다.

2번째 인자인 배열은 의존성 배열이라고도 불리어진다.

의존성 배열 안에 있는 요소의 값이 업데이트될 때만 콜백 함수를 다시 호출해서 메모이제이션되어 있던 값을 업데이트해서 다시 메모이제이션한다.

useMemo 사용법 예시)

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

2번째 인자의 배열이 빈 배열이면, 맨 처음 컴포넌트가 마운트되었을 때만 값을 계산하고 이후에는 항상 메모이제이션된 값을 꺼내와서 사용한다.

예제 1) state 가 숫자인 경우

import { useState } from "react";

const hardCalculate = (number) => {
  console.log("어려운 계산!");
  for (let i = 0; i < 9999; 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;

각각 input 의 숫자를 변경하면 자동으로 계산된 결과가 화면에 출력된다.

현재 작성된 코드의 문제는, 어려운 계산기 input 의 숫자를 변경하지 않았음에도 불구하고 만약 쉬운 계산기 input 의 숫자를 변경하면 hardCalculate 함수가 호출된다.

물론 어려운 계산기 input 의 숫자를 변경하지 않았기 때문에 이전의 값과 동일한 값을 리턴한다.

이는 불필요한 호출이다.

easyNumber 상태가 변경될 때 컴포넌트가 리렌더링되어 hardCalculate 함수가 호출되는 것이다.

이 상황에서 useMemo 훅을 사용하면 easyNumber 상태 변경 시 hardCalculate 함수의 호출을 막을 수 있다.

// ...
  const hardSum = useMemo(hardCalculate(hardNumber), [ hardNumber ]);
// ...

hardCalculate 함수에 다음과 같이 useMemo 훅을 적용하면 처음 컴포넌트가 마운트되었을 때, 그리고 오직 hardNumber 상태가 변경될 때만 호출된다.

예제2) state 가 객체인 경우

import { useEffect, useState } from "react";
import "./styles.css";

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

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

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

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

 

number state 를 증가시키면 App 함수가 다시 호출된다.

App 함수가 다시 호출된다 하더라도 겉으로 보기에 location 변수에 저장된 값은 변화가 없으므로 당연히 이전과 동일한 값이라고 생각할 것이다.

하지만 실제로는 그렇지 않았다. 왜냐하면 location 변수는 객체를 값으로 가지고 있기 때문에 값 자체를 저장하고 있지 않고, 메모리 상에 객체가 할당된 주소 값을 저장한다.

App 함수가 다시 호출되면 location 변수는 이전과 분명 동일한 객체 값이지만 메모리 상에 새로운 공간이 할당되어 메모리 주소 값이 저장된다.

아래 코드를 보면 앞서 설명한 내용 이해가 더 수월할 것이다.

const locationOne = {
  country: "korea"
}

const locationTwo = {
  country: "korea"
}

console.log(locationOne === locationTwo); // false 

코드 상 분명 두 개의 값이 같지만, 실제로 비교해보니 동일하지 않았다. 사실은 다른 객체인 것이다.

즉, location 변수에 저장되는 메모리 주소 값이 변경된다.

리액트의 관점에서 location 이 참조하고 있는 주소 값이 변경되었으므로 location state 가 변경되었다고 인지한다.

이 상황에서 useMemo 훅을 사용하면 number state 를 업데이트했을 때 location 변수가 초기화되는 것을 막아줄 수 있다.

location 변수는 isKorea state 가 업데이트되었을 때만 초기화되도록 해줄 것이다.

import { useEffect, useState, useMemo } from "react";
import "./styles.css";

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

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

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

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

레퍼런스

https://www.youtube.com/watch?v=e-CnI8Q5RY4

복사했습니다!