RainyLab
Dev · Web

Blob URL을 img src로 쓰는 법, 그리고 그 대가

데이터를 문자열로 바꿔 심는 방식은 작을 때만 이긴다. 크기가 커지면 33% 오버헤드도, DOM 에 들어앉는 수백만 글자도 감당이 안 된다. 세 번째 선택지가 여기 있다 — URL.createObjectURL() 은 원본을 메모리에 그대로 둔 채 그것을 가리키는 짧은 주소 하나만 돌려준다. 파일도 아니고 문자열도 아닌 참조다. 대신 브라우저가 알아서 치워 주지 않는다 — 다 쓴 주소는 직접 해제해야 한다.

1. 인코딩하지 않는다, 등록할 뿐이다

createObjectURL(blob) 은 데이터를 건드리지 않는다. 브라우저 내부의 Blob 레지스트리에 원본 바이너리를 등록하고, 그 항목을 가리키는 임시 주소 blob:origin/uuid 를 문자열로 준다. 파일 경로도 아니고 데이터 자체도 아닌, 메모리 조각에 붙인 이름표다. 그래서 현재 문서에서만 유효하다 — 새로고침하거나 다른 탭에 붙여 넣으면 아무것도 열리지 않는다.

<input type="file" id="fileInput" accept="image/*">
<img id="preview" alt="">
const fileInput = document.getElementById('fileInput')
const imgElement = document.getElementById('preview')

fileInput.addEventListener('change', () => {
  const file = fileInput.files[0]
  // 선택을 취소하면 files 가 비어 있다
  if (!file) return

  // 메모리의 Blob 을 가리키는 임시 주소
  const objectUrl = URL.createObjectURL(file)
  // → "blob:https://example.com/3fa4c1a2-…"
  imgElement.src = objectUrl

  // 그려지고 나면 이름표를 떼도 된다 — 여기가 핵심
  imgElement.onload = () => URL.revokeObjectURL(objectUrl)
  imgElement.onerror = () => URL.revokeObjectURL(objectUrl)
})

onload 에서 해제하는 게 안전한 이유는, 그 시점에 이미지가 이미 디코딩돼 화면에 올라가 있기 때문이다. 주소를 지워도 그려진 그림은 그대로 남는다. onerror 를 같이 다는 건 로드가 실패하면 onload 가 영영 오지 않아서다 — 그 경우 해제 코드가 실행되지 않고 등록만 남는다.

실수하기 쉬운 두 곳

2. 직접 만들고 해제해 보기

아래에서 Blob URL 을 만들고, 해제하고, 해제한 주소를 다시 써 보면 무엇이 사라지는지 바로 보인다. 데모는 매번 색이 다른 SVG 를 즉석에서 만들어 쓴다 — 외부 이미지 요청은 없다.

no image

해제 뒤 다시 쓰기를 누르면 이미지가 비고 onerror 가 뜬다. 주소 문자열은 그대로인데 가리키던 항목이 레지스트리에서 사라졌기 때문이다. URL 은 데이터가 아니라 참조라는 게 여기서 눈에 보인다.

해제를 잊으면

등록된 Blob 은 탭을 닫거나 새로고침하기 전까지 계속 메모리에 남는다. 원본 파일이 살아 있는 한 GC 도 손대지 못한다. 업로드 미리보기나 무한 스크롤 갤러리처럼 이미지를 반복해서 만드는 화면에서 revokeObjectURL 을 부르지 않으면 사용량이 단조증가한다 — 전형적인 메모리 누수다. 스크롤 몇 백 장이면 수백 MB가 된다.

3. base64 는 왜 33% 커지는가

문자열로 심는 쪽, 즉 data:image/png;base64,… 가 작은 아이콘에서는 이긴다는 건 앞 글에서 봤다. 균형을 뒤집는 값이 바로 이 33% 인데, 그게 정확히 어디서 나오는지를 보면 왜 큰 데이터에서 무너지는지도 같이 보인다.

원본 바이트는 8비트 단위다. base64 는 이걸 8비트씩 읽지 않고 6비트씩 잘라서 각 조각을 A~Z a~z 0~9 + / 중 한 글자에 대응시킨다. 6비트는 0~63 밖에 표현하지 못하므로, 잘라낸 조각 하나가 다시 8비트짜리 문자 하나에 담긴다.

// 원본: 8비트씩 3글자 = 24비트
'A' = 01000001   'B' = 01000010   'C' = 01000011

// 이어 붙인 24비트를 6비트씩 4조각으로 다시 자른다
010000  010100  001001  000011
  16      20       9       3     ← 각 조각의 값 (0~63)
  'Q'     'U'     'J'     'D'    ← base64 문자표에서 조회

// "ABC" 3바이트 → "QUJD" 4글자

3바이트가 4바이트가 되므로 정확히 4/3 ≈ 1.33배다. Blob URL 은 이 재인코딩을 아예 하지 않으므로 오버헤드가 0이다.

원본 바이너리1,000 KB
Blob URL1,000 KB — 그대로
base64 data URL≈ 1,333 KB (+33%)

아이콘 1KB 에서는 330바이트 차이라 없는 셈이다. 2MB 사진에서는 660KB 가 늘고, 그 문자열이 DOM 에도 그대로 들어앉는다. 차이를 만드는 건 비율이 아니라 크기다.

4. CSS 에 쓸 수 있는가

런타임에는 되고, 정적 .css 파일에는 안 된다. 인라인 스타일이나 스크립트로 삽입하는 규칙에서는 정상 동작한다.

const url = URL.createObjectURL(blob)

// 인라인 스타일 — 된다
element.style.backgroundImage = `url(${url})`

// CSSOM 으로 규칙 삽입 — 된다
sheet.insertRule(`.thumb { background-image: url(${url}); }`)

안 되는 쪽은 원리상 안 되는 것이다. Blob URL 은 실행 중에 생기는 값이라 빌드 시점에 고정되는 파일에 적어 둘 방법이 없다. 반대로 base64 는 값 자체가 문자열이라 .css 에 그대로 박힌다. "정적 파일에 기록 가능한가"만 놓고 보면 base64 가 유일한 선택지이고, Blob URL 은 JS 가 도는 상황을 전제한다.

5. 소스에 박으면 이력이 나빠진다

Blob URL 은 소스에 값이 남지 않으니 이 문제에서 자유롭다. base64 를 .html·.css·.js 에 직접 하드코딩하면 한 줄이 수천~수만 자가 된다.

git diff — 아이콘 하나 바꿨을 뿐인데
.icon-logo {
+ background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAABQAAAAKACAYAAAB…(한 줄에 8,000자 이상 이어짐)…);
}
↳ 줄 전체가 "변경됨"으로 잡힌다. 무엇이 달라졌는지 리뷰할 방법이 없다
리뷰가 안 된다
  • 한 줄짜리 초장문이라 줄 단위 diff 가 무의미하다
  • 이미지가 바뀐 건지 오타인지 구분할 수 없다
  • PR 목록이 지저분해지고 읽는 흐름이 끊긴다
저장소가 붓는다
  • base64 는 텍스트라 델타 압축 효율이 낮다
  • 교체할 때마다 이전 버전이 .git 에 계속 쌓인다
  • 별도 파일이면 LFS 로 뺄 수 있지만 인라인 텍스트는 대상이 아니다

그래서 base64 를 쓰더라도 손으로 박기보다 빌드 타임에 인라인하는 편이 낫다. 소스에는 이미지 파일이 그대로 남고, 부풀린 문자열은 산출물에만 생긴다.

6. 그래서 어느 쪽을 쓰나

기준Blob URLbase64 data URL
데이터 형태원본 바이너리 참조텍스트로 재인코딩
용량 오버헤드없음약 +33%
메모리 해제수동 (revokeObjectURL)불필요 (문자열이라 GC 대상)
정적 파일 기록불가 (런타임 전용)가능
새로고침 후무효유지
맞는 크기수백 KB ~ 수십 MB1~2KB 아이콘
Blob URL 이 맞는 자리
  • 사용자가 올린 파일의 미리보기
  • fetch·canvas.toBlob() 으로 런타임에 만든 이미지
  • 수백 KB 이상의 큰 파일, 동영상
  • 페이지가 살아 있는 동안만 필요한 것
base64 가 맞는 자리
  • 1~2KB 짜리 고정 아이콘
  • 정적 CSS/HTML 에 박아 둬야 하는 것
  • JS 없이도 보여야 하는 것
  • 단, 손으로 넣지 말고 빌드가 넣게 한다

한 줄로 줄이면 이렇다. 작고 고정이면 문자열(base64), 크고 흘러가면 참조(Blob URL), 두고두고 재사용하면 파일. 마지막까지 왔다면 해제까지가 한 세트다.