들어가며

성능 최적화 과제를 받았습니다. 홈 화면에 Hero 이미지가 하나 있는데, 열어보니 이랬습니다.

hero-original.jpg   3840 × 2160   7.5MB

모바일 화면에서 332 × 415로 그려지는 이미지였습니다.

Slow 4G에서 Lighthouse를 돌렸더니 LCP가 41초로 나왔습니다. 처음엔 측정이 잘못된 줄 알았는데, 계산해보니 맞는 값이었습니다.

7.5MB × 8 ÷ 1.5Mbps ≈ 41초

이 조건에서 LCP는 사실상 Hero 이미지 다운로드 시간 그 자체였습니다.

Network 탭에서 hero-original.jpg가 45초 Network lighthouse
같은 화면 안에서 raw img(7.5MB)와 next/image 경유(72KB)가 100배 넘게 차이 나며 나란히 찍혔습니다.

문제의 원인 파악하기

LCP가 느리다만으로는 뭘 고쳐야 할지 알 수 없었습니다. Lighthouse의 LCP breakdown이 네 구간으로 나눠줍니다.

구간No throttlingSlow 4G
Time to first byte513ms (73%)530ms
Resource load delay16ms10ms
Resource load duration89ms약 41초 (98%)
Element render delay107ms110ms

여기서 한 가지 배웠습니다. 같은 코드인데 조건에 따라 1순위가 뒤집힙니다.

localhost에 스로틀을 안 걸면 대역폭이 사실상 무한이라 7.5MB가 익숙한 빠르기로 전송되었습니다. 따라서 이미지 전송량 문제가 시간으로 드러나지 않는다고 판단했습니다. 그래서 no throttling 옵션에서는 서버 응답(TTFB 513ms)이 병목처럼 보이고, Slow 4G에서는 전송이 98%를 차지합니다.

어느 쪽도 틀린 측정이 아니라고 생각합니다.

TTFB는 회선과 무관한 고정 비용이라 로컬에서 두드러지고, 전송량은 회선이 느릴수록 커져 실사용자에게 두드러집니다. 그래서 두 조건을 다 남기고, 각 개입의 근거를 각각의 조건에서 가져왔습니다.

전송 쪽 원인은 명확했습니다. 필요한 것보다 훨씬 큰 이미지를 받고 있었습니다.

항목
실제 표시 크기332 × 415 CSS px
표시에 필요한 픽셀약 581 × 726
실제 원본3840 × 2160
Lighthouse 낭비 판정약 7.4MB (98%)

그냥 next/image 쓰면 되는 거 아닌가?

Next.js를 쓰고 있으니 첫 생각은 당연했습니다. <img><Image>로 바꾸면 끝나는 문제 아닌가?

그런데 이 Hero에는 조건이 하나 더 있었습니다. 모바일 기준으로 성능 최적화를 진행하고 있었는데, 뷰포트마다 비율이 다릅니다.

.hero {
  aspect-ratio: 4 / 5; /* 모바일: 세로로 긴 */
}
 
@media (min-width: 641px) {
  .hero {
    aspect-ratio: 16 / 9; /* 데스크탑: 가로로 긴 */
  }
}

같은 원본을 모바일에서는 4:5로, 데스크탑에서는 16:9로 보여줘야 합니다. 이걸 art direction이라고 부릅니다.

여기서 처음 알게 된 구분이 있었습니다.

무엇을 하나방법
Resolution switching같은 그림을 크기만 다르게srcset + sizes
Art direction뷰포트마다 다른 그림(다른 크롭·비율)<picture> + <source media>

최적화를 진행하며 picture 태그와 source 태그의 조합을 처음 보게 되었는데, CSS의 미디어 쿼리처럼 화면 크기에 맞는 이미지를 골라주는 요소였습니다.

next/image는 art direction을 해주지 않는 것일까? 의문이 들었습니다.

AI에게 시켜보기

AI에게 시켜서 최적화 엔드포인트에 직접 요청해봤습니다. 원본이 3840 × 2160(16:9)이니 4:5가 나오는지 보면 됩니다.

curl "localhost:3000/_next/image?url=%2Fimages%2Fhero-original.jpg&w=640&q=75"
#  → 640 × 360   (16:9, 원본 비율 그대로)
 
curl "…&w=640&h=800&q=75"      # 높이를 줘서 세로로 잘라보려 시도
#  → 640 × 360   (h는 무시됨)
 
curl "…&w=600&q=75"            # 임의 폭 요청
#  → 400  "w" parameter (width) of 600 is not allowed

세 번의 요청으로 두 가지를 확인했습니다.

  1. 이 엔드포인트가 받는 건 url · w · q 세 개뿐입니다. 폭만 줄이고 비율은 원본을 따라갑니다.
  2. w도 아무 값이나 안 되고, next.configimages.deviceSizes · imageSizes에 있는 값이어야 합니다.

CSS로 자르면 되지 않나?

object-fit: cover로 화면상 4:5처럼 보이게 할 수는 있습니다. 하지만 그건 표시만 자르는 것이고, 내려받는 바이트는 16:9 전체 그대로입니다. 전송량은 그대로라 이번 목표에는 답이 되지 않았습니다.

AI가 만든 sharp 스크립트

sharpnext/image가 내부에서 쓰는 이미지 처리 라이브러리입니다. 이미 의존성에 들어와 있어 따로 설치할 필요가 없고, 같은 인코더를 쓰니 미리 만든 파일과 next/image가 만들었을 결과의 특성이 어긋나지 않는다는 근거로 AI가 스크립트를 만들었습니다.

돌려보니 같은 원본에서 600 × 750(4:5)이 나왔습니다. next/image로는 만들 수 없던 이미지였습니다.


후보 만들기

빌드 타임에 sharp로 12개 파일을 미리 만들었습니다.

hero-portrait-600   .avif .webp .jpg    (4:5,  모바일)
hero-portrait-900   .avif .webp .jpg
hero-landscape-1280 .avif .webp .jpg    (16:9, 데스크탑)
hero-landscape-1920 .avif .webp .jpg

그리고 이를 picture 태그로 묶었습니다. 브라우저는 조건에 맞는 첫 번째 source를 고른다는 걸 알게 됐습니다. 그래서 좁은 화면 후보를 먼저, 같은 화면 안에서는 압축률이 높은 포맷을 먼저 둡니다.

const PORTRAIT = {
  media: "(max-width: 640px)",
  widths: [600, 900],
  sizes: "calc(100vw - 96px)",
} as const

이미지 자르기

만들어진 이미지를 직접 눈으로 보니 구도가 어긋나 있었습니다. Lighthouse는 아무 문제 없다고 했습니다.

LCP도, CLS도, 전송량도 전부 정상이었습니다. 사람이 봐야만 확인할 수 있는 지점이었습니다.

Hero의 CSS에 이런 게 있었습니다.

object-position: 56% center;

저는 이걸 처음에는 가로 56% 지점을 중심에 둔다로 해석했습니다. 하지만 실제 의미는 넘치는 부분을 56:44로 분배하는 것이었습니다.

// 내가 이해한 것 — 56% 지점을 중심에
const left = Math.round(sourceWidth * FOCAL_X - width / 2)
 
// 실제 — 넘치는 만큼을 56:44로 나눔
const left = Math.round((sourceWidth - width) * FOCAL_X)

약 100픽셀 차이였습니다.

계측이 정상인데 잘못돼 있던 것

이 오류는 어떤 수치로도 안 잡힙니다. 사람이 눈으로 봐야 발견되고, 확정은 픽셀 대조로 하는 식이었습니다. 계측기를 늘려도 못 잡는 종류가 있다는 걸 처음 실감했습니다.

DPR — 두 계측이 다른 파일을 받았습니다

DPR이란?

Device Pixel Ratio의 줄임말로 CSS 픽셀 1개가 실제 화면의 물리 픽셀 몇 개로 그려지는가입니다.

예를 들어 DPR이 2인 환경에서 css 1280px을 그린다고 하면 실제 DPR 2 기기에서는 2560px 크기로 그립니다.

Lighthouse CLI로 재니 hero-portrait-600.avif(28KB)를 받았는데, DevTools Performance 패널로 재니 hero-portrait-900.avif(54KB)를 받았습니다.

처음엔 뭐가 잘못된 줄 알았는데, 계산해보니 둘 다 맞았습니다.

계측DPRsizes 계산 폭선택된 후보
Lighthouse CLI1.75316 × 1.75 = 553portrait-600
DevTools (Retina)2316 × 2 = 632portrait-900

브라우저는 sizes로 계산한 CSS 폭에 DPR을 곱한 값 이상인, 가장 작은 후보를 고릅니다.

두 계측이 서로 다른 파일을 받은 게 오히려 srcset이 동작한다는 증거였습니다. 어느 쪽이든 원본 7.5MB보다 100배 넘게 작았습니다.


결과

조건BeforeAfter
Mobile / No throttling LCP701ms79ms
Mobile / Slow 4G LCP41초2.9초
Hero 전송7.5MB28KB
페이지 총 전송8.0MB485KB

느낀 점

이미지 최적화라고 하면 포맷을 바꾸고 품질을 낮추는 걸 먼저 떠올렸는데, 정작 효과가 컸던 건 필요한 크기가 얼마인지 브라우저에게 정확히 알려주는 것이었습니다.