기본 콘텐츠로 건너뛰기

큰 이미지가 느릴 때 렌더링 속도를 높이는 법: 포맷, 크기, 로딩 전략 한 번에 정리

큰 이미지가 느릴 때 렌더링 속도를 높이는 법: 포맷, 크기, 로딩 전략 한 번에 정리

빠른 답

  • 원본 이미지를 그대로 내려보내지 말고 실제 표시 크기와 DPR에 맞춘 파일을 서버나 CDN에서 변환해 제공합니다.
  • AVIF와 WebP를 우선하되 로 JPEG 또는 PNG fallback을 함께 두어 브라우저 호환성을 확보합니다.
  • 첫 화면 핵심 이미지는 무조건 lazy 처리하지 말고 fetchpriority="high" 또는 preload를 검토하고, 나머지 이미지만 지연 로딩합니다.
  • Network와 Performance 패널에서 전송 크기, 선택된 소스, 디코드 시간, LCP 후보 이미지를 함께 봐야 병목을 정확히 찾을 수 있습니다.

브라우저 렌더링 흐름을 중심으로 초안을 다시 정리하고, 현재 기준의 포맷 지원과 우선순위 관련 공식 문서 포인트를 짧게 확인하겠습니다. 오래된 설명과 지금 설명이 갈리는 부분도 함께 정리해서 발행용 본문으로 맞추겠습니다.# 큰 이미지가 느릴 때 렌더링 속도를 높이는 법: 포맷, 크기, 로딩 전략 한 번에 정리

시간 흐름으로 이해하기

HTML 파싱 시점
img 는 비교적 빨리 발견되지만 CSS 배경 이미지는 CSS를 읽은 뒤에야 요청 후보가 되는 경우가 많습니다.
전송 시점
파일 크기, 캐시 적중 여부, CDN 거리 차이가 첫 바이트와 다운로드 시간을 바꿉니다.
디코드 시점
압축된 파일을 화면용 비트맵으로 풀어내는 과정에서 CPU와 메모리를 사용합니다.
레이아웃 시점
이미지 비율이 늦게 확정되면 자리 계산이 다시 일어나고 CLS 나 추가 Layout 이 생길 수 있습니다.
페인트 시점
큰 이미지와 필터, 블러, 반투명 오버레이는 Paint 와 Composite 비용을 키웁니다.

흐름으로 보기

흐름 다이어그램
큰 이미지가 느릴 때 렌더링 속도를 높이는 법: 포맷, 크기, 로딩 전략 한 번에 정리 흐름 다이어그램

이미지 최적화는 파일 용량만 줄이는 작업에 가깝지 않습니다. 브라우저가 이미지를 언제 발견하고, 어떤 우선순위로 가져오고, 언제 디코드하고, 레이아웃과 페인트에 언제 반영하는지를 함께 봐야 최종 표시 시점을 줄일 수 있습니다.

브라우저 안에서 실제로 무슨 순서로 일어날까

브라우저는 HTML을 파싱해 DOM을 만들고, CSS를 파싱해 CSSOM을 만든 뒤, 두 정보를 바탕으로 렌더 트리를 구성합니다. 그다음 각 요소의 크기와 위치를 계산하는 Layout, 픽셀을 그리는 Paint, 레이어를 합치는 Compositing으로 이어집니다.

큰 이미지가 느리게 느껴지는 이유도 이 흐름 안에서 이해하는 편이 정확합니다. 예를 들어 첫 화면 대표 이미지가 img로 문서 안에 있으면 HTML 파서 단계에서 비교적 빨리 발견됩니다. 반면 background-image로만 들어 있으면 CSS를 내려받고 CSSOM을 만든 뒤에야 요청 후보가 되기 쉬워서, 같은 파일 크기여도 훨씬 늦게 보일 수 있습니다.

이 차이는 LCP와도 바로 연결됩니다. web.dev의 LCP 가이드에 따르면 LCP는 페이지가 로드되기 시작한 시점부터 가장 큰 텍스트 블록이나 이미지가 그려질 때까지를 봅니다. 대표 이미지가 LCP 후보라면, 다운로드 크기만 줄이는 것보다 발견 시점과 우선순위를 먼저 점검하는 편이 도움이 됩니다.

큰 이미지가 느린 이유는 다운로드만이 아니다

이미지가 클수록 느려지는 이유는 보통 네 가지가 겹칩니다.

  • 전송 바이트가 많아 네트워크 시간이 길어집니다.
  • 해상도가 높을수록 디코드 비용이 커집니다.
  • 크기 정보가 늦게 정해지면 레이아웃이 다시 계산될 수 있습니다.
  • 그려야 할 픽셀이 많아 PaintComposite 비용이 커집니다.

디코드 비용은 특히 과소평가되기 쉽습니다. 예를 들어 4000x3000 이미지를 RGBA 비트맵으로 펼치면 대략 4000 x 3000 x 4 바이트, 약 45.8MiB 수준의 메모리를 다루게 됩니다. 전송 파일이 500KB였더라도, 브라우저가 최종적으로 처리해야 하는 화면용 데이터는 훨씬 클 수 있다는 뜻입니다.

그래서 “파일 크기는 줄였는데 페이지가 여전히 답답하다”는 상황이 생깁니다. 요청이 늦게 발견되었거나, 필요 이상으로 큰 이미지를 내려보냈거나, widthheight가 없어 레이아웃이 흔들리면 최종 표시 시점은 기대만큼 줄지 않습니다.

현재 기준 포맷 선택과 오래된 설명의 차이

2026년 4월 기준으로 보면, 이미지 최적화에 대한 오래된 설명 중 일부는 그대로 쓰기 어렵습니다.

  • 포맷 지원: WebP는 더 이상 “최신 브라우저에서만 되는 포맷”으로 보기 어렵습니다. MDN 이미지 포맷 가이드WebP를 주요 브라우저 전반에서 폭넓게 지원하는 포맷으로 설명합니다.
  • AVIF의 위치: AVIF도 현재 주요 브라우저에서 충분히 검토할 수 있는 선택지입니다. MDN은 AVIF 지원을 Chrome 85, Firefox 93, Safari 16.1 이상으로 안내합니다. 다만 역사적 지원 폭이 WebP보다 얕기 때문에 fallback은 여전히 유용합니다.
  • 우선순위 API 명칭: 예전 글에서 보이던 Priority Hints라는 이름은 현재 Fetch Priority APIfetchpriority 속성으로 정리되어 있습니다.
  • 지연 로딩 관성: “모든 이미지에 lazy를 붙이면 된다”는 조언은 지금 기준으로는 거칠게 느껴집니다. web.dev LCP 가이드LCP 이미지에 loading="lazy"를 두면 불필요한 지연이 생긴다고 경고합니다.
  • 용어 사용: 예전에는 reflow라는 표현이 자주 쓰였지만, 현재 DevTools에서는 Layout, Paint, Composite 단위로 보는 편이 병목을 더 직접적으로 설명해 줍니다.

포맷 선택도 파일 성격에 따라 나누어 보는 편이 낫습니다.

  • 사진성 이미지: AVIF 또는 WebP
  • 투명 배경이 필요한 일반 이미지: WebP 또는 PNG
  • 텍스트가 많은 스크린샷: 손실 없는 WebP 또는 PNG
  • 아이콘, 로고, 도형: SVG
  • 범용 fallback: JPEG 또는 PNG

한 가지 더 볼 점은 AVIF가 항상 체감 표시 속도까지 가장 빠르다고 단정하기 어렵다는 점입니다. MDN은 AVIFJPEGPNG보다 더 작은 파일을 만들 수 있다고 설명하면서도, AVIF는 progressive rendering을 지원하지 않는다고 적고 있습니다. 따라서 아주 큰 대표 이미지에서는 전송량 절감 효과와 실제 표시 시점을 둘 다 비교해 보는 편이 좋습니다.

요청 시점과 우선순위 다루기

첫 화면 대표 이미지는 “언제 발견되는가”와 “어느 정도 우선순위로 가져오는가”가 중요합니다. 문서 안의 img로 배치할 수 있다면 그 편이 보통 더 빠릅니다. CSS 배경 이미지를 대표 이미지로 써야 한다면 preload를 함께 두는 쪽이 발견 지연을 줄이는 데 도움이 됩니다.

이미지가 여러 포맷으로 제공될 때는 picturesrcset을 함께 쓰는 구성이 익숙합니다. 이때 preload는 모든 포맷에 다 거는 방식보다, 다수 사용자에게 실제로 선택될 가능성이 큰 한 포맷에만 적용하는 편이 낫습니다. MDN의 preload 문서는 동일한 리소스의 여러 형식을 한꺼번에 preload하면 실제 사용되지 않을 파일까지 미리 내려받을 수 있다고 설명합니다.

<head>
  <link
    rel="preload"
    as="image"
    href="/images/hero-1280.avif"
    type="image/avif"
    fetchpriority="high">
</head>
<body>
  <picture>
    <source
      type="image/avif"
      srcset="/images/hero-640.avif 640w, /images/hero-960.avif 960w, /images/hero-1280.avif 1280w"
      sizes="(max-width: 768px) 92vw, 960px">
    <source
      type="image/webp"
      srcset="/images/hero-640.webp 640w, /images/hero-960.webp 960w, /images/hero-1280.webp 1280w"
      sizes="(max-width: 768px) 92vw, 960px">
    <img
      src="/images/hero-960.jpg"
      srcset="/images/hero-640.jpg 640w, /images/hero-960.jpg 960w, /images/hero-1280.jpg 1280w"
      sizes="(max-width: 768px) 92vw, 960px"
      width="960"
      height="540"
      alt="제품 대표 이미지"
      fetchpriority="high"
      decoding="async">
  </picture>
</body>

이 예시에서 fetchpriority="high"는 우선순위 힌트이고, preload는 빠른 발견을 돕습니다. 둘은 역할이 다릅니다. preload는 “일찍 알리기”, fetchpriority는 “가져올 때 더 중요하게 다뤄 달라고 힌트 주기”에 가깝습니다. decoding="async"도 유용할 수 있지만 디코드 비용 자체를 없애 주는 것은 아니므로, 큰 원본을 그대로 유지한 채 속성이 몇 개 추가된다고 병목이 사라지지는 않습니다.

표시 크기와 레이아웃 안정성 맞추기

브라우저가 srcset 후보를 고를 때 보는 것은 “현재 뷰포트 폭”만이 아닙니다. sizes가 알려주는 슬롯 크기와 기기 DPR을 함께 고려합니다. 그래서 sizes에는 “이미지가 실제로 차지할 자리 크기”를 넣는 편이 맞습니다.

같은 장면을 단지 더 작게 또는 더 크게 내려주고 싶다면 srcsetsizes가 해상도 전환 문제를 다룹니다. 반대로 모바일에서는 다른 크롭을 쓰고 싶다면 picture가 더 적합합니다. MDN의 responsive images 가이드는 이 둘을 resolution switchingart direction으로 구분해 설명합니다.

구성은 슬롯 크기 기준으로 잡아두면 관리하기 편합니다.

hero_image:
  widths: [640, 960, 1280]
  dpr: [1, 2]
  formats: [avif, webp, jpg]
  quality:
    avif: 45
    webp: 70
    jpg: 78
  sizes: "(max-width: 768px) 92vw, 960px"
  cache:
    max_age: 31536000
    immutable: true

이 설정에서 중요한 점은 화면 전체 폭이 아니라 컴포넌트 슬롯 폭을 기준으로 폭 후보를 정했다는 점입니다. 카드가 최대 360px까지만 보이는데 1600px 후보를 계속 만들면, 반응형 이미지를 써도 과한 소스가 선택될 여지가 남습니다.

레이아웃 안정성도 함께 맞춰 두는 편이 좋습니다. widthheight를 마크업에 넣거나, CSS에서 aspect-ratio를 지정하면 브라우저가 자리부터 확보할 수 있습니다.

.hero-media {
  width: min(100%, 960px);
  aspect-ratio: 16 / 9;
  overflow: clip;
}

.hero-media img {
  width: 100%;
  height: 100%;
  object-fit: cover;
}

.promo-banner {
  background-image: image-set(
    url("/images/promo-960.avif") type("image/avif") 1x,
    url("/images/promo-960.jpg") type("image/jpeg") 1x
  );
  background-size: cover;
  background-position: center;
}

배경 이미지는 image-set()으로 해상도나 포맷 분기를 줄 수 있지만, 첫 화면 핵심 배경 이미지라면 여전히 preload를 별도로 검토할 만합니다. CSS 배경은 발견 시점이 늦기 때문입니다.

CDN, 캐시, 변환 파이프라인에서 줄일 수 있는 비용

이미지 최적화가 서버나 CDN 단계에서 끝나면 브라우저가 받아야 하는 후보 자체가 훨씬 정리됩니다. 보통은 원본 한 장을 보관하고, 요청 시점에 폭과 포맷을 변환해 파생본을 만드는 방식을 많이 씁니다.

이 단계에서는 캐시 정책을 함께 보는 편이 좋습니다. URL에 폭과 포맷이 포함되어 버전이 안정적으로 바뀐다면 긴 max-ageimmutable이 잘 맞습니다. 반대로 Accept 헤더를 보고 자동으로 포맷을 바꾸는 구조라면 Vary: Accept가 빠지지 않아야 캐시 오염을 줄일 수 있습니다.

$ curl -I "https://cdn.example.com/images/hero?w=1280&format=avif"
HTTP/2 200
content-type: image/avif
content-length: 151284
cache-control: public, max-age=31536000, immutable
vary: Accept
etag: "hero-1280-avif-v4"
server-timing: cdn-cache; desc=HIT

이런 응답에서는 세 가지를 먼저 확인해 볼 수 있습니다.

  • content-type이 의도한 포맷과 맞는지
  • cache-control이 재요청을 줄일 수 있게 설정되어 있는지
  • server-timing이나 CDN 전용 헤더가 HIT인지 MISS인지

첫 화면이 느린데 cdn-cache가 반복해서 MISS로 보인다면, 브라우저 최적화보다 캐시 키 설계나 변환 URL 전략을 먼저 손보는 편이 더 큰 차이를 만들 수 있습니다.

DevTools로 병목 확인하기

이미지 최적화는 적용보다 검증이 더 중요할 때가 많습니다. 2026년 4월 기준 Chrome DevTools에서는 NetworkPerformance 패널을 함께 보는 흐름이 가장 설명력이 좋습니다. LCP 목표값은 web.dev에 따르면 75퍼센타일 기준 2.5초 이하입니다.

먼저 브라우저가 실제로 어떤 이미지를 골랐는지 확인해 볼 수 있습니다.

const image = document.querySelector("[data-hero-image]");

new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    console.log("[LCP]", {
      startTime: entry.startTime.toFixed(1),
      url: entry.url || image.currentSrc,
      size: entry.size,
    });
  }
}).observe({ type: "largest-contentful-paint", buffered: true });

console.log("[IMG]", {
  currentSrc: image.currentSrc,
  complete: image.complete,
  naturalSize: `${image.naturalWidth}x${image.naturalHeight}`,
  renderedSize: `${image.clientWidth}x${image.clientHeight}`,
  fetchPriority: image.fetchPriority,
  loading: image.loading,
  decoding: image.decoding,
});

출력은 대략 이런 식으로 보입니다.

[IMG] {
  currentSrc: "https://cdn.example.com/images/hero?w=1280&format=avif",
  complete: true,
  naturalSize: "1280x720",
  renderedSize: "390x219",
  fetchPriority: "high",
  loading: "eager",
  decoding: "async"
}

[LCP] {
  startTime: "1842.3",
  url: "https://cdn.example.com/images/hero?w=1280&format=avif",
  size: 85320
}

Network:
hero?w=1280&format=avif  200  img  High  148 kB transferred  412 kB resource size

Performance:
Image Decode  46 ms
Layout        12 ms
Paint         18 ms

이 출력에서 볼 포인트는 몇 가지로 정리됩니다.

  • currentSrc가 기대한 후보인지 확인합니다. 960w를 기대했는데 1920w가 선택됐다면 sizes나 후보 폭 구성이 맞지 않을 수 있습니다.
  • Network에서는 Priority, Initiator, transferred, resource size를 함께 봅니다. 전송량만 작은지, 실제 선택도 적절한지 구분할 수 있습니다.
  • Performance에서는 LCP 마커 직전의 Image Decode, Layout, Paint 시간을 봅니다. 다운로드는 빨랐는데 decode가 길어지는 경우가 여기서 드러납니다.
  • 캐시를 끈 상태와 켠 상태를 나눠 보아야 초회 방문 병목과 재방문 병목을 구분하기 쉽습니다.

관련 패널 기능은 Chrome DevTools Network referenceChrome DevTools Performance reference에서 확인할 수 있습니다.

무엇부터 바꾸면 좋을까

이미지 최적화는 한 번에 모든 속성을 붙이는 것보다, 병목이 생기는 순서대로 보는 편이 읽기 쉽고 유지보수도 수월합니다.

  • 요청 발견 시점: 대표 이미지가 img인지, CSS 배경인지, preload가 필요한지 봅니다.
  • 표시 크기: 슬롯 크기와 DPR 기준으로 파생본 폭이 과하지 않은지 봅니다.
  • 포맷: AVIF, WebP, JPEG, PNG, SVG를 이미지 성격에 맞춰 나눕니다.
  • 우선순위: fetchpriority, lazy, preload가 서로 충돌하지 않는지 봅니다.
  • 레이아웃 안정성: width, height, aspect-ratio가 있는지 확인합니다.
  • 검증: DevTools에서 LCP, Image Decode, Layout, Paint를 다시 확인합니다.

다운로드 바이트만 줄이면 끝나는 문제였다면 이미지 최적화는 훨씬 단순했을 것입니다. 실제로는 DOM과 CSSOM 위에서 어떤 시점에 발견되고, 렌더 트리에 언제 반영되고, 디코드와 페인트가 얼마나 걸리는지까지 보아야 “느리다”는 체감이 줄어듭니다. 그래서 큰 이미지를 다룰 때는 포맷, 크기, 로딩 전략을 따로 보지 않고 브라우저 렌더링 흐름 안에서 함께 보는 편이 더 정확합니다.

공식 문서와 레퍼런스

원문 참고

https://www.maeil-mail.kr/question/54

댓글

이 블로그의 인기 게시물

아이콘 폰트 (icomoon 사용법)

 장난감 프로젝트를 만들다 보면, 아이콘이 필요한 경우가 있다. 간단하게 아이콘을 인터넷에서 검색하여, 이미지로 넣어두고 이미지 태그를 이용하여, 사용하는 경우가 일반적이였지만...  요즘에는 대부분 폰트를 이용하여 아이콘을 노출 한다. 나 같은 경우에도 기본적으로  https://material.io/resources/icons 를 참고하여 아이콘 폰트를 이용할 수 있도록 처리하고, 추가적으로 필요한 아이콘이고, 일상적으로 사용 되지 않는 아이콘의 경우에는  https://icomoon.io 에서 제작하여, 아이콘 폰트로 이용 하곤 한다.  그래서 이번에는 아이콘  https://icomoon.io 의 사용법을 간단히 공유하고자 한다.   들어가자 마자 위의 icoMoonApp버튼을 누르면 아래와 같은 화면이 나타난다.  icomoon에서 무료로 제공하는 아이콘들이 보이면 위에 파란색으로 표시 되어있는 집 모양 세가지를 선택한 후, 아래의 빨간색으로 표시되어있는 Generate Font를 눌러보자.  그리고 나서 바로 다운로드를 요청해보자. icomoon.zip이 다운로드가 될텐데, 압축을 해제해 보면, 아래의 폴더 및 파일들이 있다. 아래에서 중요한 것은 font 폴더와 style.css이다. demo-files fonts demo.html Read Me.txt selection.json style.css <!doctype html > <html> <head> <link rel ="stylesheet" href ="style.css" ></head> </head> <body> <span class ="icon-home" ></span> <span class ="icon-home2" ></span> <span class ="icon-hom...

Chart js와 amchart 비교

Chart js 특징은 위의 그림으로 대체 할 수 있을 듯 하다. 오픈 소스이고, 기본으로 제공하는 차트 종류가 8가지 Canv a s를 이용해서 차트를 그리고, 반응형을 지원한다. amchart amchart는 기본적으로 유료이며, 기본으로 제공하는 차트 종류가 기본적인 차트 + 주식 처럼 보이는 차트 + 지도에 관련된 차트(?) 까지 하면, 기본 제공 하는 종류가 20개 내외 이려나, 일일이 세기에는 양이 좀 많아 보인다. 렌더링은 svg를 통하여 그려지고, 당연 반응형도 지원이 된다. 그러면, 이 둘중에 어떤것이 내 프로젝트에 적합 하냐는 것이 문제이다. 일단, 주식 처럼 보이는 차트나 지도에 관련된 차트(?)가 필요하면, amchart를 선택해야 되는 것은 맞다. 그건 당연한 것이니 빼고 얘기 해보자! 여러 종류의 차트가 필요하다면, 일단은 amchart를 염두해 두는 것이 좋다. 돈 낸 만큼은 하는 듯 하다. 하지만, 기본적인 막대 그래프, 도넛 차트 등, 아주 기본적인 차트들인데, Chart js도 amchart도 그러한 차트가 없을 때가 문제가 된다. 그렇다면, 조금이라도 커스텀이 용이한 것을 찾는 것이 좋을 것이다.  일단 amchart에서 custom이라고 검색 하였을 때, 검색 결과가 61가지가 나온다. 차트의 종류도 많고, 각 차트마다 들어가는 속성이 매우 많기 때문에, 웬만한 내용들은 속성 값을 어떻게 주느냐에 따라서 변경이 가능 하게 된다. 커스텀의 예를 들면, 기본적으로 도넛 파이의 형태를 띄면서, 화살표로 목표를 표시해주는 차트가 필요하다고 생각 해보자. 이것은 amchart로 만든 그래프이고 이것은 chart js로 만든 그래프이다. 모양이 살짝 다르긴 하지만, 완벽하게 똑같이 구현 할 수도 있다. amchart로 만든 그래프의 경우, 저것은 도넛그래프가 아닌 guage 그래프이다. 원래 게이지 그래프는 이와 같...

javascript 압축 파일 다운로드

이번에는 전 게시글의 응용판? 이라고 해야하나....? 어쨋든! 우리는 각각의 파일들을 다운로드 해보았다. 그런데 생각보다 귀찮음?을 느꼇을 것이다. 파일을 각각 다운 받아야 한다는 현실때문에! 그래 파일 두개야 뭐 그렇다 치지... 하지만, 개발자도 사용자도 게으름뱅이이다. 자 결국, 우리가 해야 하는 것은 파일을 한 번에 둘다! 다운 받는 것이다. 물론, 클릭 한번에 여러개의 함수를 엮어서 다운받게 하면 되지만! 크롬에서 자주 봤듯이, 여러개의 파일을 다운로드를 시도하면 <- 여러개의 파일을 다운로드 합니다. 허용 합니까? 하고 물어보는 것을 볼 수 있다. 게다가 다운로드 한 파일들을 찾기도 귀찮다는 것. 자 해결책을 제시해보자면, https://github.com/Stuk/jszip 클라이언트 단에서 파일을 zip파일로 압축을 할 수가 있다! 필요한 작업은 아래와 같다. 0. 데이터 준비 1. BLOB(binary large object)를 만든다. 2. Blob을 URL.createObjectURL을 사용하여, 해당 binary의 주소를 생성. 3. 다운로드가 필요한 파일들을 Zip 객체에 셋팅! 4. a태그를 이용하여, 해당 url 셋팅 하고, 다운로드. 전 게시물과 별로 달라진게 없네... 자 그럼 샘플! 샘플을 보자! http://embed.plnkr.co/NMprnRxqYG0fkHa2J55D/ var util = {} function fixBinary(bin) { //binary to arrayBuffer var length = bin.length var buf = new ArrayBuffer(length) var arr = new Uint8Array(buf) for (var i = 0; i < length; i++) { arr[i] = bin.charCodeAt(i) } return buf } window.onload = function() { ...