HTML img 태그 제대로 쓰기: srcset, sizes, loading까지

HTML img 태그 제대로 쓰기: srcset, sizes, loading까지

이미지 한 장 넣는 데 왜 이렇게 속성이 많을까요? <img src="..."> 한 줄이면 화면에 그림은 뜨지만, 실제 운영 중인 사이트의 <img> 태그를 열어보면 srcset, sizes, loading, width, height, fetchpriority까지 줄줄이 따라붙어 있는 모습을 자주 보게 됩니다.

이 속성들이 거추장스러워 보일 수 있지만 각각 다른 문제를 풀고 있는데요. 폴백 이미지를 정하고, 접근성을 챙기고, 레이아웃 시프트를 막고, 화면에 맞는 해상도를 고르고, 화면 밖 이미지는 늦게 받아오게 미루는 일을 한 태그 안에서 해냅니다. 이 글에서는 평범해 보이는 <img> 태그의 속성을 하나씩 풀어보면서, 언제 어떤 속성을 챙겨야 하는지 정리해 보겠습니다.

src와 alt부터

가장 기본이 되는 두 속성입니다. src는 이미지의 URL이고 alt는 이미지를 설명하는 텍스트인데요. 너무 당연해서 그냥 지나치기 쉽지만 한 가지 짚어둘 게 있습니다.

alt는 이미지가 안 보일 때 보여주는 “대체 텍스트”라기보다 이미지의 역할을 글로 옮긴 것에 가깝습니다. 스크린 리더 사용자에게는 이 텍스트가 곧 이미지 자체고, 검색 엔진도 이 값을 이미지의 의미로 받아들이거든요. 그래서 alt="이미지", alt="사진" 같이 형식적으로 채우는 건 거의 도움이 안 됩니다.

<!-- ❌ 의미 없는 alt -->
<img src="chart.png" alt="이미지" />

<!-- ✅ 이미지가 전달하는 정보를 글로 -->
<img
  src="chart.png"
  alt="2024년 분기별 매출 추이, 4분기에 전년 대비 35% 증가"
/>

반대로 순수하게 장식용인 이미지라면 alt=""로 비워두는 게 맞습니다. 스크린 리더가 의미 없는 정보를 읽고 지나가지 않도록 명시적으로 “읽을 게 없다”고 알려주는 방식이죠. alt 속성을 아예 빼버리면 스크린 리더가 파일명을 읽어버리는 경우가 있어서 빈 값이라도 적어두는 편이 안전합니다.

width와 height — 레이아웃 시프트 막기

widthheight 속성은 단순히 이미지 크기를 지정하는 게 아닙니다. 사실 요즘은 CSS로 너비를 조절하는 게 일반적이라 “어차피 CSS가 덮어쓸 텐데 왜 적지?”라는 생각이 들 수 있는데요. 이 두 속성의 진짜 역할은 이미지의 종횡비를 미리 알려주는 것입니다.

<img src="hero.jpg" alt="히어로 배너" width="800" height="450" />

브라우저가 이미지를 다 받기 전에 이 속성을 보면 “아, 800:450 비율이구나”를 알 수 있습니다. 그러면 이미지가 들어갈 자리를 미리 비워둘 수 있고, 이미지가 늦게 도착해도 페이지가 출렁이지 않습니다. 이걸 막지 못하면 Core Web Vitals의 CLS(Cumulative Layout Shift) 점수가 깎이는 건 물론이고, 사용자가 읽던 텍스트가 갑자기 아래로 밀려나는 짜증나는 경험이 됩니다.

CSS로 width: 100%를 주더라도 브라우저는 aspect-ratio: 800 / 450을 자동으로 계산해서 적용합니다. 그러니까 실제 표시 크기와 다르더라도 원본의 가로/세로 픽셀 값을 그대로 적어주면 됩니다.

img {
  width: 100%;
  height: auto;
}

height: auto를 함께 쓰면 종횡비를 유지하면서 너비에 맞춰 늘어납니다. 이 두 줄을 CSS에 한 번만 정의해 두고, HTML의 width/height에는 원본 크기를 적는 게 표준적인 패턴입니다.

srcset — 해상도에 맞는 이미지 고르기

같은 이미지를 4K 모니터와 모바일 화면에서 똑같은 크기로 내려보낼 이유는 없습니다. 모바일에는 작은 이미지를, 고해상도 화면에는 큰 이미지를 보내고 싶은데, 이걸 자바스크립트 없이 HTML 차원에서 해결하는 게 srcset 속성입니다.

srcset에는 두 가지 문법이 있는데요. 픽셀 밀도 서술자(1x, 2x)와 너비 서술자(400w, 800w)입니다. 둘은 비슷해 보여도 동작 방식이 꽤 다릅니다.

<!-- 픽셀 밀도 서술자 -->
<img src="logo.png" srcset="logo.png 1x, logo@2x.png 2x" alt="회사 로고" />

픽셀 밀도 방식은 “이건 일반 디스플레이용, 저건 고밀도 디스플레이용”을 직접 지정합니다. 로고처럼 표시 크기가 고정된 이미지에 잘 어울리는데요. Retina 디스플레이에서는 자동으로 2x 이미지를 받습니다.

<!-- 너비 서술자 -->
<img
  src="hero-800.jpg"
  srcset="hero-400.jpg 400w, hero-800.jpg 800w, hero-1200.jpg 1200w"
  sizes="(max-width: 640px) 100vw, 400px"
  alt="히어로 이미지"
/>

너비 서술자 방식은 각 파일의 실제 픽셀 너비를 알려주는 식입니다. 400w는 “이 파일이 400픽셀 너비”라는 뜻이고요. 브라우저는 이 정보와 sizes 속성을 조합해서 화면 크기, 픽셀 밀도, 네트워크 상태까지 종합해 최적의 후보를 골라냅니다.

여기서 헷갈리기 쉬운 점이 하나 있는데요. 너비 서술자를 쓸 때는 sizes 속성이 거의 필수입니다. sizes 없이 400w, 800w만 적어두면 브라우저가 이미지의 표시 너비를 추측할 수 없어서 가장 큰 이미지를 선택해버리는 경우가 많습니다. 두 문법은 섞어 쓰지 말고 상황에 따라 하나만 고르면 됩니다.

sizes — 브라우저에 레이아웃 힌트 주기

sizes 속성이야말로 처음 보면 가장 헷갈리는 속성입니다. CSS 셀렉터처럼 생겼지만 셀렉터가 아니고, 길이 단위가 들어가 있지만 CSS도 아닙니다. 정체는 미디어 조건 + 이미지가 차지할 너비의 쌍을 나열한 문법인데요.

sizes="(max-width: 640px) 100vw, 400px"

이건 “뷰포트 너비가 640px 이하면 이미지가 화면 전체 너비(100vw)를 차지하고, 그 외에는 400px를 차지한다”는 뜻입니다. 브라우저는 이 정보를 받아서 “그렇다면 이 화면에서는 600px짜리 이미지가 필요하겠군” 같은 계산을 하고, srcset 후보 중에서 가장 가까운 걸 고릅니다.

왜 이런 정보를 굳이 개발자가 알려줘야 할까요? 브라우저가 이미지를 결정해야 하는 시점은 HTML을 파싱하는 직후입니다. 그때는 아직 CSS가 적용되지 않았거나 레이아웃 계산이 끝나지 않은 상태라서, 이미지가 실제로 화면에서 얼마나 큰 영역을 차지할지 모릅니다. CSS로 width: 50%를 줬더라도 브라우저는 그 정보를 늦게 받죠. 그래서 미리 힌트를 받아두는 겁니다.

여러 조건을 나열할 때는 <picture><source>처럼 위에서부터 순서대로 평가되니까 좁은 화면 조건을 먼저 두는 게 일반적입니다.

sizes=" (max-width: 480px) 100vw, (max-width: 1024px) 50vw, 400px "

모바일에서는 전체 너비, 태블릿에서는 절반 너비, 데스크톱에서는 400px 고정 — 이런 식으로 화면별 레이아웃을 그대로 옮겨 적으면 됩니다. 마지막 값은 조건 없이 적어서 폴백으로 둡니다.

loading과 decoding으로 미루기

화면 밖에 있는 이미지를 페이지 로드 시점에 한꺼번에 받아오는 건 낭비입니다. loading="lazy"를 붙이면 브라우저가 알아서 뷰포트에 가까워졌을 때 이미지를 받아오는데요.

<img src="far-below.jpg" alt="스크롤해야 보이는 이미지" loading="lazy" />

이걸 모든 이미지에 일괄 적용하면 안 됩니다. 페이지 첫 화면에 보이는 이미지(특히 LCP(Largest Contentful Paint) 후보)에 loading="lazy"를 붙이면 오히려 로딩이 늦어집니다. 화면 위쪽 이미지는 기본값(eager)으로 두고, 스크롤해야 보이는 이미지에만 lazy를 적용하는 게 맞습니다.

비슷한 맥락으로 decoding 속성도 있는데요. 이건 이미지를 디코딩하는 방식을 알려주는 힌트입니다.

<img src="hero.jpg" alt="히어로" decoding="sync" />
<img src="below-fold.jpg" alt="아래쪽" decoding="async" />

sync는 이미지 디코딩이 끝날 때까지 페이지 표시를 기다리고, async는 디코딩이 끝나는 대로 표시합니다. 대부분의 경우 브라우저 기본값(auto)에 맡겨도 충분하고, 명시적으로 지정해야 한다면 첫 화면 이미지는 sync, 나머지는 async를 쓰는 식으로 나눕니다.

fetchpriority로 우선순위 끌어올리기

loading="lazy"가 우선순위를 낮추는 거라면 fetchpriority="high"는 반대로 끌어올리는 속성입니다. 브라우저는 이미지의 중요도를 추측해서 다운로드 순서를 정하는데요. 히어로 이미지나 LCP 후보처럼 사용자에게 중요한 이미지는 명시적으로 우선순위를 알려줄 수 있습니다.

<img
  src="hero.jpg"
  alt="메인 배너"
  width="1200"
  height="600"
  fetchpriority="high"
/>

반대로 별로 중요하지 않은 이미지(광고 배너, 사이드바 썸네일 등)에는 fetchpriority="low"를 줘서 다른 리소스에 양보하게 만들 수도 있습니다. 다만 모든 이미지에 high를 붙이면 의미가 없어지니까, 페이지당 한두 개의 핵심 이미지에만 신중하게 적용해야 합니다.

통합 예제

지금까지 살펴본 속성을 모두 합치면 이런 모양이 됩니다.

<img
  src="https://img.example.com/hero-800.jpg"
  srcset="
    https://img.example.com/hero-400.jpg   400w,
    https://img.example.com/hero-800.jpg   800w,
    https://img.example.com/hero-1200.jpg 1200w
  "
  sizes="(max-width: 640px) 100vw, 400px"
  alt="플랫폼 메인 화면 스크린샷"
  width="800"
  height="450"
  loading="lazy"
/>

이 한 줄짜리(처럼 보이는) 태그가 하는 일을 풀어보면 이렇습니다. src에 적은 800px 이미지를 폴백으로 두고, srcset으로 400/800/1200 세 가지 크기를 후보로 제시합니다. sizes로 “모바일에서는 화면 전체, 그 외에는 400px 차지”라는 레이아웃 힌트를 주고, 브라우저는 이걸 종합해서 화면 크기와 픽셀 밀도에 맞는 이미지를 고릅니다. alt로 접근성을 챙기고, width/height로 종횡비를 미리 알려 CLS를 막고, loading="lazy"로 뷰포트에서 멀면 받아오지 않게 미룹니다.

CDN을 사용한다면 srcset의 후보 URL을 동일한 원본에서 너비만 다르게 생성하는 패턴이 흔한데요. Cloudflare Images TransformationsCloudinary처럼 URL 파라미터로 크기를 지정할 수 있는 서비스를 쓰면 원본 한 장으로 모든 후보를 만들 수 있습니다. Astro 같은 프레임워크를 쓰고 있다면 Astro의 이미지 최적화에서 <Image> 컴포넌트가 이런 속성들을 자동으로 채워주기도 합니다.

흔히 저지르는 실수

마지막으로 실무에서 자주 보는 실수 몇 가지를 짚고 가겠습니다.

우선 widthheight를 빼먹는 경우가 가장 흔한데요. CSS로 크기를 조절한다는 이유로 HTML에서 생략하면 레이아웃 시프트의 주범이 됩니다. CSS로 어떻게 크기를 바꾸든 HTML에는 원본 픽셀 값을 적어두세요.

또한 loading="lazy"를 첫 화면 이미지에 붙이는 경우도 자주 보입니다. 모든 이미지에 일괄 적용하기 쉬운 속성이라 그런데, 첫 화면의 LCP 이미지에 lazy를 붙이면 LCP가 눈에 띄게 느려집니다. 첫 화면 이미지는 명시적으로 빼두거나 빌드 도구에서 자동으로 처리하게 만드는 게 좋습니다.

마지막으로 srcset을 픽셀 밀도와 너비 서술자로 섞어 쓰는 경우인데요. srcset="hero.jpg 1x, hero@2x.jpg 800w" 같은 식의 혼용은 유효하지 않은 마크업입니다. 하나의 srcset 안에서는 한 가지 방식만 써야 합니다.

마치며

<img> 태그는 단순해 보이지만 속성 하나하나에 명확한 역할이 있습니다. srcalt로 의미를 담고, width/height로 레이아웃을 안정시키고, srcset/sizes로 화면에 맞는 이미지를 고르고, loading/fetchpriority로 우선순위를 조절합니다. 각 속성이 서로 다른 자리를 맡고 있어서, 빼먹으면 그 자리만큼의 문제가 그대로 드러나는 셈이죠.

화면 크기에 따라 같은 이미지의 해상도만 바꾸는 거라면 <img> 태그의 srcsetsizes만으로 충분하지만, 아예 다른 구도의 이미지를 보여주거나 AVIF/WebP 같은 포맷에 따라 분기해야 한다면 HTML picture 요소로 넘어가야 합니다. 두 도구를 상황에 맞게 골라 쓰면 됩니다.

각 속성의 정확한 스펙이 궁금하다면 MDN의 img 요소 문서에서 확인하실 수 있습니다.

This work is licensed under CC BY 4.0 CC BY

개발자를 위한 뉴스레터

달레가 정리한 AI 개발 트렌드와 직접 만든 콘텐츠를 전해드립니다.

Discord