Optimal Images in HTML를 번역한 글입니다. 피드백은 댓글로 부탁드립니다.
자 여러분은 멋진 페이지를 만들었고, 이제 배경 이미지를 넣으려 하는데...
.hero {
/* 🚩 */
background-image: url('/image.png');
}
잠깐!
이러면 여러가지 이유로 성능 최적화가 안된다는 걸 아셨나요?
SVG를 사용하는 경우를 제외하고, 요즘 기기들의 다양한 화면 크기와 해상도를 생각해 보면, 사이트의 방문자 모두가 동일한 이미지 파일을 받아야 하는 경우는 사실상 없습니다.
여러분 사이트는 스마트 워치에서도 작동하죠? (농담입니다… 제 생각에는요)
여러분은 "아, 미디어 쿼리! 화면 크기랑 이미지 크기의 범위를 직접 지정할게요."라고 할 수 있을 겁니다.
/* 🚩 */
.hero { background-image: url('/image.png'); }
@media only screen and (min-width: 768px) {
.hero { background-image: url('/image-768.png'); }
}
@media only screen and (min-width: 1268px) {
.hero { background-image: url('/image-1268.png'); }
}
음, 여기엔 문제가 있습니다. 꽤나 지겹고 장황하다는 것 외에도, 화면 크기만 고려할 뿐 해상도는 고려하지 않고 있어요.
그럼 여러분은 "아하! 저 여기에 맞는 멋진 트릭 알아요. 다양한 해상도에 맞는 이미지 크기를 지정하는 image-set
을 사용하면 돼요."라고 할 수도 있겠죠.
/* 🚩 */
.hero {
background-image: image-set(url("/image-1x.png") 1x, url("/image-2x.png") 2x);
}
여러분이 맞을 수도 있어요. 약간의 이점이 있죠. 하지만 일반적으로 화면 크기와 해상도 모두 고려해야 합니다.
그렇다면 미디어 쿼리와 image-set
가 합쳐져 잔뜩 커진 CSS를 작성할 수도 있겠지만, 이건 그냥 복잡해질 뿐이고, 각각의 화면에 대응하는 정확한 이미지 사이즈를 알아야 한다는 걸 의미합니다. 심지어 시간이 지나 사이트의 레이아웃이 변하더라도 말이죠.
그리고 이런 방식은 레이지 로딩, 차세대 포맷, 우선순위 힌트, 비동기 디코딩 등 중요한 것들을 지원하지 않습니다.
이외에도 여기엔 연쇄적 요청이라는 문제가 있습니다.
이미지 태그를 보면 src
로 향하는 링크가 HTML에 바로 있습니다. 그러니 브라우저는 초기 HTML을 fetch 하고, 이미지가 있나 스캔하고, 우선순위가 높은 이미지의 fetch를 즉시 시작할 수 있습니다.
모든 곳에서 인라인 style
을 사용하는 게 아니라 다들 하는 방식으로 link rel="styleshset"
로 외부 스타일 시트를 사용한다고 가정했을 때, CSS에서 이미지를 로드하는 경우, 브라우저는 HTML을 스캔하고, CSS를 fetch 한 다음, 요소에 background-image
가 적용됐다는 걸 발견하고, 이 모든 것이 끝난 후에야 해당 이미지를 fetch 할 수 있습니다. 이게 더 오래 걸리겠죠.
예, 맞아요. 인라인 CSS, 이미지 preload, preconnect 오리진 같은 대책이 있죠. 하지만 이 글을 계속 읽다 보면, CSS의 background-image
에서는 슬프게도 얻을 수 없지만, HTML img
태그로는 얻을 수 있는 추가적인 이점을 알게 될 겁니다.
이미지를 로드하는 가장 최적의 방법에 대해 논하기 전에, 모든 규칙과 마찬가지로 예외가 있다는 걸 기억해야 합니다. 예를 들어, 아주 작은 이미지를 background-repeat
으로 타일링하려는 경우, img
태그로 할 수 있는 쉬운 방법은 (제가 알기로) 없습니다.
하지만 한 50px보다 큰 이미지의 경우 CSS에서 설정하기보단, 전부 img
태그를 사용할 것을 강력히 제안합니다.
지금까지 CSS에서 background-image
를 사용하는 게 얼마나 문제인지 주장했으니, 이제 실제 해결책에 대해 이야기해 봅시다.
모던 HTML에서, img
태그는 이미지를 최적으로 로드할 수 있는 유용한 속성을 많이 제공합니다. 그것들을 살펴보도록 하죠.
이미지 성능 향상을 위해 img
태그에서 사용할 수 있는 첫번째 놀라운 속성은 loading=lazy
입니다.
<!-- 😍 -->
<img
loading="lazy"
...
>
이제 방문자들은 뷰포트에 보이지도 않는 이미지를 자동으로 로드하지 않을 테니, 이것만으로 이미 엄청난 개선입니다. 더 좋은 점은 성능이 아주 좋고, 브라우저 내부적으로 완전히 구현되어 있으며, 자바스크립트가 필요하지도 않고, 모든 모던 브라우저에서 지원된다는 겁니다.
주의 중요한 디테일이 하나 있습니다. "접힘 선 위의" 이미지(즉, 페이지를 처음 로드할 때 브라우저의 뷰포트에 있는 이미지)를 레이지 로드하지 마세요. 그러면 가장 중요한 이미지가 가능한 빨리 로드되고 다른 모든 이미지는 필요한 경우에만 로드되는 걸 보장해줍니다.
P.S.: loading=lazy
는 iframes
에서도 동작합니다.😍
이미지에 srcset
을 사용하는 건 중요합니다. SVG를 로드하는 게 아니라면, 다양한 스크린 사이즈와 해상도가 최적인 크기의 이미지를 받을 수 있도록 보장할 필요가 있습니다.
<img
srcset="
/image.png?width=100 100w,
/image.png?width=200 200w,
/image.png?width=400 400w,
/image.png?width=800 800w
"
...
>
주목해야 할 중요한 점은 img
의 srcset
에 w
단위를 사용할 수 있기 때문에, CSS의 image-set
보다 더 강력하다는 겁니다.
srcset
은 크기와 해상도를 모두 고려한다는 점에서 유용합니다. 따라서 현재 이미지가 픽셀 밀도 2배인 장치에서 너비 200px로 표시되어 있다면, 브라우저는 위의 srcset
를 통해 400w
의 이미지(즉, 너비가 400px이므로 픽셀 밀도 2배인 기기에서 완벽하게 표시되는 이미지)를 선택해야 함을 알게 될 겁니다. 마찬가지로, 동일한 이미지에 대해 픽셀 밀도 1배인 기기에선 200w
의 이미지를 선택할 겁니다.
예제에서 .png
를 사용하고 있단 걸 눈치챘을 겁니다. .png
는 모든 브라우저에서 지원되지만 가장 최적의 이미지 포맷은 절대 아닙니다.
img
를 picture
로 감싸면 source
태그를 사용해서 webp처럼 더 현대적인 최적의 포맷을 지정할 수 있고, 해당 포맷을 지원하는 브라우저는 이런 포맷들을 선호합니다.
<picture>
<source
type="image/webp"
srcset="
/image.webp?width=100 100w,
/image.webp?width=200 200w,
/image.webp?width=400 400w,
/image.webp?width=800 800w
" />
<img ... />
</picture>
또 다른 옵션으로, AVIF 같은 다른 포맷을 지원할 수도 있습니다.
<picture>
<source
type="image/avif"
srcset="/image.avif?width=100 100w, /image.avif?width=200 200w, /image.avif?width=400 400w, /image.avif?width=800 800w, ...">
<source
type="image/webp"
srcset="/image.webp?width=100 100w, /image.webp?width=200 200w, /image.webp?width=400 400w, /image.webp?width=800 800w, ...">
<img ...>
</picture>
레이아웃 이동 방지를 항상 염두에 두는 것도 중요합니다. 이미지 다운로드 전에 이미지의 정확한 크기를 지정해놓지 않으면, 이미지가 로드될 때 레이아웃 이동이 발생합니다. 이 문제를 해결하려면 두 가지 방법이 있습니다.
첫 번째는 이미지의 width
와 height
속성을 지정하는 겁니다. 그리고 이건 옵션이지만 자주 유용한데, 이미지의 height
를 auto
로 설정해서, 화면 크기가 변경될 때 이미지가 적절히 반응하도록 하는 겁니다.
<img
width="500"
height="300"
style="height: auto"
...
>
두 번째로, 항상 올바른 종횡비를 자동으로 유지하고 싶다면 CSS의 새로운 속성인 aspect-ratio
를 사용하면 됩니다. 이 속성을 사용하면 이미지의 정확한 너비와 높이는 알 필요가 없고 종횡비만 알면 됩니다.
<img style="aspect-ratio: 5 / 3; width: 100%" ...>
aspect-ratio
는 백그라운드 이미지의 background-size
와 background-position
과 비슷한 object-fit
, object-position
과의 궁합도 좋습니다.
.my-image {
aspect-ratio: 5 / 3;
width: 100%;
/* 이미지의 원래 종횡비가 달라도, 가능한 공간을 채운다. */
object-fit: cover;
}
추가로 이미지에 decoding="async"
를 지정해서 브라우저가 이미지 디코딩을 메인 스레드에서 처리하지 않게 할 수도 있습니다. MDN은 이걸 화면 밖으로 벗어난 이미지에 사용할 것을 추천합니다.
<img decoding="async" ... >
마지막으로 좀 더 고급 옵션은 fetchpriority
입니다. 이 속성은 이미지가 LCP 이미지처럼 우선순위가 아주 높은 이미지인지 브라우저에게 힌트를 줄 수 있습니다.
<img fetchpriority="high" ...>
아니면 캐러셀의 다른 페이지에 있는 이미지처럼, 접힘 선 위의 이미지지만 크게 중요하지 않은 이미지의 우선순위를 낮출 수 있습니다.
<div class="carousel">
<img class="slide-1" fetchpriority="high">
<img class="slide-2" fetchpriority="low">
<img class="slide-3" fetchpriority="low">
</div>
그래요, alt
텍스트는 접근성과 SEO에 중요하며, 간과할 게 아닙니다.
<img
alt="Builder.io drag and drop interface"
...
>
아니면 순전히 보여주기용 이미지(추상적인 형태나 색이나 그래디언트)는, role
속성으로 보여주기 용이라고 명시할 수 있습니다.
<img role="presentation" ... >
위에서 언급한 srcset
속성에 대해 한 가지 중요한 주의 사항이 있는데, 브라우저가 fetch 하기 가장 적합한 크기의 이미지를 고르려면 이미지가 렌더링 될 크기를 알아야 한다는 겁니다.
즉, 이미지가 렌더링 되면, 브라우저는 표시되는 실제 크기를 알게 되고, 거기에 픽셀 밀도를 곱해서 srcset
에서 가능한 가장 가까운 크기의 이미지를 fetch 한다는 뜻입니다.
하지만 초기 페이지 로드 단계에서, 크롬 같은 브라우저의 preload 스캐너는 HTML에서 img
태그를 찾아 즉시 prefetch를 시작합니다.
문제는 prefetch가 페이지의 렌더링 전에 일어난다는 겁니다. 말하자면 CSS가 아직 fetch 되지도 않아서 이미지가 어떻게, 어떤 크기로 표시될지에 대한 정보가 없는 거죠. 따라서 브라우저는 몇 가지 가정을 해야만 합니다.
기본적으로 브라우저는 모든 이미지가 100vw
(전체 페이지 너비)라고 가정합니다. 이미지의 실제 크기보다 조금 작거나 엄청 클 수 있으니 범위가 너무 넓죠. 그러니 최적의 크기랑은 거리가 멉니다.
이때 유용한 게 sizes
속성입니다.
<img
srcset="..."
sizes="(max-width: 400px) 200px, (max-width: 800px) 100vw, 50vw"
...
>
이 속성을 사용하면 다양한 창 크기의 브라우저에게 원하는 이미지의 크기를 알려줄 수 있습니다 (500px
처럼 정확한 픽셀 값으로 알려주는 방식이나, 창 너비의 약 50%로 표시되어야 하는 50vw
처럼 창에 상대적인 값으로 알려주는 방식 둘 다 가능하죠).
위의 예제의 경우, 너비가 900px
인 화면은 처음 두 개의 조건절(400px
, 800px
) 중 어느 것과도 일치하지 않지만, 그보다 더 큰 화면에선 이미지가 50vw
로 표시될 것으로 상정하는 대체 조건절과 일치합니다.
따라서 50vw * 900px = 450px
이므로, 브라우저는 픽셀 밀도 1배
인 화면에는 너비가 450px
인 이미지, 픽셀 밀도 2배
인 디스플레이에는 너비가 900px
인 이미지를 목표로 합니다. 그런 다음 srcset
에서 가장 가까운 크기의 이미지를 prefetch 할 이미지로 삼을 겁니다.
와, 엄청 길었네요. 종합해 봅시다.
여기 로딩에 최적화된 이미지의 좋은 예가 있습니다.
<picture>
<source
type="image/avif"
srcset="/image.avif?width=100 100w, /image.avif?width=200 200w, /image.avif?width=400 400w, /image.avif?width=800 800w" />
<source
type="image/webp"
srcset="/image.webp?width=100 100w, /image.webp?width=200 200w, /image.webp?width=400 400w, /image.webp?width=800 800w" />
<img
src="/image.png"
srcset="/image.png?width=100 100w, /image.png?width=200 200w, /image.png?width=400 400w, /image.png?width=800 800w"
sizes="(max-width: 800px) 100vw, 50vw"
style="width: 100%; aspect-ratio: 16/9"
loading="lazy"
decoding="async"
alt="Builder.io drag and drop interface"
/>
</picture>
우선순위가 높은 이미지
위의 이미지는 좋은 디폴트이고, 접힘 선 아래의 이미지(페이지를 처음 로드할 때 브라우저의 뷰포트에 없는 이미지)에는 최고입니다.
하지만 우선순위가 가장 높은 이미지에는 loading="lazy"
와 decoding="async"
를 없애야 하고, LCP 이미지처럼 절대적으로 최고 우선순위의 이미지라면 fetchpriority="high"
추가를 고려해야 합니다.
style="width: 100%; aspect-ratio: 16/9"
- loading="lazy"
- decoding="async"
+ fetchpriority="high"
alt="Builder.io drag and drop interface"
SVG 등의 벡터
SVG 등의 벡터 포맷에는 여러 사이즈나 포맷을 넣어줄 필요 없이, 아래의 것들만 포함시켜주면 됩니다.
<!-- SVG 용 -->
<img
src="/image.svg"
style="width: 100%; aspect-ratio: 16/9"
loading="lazy"
decoding="async"
alt="Builder.io drag and drop interface"
/>
<picture>
와 <source>
태그, srcset
과 sizes
속성은 더 이상 필요 없어서 지웠다는 것에 주의하세요.
높은 우선순위의 SVG에는 위에서 말했던 규칙(loading
과 decoding
제거하고, LCP 이미지면 fetchpriority="high"
선택적으로 추가)을 똑같이 적용하세요.
아, 이 글을 원래의 사용 예시인 배경 이미지에 관한 얘기로 시작했었다는 걸 까먹을 뻔했네요.
이 글에서 논한 이미지 최적화는 사용하고자 하는 모든 유형의 이미지(배경, 전경 등)에 적용되지만, img
를 background-image
처럼 동작하도록 만드는 데는 약간의 CSS(말하자면, 몇 가지 absolute 포지셔닝과 object-fit 속성)만 있으면 됩니다.
다음은 직접 시도해 볼 수 있는 간단한 예입니다.
<div class="container">
<picture class="bg-image">
<source type="image/webp" ...>
<img ...>
</picture>
<h1>I am on top of the image</h1>
</div>
<style>
.container { position: relative; }
h1 { position: relative; }
.bg-image { position: absolute; inset: 0; }
.bg-image img { width: 100%; height: 100%; object-fit: cover; }
</style>
그렇기도 하고 아니기도 하지만, 주로 아닙니다.
이미지가 (바이트로 따졌을 때) 얼마나 큰지 쉽게 잊습니다. HTML에 고작 몇 바이트 추가하고 최적화된 이미지를 로딩함으로써 수천, 심지어 수백만 바이트를 아낄 수 있습니다.
두 번째로, gzip 압축의 대단함을 잊지 맙시다. 각 이미지에 넣은 추가적인 마크업은 금방 엄청나게 중복되어서, gzip이 deflate 해버리기 딱 좋아질 겁니다.
그러니까 DOM과 페이로드 크기에 항상 신경써야 하는 건 분명하지만, 이거에 관해선 트레이드오프가 여러분 편이라 말하고 싶습니다.
요즘에는 저 모든 것들을 손으로 직접 작성할 필요가 거의 없습니다. NextJS와 Qwik과 같은 프레임워크, 그리고 Cloudinary와 Builder.io과 같은 플랫폼은 이걸 단순화한 아래와 같은 이미지 컴포넌트를 제공합니다.
<!-- 😍 -->
<Image
src="/image.png"
alt="Builder.io drag and drop interface" />
이를 통해, 위의 최적화(다양한 이미지의 크기와 포맷을 생성하는 모든 것 포함)를 대부분 자동으로 할 수 있습니다.
하지만 이미지의 우선 순위가 높을 땐, 대부분의 경우 아직 아래처럼 지정해줘야 함에 주의하세요.
<!-- 높은 우선순위의 이미지 -->
<Image
priority
src="/image.png"
alt="Builder.io drag and drop interface" />
또, sizes
속성을 사용하고 싶은 경우에도 수동으로 지정해줘야 합니다. 이런 컴포넌트들 대부분은 옵션을 아래처럼 직접 넘겨줄 수 있게 해줍니다.
<!-- sizes 직접 지정 -->
<Image
sizes="(max-width: 500px) 200px, 50vw"
src="/image.png"
alt="Builder.io drag and drop interface" />
제가 알기로 sizes
속성 설정을 자동화할 수 있는 이미지 컴포넌트는 Builder.io 용으로 제가 만든 게 유일합니다. 백그라운드에서 puppetier 스크립트를 실행해서 다양한 화면 크기에서 실제로 표시되는 이미지의 레이아웃을 분석하고 그에 맞게 생성할 수 있도록 했습니다.
할 수 있다면 CSS의 background-image
보다 HTML의 img
를 사용하세요. 이미지를 가장 최적의 방식으로 전송하려면 레이지 로드, srcset
, picture
태그와 위에서 설명한 다른 최적화를 사용하세요. 우선 순위가 낮은 이미지와 높은 이미지를 인지하고 그에 맞게 속성을 변경하세요.
아니면 그냥 좋은 프레임워크(NextJS나 Qwik)나 좋은 플랫폼(Cloudinary나 Builder.io)을 사용하면 쉽게 할 수 있습니다.