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 가 영영 오지 않아서다 —
그 경우 해제 코드가 실행되지 않고 등록만 남는다.
실수하기 쉬운 두 곳
-
요소를 먼저 가져오지 않는 것.
fileInput은 그 id 를 가진 요소가 HTML 에 있고getElementById로 실제로 잡아 온 뒤에만 존재한다. 그 두 줄 없이fileInput.files[0]부터 쓰면ReferenceError가 난다. -
선택 전에 읽는 것.
files[0]은 사용자가 파일을 고른 뒤에야 채워진다. 페이지 로드 직후 실행되는 자리에 두면 항상undefined다. 반드시change이벤트 안에서 읽어야 한다.
2. 직접 만들고 해제해 보기
아래에서 Blob URL 을 만들고, 해제하고, 해제한 주소를 다시 써 보면 무엇이 사라지는지 바로 보인다. 데모는 매번 색이 다른 SVG 를 즉석에서 만들어 쓴다 — 외부 이미지 요청은 없다.
해제 뒤 다시 쓰기를 누르면 이미지가 비고 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이다.
아이콘 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 에 직접 하드코딩하면 한 줄이 수천~수만 자가 된다.
- 한 줄짜리 초장문이라 줄 단위 diff 가 무의미하다
- 이미지가 바뀐 건지 오타인지 구분할 수 없다
- PR 목록이 지저분해지고 읽는 흐름이 끊긴다
- base64 는 텍스트라 델타 압축 효율이 낮다
- 교체할 때마다 이전 버전이
.git에 계속 쌓인다 - 별도 파일이면 LFS 로 뺄 수 있지만 인라인 텍스트는 대상이 아니다
그래서 base64 를 쓰더라도 손으로 박기보다 빌드 타임에 인라인하는 편이 낫다. 소스에는 이미지 파일이 그대로 남고, 부풀린 문자열은 산출물에만 생긴다.
6. 그래서 어느 쪽을 쓰나
| 기준 | Blob URL | base64 data URL |
|---|---|---|
| 데이터 형태 | 원본 바이너리 참조 | 텍스트로 재인코딩 |
| 용량 오버헤드 | 없음 | 약 +33% |
| 메모리 해제 | 수동 (revokeObjectURL) | 불필요 (문자열이라 GC 대상) |
| 정적 파일 기록 | 불가 (런타임 전용) | 가능 |
| 새로고침 후 | 무효 | 유지 |
| 맞는 크기 | 수백 KB ~ 수십 MB | 1~2KB 아이콘 |
- 사용자가 올린 파일의 미리보기
fetch·canvas.toBlob()으로 런타임에 만든 이미지- 수백 KB 이상의 큰 파일, 동영상
- 페이지가 살아 있는 동안만 필요한 것
- 1~2KB 짜리 고정 아이콘
- 정적 CSS/HTML 에 박아 둬야 하는 것
- JS 없이도 보여야 하는 것
- 단, 손으로 넣지 말고 빌드가 넣게 한다
한 줄로 줄이면 이렇다. 작고 고정이면 문자열(base64), 크고 흘러가면 참조(Blob URL), 두고두고 재사용하면 파일. 마지막까지 왔다면 해제까지가 한 세트다.