4. Blob URL을 사용한 렌더링과 최적화
2026년 2월 10일
제가 구현한, 백엔드에서 gltf파일을 받아와 화면에 출력하고, 또 각 부품의 매트릭스 값을 저장하는 방식을 설명하고자 합니다.
전체 과정을 요약하면 아래와 같습니다.
- 서버에서 ZIP 다운로드 → ArrayBuffer
- ZIP 압축 해제 →
.gltf,.bin, 텍스처 파일 추출 - Blob URL 생성 → 각 파일을 메모리 객체로 만들고 가상 URL 부여
- Three.js 로더에 URL 매핑 → GLTF가 상대 경로로 요청할 때 Blob URL로 치환
- 3D 씬 렌더링 → useGLTF로 모델 로드 및 표시
- 저장 → 노드별 변환 행렬(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.bin은 https://example.com/models/model.bin으로 요청됩니다.
2-2. 문제: ZIP 안의 파일에는 URL이 없다
우리는 서버에서 ZIP 파일로 모델을 받습니다. ZIP 안에는:
manifest.jsondefault.gltf/custom.gltfmodel.bintexture.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]);
modelUrls는downloadAndExtractModelZip의 반환값입니다.- 컴포넌트가 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 생성 → createObjectURL → setURLModifier로 로더 요청 치환 |
| 해제 | URL.revokeObjectURL로 사용 후 해제, unmount 시 revoke() 호출 |
| 로드 중 unmount | disposed 플래그 + AbortController로 setState 방지 및 Blob 즉시 해제 |
| 저장 최적화 | matrix만 추출, Promise.all로 병렬 저장 |
혹시모를 메모리 누수와 최적화에 신경을 많이 썼던 것 같습니다.
항상 말했듯이 3d환경을 웹에 구현하는 이 주제에서 가장 중요한게 성능과 디테일 경험이지 않겠습니까.
예를들어 로드중 unmount시에는, disposed플래그와 AbortController를 같이 사용합니다.
즉, fetch중에 unmount 한 상황을 대비해 abortController를 사용했고, 요청은 끝났는데 바로 unmount되는 상황? 을 대비해 그 이후에 의미없는 상태변경을 하지 않기 위해 disposed플래그를 사용했습니다.
또 저장시에 최대한 성능적으로 이상이 없도록, 저장매커니즘을 프론트단에서는 matrix만 보내고, 또 매트릭스 저장, 노트저장, 등 다양한 저장을 병렬로 처리하여 최대한 병목을 없애고 저장 중에도 원활한 3d환경을 체험할 수 있도록 제공하였습니다.