WebGL 메모리 누수 잡기 - 일주일간의 디버깅 기록
프로젝트 데모를 보여주는데 갑자기 브라우저가 멈췄습니다. 탭이 응답 없음 상태가 되고, 잠시 후 크래시. 클라이언트 앞에서 최악의 상황이었죠. 이후 일주일간 메모리 누수를 잡느라 고생한 이야기입니다.
증상 파악 #
문제는 즉시 나타나지 않았습니다. 5분 정도 사용하면 점점 느려지다가 결국 크래시. 모바일에서는 더 빨리 죽었습니다.
Chrome 작업 관리자를 열어보니 해당 탭의 메모리 사용량이 계속 증가하고 있었습니다. 시작할 때 200MB였던 것이 10분 후에는 2GB를 넘겼습니다.
첫 번째 용의자: 이벤트 리스너 #
가장 먼저 의심한 것은 이벤트 리스너였습니다.
// 문제의 코드
class Scene {
init() {
window.addEventListener('resize', this.handleResize);
window.addEventListener('mousemove', this.handleMouseMove);
}
// destroy 메서드가 없었음!
}
// 씬을 교체할 때마다 새 인스턴스 생성
function changeScene() {
currentScene = new Scene(); // 이전 씬의 리스너는 계속 남아있음
currentScene.init();
}
씬을 교체할 때마다 새 이벤트 리스너가 추가되고, 이전 것은 제거되지 않았습니다.
// 수정된 코드
class Scene {
init() {
this.boundHandleResize = this.handleResize.bind(this);
this.boundHandleMouseMove = this.handleMouseMove.bind(this);
window.addEventListener('resize', this.boundHandleResize);
window.addEventListener('mousemove', this.boundHandleMouseMove);
}
destroy() {
window.removeEventListener('resize', this.boundHandleResize);
window.removeEventListener('mousemove', this.boundHandleMouseMove);
}
}
function changeScene() {
if (currentScene) {
currentScene.destroy();
}
currentScene = new Scene();
currentScene.init();
}
두 번째 용의자: dispose 누락 #
이벤트 리스너를 정리했는데도 메모리가 계속 새고 있었습니다. Chrome DevTools의 Memory 탭에서 힙 스냅샷을 찍어봤습니다.
"Detached" 검색을 하니 분리된 DOM 요소들이 보였습니다. 하지만 주범은 따로 있었습니다. BufferGeometry와 Material이 계속 쌓이고 있었습니다.
// 문제의 코드
function createParticles() {
const geometry = new THREE.BufferGeometry();
const material = new THREE.PointsMaterial({ color: 0xffffff });
const particles = new THREE.Points(geometry, material);
scene.add(particles);
return particles;
}
function updateParticles() {
scene.remove(currentParticles);
currentParticles = createParticles(); // 이전 geometry, material은 메모리에 남음
}
Three.js에서 scene.remove()는 객체를 씬에서 제거할 뿐, GPU 메모리를 해제하지 않습니다. 명시적으로 dispose()를 호출해야 합니다.
// 수정된 코드
function disposeObject(obj) {
if (obj.geometry) {
obj.geometry.dispose();
}
if (obj.material) {
if (Array.isArray(obj.material)) {
obj.material.forEach(material => disposeMaterial(material));
} else {
disposeMaterial(obj.material);
}
}
if (obj.children) {
obj.children.forEach(child => disposeObject(child));
}
}
function disposeMaterial(material) {
material.dispose();
// 텍스처도 dispose 필요
for (const key in material) {
const value = material[key];
if (value && typeof value.dispose === 'function') {
value.dispose();
}
}
}
function updateParticles() {
if (currentParticles) {
scene.remove(currentParticles);
disposeObject(currentParticles);
}
currentParticles = createParticles();
}
세 번째 용의자: 텍스처 캐시 #
그래도 메모리가 새고 있었습니다. 이번에는 renderer.info를 확인해봤습니다.
console.log(renderer.info.memory);
// { geometries: 245, textures: 1893 } // 텍스처가 비정상적으로 많음
텍스처가 거의 2천 개? 이상했습니다. 사용하는 텍스처는 20개도 안 되는데요.
범인은 동적으로 생성되는 텍스처였습니다.
// 문제의 코드
function createTextTexture(text) {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
ctx.fillText(text, 0, 0);
const texture = new THREE.CanvasTexture(canvas);
return texture; // 매번 새 텍스처 생성
}
// 매 프레임마다 호출되고 있었음
function updateLabel() {
mesh.material.map = createTextTexture(currentTime);
}
매 프레임마다 새 텍스처를 만들고 이전 것은 dispose하지 않았습니다.
// 수정된 코드 - 텍스처 재사용
const textCanvas = document.createElement('canvas');
const textCtx = textCanvas.getContext('2d');
const textTexture = new THREE.CanvasTexture(textCanvas);
function updateTextTexture(text) {
textCtx.clearRect(0, 0, textCanvas.width, textCanvas.height);
textCtx.fillText(text, 0, 0);
textTexture.needsUpdate = true; // 기존 텍스처 업데이트
}
function updateLabel() {
updateTextTexture(currentTime);
// mesh.material.map은 처음에 한 번만 설정
}
디버깅 도구 활용 #
renderer.info #
Three.js가 제공하는 디버깅 정보입니다.
// 주기적으로 확인
setInterval(() => {
console.log('Geometries:', renderer.info.memory.geometries);
console.log('Textures:', renderer.info.memory.textures);
console.log('Draw calls:', renderer.info.render.calls);
}, 5000);
숫자가 계속 증가하면 누수가 있는 것입니다.
Chrome DevTools Memory 탭 #
- Heap Snapshot: 특정 시점의 메모리 상태 캡처
- Allocation Timeline: 시간에 따른 메모리 할당 추적
- Allocation Sampling: CPU 프로파일링과 함께 메모리 추적
저는 Heap Snapshot을 두 번 찍어서 비교하는 방법을 주로 썼습니다.
- 페이지 로드 직후 스냅샷
- 문제 상황 재현 (씬 전환 등)
- 다시 스냅샷
- "Comparison" 뷰에서 새로 추가된 객체 확인
Performance Monitor #
DevTools → More tools → Performance Monitor
실시간으로 JS heap size, DOM Nodes 수, GPU memory 등을 볼 수 있습니다.
흔한 메모리 누수 패턴 #
1. 애니메이션 루프에서 객체 생성 #
// 나쁨
function animate() {
const vector = new THREE.Vector3(); // 매 프레임 새 객체
requestAnimationFrame(animate);
}
// 좋음
const vector = new THREE.Vector3(); // 재사용
function animate() {
vector.set(x, y, z);
requestAnimationFrame(animate);
}
2. 클로저가 큰 객체 참조 #
// 나쁨
function setup() {
const hugeData = loadHugeData();
button.onclick = () => {
console.log(hugeData.length); // hugeData가 절대 GC되지 않음
};
}
// 좋음
function setup() {
const hugeData = loadHugeData();
const length = hugeData.length; // 필요한 것만 캡처
button.onclick = () => {
console.log(length);
};
}
3. 취소되지 않은 requestAnimationFrame #
// 나쁨
function startAnimation() {
function animate() {
requestAnimationFrame(animate);
render();
}
animate();
}
// 좋음
let animationId;
function startAnimation() {
function animate() {
animationId = requestAnimationFrame(animate);
render();
}
animate();
}
function stopAnimation() {
cancelAnimationFrame(animationId);
}
4. 제거되지 않은 타이머 #
// 나쁨
class Component {
start() {
setInterval(this.update, 1000);
}
}
// 좋음
class Component {
start() {
this.intervalId = setInterval(this.update, 1000);
}
destroy() {
clearInterval(this.intervalId);
}
}
예방 체크리스트 #
프로젝트 시작 시 이 체크리스트를 만들었습니다.
- [ ] 모든 Geometry는 dispose 되는가?
- [ ] 모든 Material은 dispose 되는가?
- [ ] 모든 Texture는 dispose 되는가?
- [ ] 이벤트 리스너는 정리되는가?
- [ ] setInterval/setTimeout은 정리되는가?
- [ ] requestAnimationFrame은 취소 가능한가?
- [ ] 애니메이션 루프에서 객체 생성을 피했는가?
결론 #
일주일간의 디버깅 끝에 메모리 누수를 모두 잡았습니다. 교훈은 명확합니다.
- Three.js에서는 수동 메모리 관리가 필수
- 처음부터 dispose 패턴을 습관화
- renderer.info를 주기적으로 확인
- DevTools Memory 탭에 익숙해지기
3D 웹 개발은 일반 웹 개발보다 메모리 관리에 훨씬 신경 써야 합니다. JavaScript가 가비지 컬렉션을 해준다고 해서 방심하면 안 됩니다. GPU 메모리는 개발자가 직접 관리해야 합니다.
- Previous: 3D 포트폴리오 웹사이트 제작기 - 기획부터 배포까지
- Next: 클라이언트 요청 - 3D 제품 뷰어 제작 후기