들어가며
성능 최적화 과제를 받았습니다. 홈 화면에 Hero 이미지가 하나 있는데, 열어보니 이랬습니다.
hero-original.jpg 3840 × 2160 7.5MB
모바일 화면에서 332 × 415로 그려지는 이미지였습니다.
Slow 4G에서 Lighthouse를 돌렸더니 LCP가 41초로 나왔습니다. 처음엔 측정이 잘못된 줄 알았는데, 계산해보니 맞는 값이었습니다.
7.5MB × 8 ÷ 1.5Mbps ≈ 41초
이 조건에서 LCP는 사실상 Hero 이미지 다운로드 시간 그 자체였습니다.
문제의 원인 파악하기
LCP가 느리다만으로는 뭘 고쳐야 할지 알 수 없었습니다. Lighthouse의 LCP breakdown이 네 구간으로 나눠줍니다.
| 구간 | No throttling | Slow 4G |
|---|---|---|
| Time to first byte | 513ms (73%) | 530ms |
| Resource load delay | 16ms | 10ms |
| Resource load duration | 89ms | 약 41초 (98%) |
| Element render delay | 107ms | 110ms |
여기서 한 가지 배웠습니다. 같은 코드인데 조건에 따라 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세 번의 요청으로 두 가지를 확인했습니다.
- 이 엔드포인트가 받는 건
url·w·q세 개뿐입니다. 폭만 줄이고 비율은 원본을 따라갑니다. w도 아무 값이나 안 되고,next.config의images.deviceSizes·imageSizes에 있는 값이어야 합니다.
CSS로 자르면 되지 않나?
object-fit: cover로 화면상 4:5처럼 보이게 할 수는 있습니다. 하지만 그건 표시만 자르는 것이고, 내려받는 바이트는 16:9 전체 그대로입니다. 전송량은 그대로라 이번 목표에는 답이 되지 않았습니다.
AI가 만든 sharp 스크립트
sharp는 next/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)를 받았습니다.
처음엔 뭐가 잘못된 줄 알았는데, 계산해보니 둘 다 맞았습니다.
| 계측 | DPR | sizes 계산 폭 | 선택된 후보 |
|---|---|---|---|
| Lighthouse CLI | 1.75 | 316 × 1.75 = 553 | portrait-600 |
| DevTools (Retina) | 2 | 316 × 2 = 632 | portrait-900 |
브라우저는 sizes로 계산한 CSS 폭에 DPR을 곱한 값 이상인, 가장 작은 후보를 고릅니다.
두 계측이 서로 다른 파일을 받은 게 오히려 srcset이 동작한다는 증거였습니다. 어느 쪽이든 원본 7.5MB보다 100배 넘게 작았습니다.
결과
| 조건 | Before | After |
|---|---|---|
| Mobile / No throttling LCP | 701ms | 79ms |
| Mobile / Slow 4G LCP | 41초 | 2.9초 |
| Hero 전송 | 7.5MB | 28KB |
| 페이지 총 전송 | 8.0MB | 485KB |
느낀 점
이미지 최적화라고 하면 포맷을 바꾸고 품질을 낮추는 걸 먼저 떠올렸는데, 정작 효과가 컸던 건 필요한 크기가 얼마인지 브라우저에게 정확히 알려주는 것이었습니다.