logo

DowanKim

4. Blob URL을 사용한 렌더링과 최적화

2026년 2월 10일

SIMVEX: 공학 학습용 웹 기반 3D 기계 부품 뷰어

제가 구현한, 백엔드에서 gltf파일을 받아와 화면에 출력하고, 또 각 부품의 매트릭스 값을 저장하는 방식을 설명하고자 합니다.

전체 과정을 요약하면 아래와 같습니다.

  1. 서버에서 ZIP 다운로드 → ArrayBuffer
  2. ZIP 압축 해제.gltf, .bin, 텍스처 파일 추출
  3. Blob URL 생성 → 각 파일을 메모리 객체로 만들고 가상 URL 부여
  4. Three.js 로더에 URL 매핑 → GLTF가 상대 경로로 요청할 때 Blob URL로 치환
  5. 3D 씬 렌더링 → useGLTF로 모델 로드 및 표시
  6. 저장 → 노드별 변환 행렬(matrix)만 추출해 서버에 동기화

2. Blob URL을 왜 사용하는가?

2-1. GLTF는 상대 경로를 참조하는 이슈

GLTF/GLB 파일을 직접 열어보면 다음과 같이 상대 경로로 리소스를 참조하고 있습니다.

// model.gltf { "buffers": [{ "uri": "model.bin" }], "images": [{ "uri": "texture.png" }] }

Three.js GLTFLoader는 model.gltf를 로드한 뒤, model.bin, texture.png같은 기준 경로에서 요청합니다.
즉, model.gltf의 URL이 https://example.com/models/scene.gltf라면, model.binhttps://example.com/models/model.bin으로 요청됩니다.

2-2. 문제: ZIP 안의 파일에는 URL이 없다

우리는 서버에서 ZIP 파일로 모델을 받습니다. ZIP 안에는:

  • manifest.json
  • default.gltf / custom.gltf
  • model.bin
  • texture.png

같은 파일들이 파일 시스템 경로 없이 메모리에만 존재합니다.
브라우저가 model.bin을 요청할 때 사용할 실제 URL이 없습니다.

2-3. Blob URL로 가상 URL 만들기

각 파일을 메모리(Blob)로 만들고, URL.createObjectURL(blob)으로 임시 URL을 부여합니다.

model.bin (ArrayBuffer) → Blob → blob:https://origin/uuid
texture.png (ArrayBuffer) → Blob → blob:https://origin/uuid2

이 URL들은 브라우저가 해당 Blob을 가리키는 실제 HTTP URL처럼 동작합니다.
GLTFLoader가 model.bin을 요청하면, 우리가 만든 Blob URL로 요청을 보내도록 매핑해 주면 됩니다.


3. Blob URL을 어떻게 사용하지

3-1. 파일별 Blob URL 생성

// ZIP에서 각 파일 추출 const buffer = await file.async('arraybuffer'); const blob = new Blob([buffer], { type: getMimeType(filename) }); const url = URL.createObjectURL(blob); urlMap.set(filename, url); // "default.gltf" → blob:... urlMap.set(base, url); // "model.bin" → blob:...
  • filename: ZIP 내 전체 경로 (예: default.gltf)
  • base: 파일명만 (예: model.bin)
    → GLTF가 model.bin으로 참조하므로, 파일명 기준으로도 매핑합니다.

3-2. Three.js 로더에 URL 치환 등록

THREE.DefaultLoadingManager.setURLModifier((url) => { const base = url.split('/').pop() ?? url; return urlMap.get(url) ?? urlMap.get(base) ?? url; });
  • GLTFLoader가 model.bin 같은 상대 경로로 요청하면, setURLModifier가 호출됩니다.
  • urlMap에 해당 파일명이 있으면 Blob URL로 치환하고, 없으면 원래 URL을 그대로 사용합니다.
  • 이렇게 해서 ZIP에서 풀어낸 파일들이 로더에 정상적으로 전달됩니다.

4. 트러블 슈팅: Blob URL 해제와 메모리

4-1. Blob URL은 메모리를 잡고 있다

URL.createObjectURL()로 만든 URL은 해당 Blob을 가리키는 참조입니다.
이 URL을 해제하지 않으면, Blob이 GC 대상이 되지 않아 메모리 누수가 발생합니다.

4-2. 해제 시점: 언제 revoke할 것인가?

  • 페이지 이탈(unmount) 시: 모든 Blob URL 해제 필요

4-3. 구현: revoke 함수와 cleanup

// modelZip.ts - 반환 객체에 revoke 포함 return { defaultUrl, customUrl, parts, modelName, revoke: () => { revokeUrls.forEach((url) => URL.revokeObjectURL(url)); }, };
// page.tsx - unmount 시 cleanup useEffect(() => { return () => { if (modelUrls) { modelUrls.revoke(); } }; }, [modelUrls]);
  • modelUrlsdownloadAndExtractModelZip의 반환값입니다.
  • 컴포넌트가 unmount될 때 revoke()를 호출해, 해당 모델에 대해 생성한 모든 Blob URL을 해제합니다.

5. 트러블 슈팅: 로드 중 unmount

5-1. 문제

ZIP 다운로드 및 압축 해제는 비동기입니다.
사용자가 로딩 중에 페이지를 떠나면:

  • setState가 unmount된 컴포넌트에 호출될 수 있음 (React 경고/에러)
  • 이미 불필요한 Blob URL이 생성·유지될 수 있음

5-2. 해결: disposed 플래그 + AbortController

useEffect(() => { let disposed = false; const controller = new AbortController(); const loadModels = async () => { try { const result = await downloadAndExtractModelZip({ sceneId: sceneIdParam, signal: controller.signal, }); if (disposed) { result.revoke(); // 이미 unmount됐으면 Blob URL 즉시 해제 return; } setModelUrls(result); // ... } catch (error) { if (controller.signal.aborted) return; // ... } }; loadModels(); return () => { disposed = true; controller.abort(); }; }, [sceneIdParam]);
  • disposed: unmount 후에는 setState를 호출하지 않고, result.revoke()만 수행
  • AbortController: fetch 등에서 signal을 사용해 진행 중인 요청을 취소 (필요 시)
  • cleanup에서 disposed = true, controller.abort()로 비동기 작업을 정리합니다.

6. 저장 최적화

6-1. 저장하는 데이터 최소화

저장 시 노드별 변환 행렬(matrix, 16개 float)만 추출합니다.

const payload = { components: sceneState.nodeTransforms.map(({ nodeId, nodeName, matrix }) => ({ nodeName, matrix, })), };
  • 위치·회전·스케일을 각각 보내지 않고, 4x4 matrix 하나로 표현
  • 전송량과 파싱 비용을 줄입니다.

즉, 받아올때는 zip(gltf파일포함된)으로 받아오고, 저장은 10초마다 자동저장되기 때문에 최대한 병목을 줄이기 위해서 매트릭스 값만 보냅니다. 그러면 백엔드에선, 매트릭스 값 기반으로 custom.gltf파일을 변경시켜서 업데이트해줍니다. 그래서 다음에 받아올땐, 업데이트된 저장된 gltf파일을 받아올 수 있습니다.

6-2. 병렬 저장

const tasks = [ syncSceneState(sceneIdParam, payload), updateDisassemblyLevel(sceneIdParam, assemblyValue), updateSceneNote(sceneIdParam, notePayload), ]; await Promise.all(tasks);
  • 씬 상태, 조립/분해 레벨, 노트를 동시에 저장해 전체 저장 시간을 단축합니다.

항목내용
Blob URL 사용 이유ZIP 내부 파일에는 URL이 없고, GLTF는 상대 경로로 리소스를 참조하기 때문
Blob URL 사용 방법파일별 Blob 생성 → createObjectURLsetURLModifier로 로더 요청 치환
해제URL.revokeObjectURL로 사용 후 해제, unmount 시 revoke() 호출
로드 중 unmountdisposed 플래그 + AbortController로 setState 방지 및 Blob 즉시 해제
저장 최적화matrix만 추출, Promise.all로 병렬 저장

혹시모를 메모리 누수와 최적화에 신경을 많이 썼던 것 같습니다.

항상 말했듯이 3d환경을 웹에 구현하는 이 주제에서 가장 중요한게 성능과 디테일 경험이지 않겠습니까.

예를들어 로드중 unmount시에는, disposed플래그와 AbortController를 같이 사용합니다.

즉, fetch중에 unmount 한 상황을 대비해 abortController를 사용했고, 요청은 끝났는데 바로 unmount되는 상황? 을 대비해 그 이후에 의미없는 상태변경을 하지 않기 위해 disposed플래그를 사용했습니다.

또 저장시에 최대한 성능적으로 이상이 없도록, 저장매커니즘을 프론트단에서는 matrix만 보내고, 또 매트릭스 저장, 노트저장, 등 다양한 저장을 병렬로 처리하여 최대한 병목을 없애고 저장 중에도 원활한 3d환경을 체험할 수 있도록 제공하였습니다.