[WEB] 프론트엔드

React, Memoization

[FE] Lighthouse 2025. 6. 18. 18:11

Overview

React를 사용해 Web Application을 개발한다면 메모이제이션(Memoization) 이라는 단어는 익숙한 단어입니다.

이번 글에서는 메모이제이션이란 무엇인지, 메모이제이션을 통해 해결하려는 것은 무엇인지 살펴보겠습니다.

 

메모이제이션(Memoization)

메모이제이션의 사전적 의미는 컴퓨터 프로그램이 복잡한 함수 호출의 결과값을 함수에 저장해놓고, 같은 입력이 반복될 때 저장한 값을 반환하도록 하여 속도를 높이는 최적화 기술이다. 라고 합니다.

 

쉽게 말하면 값 비싼 연산으로 인해 도출된 값을 캐싱한 후 같은 Input일 경우 캐싱된 값을 반환한다고 생각할 수 있습니다. 그럼 이 개념이 왜 React에 적용이 되는 것 일까요?

 

React, Component, 살펴보기

React를 사용해 Web Application을 개발한다면 컴포넌트(Component)단위로 UI를 분리하고, 컴포넌트를 조립하여 결과물을 만들고는 합니다.

 

컴포넌트에는 여러 비즈니스 로직이 포함될 수 있는데, 그 중 컴포넌트에 전달되는 props와 컴포넌트에서 소유하는 state는 값이 변경될 때 컴포넌트의 랜더링이 발생합니다.

 

props와 state가 변경될 때 마다 해당 값을 참조하고 있는 컴포넌트는 반드시 랜더링이 발생합니다. 이때 발생되는 랜더링은 불필요한 랜더링 일수도 있습니다. 예시를 들어보도록 하겠습니다.

 

예제 코드

type CheckboxProps = React.InputHTMLAttributes<HTMLInputElement> & {
  label: string;
};

const Checkbox: React.FC<CheckboxProps> = ({ label, ...props }) => {
  return (
    <div>
      <input type="checkbox" {...props} />
      <label htmlFor={props.id}>{label}</label>
    </div>
  );
};

export default Checkbox;
import { useState } from "react";
import Checkbox from "./checkbox";

const Test = () => {
  const [value, setValue] = useState({ id: "", checked: false });

  const handleChangeCheckbox = (e: React.SyntheticEvent<HTMLInputElement>) => {
    const id = e.currentTarget.id;
    const checked = e.currentTarget.checked;

    setValue({ id, checked });
  };

  const isChecked = (id: string) => id === value.id && value.checked;

  return (
    <div>
      {options.map((option) => (
        <Checkbox
          id={option.id}
          key={option.id}
          label={option.label}
          checked={isChecked(option.id)}
          onChange={handleChangeCheckbox}
        />
      ))}
    </div>
  );
};

export default Test;

 

우리는 Option 4 체크박스를 클릭했고 해당 체크박스만 랜더링이 발생하는 것을 기대하고 있습니다. 하지만 우리의 의도와는 다르게 모든 체크박스 컴포넌트에서 랜더링이 발생합니다. 이는 불필요한 랜더링입니다.

랜더링이 발생하는 원인을 분석해보기 위해 React Profiler을 활용해봅시다.

 

각 랜더링이 발생한 원인을 살펴보니 Props가 변경됐고, 불필요한 랜더링이 발생한 컴포넌트는 onChange 함수로 인해 변경이 됐다는 것을 확인할 수 있습니다.

 

props로 전달되는 onChange 함수인 handleChangeCheckbox 함수가 변경됐고, 이로 인해 다른 Checkbox의 리랜더링이 발생합니다. 그럼 handleChangeCheckbox 함수는 왜 변경이 됐을지 생각해봅시다!

 

handleChangeCheckbox 함수를 살펴보면 setValue를 통해 state 값을 변경하고 있습니다. 이로 인해 Test 컴포넌트가 리랜더링이 되며 리랜더링이 될 때 handleChangeCheckbox 함수가 생성이 됩니다. handleChangeCheckbox 함수가 변경이 됐고 props로 전달되는 값이 변경이 됐기에 Checkbox 컴포넌트가 리랜더링이 발생합니다.

 

위 과정(불필요한 리랜더링)을 최적화 해봅시다!

 

memo, useCallback

React에서 제공하는 메모이제이션 API인 memo 함수와 함수를 메모이제이션 할 수 있는 useCallback 훅을 사용합니다.

 

memo 훅은 얕은 비교를 통해 컴포넌트의 리랜더링을 최적화합니다. useCallback 훅은 컴포넌트 랜더링 시 생성된 함수를 메모이제이션 하고, 리랜더링 시 Dependency Array의 값이 변경됐는지 체크한 다음 변경되지 않았다면 메모이제이션 된 함수를 재사용합니다.

useCallback 구현체를 살펴보고 싶다면 다음 글을 확인해주세요.
useCallback 알아보기

 

 

리랜더링 최적화

우리가 최적화 하고 싶은 컴포넌트는 Checkbox 컴포넌트 입니다. 해당 컴포넌트를 memo로 감싸서 export 합니다.

import { memo } from "react";

type CheckboxProps = React.InputHTMLAttributes<HTMLInputElement> & {
  label: string;
};

const Checkbox: React.FC<CheckboxProps> = ({ label, ...props }) => {
  return (
    <div>
      <input type="checkbox" {...props} />
      <label htmlFor={props.id}>{label}</label>
    </div>
  );
};

export default memo(Checkbox);

 

값이 변경될 때 Test 컴포넌트의 리랜더링이 발생한다고 했습니다. 이때 Checkbox 컴포넌트의 onChange Props로 넘겨주는 handleChangeCheckbox 함수가 다시 생성되지 않도록 (메모이제이션 된 값을 사용하도록) 수정해봅시다!

import { useState } from "react";
import Checkbox from "./checkbox";

const Test = () => {
  const [value, setValue] = useState({ id: "", checked: false });

  const handleChangeCheckbox = useCallback(
    (e: React.SyntheticEvent<HTMLInputElement>) => {
      const id = e.currentTarget.id;
      const checked = e.currentTarget.checked;
      setValue({ id, checked });
    },
    [setValue]
  );

  const isChecked = (id: string) => id === value.id && value.checked;

  return (
    <div>
      {options.map((option) => (
        <Checkbox
          id={option.id}
          key={option.id}
          label={option.label}
          checked={isChecked(option.id)}
          onChange={handleChangeCheckbox}
        />
      ))}
    </div>
  );
};

export default Test;

 

handleChangeCheckbox 함수를 useCallback 훅의 첫 번째 인자로 넣어줬습니다. 훅의 두 번째 인자로 함수에서 의존하고 있는 외부 값을 넣어줍니다. 해당 함수에서는 setValue를 의존하고 있기 때문에 의존성 배열에 추가했습니다.

의존성 배열에 함수에서 사용하는 외부 값을 넣어주지 않는 경우 사이드 이펙트가 발생합니다. 외부 값은 변경됐는데 함수는 메모이제이션 된 함수(변경되기 이전의 값을 사용하는 함수)이기 때문입니다.

 

최적화를 진행했으니 결과를 살펴보겠습니다.

이전에는 모든 체크박스가 리랜더링 됐지만, 최적화 이후에는 실제 값이 변경된 컴포넌트만 리랜더링이 발생했습니다. 의도한 바가 정확히 적용된 것 입니다.

 

여기서 궁금한 점이 발생했습니다. Checkbox 컴포넌트에 isChecked 함수를 사용해 값을 넘겨주고 있는데 isChecked 함수에는 메모이제이션을 적용하지 않았습니다. 그럼에도 리랜더링이 발생하지 않았구요. 어떤 차이가 있는지 궁금하지 않나요?

 

스칼라 값

이 현상을 이해하기 위해선 JavaScript 언어에 대해서 이해해야 합니다.

 

JavaScript에서 스칼라 값(원시 값)은 string, number, boolean, symbol 값 입니다. 해당 값들은 값 비교 시 리터럴 그 자체를 사용해 값을 비교합니다. 반면에 스칼라 값이 아닌 값은 JavaScript에서 Object입니다. 우리가 자주 사용하는 배열, 함수, 그 외 객체 모든 리터럴(값)은 Object 형태입니다.

 

Object는 값 비교 시 주소 값을 통해 비교됩니다. 예를 들어 살펴보도록 하겠습니다.

const scala1 = 1;
const scala2 = 1;

const object1 = {
  a: 1,
};

const object2 = {
  a: 1,
};

const object3 = object1;

console.log(scala1 === scala2);
console.log(object1 === object2);
console.log(object1 === object3);

 

scala1과 scala2는 모두 number 타입의 스칼라 값이며 리터럴은 1 입니다. 값 비교 시 1 === 1 로 true를 반환합니다.

 

반면에 object1과 object2는 객체(Object) 타입이며 같은 형태를 가지고 있습니다. 하지만 런타임 시 객체는 메모리에 생성되고 메모리 상에서 object1, object2 변수는 해당 객체의 주소 값을 가지고 있습니다.

 

따라서 object1과 object2을 비교했을 때 (15행) 서로 다른 주소 값을 가지고 있기에 false를 반환합니다.

 

반대로 object3은 object1의 주소 값을 할당 받았습니다. 따라서 같은 메모리 주소를 바라보고 있고, object1과 object3을 비교했을 때 true를 반환합니다.

 

우리는 스칼라 값을 비교할 때 값 자체를 비교한다는 것을 확인했습니다. 그럼 isChecked를 왜 메모이제이션 하지 않아도 리랜더링이 발생하지 않는지 살펴봅니다.

// 생략

  return (
    <div>
      {options.map((option) => (
        <Checkbox
          id={option.id}
          key={option.id}
          label={option.label}
          checked={isChecked(option.id)}
          onChange={handleChangeCheckbox}
        />
      ))}
    </div>
  );
};

 

checked props를 살펴봅시다 isChecked 함수에 option.id 값을 넘겨주어 실행하고 있습니다. isChecked 함수는 실행되어 스칼라 값인 boolean을 리턴합니다. 따라서 checked props에 넘겨지는 값은 boolean 값 입니다.

 

메모이제이션 된 컴포넌트는 얕은 비교를 통해 리랜더링 합니다. scala 값인 checked는 실제 값이 변경되지 않았다면 이전과 같은 값이기에 비교 결과 true를 리턴합니다. 그렇기 때문에 isChecked 함수를 메모이제이션 하지 않아도 리랜더링이 발생하지 않습니다.

 

어떤 값을 메모이제이션 해야할까?

메모이제이션을 활용해 최적화 하는 과정을 살펴봤습니다. 이 과정을 통해 우리는 메모이제이션을 하는 목적은 랜더링 최적화를 위해서 라는 것을 알게 됐습니다. 그럼 어떤 값을 메모이제이션 하는 것이 좋을까요?

 

얕은 비교 시 객체는 메모리의 주소 값으로 비교합니다. 컴포넌트가 리랜더링이 된 후 객체가 다시 생성 된다면 메모리의 주소가 달라지기 때문에 얕은 비교에서 항상 리랜더링을 발생 시킵니다.

 

따라서 객체의 값은 메모이제이션을 통해 의존성 배열이 변경되지 않는 경우 객체의 값은 메모이제이션 된 값을 사용하도록 하는 것이 좋습니다.

 

반대로 스칼라 값은 얕은 비교 시 값 그 자체로 비교합니다. 따라서 메모이제이션을 하지 않아도 항상 동일한 값을 반환하기 때문에 부모 컴포넌트에서 리랜더링이 발생해도 해당 값(props)으로 인한 리랜더링이 발생하지 않습니다.

 

값 비싼 연산의 최적화

스칼라 값은 값 그 자체로 비교하기 때문에 메모이제이션을 하지 않아도 된다고 했습니다. 하지만 해당 값을 연산하는 과정으로 인해 UI 블로킹이 발생한다면 어떨까요?

 

예시를 들어보도록 하겠습니다.

const veryExpensiveCompute = (count: number) => {
  // 복잡한 연산을 시뮬레이션
  let sum = 0;
  for (let i = count; i < 1e6; i++) {
    for (let j = 0; j < 1000; j++) {
      // 이중 루프를 통해 복잡한 연산을 시뮬레이션
      sum += i + j;
    }
  }
  return sum;
};

 

우리가 사용할 veryExpensiveCompute 위 함수를 사용해 number 타입의 스칼라 값을 반환 받습니다. 해당 함수는 이중 루프이며 엄청나게 많은 연산을 수행하고 있습니다.

import { useCallback, useMemo, useState } from "react";
import Checkbox from "./checkbox";

const options = [
  { id: "option1", label: "option 1" },
  { id: "option2", label: "option 2" },
  { id: "option3", label: "option 3" },
  { id: "option4", label: "option 4" },
];

const Test = () => {
  const [count, setCount] = useState(0);
  const [value, setValue] = useState({ id: "", checked: false });

  const handleChangeCheckbox = useCallback(
    (e: React.SyntheticEvent<HTMLInputElement>) => {
      const id = e.currentTarget.id;
      const checked = e.currentTarget.checked;

      setValue({ id, checked });
    },
    [setValue]
  );

  const isChecked = (id: string) => id === value.id && value.checked;

  const computedValue = veryExpensiveCompute(count);
  console.log("Computed Value:", computedValue);

  return (
    <div>
      {options.map((option) => (
        <Checkbox
          id={option.id}
          key={option.id}
          label={option.label}
          checked={isChecked(option.id)}
          onChange={handleChangeCheckbox}
        />
      ))}
    </div>
  );
};

export default Test;

 

지금까지 최적화 한 컴포넌트에서 해당 값을 연산하고 console을 통해 출력해보도록 합니다.

컴포넌트가 랜더링 될 때 이미 값이 연산 됐습니다. 클릭 이벤트가 발생할 때 Test 컴포넌트의 상태가 변경되어 리랜더링이 발생하고 veryExpensiveCompute 함수 통해 값을 다시 연산합니다.

 

이때 연산 과정이 JavaScript의 메인 스레드를 점유하게 되고 이로 인해 사용자의 이벤트로 인한 UI 랜더링이 블로킹되는 현상이 발생합니다. (UI Blocking)

 

이를 막기 위해 우리는 메모이제이션을 사용할 수 있습니다. 메모이제이션을 사용해 컴포넌트의 리랜더링 시 이전에 생성된 값을 재사용 하는 것 입니다.

 

useMemo

useMemo 훅을 사용해 값을 메모이제이션 합시다.

import { useCallback, useMemo, useState } from "react";
import Checkbox from "./checkbox";

const options = [
  { id: "option1", label: "option 1" },
  { id: "option2", label: "option 2" },
  { id: "option3", label: "option 3" },
  { id: "option4", label: "option 4" },
];

const Test = () => {
  const [count, setCount] = useState(0);
  const [value, setValue] = useState({ id: "", checked: false });

  const handleChangeCheckbox = useCallback(
    (e: React.SyntheticEvent<HTMLInputElement>) => {
      const id = e.currentTarget.id;
      const checked = e.currentTarget.checked;

      setValue({ id, checked });
    },
    [setValue]
  );

  const isChecked = (id: string) => id === value.id && value.checked;

  const computedValue = useMemo(() => veryExpensiveCompute(count), [count]);
  console.log("Computed Value:", computedValue);

  return (
    <div>
      {options.map((option) => (
        <Checkbox
          id={option.id}
          key={option.id}
          label={option.label}
          checked={isChecked(option.id)}
          onChange={handleChangeCheckbox}
        />
      ))}
    </div>
  );
};

export default Test;

 

그리고 결과를 살펴봅니다.

 

 

컴포넌트 최초 랜더링(마운트) 시 연산을 수행하고 해당 값을 메모이제이션 합니다.

 

그리고 Checkbox가 클릭된 후 메모이제이션 된 값을 재사용 하기 때문에 (연산 과정을 스킵) 이전에 발생한 UI Blocking 현상이 발생하지 않습니다.

 

물론 최초 랜더링 시 버벅거림 (UI Blocking) 현상은 메모이제이션을 통해 최적화 할 수 없습니다. 반드시 한번은 연산을 실행해야 값을 도출할 수 있으니깐요. 만약 실제 프로젝트에서 이런 문제가 발생한다면 근본적으로 문제를 해결하기 위해서 워커 스레드를 사용하여 문제를 해결하는 것도 좋은 방안일 것 같습니다.

 

메모이제이션 시 주의해야 할 점

메모이제이션을 통해 성능 최적화를 할 때 고려해야 할 몇 가지 사항이 있습니다. 먼저 최적화에는 언제나 트레이드 오프가 존재한다는 것 입니다.

 

메모이제이션은 컴퓨트 된 값을 캐싱하여 재사용하는 방식입니다. 이 방식은 컴퓨트 시간을 줄여주지만 캐싱을 위해 메모리 공간을 점유합니다. 따라서 무분별한 메모이제이션 보다는 랜더링 퍼포먼스를 측정하고 불필요한 리랜더링이 발생하는 변수를 최적화 하는 것이 중요합니다.

 

또한 값 자체를 메모이제이션 하는 것 만으로는 랜더링 최적화를 진행할 수 없는 케이스도 존재합니다. 이런 경우에 랜더링을 유발하는 값이 적절하게 설계 되었는지를 다시 한번 검토한 후 로직을 적절하게 분리하여 랜더링 최적화를 진행하는 것이 중요합니다.

 

마치며

이번 글을 통해 메모이제이션을 통해 React의 랜더링 최적화를 하는 방식에 대해서 살펴봤습니다.

 

최근 React 19 버전에는 메모이제이션을 자동으로 해주는 React Compiler(Forget)이 등장했습니다. 아직 RC 단계이지만 React Compiler을 통해 메모이제이션에 들어가는 리소스를 최적화 할 수 있다면 React의 DX가 한층 상승하지 않을까 생각하고 있습니다.

 

읽어봐주셔서 감사합니다.