Three.js + Next.js requestAnimationFrame 메모리 누수 방지

leave_a_comment·2026년 5월 26일
post-thumbnail

Three.js를 Next.js에서 처음 붙여보면 금방 이상한 걸 느끼게 된다.

"페이지를 이동했다 돌아오면 왜 점점 느려지지?"

처음엔 렌더링 문제처럼 보이지만 실제 원인은 대부분 cleanup 누락이다.

특히 requestAnimationFrame은 컴포넌트가 사라져도 자동으로 멈추지 않는다.

명시적으로 취소하지 않으면 이전 루프가 계속 살아 있고, GPU 메모리도 계속 쌓인다.

App Router처럼 페이지 전환이 잦은 환경에서는 특히 더 잘 드러난다.

  • 이전 페이지의 animation loop가 계속 실행되고
  • geometry / material / texture가 GPU 메모리에 남아 있으며
  • 결국 탭이 느려지거나 WebGL context limit에 걸리기도 한다

이번 글에서는 Three.js를 React useEffect 안에서 안전하게 사용하는 방법과
정확히 무엇을 cleanup 해야 하는지 정리해보려 한다.


문제 — 멈추지 않는 루프

보통 Three.js를 처음 세팅하면 이렇게 작성한다.

useEffect(() => {
  const renderer = new THREE.WebGLRenderer({ canvas });

  const scene = new THREE.Scene();

  const geometry = new THREE.BoxGeometry();

  const material = new THREE.MeshBasicMaterial({
    color: "blue",
  });

  const mesh = new THREE.Mesh(geometry, material);

  scene.add(mesh);

  const animate = () => {
    requestAnimationFrame(animate);

    mesh.rotation.y += 0.01;

    renderer.render(scene, camera);
  };

  animate();
}, []);

[] 덕분에 마운트 시 한 번만 실행된다.

문제는:

언마운트 시 아무것도 정리하지 않는다는 것

이다.

컴포넌트가 사라져도 내부적으로는:

animate()
  ↓
requestAnimationFrame(animate)
  ↓
animate()
  ↓
...

루프가 계속 반복된다.

즉 페이지를 이동해도 이전 animation loop가 백그라운드에서 계속 살아 있게 된다.


해결 — cleanup 함수 사용

useEffect는 cleanup 함수를 반환할 수 있다.

useEffect(() => {
  // 초기화

  return () => {
    // cleanup
  };
}, []);

이 함수는:

  • 컴포넌트 언마운트 시
  • 의존성 변경 직전

자동으로 실행된다.

Three.js 리소스 정리는 모두 여기서 수행해야 한다.


requestAnimationFrame 취소

먼저 animation loop를 멈춰야 한다.

let rafId = 0;

const animate = () => {
  rafId = requestAnimationFrame(animate);

  renderer.render(scene, camera);
};

animate();

return () => {
  cancelAnimationFrame(rafId);
};

핵심은:

rafId = requestAnimationFrame(animate);

처럼:

매 프레임마다 최신 ID를 저장하는 것

이다.

cleanup 시 마지막 예약 ID를 취소하면 다음 프레임부터 루프가 멈춘다.


중요한 문제 — GPU 메모리는 자동 해제되지 않는다

루프를 멈췄다고 끝난 게 아니다.

Three.js의:

  • geometry
  • material
  • texture

같은 객체들은 내부적으로 GPU(WebGL)에 업로드된다.

즉 JavaScript 객체가 GC 대상이 되더라도:

GPU 메모리는 별도로 dispose 해야 한다.


dispose()

return () => {
  cancelAnimationFrame(rafId);

  geometry.dispose();

  material.dispose();

  texture.dispose();

  renderer.dispose();
};

각각 의미는 다음과 같다.

리소스역할
geometry.dispose()GPU 버텍스 버퍼 해제
material.dispose()셰이더 / 유니폼 해제
texture.dispose()텍스처 메모리 해제
renderer.dispose()WebGL context 정리

특히 texture.dispose()는 자주 빠뜨린다.

material.dispose()를 호출해도 texture는 자동 해제되지 않는다.


실제 패턴

보통은 이런 형태로 정리한다.

useEffect(() => {
  const canvas = canvasRef.current;

  if (!canvas) return;

  const renderer = new THREE.WebGLRenderer({
    canvas,
    antialias: true,
  });

  const scene = new THREE.Scene();

  const camera = new THREE.PerspectiveCamera(
    60,
    canvas.clientWidth / canvas.clientHeight,
    0.1,
    100,
  );

  camera.position.z = 3;

  const geometry = new THREE.BoxGeometry(1.5, 1.5, 1.5);

  const material = new THREE.MeshBasicMaterial({
    color: "#2563eb",
    wireframe: true,
  });

  const mesh = new THREE.Mesh(geometry, material);

  scene.add(mesh);

  let rafId = 0;

  const animate = () => {
    rafId = requestAnimationFrame(animate);

    mesh.rotation.y += 0.01;

    renderer.render(scene, camera);
  };

  animate();

  return () => {
    cancelAnimationFrame(rafId);

    geometry.dispose();
    material.dispose();

    renderer.dispose();
  };
}, []);

데모 전환 시 — 의존성 변경 패턴

탭 기반으로 여러 데모를 교체하는 경우에는:

useEffect(() => {
  const cleanup = initDemo(activeDemo, canvas);

  return () => {
    cleanup();
  };
}, [activeDemo]);

형태를 자주 사용한다.

흐름은:

이전 cleanup 실행
  ↓
raf 취소 + dispose
  ↓
새 데모 초기화

순서로 진행된다.

즉 데모를 빠르게 전환해도 이전 WebGL 리소스가 남지 않는다.


OrbitControls / GUI도 cleanup 필요

Three.js 자체뿐 아니라 부가 라이브러리도 정리해야 한다.

리소스cleanup
OrbitControlscontrols.dispose()
lil-guigui.destroy()
Stats.jsdom 제거
postprocessing composerdispose()

특히 OrbitControls는 이벤트 리스너를 등록하기 때문에 dispose하지 않으면 마우스 이벤트가 계속 살아 있다.


window 이벤트 리스너도 주의

이건 Three.js 외부 문제지만 정말 자주 놓친다.

const onResize = () => {
  // resize 처리
};

window.addEventListener("resize", onResize);

cleanup에서 반드시 제거해야 한다.

return () => {
  window.removeEventListener("resize", onResize);
};

canvas 이벤트도 동일하다.

canvas.addEventListener("mousemove", onMouseMove);

canvas.removeEventListener("mousemove", onMouseMove);

정리하지 않으면:

  • stale closure
  • 중복 이벤트 실행
  • 메모리 누수

문제가 발생할 수 있다.


정리

항목누락 시 문제해결 방법
cancelAnimationFrameanimation loop 지속rafId 추적 후 취소
geometry.dispose()GPU 버퍼 누수cleanup에서 해제
material.dispose()shader 누수cleanup에서 해제
texture.dispose()texture 메모리 누수명시적 dispose
renderer.dispose()WebGL context 누수마지막에 dispose
removeEventListenerstale callbackcleanup에서 제거

Three.js를 React에서 사용할 때 핵심은 결국 하나다.

초기화한 것은 반드시 cleanup에서 역순으로 정리한다.

  • animation loop 중단
  • GPU 리소스 dispose
  • 이벤트 리스너 제거

이 세 가지만 제대로 관리해도 Next.js 환경에서 Three.js를 훨씬 안정적으로 사용할 수 있다.

profile
나도 성장하고파

0개의 댓글