태그 GLB 용량3D 모델 용량 줄이기glTFthree.js앱 용량 줄이기리액트 네이티브
전체 글 보기

GLB 용량

3D 파일 GLB 용량의 99%가 앱이 안 쓰는 그림이었습니다

하늘에 띄울 해와 달을 3D로 받았더니 두 개가 8MB였습니다. 모양이 복잡해서가 아니라, GLB 용량의 대부분이 앱이 쳐다보지도 않는 겉면 그림이었습니다.

2026년 8월 11일 7분

제가 만드는 앱은 화면 한가운데에 행성이 하나 떠 있습니다. 그 뒤 하늘에 해와 달을 하나씩 놓고 싶어서, 3D 모델을 두 개 부탁했습니다. 받은 파일은 이랬습니다.

  • sun.glb - 4.8MB
  • moon.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.glb5,056,9961,038개4장98.7%
moon.glb3,277,9721,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,9961,038개4장
지금 앱에 들어 있는 것50,4241,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년부터 그대로라 위 코드는 버전을 타지 않습니다. 버전을 타는 건 “재질이 버려진다” 는 쪽입니다. 그건 제 앱의 사정이지 모두의 사정이 아닙니다. 지우기 전에 본인 코드를 먼저 확인하세요.

이전 글 Turborepo가 캐시 적중이라며 아무것도 만들지 않았습니다