[성능개선] 쓸모없는 메모이제이션을 제거하고 최적화 결과값을 측정해보자!

김유진·2025년 8월 10일
post-thumbnail

부서배치를 받아 우리 팀에 막 합류하였을 때, 가장 뜨거운 감자였던 것은 성능 개선이었다.
그 중, 우리 팀은 메모이제이션을 많이 사용하는 편이었는데, 의미 없이 메모이제이션을 사용하고 있는 경우가 많아 이를 개선하는 과제를 받게 되었다.

메모이제이션을 사용하는 이유

리랜더링은 리액트에서 성능 저하를 일으키는 원인 중의 하나이다. 메모이제이션은 메모리를 차지하는 대신 리랜더링을 최소화하고, 비싼 연산을 1회만 수행할 수 있게 해주는 유용한 툴이다.

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

function ExpensiveCalculation(num) {
  let total = 0;
  for (let i = 0; i < 1_000_000_000; i++) {
    total += num;
  }
  return total;
}

export default function App() {
  const [count, setCount] = useState(1);
  const [theme, setTheme] = useState(false);

  const result = useMemo(() => ExpensiveCalculation(count), [count]);

  return (
    <div
      style={{
        background: theme ? "black" : "white",
        color: theme ? "white" : "black",
      }}
    >
      <h1>결과: {result}</h1>
      <button onClick={() => setCount(c => c + 1)}>숫자 증가</button>
      <button onClick={() => setTheme(t => !t)}>테마 변경</button>
    </div>
  );
}

위와 같이 메모이제이션의 결과값을 저장해두면, 비싼 연산을 다시 수행하지 않고도 연산 결과값을 재사용할 수 있다.
또한 결과값을 props로 넘겨줄 때에도 참조값이 안정적으로 유지되므로 리랜더링이 일어나지 않는다 (보통 React는 얕은 비교 연산을 수행하기 때문이다)

불필요한 메모이제이션이란?

하지만, 우리 팀의 레포지토리 곳곳에서는 메모이제이션을 거의 모든 함수, 값에 붙이다 보니 아래와 같은 연산에도 메모이제이션을 사용하고 있었다.

  • 매우 단순한 연산
  • 의존성 배열에 항상 불안정한 참조값을 가지고 있는 함수, 값

불필요한 메모이제이션은 아래 예시들로 설명할 수 있다.

매우 단순한 연산

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

export default function App() {
  const [number, setNumber] = useState(1);

  const squared = useMemo(() => number * number, [number]);

  return (
    <div>
      <h1>숫자: {number}</h1>
      <h2>제곱: {squared}</h2>
      <button onClick={() => setNumber(n => n + 1)}>+1</button>
      <button onClick={() => setNumber(n => n)}>같은 값</button>
    </div>
  );
}

제곱 연산을 수행하는 아주 단순한 연산인데 메모이제이션을 사용하고 있다.
이와 같은 경우에서는 연산이 차지하는 계산값보다 메모이제이션을 하는 값이 더욱 크다.
불필요한 메모이제이션을 차지하고 있는 것이다.

의존성 배열에 항상 불안정한 참조값을 가지고 있는 경우

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

export default function App() {
  const [count, setCount] = useState(0);

  const unstableObj = { value: count };

  const computed = useMemo(() => {
    return unstableObj.value * 2;
  }, [unstableObj]);

  return (
    <div>
      <h1>계산 값: {computed}</h1>
      <button onClick={() => setCount(c => c + 1)}>+1</button>
      <button onClick={() => setCount(c => c)}>같은 값</button>
    </div>
  );
}

이럴 경우 매번 랜더링을 할 때마다 unstableObj가 새로운 객체 참조를 갖는 것이 되어 computed의 메모이제이션이 의미가 없어진다.

마감 시간을 생각하며 개발을 하다 보면 메모이제이션 의존성에 무엇이 들어가 있는지 모두 신경쓰면서 개발하기 어렵다.
그렇기 때문에 이번 기회에 시간을 두고 보며 메모이제이션을 올바르게 사용하고 있지 못하는 부분을 전수검사하는 시간을 가졌다.

메모이제이션 성능 개선 정량적으로 측정하기

불필요하게 메모이제이션을 하고 있는 코드를 제거하는 작업을 하며 한가지 아쉬웠던 점은, 성능 개선의 결과값을 정량적으로 측정할 수 있는 방법이 존재하지 않는다는 것이었다.
하지만 방법이 존재하지 않는다고 포기해버리면 안되죠..(?!)
아쉬운 마음에 서칭을 지속하였고 그 결과 2023년 베를린에서 진행된 React 커뮤니티 발표회에서 "How Much RAM Is Your UseMemo Using? Let’s Profile It!"이라는 주제로 발표를 한 영상을 보게 되었다.

위 영상에서 매우 좋은 툴인 memlab을 소개해 주었는데, meta에서 만든 메모리 분석 라이브러리다. memlab의 문서를 읽어보니 이를 활용하여 원하는 결과를 얻을 수 있을 것이라 생각하여 바로 적용해 보았다.

memelab의 기능

  • 자동화된 메모리 누수 감지: memlab은 E2E 테스트 연동, JavaScript API를 제공하여 메모리 분석을 스크립트로 만들어 자동화된 테스트를 제공합니다. 그 과정에서 힙 스냅샷을 찍어 메모리 사용량을 분석하여 메모리 누수를 쉽게 찾아낼 수 있다.
  • 다양한 분석 기법 제공: 단순히 메모리 사용량 변화를 보여주는 것뿐만 아니라, Retained Paths 분석, 순환 참조 감지 등 다양한 분석 기법을 제공하여 디버깅에 용이하다.
  • 메모리 활용 보고서 생성: 분석 결과를 이해하기 쉬운 형태로 보고서를 생성해 준다. 보고서를 통해 어떤 객체가 메모리를 많이 차지하고 있는지 직관적으로 확인할 수 있다.
  • Node.js 및 브라우저 환경 지원: Node.js 환경과 브라우저 환경에서 메모리 분석을 수행할 수 있도록 지원한다.
  • 스크립트 기반 자동화: JavaScript API를 제공하여 메모리 분석 과정을 스크립트로 자동화할 수 있다. 이를 통해 특정 테스트 시나리오나 사용자 흐름에 대한 메모리 분석을 반복적으로 수행하고 결과를 비교 분석하는 것이 용이하다.

메모이제이션 성능 개선 정량적으로 측정하기

프로젝트 목표

  • 애플리케이션 JS Heap에서 useMemo, React.memo가 차지하고 있는 메모리 값 측정
  • 애플리케이션 JS Heap에서 useCallback이 차지하고 있는 메모리 값 측정
    아쉽게도, memlab에서 자체적으로 useMemouseCallback이 존재하는 메모이제이션 메모리 공간인 memoizedState의 메모리를 계산해주는 유틸이 존재하지 않기 때문에, 분석 스크립트를 직접 만들어 활용해야 한다.

memlab 설치하기

npm install -g memlab

위 명령어를 입력하여 메모리를 분석하고자 하는 저장소에서 활용하면 된다.

memoizedState 분석 스크립트 작성하기

메모리 분석 스크립트를 커스텀하게 작성하게 해주는 파일을 생성하면 된다. 파일명은 Analysis.js로 맞추고, 그 안에 스크립트를 작성한 다음 아래 명령어를 실행하면 분석 스크립트를 실행할 수 있다.

yarn memlab analyze my-analysis --analysis-plugin Analysis.js

그럼 Analysis.js에 작성한 메모리 분석 스크립트가 돌아가게 되고, 아래와 같이 결과물을 출력하게 된다.

그럼 이제,Analysis.js 파일이 어떻게 애플리케이션 내에 존재하는 memoizedState 값을 계산하는지 알아보아야 한다. 메모리 분석 스크립트를 이해하기 위해서는 React가 어떻게 메모이제이션 값을 메모리에 저장하는지 알아보자.

memoizedState 저장소

memoizedState의 저장 위치를 이해하기 위해서는 FiberNode에 대하여 이해하고 있어야 한다. FiberNode는 React가 내부적으로 사용하고 있는 자바스크립트 객체이며, 상태를 추적하고 업데이트하는 reconciliation 과정을 수행하는 데 필요한 데이터이다. 이들은 컴포넌트의 props, state, 어떤 DOM 요소에 대응하는지 등에 대한 정보를 갖고 있다.


훅을 통하여 관리되는 상태 값들이 FiberNode 내의 memoizedState에 저장되어 있다. memoizedState는 FiberNode에 연결된 첫번째 훅을 가리키며, 이러한 값들은 linked List 형태로 서로 연결되어 있다.
각 개별 hookNode 안에 또다시 memoizedState를 찾으면, 이 때 실제로 Hook에 저장하고 있는 값에 접근할 수 있다.
만약 현재의 hookNode에서 next라른 이름이 참조를 찾게 되면, 다음 hook으로 이동할 수 있다.

메모리 분석 스크립트 Analysis.js 뜯어보기

먼저 코드 전문을 보자.

const { BaseAnalysis, getFullHeapFromFile } = require("memlab");
 
class MyAnalysisTest extends BaseAnalysis {
  getCommandName() {
    return "my-analysis"; //분석 스크립트 이름을 명명하는 코드
  }
 
  getDescription() {
    return "useMemo를 통해 저장된 메모리와 useCallback을 통하여 저장된 메모리 값을 계산합니다.";
  }
 
  async process(options) {
    const heap = await getFullHeapFromFile(
      "./snapshots/personalTest5.heapsnapshot"
    );
 
    let totalUseMemoSize = 0;
 
    heap.nodes.forEach((node) => {
      //여러 노드 중 React FiberNode를 찾습니다.
      if (node.name === "FiberNode") {
        //해당 노드가 참조하고 있는 값들 중에서 memoizedState를 찾습니다.
 
        const memoStateRef = node.references.find(
          (ref) => ref.name_or_index === "memoizedState"
        );
        if (!memoStateRef) return;
 
        //memoizedState를 통해 linked list로 연결된 노드를 찾습니다.
        let hookNode = memoStateRef.toNode;
 
        //linked list를 순회하며 useMemo와 useCallback을 찾습니다.
        while (hookNode) {
          const memoizedValRef = hookNode.references.find(
            (ref) => ref.name_or_index === "memoizedState"
          );
          const memoizedValue = memoizedValRef?.toNode;
 
          let isUseMemo = false;
          let isUseCallback = false;
 
          if (memoizedValue) {
            const type = memoizedValue.type;
            const valueObj = memoizedValue.getJSONifyableObject?.() ?? {};
            //useCallback은 클로저 함수를 반환함
            if (type === "closure") {
              isUseCallback = true;
              //useMemo는 memoizedState의 type이 object 또는 array이고, create, destroy, deps가 없는 경우입니다.(생명주기) useEffect를 제외하기 위함
            } else if (type === "object" || type === "array") {
              const hasEffectShape =
                valueObj?.create || valueObj?.destroy || valueObj?.deps;
              if (!hasEffectShape) {
                isUseMemo = true;
              }
            }
 
            if (isUseMemo) {
              totalUseMemoSize += memoizedValue.self_size;
            } else if (isUseCallback) {
              totalUseMemoSize += memoizedValue.self_size;
            }
          }
          const nextRef = hookNode.references.find(
            (ref) => ref.name_or_index === "next"
          );
          hookNode = nextRef?.toNode;
        }
      }
    });
 
    console.log(
      "메모이제이션이 차지하고 있는 용량:",
      totalUseMemoSize,
      "bytes"
    );
  }
}
module.exports = MyAnalysisTest;

Analysis 초기 세팅

1. BaseAnalysis 클래스 오버라이드
memlab은 BaseAnalysis라는 클래스의 메서드를 동작시켜 분석을 진행한다. 그렇기 때문에 기존에 존재하던 BaseAnalysis 클래스를 오버라이드하여 메서드를 작성해야 한다.

class MyAnalysisTest extends BaseAnalysis {

2. Analysis setup
실행 명령어는 아래와 같이 플러그인의 이름을 명명해야 하기 때문에, 해당 분석에 대한 이름과 설명을 정리한다.

memlab analyze <PLUGIN_NAME> [PLUGIN_OPTIONS]
getCommandName() {
  return "my-analysis"; //분석 스크립트 이름을 명명하는 코드
}
 
getDescription() {
  return "useMemo를 통해 저장된 메모리와 useCallback을 통하여 저장된 메모리 값을 계산합니다.";
}

또한, 분석하고자 하는 JS 스냅샷을 가져온다. 스냅샷은 구글 개발자 도구에서 쉽게 가져올 수 있다. (개발자 도구 > Memory > 스냅샷 촬영 > Save Profile)

실제로 플러그인이 돌아가면서 코드 분석을 진행하는 것은 process 함수에서 진행한다. 그렇기 때문에 해당 함수도 오버라이드가 필요하다.

async process(options) {
  const heap = await getFullHeapFromFile(
    "./snapshots/personalTest5.heapsnapshot"
  );
...
}

3. useMemo, useCallback 메모리 가져오기
두 정보는 React의 Fibernode에 저장되어 있기 때문에 여러 노드 중 FiberNode를 찾아야 한다.
또한, 해당 FiberNode가 참조하고 있는 객체들 중 memoizedState 값을 가진 노드를 찾는다.

heap.nodes.forEach((node) => {
  //여러 노드 중 React FiberNode를 찾습니다.
  if (node.name === "FiberNode") {
    //해당 노드가 참조하고 있는 값들 중에서 memoizedState를 찾습니다.
    const memoStateRef = node.references.find(
      (ref) => ref.name_or_index === "memoizedState"
    );
    if (!memoStateRef) return;...})

맨 처음으로 찾게 된 노드를 hookNode에 저장한다.

heap.nodes.forEach((node) => {
   ...
   let hookNode = memoStateRef.toNode;
   ...
})

이제, linkedList로 연결된 노드들을 순회하면서 해당 FiberNode안에서 메모이제이션 된 값 (useMemo, useCallback)이 어떤 크기로 존재하는지 조회합니다.

heap.nodes.forEach((node) => {
    ...
    while (hookNode) {
       const memoizedValRef = hookNode.references.find((ref) => ref.name_or_index === "memoizedState")); //hookNode 안에 존재하는 memoizedState 참조
       const memoizedValue = memoizedValRef?.toNode;
       let isUseMemo = false;
       let isUseCallback = false;
 
       //moizedValue가 존재할 때에만 연산 진행
       if (memoizedValue) {
         const type = memoizedValue.type;
         const valueObj = memoizedValue.getJSONifyableObject?.() ?? {};
 
         //useCallback은 클로저 함수를 반환함
         if (type === "closure") {
            isUseCallback = true;
            //useMemo는 memoizedState의 type이 object 또는 array이고, create, destroy, deps가 없는 경우입니다.(생명주기) useEffect를 제외하기 위함
         } else if (type === "object" || type === "array") {
            const hasEffectShape = valueObj?.create || valueObj?.destroy || valueObj?.deps;
            if (!hasEffectShape) {
              isUseMemo = true;
             }
          }
            if (isUseMemo || isUseCallback) {
              totalUseMemoSize += memoizedValue.self_size;
            }
          }
          const nextRef = hookNode.references.find(
            (ref) => ref.name_or_index === "next"
          );
          hookNode = nextRef?.toNode;
        }
     }
});

여기서 조금 주의 깊게 보아야 하는 것은 메모리의 크기를 연산하는 부분이다.

if (memoizedValue) {
    const type = memoizedValue.type;
    const valueObj = memoizedValue.getJSONifyableObject?.() ?? {};
 
    //useCallback은 클로저 함수를 반환함
    if (type === "closure") {
        isUseCallback = true;
        //useMemo는 memoizedState의 type이 object 또는 array이고, create, destroy, deps가 없는 경우입니다.(생명주기) useEffect를 제외하기 위함
    } else if (type === "object" || type === "array") {
        const hasEffectShape = valueObj?.create || valueObj?.destroy || valueObj?.deps;
        if (!hasEffectShape) {
          isUseMemo = true;
        }
     }
}

분기처리가 복잡하게 되어 있어 아래 표에 정리해 보았다.

메서드설명
useMemo, React.memoreturn type이 object 또는 array이다. 만약, create/destroy/deps가 존재한다면 생명주기가 있는 값이므로 useEffect로 판단하여야 함
useCallback클로저 함수 반환

만약 메모이제이션 되어 있는 값이라면 그 크기를 계산하여 totalSize 에 반영합니다.

if (isUseMemo || isUseCallback) {
  totalUseMemoSize += memoizedValue.self_size;
}

계산이 완료되었으므로 다음 노드를 찾아갑니다. 다음 노드가 null이라면 링크드 리스트의 끝에 다다른 것이므로 while문을 종료합니다.

const nextRef = hookNode.references.find((ref) => ref.name_or_index === "next");
hookNode = nextRef?.toNode;

이렇게 해서 Analysis.js 에 대한 코드 전문 분석이 완료되었다.

성능 개선 측정 결과

약 3주 동안 불필요한 메모이제이션을 제거하여 최적화하는 과정을 거쳤다. 결과적으로는 성공적인 성과를 거둘 수 있었다! 🎉

3주동안 열심히 진행하였던 최적화의 결과물로 인하여 총 60.63KB 메모리 사용량이 감소되었음을 알 수 있었다. (-10.42%)

고려해야 할 점

현재 작성된 스크립트는 memoizedValue.self_size를 사용하여 메모이제이션 된 항목의 크기를 계산하고 합산한다.

하지만, self_size 는 해당 객체의 shallow size만을 나타낸다.

메모이제이션을 제거하는 작업을 할 때에는 유의미한 사이즈 변화 결과값을 얻을 수 있지만, 메모이제이션 된 객체 내부를 최적화하거나 메모리 누수를 추적할 때에는 유의미한 결과값을 얻지 못할 수 있다. 즉, 스크립트가 계산하는 totalUseMemoSize는 "메모이제이션된 값 또는 함수 객체 자체의 크기 합"이며, 이들로 인해 실제 애플리케이션에서 유지되는 전체 메모리 점유량(retained size)과는 다를 수 있다는 점을 인지해야 한다.

참고 링크 및 Github

1개의 댓글

comment-user-thumbnail
2025년 9월 11일

매번 메모이제이션의 비용이 크다라는 이야기만 들었지, 측정하는 방법이나 도구에대해서 의문을 표했는데요 (메모이 제이션 비용이 더크니까 그냥 렌더링 하는게 좋다. 라는 기준?)

해당 글에서 의문을 해결할 수 있었습니다 감사합니닷 !!

답글 달기