제가 만드는 앱은 화면 한가운데에 행성이 하나 떠 있습니다. 그 뒤 하늘에 해와 달을 하나씩 놓고 싶어서, 3D 모델을 두 개 부탁했습니다. 받은 파일은 이랬습니다.
sun.glb- 4.8MBmoon.glb- 3.1MB
그런데 그 앱에 이미 들어가 있는 3D 오브젝트를 전부 합치면 2.9MB입니다. 행성도, 행성 위에 자라는 나무와 건물도 다 합쳐서 그렇습니다. 장식으로 넣을 두 개가 제품 전체의 3D 자산을 세 배로 만들 참이었습니다.
여기서 보통 이렇게 생각합니다. “모델이 너무 정교하게 만들어졌나 보다. 좀 단순하게 다시 부탁해야겠다.”
저도 그렇게 생각했고, 그게 틀렸습니다. 이 글은 그 착각이 어디서 왔는지에 대한 이야기입니다. 결론부터 적으면, 모양 데이터는 파일의 1%였습니다. 나머지 99%는 제 앱이 열어보지도 않는 그림 파일이었습니다.
3D 파일 안에는 크게 두 가지가 들어 있습니다
GLB 용량이 왜 그렇게 커지는지 보려면 기본적인 것부터 짚어야 합니다. 이걸 모르면 뒤의 이야기가 안 풀립니다.
3D 모델 파일에는 성격이 다른 두 가지가 같이 들어 있습니다.
첫째는 모양입니다. 점이 어디에 찍혀 있고, 그 점들을 어떻게 삼각형으로 이어 붙였는지에 대한 좌표 목록입니다. 찰흙으로 치면 빚어놓은 형태 자체입니다. 이걸 보통 지오메트리(geometry)라고 부릅니다.
둘째는 겉면입니다. 그 형태 위에 무슨 색을 칠할지, 어디가 거칠고 어디가 반질거리는지, 어디가 스스로 빛나 보이는지를 담은 그림들입니다. 찰흙에 입히는 물감과 무늬에 해당합니다. 이 그림들을 텍스처(texture)라고 하고, 보통 한 모델에 여러 장이 붙습니다.
용량 이야기를 할 때 사람들이 대개 첫 번째를 떠올립니다. “폴리곤이 많다”, “너무 정교하다” 는 전부 모양 이야기입니다. 그런데 실제로 파일을 무겁게 만드는 쪽은 대개 두 번째입니다. 좌표 몇천 개는 숫자 목록이라 작지만, 그림은 사진과 같아서 한 장에 수백 KB에서 수 MB까지 갑니다.
GLB 용량이 어디로 갔는지 직접 열어봅니다
추측하지 말고 파일을 열면 됩니다. GLB 용량이 무엇으로 채워졌는지 보는 데는 프로그램을 따로 깔 필요도 없습니다.
.glb 파일은 통째로 알아볼 수 없는 덩어리가 아닙니다.
앞의 12바이트가 이 파일이 GLB라는 것을 알려주는 표식이고, 그 다음에 설명서 역할을 하는 글자 뭉치가 붙어 있습니다.
이 설명서는 그냥 JSON입니다.
안에 덩어리가 몇 개인지, 점이 몇 개인지, 그림이 몇 장이고 각각 몇 바이트인지가 전부 적혀 있습니다.
즉 파일을 읽어서 그 JSON만 꺼내 보면 답이 나옵니다.
import { readFileSync } from 'node:fs';
export function inspect(path) {
const buf = readFileSync(path);
if (buf.toString('ascii', 0, 4) !== 'glTF') throw new Error('GLB 파일이 아닙니다');
// 12번째 바이트에 "설명서가 몇 글자인지" 가 적혀 있다. 그만큼 잘라 읽으면 JSON 이다
const jsonLen = buf.readUInt32LE(12);
const json = JSON.parse(buf.toString('utf-8', 20, 20 + jsonLen));
// 삼각형 수 = 모양이 얼마나 복잡한가
const triangles = (json.meshes ?? [])
.flatMap((m) => m.primitives)
.reduce((sum, p) => {
const acc = json.accessors[p.indices ?? p.attributes.POSITION];
return sum + Math.floor(acc.count / 3);
}, 0);
// 그림은 파일 안에 통째로 박혀 있다. 그 길이를 다 더하면 그림이 먹는 용량이다
const textureBytes = (json.images ?? []).reduce(
(sum, im) => sum + (im.bufferView != null ? json.bufferViews[im.bufferView].byteLength : 0),
0,
);
return {
전체: buf.length,
삼각형: triangles,
그림장수: (json.images ?? []).length,
그림비중: `${((textureBytes / buf.length) * 100).toFixed(1)}%`,
// 점마다 어떤 정보를 들고 있는지. 뒤에서 이게 중요해집니다
속성: [...new Set((json.meshes ?? [])
.flatMap((m) => m.primitives)
.flatMap((p) => Object.keys(p.attributes)))],
};
}
받은 파일 두 개에 그대로 돌렸습니다.
| 파일 | 전체 바이트 | 삼각형 | 그림 | 그림 비중 |
|---|---|---|---|---|
sun.glb | 5,056,996 | 1,038개 | 4장 | 98.7% |
moon.glb | 3,277,972 | 1,050개 | 4장 | 98.3% |
각각 그림이 4장씩 들어 있고, 전부 가로세로 2048픽셀입니다. 색을 칠하는 그림 한 장, 표면의 오돌토돌한 느낌을 흉내내는 그림 한 장, 어디가 금속처럼 반질거리는지 적은 그림 한 장, 스스로 빛나는 부분을 표시한 그림 한 장. 해 쪽은 이 중 오돌토돌한 느낌을 담당하는 그림 한 장만 2,153KB였습니다. 모양 데이터 전체의 마흔 배입니다.
그러니까 모양은 처음부터 문제가 아니었습니다. 하늘에 작게 뜨는 장식에 삼각형 1,038개면 오히려 알맞은 수준입니다.
그리고 여기서 분명히 해둘 게 있습니다. 모델을 만들어 준 사람은 잘못한 게 없습니다. 완성된 3D 자산이란 원래 재질이 다 붙어 있는 것이고, 그렇게 내보내는 게 정상입니다. 받는 쪽이 그 재질을 통째로 버린다는 사실을 아무도 말해주지 않았을 뿐입니다.
왜 제 앱은 그 그림을 전부 버릴까
제 앱의 해와 달은 시간대에 따라 색이 바뀝니다. 새벽에는 해가 따뜻한 주황빛이고, 한낮에는 흰색에 가깝습니다. 달은 밤에 은빛입니다.
만약 그 색이 그림에 미리 칠해져 있다면, 시간대마다 그림을 따로 만들어야 합니다. 새벽용 해, 아침용 해, 낮용 해, 저녁용 해. 게다가 그 색이 화면 조명과도 어긋나면 안 됩니다. 조명은 코드가 정합니다.
그래서 이 앱은 처음부터 반대로 만들어져 있습니다. 파일은 모양만 대고, 색은 코드가 그때그때 입힙니다.
화면에 그리는 코드도 그 구조 그대로입니다. 파일에서 모양만 꺼내오고, 나머지는 읽지도 않습니다.
// 파일에서 가져오는 건 모양 하나뿐이다.
// 색·재질·그림은 아예 읽지 않는다.
const geometry = await loadAssetGeometry(SKY_ASSETS.sun);
<mesh geometry={geometry}>
<meshStandardMaterial
color={daylight.skyBody.color} // 시간대에 따라 코드가 정하는 색
emissiveIntensity={daylight.emissive}
roughness={0.55}
metalness={0.05}
/>
</mesh>
이 구조에서 2048픽셀 그림 4장은 “무거운 자산” 이 아닙니다. 읽어서 메모리에 올린 다음 한 번도 쓰이지 않고 버려지는 데이터입니다.
휴대폰에서는 이게 저장 공간만 낭비하는 게 아닙니다. 앱을 켤 때마다 그 그림들을 풀어내는 시간이 들고, 그 순간 메모리도 그만큼 더 씁니다. 그 대가로 화면에 나오는 픽셀은 하나도 없습니다.
그래서 걷어냈습니다. 점의 위치, 면이 향한 방향, 삼각형을 잇는 순서만 남기고 그림과 재질 정보를 전부 지웠습니다.
| 해 | 전체 바이트 | 삼각형 | 그림 |
|---|---|---|---|
| 받은 원본 | 5,056,996 | 1,038개 | 4장 |
| 지금 앱에 들어 있는 것 | 50,424 | 1,038개 | 0장 |
| 차이 | -99.0% | 그대로 |
두 파일 다 아직 디스크에 남아 있어서, 위 숫자는 기억이 아니라 오늘 다시 잰 값입니다. 삼각형 수가 양쪽 다 1,038로 똑같습니다.
모양은 하나도 안 잃었습니다. 모양에 해당하는 것을 지운 게 아니기 때문입니다. 이건 화질을 낮춰서 용량을 줄이는 압축이 아니고, 형태를 뭉개서 단순하게 만드는 작업도 아닙니다. 그래서 “용량 대신 무엇을 포기했나” 를 따질 게 없습니다. 프로그램이 열지도 않는 짐을 내렸을 뿐입니다.
달도 같은 원본에서 같은 처리를 했습니다. 다만 그때 정리한 파일이 몇 바이트였는지는 여기 안 적겠습니다. 그 파일이 지금 없기 때문입니다. 달은 곧 형태를 다시 잡아서 새로 받았고, 실제로 앱에 들어간 건 그 새 파일입니다. 그리고 두 번째 문제는 바로 거기서 나왔습니다.
첫 번째 뒤에 숨어 있던 두 번째
새로 받은 달은 초승달 모양이었고, 파일은 133,868바이트에 그림이 0장이었습니다. 텍스처 이야기는 잘 전달된 겁니다. 숫자만 봐도 깨끗해 보였습니다.
그런데 아까 그 도구를 습관적으로 한 번 더 돌렸더니, 속성 목록에 이런 게 남아 있었습니다.
속성: [ 'POSITION', 'NORMAL', 'TEXCOORD_0' ]
앞의 둘은 각각 점의 위치와 면이 향한 방향입니다. 모양을 그리는 데 꼭 필요합니다.
세 번째 TEXCOORD_0 은 다릅니다.
이건 점마다 “겉면 그림의 어느 지점을 여기다 붙일지” 를 적어둔 좌표입니다.
지구본에 세계지도를 씌울 때, 지도의 어느 부분이 지구본의 어디에 닿는지 미리 정해둔 표라고 생각하면 됩니다.
문제는 씌울 지도가 이미 없다는 것입니다. 그림을 다 지웠으니까요. 아무 데도 가리키지 않는 좌표표를 매번 같이 싣고, 앱을 켤 때마다 같이 읽고 있었습니다.
그 속성 하나를 지우니 133,868바이트에서 105,224바이트가 됐습니다. 21.4% 줄었고, 화면은 1픽셀도 안 바뀝니다.
저는 이쪽이 더 중요한 실패라고 생각합니다. 4.8MB짜리 파일은 스스로를 알립니다. 목록에서 한 번만 봐도 눈에 걸립니다.
그런데 134KB짜리는 멀쩡해 보입니다. 방금 문제 삼았던 파일보다 이미 97% 작고, 그림도 없고, 무엇보다 어떤 기준을 세워도 경고가 울릴 수 없는 숫자입니다. 이게 잡힌 건 제가 의심해서가 아닙니다. 같은 도구를 습관적으로 한 번 더 돌렸기 때문입니다.
고정된 검사는 문제의 조용한 버전까지 잡아냅니다. “생각나면 확인하기” 는 못 잡습니다. 이번 건 운이 좋았던 쪽에 가깝습니다.
같은 앱에서 앱을 켤 때마다 오늘 알림을 전부 지우던 버그도 정확히 같은 모양이었습니다. 에러는 안 나고, 결과만 조용히 틀립니다.
결국 남은 규칙 하나
처음에 제가 세운 틀은 “3D 자산은 무거우니 폴리곤을 줄이자” 였습니다. 그 틀대로 갔으면 더 단순한 해를 다시 요청했을 겁니다. 모델은 나빠지고 용량은 거의 그대로였을 겁니다. 모양 데이터가 파일의 1%였으니까요.
실제로 남은 규칙은 더 좁고 더 쓸모 있습니다. 자산 명세에는 “어떻게 생겨야 하는가” 만이 아니라 “받는 쪽이 무엇을 읽는가” 가 적혀 있어야 합니다.
제 명세에는 파일 형식도, 덩어리 개수도, 삼각형 상한도, 어느 방향을 정면으로 둘지도 적혀 있었습니다. “재질과 그림은 버려집니다. 모양만 보내주세요” 만 없었습니다. 그 한 줄이 빠진 대가가 7.9MB였고, 빠뜨린 사람은 모델러가 아니라 저였습니다.
지금은 그 문장이 명세 첫 줄에 있고, 실제로 그걸 버리는 코드 옆에도 같은 말이 한 번 더 적혀 있습니다. 한쪽을 읽는 사람은 보통 다른 쪽을 안 읽기 때문입니다.
앱 안에 3D 파일이 들어 있다면 확인은 2분이면 끝납니다. 위 코드를 자산 폴더에 돌리고 두 칸만 보세요.
- 그림 비중이 높은가
- 속성 목록에 안 쓰는 게 섞여 있는가
첫 번째는 곧바로 답이 나오지 않습니다. 그림 비중이 높은 게 문제인지 아닌지는 화면에 그리는 코드가 그 그림을 쓰는지에 달려 있으니까요. 추측하지 말고 그 코드를 직접 여세요. 쓰고 있으면 그 그림은 필요한 용량이고, 안 쓰고 있으면 전부 죽은 용량입니다.
두 번째는 더 단순합니다.
TEXCOORD_0 이 있는데 그림이 0장이면, 어느 경우에도 지워도 되는 무게입니다.
node glb-info.mjs assets/**/*.glb
2026년 8월 11일 기준으로 측정했고, three.js 0.181.2 · @react-three/fiber 9.6.1 · Expo SDK 56 · Node 24.2.0 환경입니다. 파일 구조 자체는 glTF 2.0 명세를 따르고, 앞부분 12바이트와 배치 순서는 2017년부터 그대로라 위 코드는 버전을 타지 않습니다. 버전을 타는 건 “재질이 버려진다” 는 쪽입니다. 그건 제 앱의 사정이지 모두의 사정이 아닙니다. 지우기 전에 본인 코드를 먼저 확인하세요.