Skip to main content
민트라

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 탭 #

  1. Heap Snapshot: 특정 시점의 메모리 상태 캡처
  2. Allocation Timeline: 시간에 따른 메모리 할당 추적
  3. Allocation Sampling: CPU 프로파일링과 함께 메모리 추적

저는 Heap Snapshot을 두 번 찍어서 비교하는 방법을 주로 썼습니다.

  1. 페이지 로드 직후 스냅샷
  2. 문제 상황 재현 (씬 전환 등)
  3. 다시 스냅샷
  4. "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);
  }
}

예방 체크리스트 #

프로젝트 시작 시 이 체크리스트를 만들었습니다.

결론 #

일주일간의 디버깅 끝에 메모리 누수를 모두 잡았습니다. 교훈은 명확합니다.

  1. Three.js에서는 수동 메모리 관리가 필수
  2. 처음부터 dispose 패턴을 습관화
  3. renderer.info를 주기적으로 확인
  4. DevTools Memory 탭에 익숙해지기

3D 웹 개발은 일반 웹 개발보다 메모리 관리에 훨씬 신경 써야 합니다. JavaScript가 가비지 컬렉션을 해준다고 해서 방심하면 안 됩니다. GPU 메모리는 개발자가 직접 관리해야 합니다.