Back

[TIL] React와 Next.js의 메모리 관리 - 프론트엔드에서의 릭

2026. 03. 21.Yeji Kim
FrontendDebug

이전 네 글에서 Node.js 서버 사이드의 메모리 전체 계층을 다뤘다. 이 글에서는 시선을 브라우저와 프론트엔드 프레임워크로 옮긴다. React 컴포넌트와 Next.js 애플리케이션에서 메모리 릭이 어떤 형태로 발생하는지, 그리고 어떻게 잡으면 좋을지 살펴본다.

브라우저의 JS 엔진도 V8(Chrome)이나 SpiderMonkey(Firefox)가 GC를 돌린다. 이전 글에서 다룬 Generational GC, Mark-Sweep-Compact, tri-color marking이 동일하게 적용된다. 다만 브라우저에서는 DOM이라는 변수가 추가된다.


브라우저에서 메모리 릭이란

서버와 달리 브라우저 탭의 수명은 유한하다. 사용자가 탭을 닫으면 메모리는 전부 해제된다. 그런데 SPA(Single Page Application)에서는 탭이 닫히지 않은 채로 수십 분~수 시간 동안 사용된다. 페이지 전환이 실제 네비게이션이 아니라 클라이언트 라우팅이기 때문이다.

text
전통적 웹:
  페이지 A → 전체 새로고침 → 페이지 B
  → 페이지 A의 메모리가 완전히 해제됨
 
SPA (React / Next.js):
  페이지 A → 클라이언트 라우팅 → 페이지 B
  → 페이지 A의 컴포넌트는 unmount되지만
  → cleanup이 제대로 안 되면 참조가 남아서 메모리 릭

SPA에서의 릭은 "페이지를 오래 쓸수록 느려지는" 형태로 나타난다. 탭 메모리가 수백 MB를 넘어가면 브라우저가 GC에 시간을 쓰면서 프레임 드롭이 발생한다.


브라우저 메모리의 구조 - On-heap과 Off-heap

이전 글에서 Node.js의 Buffer 데이터가 V8 Heap 밖(off-heap)에 저장된다는 것을 다뤘다. 브라우저에서도 동일한 구조가 존재한다. DevTools에서 보이는 "JS heap size"는 전체 메모리의 일부일 뿐이다.

text
브라우저 탭의 메모리 구조:
 
V8 Heap (on-heap)                 Off-heap
┌──────────────────────┐         ┌──────────────────────────────┐
│ JS 객체               │         │ ArrayBuffer backing store    │
│ React Fiber 트리      │         │ Canvas 2D 비트맵 데이터       │
│ 클로저, Map, Set     │         │ WebGL 텍스처 / 프레임버퍼     │
│ DOM wrapper 객체      │  ───→   │ ImageBitmap 픽셀 데이터      │
│ TypedArray 메타데이터  │         │ WebAssembly.Memory           │
└──────────────────────┘         │ Video/Audio 디코딩 버퍼       │
  ↑                               │ Blob 데이터                   │
  DevTools "JS heap size"        └──────────────────────────────┘
  로 보이는 영역                    ↑
                                   DevTools에서 직접 추적하기 어려운 영역

Canvas, WebGL, Video, ArrayBuffer 등을 다루는 컴포넌트에서는 off-heap 메모리가 on-heap보다 훨씬 클 수 있다. JS heap size가 안정적이어도 탭 전체 메모리가 계속 올라간다면 off-heap 릭을 의심해야 한다.

Canvas의 off-heap 메모리

Canvas의 비트맵 데이터는 V8 Heap 안의 JS 객체가 아니다. Chrome의 경우 렌더링 엔진(Blink/Skia)이 관리하는 네이티브 메모리에 저장된다. Chrome DevTools 공식 문서에서 말하는 "objects allocated outside V8, such as C++ objects defined by Blink"에 해당한다.

jsx
// ❌ Canvas off-heap 릭
function ImageEditor({ src }) {
  const canvasRef = useRef(null);
 
  useEffect(() => {
    const canvas = canvasRef.current;
    const ctx = canvas.getContext('2d');
    const img = new Image();
    img.onload = () => {
      canvas.width = img.width;    // 4000px
      canvas.height = img.height;  // 3000px
      ctx.drawImage(img, 0, 0);
      // 4000 × 3000 × 4 bytes (RGBA) = ~46MB가 off-heap에 할당
      // 이 46MB는 DevTools의 "JS heap size"에 잡히지 않는다
    };
    img.src = src;
 
    // cleanup이 없으면:
    // 컴포넌트가 unmount되어도 canvas DOM이 Detached 상태로 남으면
    // 46MB off-heap 비트맵도 해제되지 않음
  }, [src]);
 
  return <canvas ref={canvasRef} />;
}
jsx
// ✅ Canvas off-heap 메모리 명시적 해제
function ImageEditor({ src }) {
  const canvasRef = useRef(null);
 
  useEffect(() => {
    const canvas = canvasRef.current;
    const ctx = canvas.getContext('2d');
    const img = new Image();
    img.onload = () => {
      canvas.width = img.width;
      canvas.height = img.height;
      ctx.drawImage(img, 0, 0);
    };
    img.src = src;
 
    return () => {
      // canvas width/height를 변경하면 비트맵이 리셋된다
      // (WHATWG HTML Spec: width/height 변경 시 output bitmap 초기화)
      // 이를 통해 브라우저가 이전 비트맵 메모리를 해제할 수 있게 된다
      canvas.width = 0;
      canvas.height = 0;
    };
  }, [src]);
 
  return <canvas ref={canvasRef} />;
}

getImageData / ImageBitmap

getImageData()가 반환하는 ImageData.dataUint8ClampedArray이고, 그 backing store는 off-heap ArrayBuffer다. V8 공식 블로그 "V8 release v8.3"에서 확인할 수 있듯이, ArrayBuffer의 backing store는 V8 Heap 밖에 할당된다. 이전 글에서 다룬 Node.js Buffer와 동일한 원리다.

js
const imageData = ctx.getImageData(0, 0, 4000, 3000);
// imageData.data = Uint8ClampedArray (V8 Heap에 메타데이터만)
// imageData.data.buffer = ArrayBuffer (off-heap에 ~46MB 바이너리)
 
// 이 변수를 참조하는 한 off-heap 46MB도 GC되지 않는다
// → 쓸 일이 끝나면 참조를 끊어야 함

createImageBitmap()으로 생성한 ImageBitmap도 off-heap 메모리를 차지한다. 이 경우 명시적으로 .close()를 호출해야 off-heap 메모리가 해제된다.

js
const bitmap = await createImageBitmap(imageData);
// bitmap의 픽셀 데이터는 브라우저 네이티브 메모리에 저장됨
 
// 사용이 끝나면 반드시:
bitmap.close(); // 네이티브 메모리 즉시 해제
// MDN: "Releases all graphical resources associated with an ImageBitmap"
// close() 없이 GC에 맡기면 해제 시점이 불확실하다

WebGL / WebGPU

WebGL을 사용하는 경우 off-heap 메모리가 더 크게 작용한다. 텍스처, 프레임버퍼, vertex buffer 등이 모두 GPU 메모리 또는 off-heap에 할당된다.

jsx
// Three.js / R3F(React Three Fiber) 같은 WebGL 라이브러리 사용 시
function Scene() {
  const textureRef = useRef(null);
 
  useEffect(() => {
    const loader = new THREE.TextureLoader();
    loader.load('large-texture.jpg', (texture) => {
      textureRef.current = texture;
      // 4096×4096 텍스처 = ~64MB GPU 메모리 (off-heap)
    });
 
    return () => {
      // Three.js 리소스는 반드시 dispose() 해야 GPU 메모리 해제
      if (textureRef.current) {
        textureRef.current.dispose();
      }
    };
  }, []);
 
  return <mesh><meshStandardMaterial map={textureRef.current} /></mesh>;
}
V8 Heap 밖의 메모리를 추적하는 방법

Chrome DevTools → Memory 탭의 Heap Snapshot은 V8 Heap만 보여준다. V8 Heap 밖 메모리를 포함한 전체를 보려면:

  • Chrome Task Manager (Shift+Esc): 탭의 "Memory footprint" 열이 전체 메모리를 보여준다. Chrome 공식 문서에서 이 방법을 안내하고 있다.
  • Performance 탭: 녹화 후 Memory 체크박스를 켜면 "JS Heap"과 별도로 "Documents", "Nodes", "Listeners" 추이를 볼 수 있다.
  • performance.measureUserAgentSpecificMemory(): 표준 API로, JS heap뿐 아니라 전체 메모리를 포함한다 (cross-origin isolation 필요).

Video / Audio

미디어 요소도 off-heap 메모리의 주범이다.

jsx
// ❌ Video 요소를 정리하지 않으면 디코딩 버퍼가 off-heap에 남음
function VideoPlayer({ src }) {
  const videoRef = useRef(null);
 
  useEffect(() => {
    const video = videoRef.current;
    video.src = src;
 
    return () => {
      // 미디어 리소스 명시적 해제
      video.pause();
      video.removeAttribute('src');
      video.load(); // 이전 소스의 디코딩 버퍼를 해제한다
    };
  }, [src]);
 
  return <video ref={videoRef} />;
}
off-heap은 왜 중요한가

React 앱에서 "메모리가 올라가는데 Heap Snapshot에서 원인을 못 찾겠다"는 경우, 높은 확률로 off-heap 릭이다. Canvas 비트맵, WebGL 텍스처, Video 디코딩 버퍼, 큰 ArrayBuffer 등을 다루는 컴포넌트가 있다면, cleanup에서 명시적으로 리소스를 해제하는지 확인해야 한다. dispose(), close(), canvas.width = 0 같은 명시적 해제가 필요한 이유는, 이 메모리가 V8 GC의 관리 대상이 아니기 때문이다.


React의 메모리 릭 패턴

1. useEffect cleanup 누락

가장 흔한 패턴이다. React 공식 문서에서도 cleanup의 중요성을 강조한다.

jsx
// ❌ 릭: 컴포넌트가 unmount되어도 interval이 계속 돈다
function Dashboard() {
  const [data, setData] = useState(null);
 
  useEffect(() => {
    const id = setInterval(async () => {
      const res = await fetch('/api/stats');
      setData(await res.json());
      // 이 클로저가 setData를 참조
      // → setData가 Dashboard 컴포넌트의 state updater
      // → Dashboard가 unmount되어도 interval이 도는 한
      //   setData → Dashboard의 Fiber 노드 → 전체 하위 트리가 GC되지 않음
    }, 5000);
 
    // cleanup 함수가 없다!
  }, []);
 
  return <StatsView data={data} />;
}
jsx
// ✅ cleanup 함수에서 interval 정리
function Dashboard() {
  const [data, setData] = useState(null);
 
  useEffect(() => {
    const id = setInterval(async () => {
      const res = await fetch('/api/stats');
      setData(await res.json());
    }, 5000);
 
    return () => clearInterval(id); // ← cleanup
  }, []);
 
  return <StatsView data={data} />;
}

cleanup이 필요한 대표적인 경우들:

jsx
useEffect(() => {
  // 이벤트 리스너
  const handler = () => { /* ... */ };
  window.addEventListener('resize', handler);
  return () => window.removeEventListener('resize', handler);
}, []);
 
useEffect(() => {
  // WebSocket
  const ws = new WebSocket('wss://...');
  ws.onmessage = (e) => setMessages(prev => [...prev, e.data]);
  return () => ws.close();
}, []);
 
useEffect(() => {
  // AbortController (fetch 취소)
  const controller = new AbortController();
  fetch('/api/data', { signal: controller.signal })
    .then(res => res.json())
    .then(setData)
    .catch(() => {}); // AbortError 무시
  return () => controller.abort();
}, [id]);
 
useEffect(() => {
  // IntersectionObserver
  const observer = new IntersectionObserver(callback, options);
  observer.observe(elementRef.current);
  return () => observer.disconnect();
}, []);
React 18 Strict Mode와 cleanup 검증

React 18의 Strict Mode는 개발 환경에서 useEffect를 두 번 실행한다 (mount → unmount → mount). cleanup이 제대로 작성되어 있으면 두 번째 mount에서 정상 동작한다. cleanup이 빠져있으면 리소스가 중복 생성된다. Strict Mode의 이중 실행은 릭을 일찍 발견하기 위한 의도적 설계다. React 공식 문서에서 이 동작을 설명한다.

2. 이벤트 리스너가 DOM에 쌓이는 패턴

jsx
// ❌ 렌더마다 새 리스너가 등록되고 이전 것은 해제되지 않음
function ScrollTracker() {
  const [scrollY, setScrollY] = useState(0);
 
  // deps 배열 없음 → 매 렌더마다 실행
  useEffect(() => {
    window.addEventListener('scroll', () => setScrollY(window.scrollY));
    // cleanup 없음 + 매번 새 함수 등록
    // → 렌더가 100번 일어나면 scroll 리스너 100개
  });
 
  return <div>Scroll: {scrollY}</div>;
}
jsx
// ✅ 올바른 패턴
function ScrollTracker() {
  const [scrollY, setScrollY] = useState(0);
 
  useEffect(() => {
    const handler = () => setScrollY(window.scrollY);
    window.addEventListener('scroll', handler);
    return () => window.removeEventListener('scroll', handler);
  }, []); // deps: [] → mount 시 1번만 실행
 
  return <div>Scroll: {scrollY}</div>;
}

3. 무한히 커지는 상태

jsx
// ❌ 메시지가 영원히 쌓이는 채팅
function Chat() {
  const [messages, setMessages] = useState([]);
 
  useEffect(() => {
    const ws = new WebSocket('wss://chat.example.com');
    ws.onmessage = (e) => {
      setMessages(prev => [...prev, JSON.parse(e.data)]);
      // 채팅방에 하루 종일 있으면?
      // → messages 배열이 수만 개로 커짐
      // → 각 메시지 객체 + 이전 배열의 복사본이 계속 쌓임
    };
    return () => ws.close();
  }, []);
 
  return messages.map(m => <Message key={m.id} {...m} />);
}
jsx
// ✅ 최대 크기를 제한
function Chat() {
  const [messages, setMessages] = useState([]);
  const MAX_MESSAGES = 500;
 
  useEffect(() => {
    const ws = new WebSocket('wss://chat.example.com');
    ws.onmessage = (e) => {
      setMessages(prev => {
        const next = [...prev, JSON.parse(e.data)];
        // 500개를 넘으면 오래된 것부터 버린다
        return next.length > MAX_MESSAGES
          ? next.slice(next.length - MAX_MESSAGES)
          : next;
      });
    };
    return () => ws.close();
  }, []);
 
  // + 가상화(react-window 등)로 DOM 노드 수도 제한
  return <VirtualList items={messages} renderItem={Message} />;
}

4. ref에 저장된 외부 라이브러리 인스턴스

jsx
// ❌ 차트 라이브러리 인스턴스를 정리하지 않음
function Chart({ data }) {
  const canvasRef = useRef(null);
  const chartRef = useRef(null);
 
  useEffect(() => {
    // 이전 인스턴스를 destroy하지 않고 새로 만듦
    chartRef.current = new ChartJS(canvasRef.current, {
      type: 'line',
      data: data,
    });
    // data가 바뀔 때마다 새 ChartJS 인스턴스가 생성되고
    // 이전 인스턴스는 canvas에 이벤트 리스너, 내부 캐시를 남김
  }, [data]);
 
  return <canvas ref={canvasRef} />;
}
jsx
// ✅ cleanup에서 이전 인스턴스 정리
function Chart({ data }) {
  const canvasRef = useRef(null);
 
  useEffect(() => {
    const chart = new ChartJS(canvasRef.current, {
      type: 'line',
      data: data,
    });
 
    return () => chart.destroy(); // ← 이벤트 리스너, 내부 상태 해제
  }, [data]);
 
  return <canvas ref={canvasRef} />;
}
외부 라이브러리와 메모리

지도(Mapbox, Google Maps), 차트(Chart.js, D3), 에디터(Monaco, CodeMirror), 미디어(Video.js) 등 DOM을 직접 조작하는 라이브러리는 거의 예외 없이 destroy() 또는 dispose() 메서드를 제공한다. 이 메서드를 useEffect cleanup에서 호출하지 않으면 해당 라이브러리가 등록한 이벤트 리스너, 내부 캐시, canvas 컨텍스트 등이 릭된다.


Next.js 특유의 메모리 이슈

Next.js는 서버 사이드(SSR, RSC)와 클라이언트 사이드가 공존하기 때문에, 양쪽에서 각각 다른 릭 패턴이 나타난다.

SSR에서 모듈 스코프 공유 문제

Node.js에서 모듈은 한 번 로드되면 캐시된다. SSR 환경에서 모듈 스코프에 데이터를 쌓으면, 모든 요청이 같은 데이터를 공유한다.

js
// lib/analytics.js
// ❌ 모듈 스코프에 요청별 데이터를 쌓는 패턴
 
const events = []; // 이 배열은 프로세스가 살아있는 한 유지됨
 
export function trackEvent(event) {
  events.push({
    ...event,
    timestamp: Date.now(),
  });
  // 모든 SSR 요청이 같은 events 배열에 push
  // → 요청 10만 건이면 events에 10만 개 쌓임
  // → 프로세스 재시작까지 해제되지 않음
}
js
// ✅ 요청별로 격리하거나, 외부 서비스로 보내기
export function trackEvent(event) {
  // 바로 외부로 전송 (메모리에 쌓지 않음)
  fetch('https://analytics.example.com/events', {
    method: 'POST',
    body: JSON.stringify(event),
    // fire-and-forget
  }).catch(() => {});
}

getServerSideProps / Server Actions에서의 대형 데이터

jsx
// ❌ 필요 이상으로 큰 데이터를 props로 전달
export async function getServerSideProps() {
  const allProducts = await db.products.findMany();
  // 10만 개 상품 전체를 가져와서 props로 넘기면:
  // 1. 서버 메모리에 10만 개 객체 로드
  // 2. JSON.stringify로 직렬화 (메모리 2배)
  // 3. 클라이언트에 전송 후에도 서버 GC까지 메모리 유지
 
  return { props: { products: allProducts } };
}
jsx
// ✅ 필요한 만큼만, 페이지네이션으로
export async function getServerSideProps({ query }) {
  const page = parseInt(query.page) || 1;
  const products = await db.products.findMany({
    take: 20,
    skip: (page - 1) * 20,
    select: { id: true, name: true, price: true },
    // select로 필요한 필드만 가져오기
  });
 
  return { props: { products } };
}

Server Components에서의 메모리

React Server Components(RSC)는 서버에서 렌더링되고 클라이언트에는 직렬화된 결과만 전송된다. 컴포넌트 코드와 그 의존성이 클라이언트 번들에 포함되지 않으므로 클라이언트 메모리는 줄어든다.

하지만 서버 메모리 관점에서는 주의할 점이 있다.

jsx
// app/products/page.tsx (Server Component)
 
// ❌ 동시 요청이 많으면 서버 메모리 부담
export default async function ProductsPage() {
  const products = await db.products.findMany(); // 요청마다 실행
  // 동시 요청 100개 × 10만 개 상품 = 서버 메모리 폭발
 
  return <ProductList products={products} />;
}
jsx
// ✅ 캐싱 + 페이지네이션
import { unstable_cache } from 'next/cache';
 
const getProducts = unstable_cache(
  async (page) => {
    return db.products.findMany({
      take: 20,
      skip: (page - 1) * 20,
    });
  },
  ['products'],
  { revalidate: 60 } // 60초 캐시
);
 
export default async function ProductsPage({ searchParams }) {
  const products = await getProducts(searchParams.page || 1);
  // 캐시 히트 시 DB 쿼리 없이 메모리도 절약
  return <ProductList products={products} />;
}
Next.js fetch 캐시와 메모리

Next.js의 fetch는 기본적으로 요청을 캐시한다. 이 캐시는 서버 메모리에 저장된다. 대량의 API 응답을 캐시하면 서버 메모리가 지속적으로 커질 수 있다. { cache: 'no-store' } 또는 적절한 revalidate 값으로 캐시 수명을 관리해야 한다. Next.js Caching 문서를 참고하자.


브라우저 DevTools로 React 릭 잡기

Chrome DevTools의 Memory 탭은 이전 글에서 다룬 Heap Snapshot, Allocation Timeline을 브라우저에서도 동일하게 사용할 수 있다. React 앱에서 특히 유용한 기법들을 정리한다.

Detached DOM 노드 찾기

React에서 컴포넌트가 unmount되면 해당 DOM 노드도 트리에서 제거되어야 한다. 하지만 JS 변수가 DOM 노드의 참조를 여전히 가지고 있으면, DOM 노드가 detached(트리에서 분리됐지만 메모리에 남아있는) 상태가 된다.

text
Heap Snapshot에서 "Detached" 검색:
 
Summary view → Class filter에 "Detached" 입력
→ Detached HTMLDivElement, Detached HTMLCanvasElement 등이 나옴
→ 이것들은 DOM 트리에 없는데 JS에서 참조하고 있는 노드
→ Retainer chain을 따라가면 어떤 JS 변수가 잡고 있는지 알 수 있음
jsx
// ❌ ref로 DOM 노드를 잡고 있으면서 컴포넌트 외부에 저장
let savedElement = null; // 모듈 스코프
 
function Modal({ content }) {
  const ref = useRef(null);
 
  useEffect(() => {
    savedElement = ref.current;
    // Modal이 unmount되어도 savedElement가 DOM 노드를 참조
    // → Detached DOM 노드가 됨
  }, []);
 
  return <div ref={ref}>{content}</div>;
}

Performance Monitor로 실시간 감시

DevTools → More tools → Performance Monitor를 열면 실시간으로 다음을 볼 수 있다.

지표의미
JS heap sizeV8 Heap 사용량 (우상향이면 릭)
DOM Nodes현재 DOM 트리의 노드 수 (페이지 전환 후에도 안 줄면 릭)
JS event listeners등록된 이벤트 리스너 수 (계속 늘어나면 릭)
Documentsiframe 등 document 수
text
릭 확인 시나리오:
 
1. Performance Monitor 열기
2. 페이지 A로 이동 → DOM Nodes 확인 (예: 500개)
3. 페이지 B로 이동 → DOM Nodes 확인 (예: 800개)
4. 다시 페이지 A로 이동 → DOM Nodes 확인
   정상: ~500개 (B의 노드가 해제됨)
   릭:  ~1300개 (B의 노드가 남아있음)
5. A↔B를 여러 번 반복
   정상: DOM Nodes가 일정 범위에서 유지
   릭:  이동할 때마다 계속 증가

React DevTools Profiler와 메모리

React DevTools의 Profiler는 직접적인 메모리 도구는 아니지만, 불필요한 리렌더링을 찾아서 간접적으로 메모리 문제를 줄이는 데 도움이 된다.

jsx
// 불필요한 리렌더링 → 불필요한 객체 생성 → GC 부담 증가
 
// ❌ 매 렌더마다 새 객체/배열 생성
function Parent() {
  return (
    <Child
      style={{ color: 'red' }}       // 매번 새 객체 → Child 리렌더
      items={data.filter(x => x.active)} // 매번 새 배열 → Child 리렌더
      onPress={() => handlePress()}   // 매번 새 함수 → Child 리렌더
    />
  );
}
 
// ✅ 메모이제이션으로 불필요한 생성 방지
function Parent() {
  const style = useMemo(() => ({ color: 'red' }), []);
  const items = useMemo(() => data.filter(x => x.active), [data]);
  const onPress = useCallback(() => handlePress(), []);
 
  return <Child style={style} items={items} onPress={onPress} />;
}
메모이제이션의 트레이드오프

useMemouseCallback은 메모리를 쓰는 것이다. 메모이제이션된 값 자체가 메모리를 차지한다. 모든 값을 메모이제이션하면 오히려 메모리가 늘어날 수 있다. 리렌더링 비용이 높은 컴포넌트(대형 리스트, 차트, 복잡한 계산)에만 선택적으로 적용하는 것이 맞다. React 공식 문서의 useMemo 가이드를 참고하자.


Next.js 메모리 모니터링

서버 사이드 모니터링

js
// instrumentation.ts (Next.js 15+)
// next.config.js에서 experimental.instrumentationHook: true 필요
 
export function register() {
  if (process.env.NEXT_RUNTIME === 'nodejs') {
    // 서버 사이드에서만 실행
    setInterval(() => {
      const mem = process.memoryUsage();
      console.log(JSON.stringify({
        timestamp: new Date().toISOString(),
        heap_mb: (mem.heapUsed / 1024 / 1024).toFixed(1),
        rss_mb: (mem.rss / 1024 / 1024).toFixed(1),
        external_mb: (mem.external / 1024 / 1024).toFixed(1),
      }));
    }, 30_000);
  }
}

클라이언트 사이드 모니터링

js
// 브라우저에서 메모리 모니터링 (Chrome만 지원)
if (performance.memory) {
  setInterval(() => {
    const mem = performance.memory;
    console.log({
      // V8 Heap 사용량
      usedJSHeapSize: `${(mem.usedJSHeapSize / 1024 / 1024).toFixed(1)}MB`,
      // V8 Heap 전체 크기
      totalJSHeapSize: `${(mem.totalJSHeapSize / 1024 / 1024).toFixed(1)}MB`,
      // Heap 크기 상한
      jsHeapSizeLimit: `${(mem.jsHeapSizeLimit / 1024 / 1024).toFixed(0)}MB`,
    });
  }, 10_000);
}
// 주의: performance.memory는 비표준이고 Chrome/Edge에서만 동작
// Cross-Origin-Opener-Policy 헤더가 설정된 경우에만 정확한 값을 반환
표준 API: performance.measureUserAgentSpecificMemory()

performance.memory는 비표준이다. 새로운 표준 API인 performance.measureUserAgentSpecificMemory()가 제안되어 있다. 이 API는 cross-origin isolation이 필요하지만 더 정확한 메모리 측정을 제공한다. Cross-Origin-Opener-Policy: same-originCross-Origin-Embedder-Policy: require-corp 헤더가 필요하다.


디버깅 체크리스트

프론트엔드 메모리 릭이 의심될 때의 접근 순서다.

text
Step 1: 릭 확인
  → Performance Monitor에서 JS heap size, DOM Nodes 관찰
  → Task Manager (Shift+Esc)에서 "Memory footprint" 확인 (off-heap 포함)
  → SPA 페이지 전환을 5~10회 반복
  → 수치가 계속 올라가면 릭
 
Step 2: on-heap인지 off-heap인지 구분
  → JS heap size만 올라감 → on-heap 릭 (JS 객체, 클로저, DOM wrapper)
  → JS heap은 안정인데 Memory footprint가 올라감 → off-heap 릭 (Canvas, WebGL, Video, ArrayBuffer)
  → 둘 다 올라감 → 복합 릭
 
Step 3: 범위 좁히기
  → 어떤 페이지 전환에서 DOM Nodes가 안 줄어드는지 특정
  → Heap Snapshot에서 "Detached" 검색 (on-heap)
  → Canvas, WebGL, Video 컴포넌트의 cleanup 확인 (off-heap)
  → Allocation Timeline으로 릭 발생 시점 확인
 
Step 4: 원인 찾기
  → Heap Snapshot의 Retainer chain 추적
  → useEffect cleanup 누락 확인
  → 이벤트 리스너 등록/해제 쌍 확인
  → 외부 라이브러리 destroy()/dispose()/close() 호출 확인
  → 모듈 스코프에 데이터가 쌓이는지 확인
 
Step 4: 수정 후 검증
  → 같은 시나리오(페이지 전환 반복)로 재테스트
  → Performance Monitor에서 수치가 안정적인지 확인
  → Heap Snapshot 비교(Comparison view)로 확인

정리

패턴원인해결
useEffect cleanup 누락timer, listener, WebSocket 미정리return에서 정리 함수 반환
이벤트 리스너 축적deps 없는 effect에서 매번 등록deps: []로 한 번만 등록 + cleanup
상태 무한 축적배열/객체 상태가 계속 커짐최대 크기 제한, 가상화
외부 라이브러리 미정리destroy()/dispose() 미호출cleanup에서 호출
Canvas / WebGL off-heap비트맵, 텍스처 미해제canvas.width=0, texture.dispose()
SSR 모듈 스코프 축적전역 변수에 요청별 데이터 쌓임외부 서비스로 전송, 크기 제한
Detached DOMJS 변수가 제거된 DOM 참조 유지참조 정리, ref 관리

서버와 브라우저 모두 메모리 릭의 본질은 같다:

더 이상 필요 없는 참조가 정리되지 않아서 GC가 해제하지 못하는 것.

서버에서는 --trace-gc와 Heap Snapshot으로, 브라우저에서는 DevTools Memory 탭과 Performance Monitor로 이 참조를 추적한다. 도구는 다르지만 원리는 동일하다.


레퍼런스

React 공식

Next.js 공식

브라우저 / DevTools

Off-heap / 네이티브 메모리 관련