정확한 크기 계산: Base64는 압축으로 되돌릴 수 없는 33% 세금
Base64는 입력 3바이트를 출력 4문자에 대응시키므로 인코딩 길이는 정확히 4 × ceil(n / 3)이며, 끝에 최대 2개의 = 패딩이 붙습니다. 다른 무엇보다 먼저 고정 +33.3%가 발생합니다: 30 KB PNG는 약 40 KB의 텍스트가 되고, 750 KB 사진은 꽉 찬 1메가바이트가 됩니다. 알파벳은 A–Z, a–z, 0–9, +, /입니다(RFC 4648). +와 /는 URL과 쿼리 스트링에서 안전하지 않다는 점에 유의하세요 — base64url 변형이 이를 -와 _로 바꾸는 이유이며, 표준 알파벳 문자열을 URL 파라미터에 그대로 붙여넣는 것은 고전적인 데이터 손상 원인입니다.
이 세금은 보기보다 더 나쁩니다. 전송 압축이 거의 도움이 안 되기 때문입니다. PNG, JPEG, WebP 페이로드는 이미 압축되어 있어 바이트가 통계적으로 랜덤에 가깝습니다. Base64를 거쳐도 여전히 랜덤이고, 단지 64문자 알파벳에 펼쳐졌을 뿐입니다. 주변 HTML에 적용되는 gzip이나 brotli는 인코딩된 이미지에서 보통 2~5%만 회수합니다 — 일반 HTML/CSS/JavaScript 텍스트에서 흔한 70~80% 압축률과 비교해 보세요. 즉, 압축된 페이지에 인라인된 100 KB 이미지는 여전히 와이어에서 대략 128 KB를 차지합니다.
무시해도 되는 유일한 오버헤드는 접두사입니다: data:image/png;base64,는 약 22문자를 더할 뿐입니다. 중요한 건 33%이며, 이 비용은 해당 마크업을 포함하는 모든 페이지 뷰마다 다시 지불됩니다.
const encodedSize = (bytes) => 4 * Math.ceil(bytes / 3);
encodedSize(30 * 1024); // 30 KB PNG -> 40 960 chars (+33.3%)
encodedSize(750 * 1024); // 750 KB JPEG -> ~1 MB of text
// gzip/brotli barely help: compressed image bytes remain
// statistically random after Base64 — only ~2–5% comes back인라인의 대가: 캐싱, 지연 로딩, Preload Scanner
호스팅된 이미지 파일은 HTML과 독립적으로 캐싱됩니다: 카피 문장 하나를 바꾸면 재방문자는 그 문장만 다시 받지, 로고를 다시 받지 않습니다. 인라인된 이미지는 문서에 융합되어 있어서 모든 HTML 응답에 함께 실려 오고, 배포할 때마다 무효화됩니다. 페이지가 여러 개면 손실은 배가됩니다 — 열 개 템플릿에 반복된 data URL은 열 번 다운로드되지만, 긴 Cache-Control max-age가 걸린 호스팅 파일은 한 번만 받습니다.
지연 로딩도 조용히 작동을 멈춥니다. loading="lazy"는 이미지의 네트워크 페치를 미루는 기능인데, 인라인된 이미지에는 미룰 페치 자체가 없습니다 — 바이트가 문서 안에 박혀 있어 그 아래의 모든 내용보다 먼저 전송되며, 사실상 스크롤 아래(below-the-fold) 이미지가 렌더링을 막는 페이로드로 변합니다. HTML 파싱 중에 <img> URL을 발견해 병렬로 가져오는 브라우저의 preload scanner도 잃고, CDN 측 이미지 최적화도 잃습니다: 포맷 협상(Accept 헤더에 따른 AVIF/WebP), 리사이징, 품질 조정 모두 주소를 가진 URL이 필요합니다.
인라인의 역사적 명분 — HTTP 요청 하나 절약 — 은 HTTP/1.1과 함께 죽었습니다. HTTP/2와 HTTP/3에서는 워밍업된 연결 위의 요청이 멀티플렉싱되어 왕복(round trip)이 아니라 프레임 오버헤드 몇십 바이트의 비용만 듭니다. 남는 것은 좁은 스위트 스폿뿐입니다: 첫 페인트에 렌더링되는 대략 2~5 KB 이하의 자산 — UI 아이콘 한두 개, 혹은 실제 사진이 로드되는 동안 자리를 잡아주는 블러 처리된 LQIP 플레이스홀더 같은 것들.
data: URL 해부(RFC 2397) — 그리고 SVG가 Base64를 건너뛰어야 하는 이유
RFC 2397의 문법은 data:[mediatype][;base64],<data>입니다. 콤마와 페이로드를 제외한 모든 부분이 생략 가능한데, 기본값이 함정입니다: media type을 생략하면 브라우저는 text/plain;charset=US-ASCII로 간주하므로, 완벽히 유효한 Base64 PNG가 일반 텍스트로 해석되어 깨진 이미지 아이콘으로 렌더링됩니다. 항상 data:image/png;base64,를 전부 명시하세요 — 이 도구는 자동으로 붙여줍니다.
SVG는 특별 취급이 필요합니다. SVG는 텍스트이기 때문에 Base64는 잘못된 컨테이너입니다: 33% 팽창 비용을 내는 동시에, gzip이 페이지 안에서 반복되는 XML 패턴을 찾아내는 능력을 파괴합니다. 더 나은 인코딩은 순수 URL 인코딩 data URL입니다: data:image/svg+xml,%3Csvg ... URL이나 CSS 파싱을 깨뜨리는 문자만 퍼센트 인코딩하면 됩니다 — <, >, #, 그리고 따옴표. 글자와 대부분의 문장부호는 그대로 둘 수 있습니다. 결과물은 보통 압축 전 기준으로 Base64 버전보다 15~30% 작고, SVG 소스가 압축 가능한 텍스트로 남기 때문에 압축 후에는 극적으로 작아집니다. # 규칙은 이중으로 중요합니다: fill="#f60"에는 날것의 #가 들어 있는데, 인코딩하지 않으면 URL이 fragment로 끊깁니다 — %23으로 인코딩하세요.
문자셋 함정이 하나 더 있습니다: SVG에 비ASCII 텍스트(한국어 라벨, 화살표, 이모지)가 들어 있으면 단순 URL 인코딩 data URL에서 깨질 수 있습니다. 해당 바이트를 UTF-8로 퍼센트 인코딩하거나, 그 파일만 Base64로 폴백하세요.
/* Raster: Base64 is the only option */
.logo {
background-image: url("data:image/png;base64,iVBORw0KGgoAAAANS...");
}
/* SVG: URL-encode instead — smaller and gzip-friendly */
.icon {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M12 2 2 22h20z' fill='%23f60'/%3E%3C/svg%3E");
}
/* Missing media type = text/plain assumed = broken image */
/* BAD: data:;base64,iVBORw0KGgo... */data: URL이 조용히 실패하는 곳: CSP, Gmail, 네비게이션 차단
Content Security Policy가 첫 번째 매복입니다. data: 스킴은 'self'에 포함되지 않으므로 img-src 'self' 같은 정책은 페이지의 모든 인라인 이미지를 차단합니다 — 깨진 이미지 아이콘조차 없이 빈 공간만 남고, 콘솔 깊숙이 위반 기록만 묻히는 경우가 많습니다. 이미지를 인라인한다면 정책에 img-src 'self' data:를 명시적으로 넣어야 하며, 보안 리뷰어는 당연히 이유를 물을 것입니다 — script-src나 object-src의 data:는 진짜 XSS 벡터이기 때문입니다. 이미지에만 허용하세요.
이메일이 두 번째입니다. Gmail은 <img src>의 data: URI를 통째로 제거하고, 대부분의 다른 클라이언트(데스크톱 Outlook, Yahoo)도 마찬가지입니다 — "외부 요청이 없다"는 점이 이메일 친화적으로 들리기 때문에 사람들의 기대와 정확히 반대입니다. HTML 이메일의 아이콘은 HTTPS URL로 호스팅하거나 CID 참조 인라인 첨부로 넣어야 합니다. 실제 클라이언트에서 테스트하세요. ESP의 미리보기는 진실이 아닙니다.
브라우저는 네비게이션도 강화했습니다: 2017년 이후(Chrome 60, Firefox 59) data: URL로의 최상위(top-level) 네비게이션은 차단됩니다. 전체 페이지 data: 문서가 피싱 래퍼로 인기 있었기 때문입니다. <img>, CSS, fetch() 안에서의 렌더링은 여전히 잘 작동합니다. 길이에 관해서는: 최신 Chrome과 Firefox는 src 속성과 스타일시트에서 수 메가바이트짜리 data URL도 불평 없이 처리하지만, 2MB를 넘는 URL은 devtools에서 복사·diff·검사하기가 괴로워집니다 — 그리고 레거시 IE8은 URL 전체를 32 KB로 제한했는데, 오래된 조언들이 아직도 그 숫자를 인용하는 이유가 이것입니다.
메모리 비용, atob() 유니코드 함정, 그리고 번들러에게 결정 맡기기
인라인된 이미지는 RAM에서 세 번 값을 치릅니다. 브라우저는 문서 안의 Base64 문자열, 디코딩된 파일 바이트, 그리고 — 이미지가 표시되는 순간 — 가로 × 세로 × 4바이트 크기의 디코딩된 비트맵을 모두 들고 있습니다. 마지막 항이 나머지를 압도하며 파일 크기와 완전히 무관합니다: 1000 × 1000 PNG는 파일이 20 KB든 2 MB든 비트맵으로는 약 4 MB를 차지합니다. JavaScript 번들에 구워 넣은 Base64가 최악의 변형인데, 문자열이 JS 파싱까지 거치고 힙에 상주하기 때문입니다.
이 도구 대신 코드로 인코딩한다면 고전적인 함정에 주의하세요: atob()과 btoa()는 Latin-1 문자열에서만 동작합니다. btoa('한글')은 InvalidCharacterError를 던지고, unescape(encodeURIComponent(...)) 기반의 Stack Overflow 우회책들은 전부 deprecated입니다. 파일에는 FileReader.readAsDataURL(이 페이지가 쓰는 방식)이 정답이며 이 문제를 아예 건드리지 않습니다. 바이트 배열에는 최근 표준화된 Uint8Array.prototype.toBase64()와 Uint8Array.fromBase64()가 드디어 바이너리를 제대로 처리합니다.
판단 규칙은 세 가지 조건으로 압축됩니다: 파일이 약 5 KB 미만이고, 첫 페인트에 렌더링되며, 여러 페이지에서 재사용되지 않을 때만 인라인하세요. 그 외 전부는 호스팅 파일로 남겨야 합니다. 그리고 번들러가 이미 이 규칙을 구현하고 있다는 점에 주목하세요: Vite는 기본적으로 4 KB 미만의 import된 자산을 인라인하고(build.assetsInlineLimit: 4096), webpack 5의 asset modules는 parser.dataUrlCondition.maxSize를 통해 기본 8 KB입니다. 빌드 도구가 파일로 내보내기로 결정한 이미지를 손으로 인라인하고 있다면, 대개 도구가 옳습니다.
// vite.config.js — Vite already makes the inline/file call
export default {
build: {
assetsInlineLimit: 4096 // bytes; imports under 4 KB become data: URLs
}
};
// webpack 5 equivalent (asset modules)
module.exports = {
module: {
rules: [{
test: /\.png$/i,
type: 'asset', // data URL under maxSize, separate file above
parser: { dataUrlCondition: { maxSize: 8 * 1024 } }
}]
}
};