
Three.js를 Next.js에서 처음 붙여보면 금방 이상한 걸 느끼게 된다.
"페이지를 이동했다 돌아오면 왜 점점 느려지지?"
처음엔 렌더링 문제처럼 보이지만 실제 원인은 대부분 cleanup 누락이다.
특히 requestAnimationFrame은 컴포넌트가 사라져도 자동으로 멈추지 않는다.
명시적으로 취소하지 않으면 이전 루프가 계속 살아 있고, GPU 메모리도 계속 쌓인다.
App Router처럼 페이지 전환이 잦은 환경에서는 특히 더 잘 드러난다.
이번 글에서는 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가 백그라운드에서 계속 살아 있게 된다.
useEffect는 cleanup 함수를 반환할 수 있다.
useEffect(() => {
// 초기화
return () => {
// cleanup
};
}, []);
이 함수는:
자동으로 실행된다.
Three.js 리소스 정리는 모두 여기서 수행해야 한다.
먼저 animation loop를 멈춰야 한다.
let rafId = 0;
const animate = () => {
rafId = requestAnimationFrame(animate);
renderer.render(scene, camera);
};
animate();
return () => {
cancelAnimationFrame(rafId);
};
핵심은:
rafId = requestAnimationFrame(animate);
처럼:
매 프레임마다 최신 ID를 저장하는 것
이다.
cleanup 시 마지막 예약 ID를 취소하면 다음 프레임부터 루프가 멈춘다.
루프를 멈췄다고 끝난 게 아니다.
Three.js의:
같은 객체들은 내부적으로 GPU(WebGL)에 업로드된다.
즉 JavaScript 객체가 GC 대상이 되더라도:
GPU 메모리는 별도로 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 리소스가 남지 않는다.
Three.js 자체뿐 아니라 부가 라이브러리도 정리해야 한다.
| 리소스 | cleanup |
|---|---|
| OrbitControls | controls.dispose() |
| lil-gui | gui.destroy() |
| Stats.js | dom 제거 |
| postprocessing composer | dispose() |
특히 OrbitControls는 이벤트 리스너를 등록하기 때문에 dispose하지 않으면 마우스 이벤트가 계속 살아 있다.
이건 Three.js 외부 문제지만 정말 자주 놓친다.
const onResize = () => {
// resize 처리
};
window.addEventListener("resize", onResize);
cleanup에서 반드시 제거해야 한다.
return () => {
window.removeEventListener("resize", onResize);
};
canvas 이벤트도 동일하다.
canvas.addEventListener("mousemove", onMouseMove);
↓
canvas.removeEventListener("mousemove", onMouseMove);
정리하지 않으면:
문제가 발생할 수 있다.
| 항목 | 누락 시 문제 | 해결 방법 |
|---|---|---|
| cancelAnimationFrame | animation loop 지속 | rafId 추적 후 취소 |
| geometry.dispose() | GPU 버퍼 누수 | cleanup에서 해제 |
| material.dispose() | shader 누수 | cleanup에서 해제 |
| texture.dispose() | texture 메모리 누수 | 명시적 dispose |
| renderer.dispose() | WebGL context 누수 | 마지막에 dispose |
| removeEventListener | stale callback | cleanup에서 제거 |
Three.js를 React에서 사용할 때 핵심은 결국 하나다.
초기화한 것은 반드시 cleanup에서 역순으로 정리한다.
이 세 가지만 제대로 관리해도 Next.js 환경에서 Three.js를 훨씬 안정적으로 사용할 수 있다.