들어가며
[진행한 작업에 대한 AI 초안]
성능 최적화 과제를 받았습니다. 홈 화면에 Hero 이미지가 하나 있는데, 열어보니 이랬습니다.
hero-original.jpg 3840 × 2160 7,545,239 B
7.5MB. 모바일 화면에서 332 × 415로 그려지는 이미지였습니다.
Slow 4G에서 Lighthouse를 돌렸더니 LCP가 41초로 나왔습니다. 처음엔 측정이 잘못된 줄 알았는데, 계산해보니 맞는 값이었습니다.
7,545,239 B × 8 ÷ 1,474,560 bps ≈ 40.9 s
이 조건에서 LCP는 사실상 Hero 이미지 다운로드 시간 그 자체였습니다.
이 글은 이 이미지를 28KB로 줄인 이야기입니다. 그리고 다 줄였다고 믿은 뒤에도 놓치고 있던 것에 대한 이야기이기도 합니다.
문제의 원인 파악하기
LCP가 느리다만으로는 뭘 고쳐야 할지 알 수 없었습니다. Lighthouse의 LCP breakdown이 네 구간으로 나눠줍니다.
| 구간 | No throttling | Slow 4G |
|---|---|---|
| Time to first byte | 513 ms (73%) | 530 ms |
| Resource load delay | 16 ms | 10 ms |
| Resource load duration | 89 ms | ~40,000 ms (98%) |
| Element render delay | 107 ms | 110 ms |
여기서 한 가지 배웠습니다. 같은 코드인데 조건에 따라 1순위가 뒤집힙니다.
localhost에 스로틀을 안 걸면 대역폭이 사실상 무한이라 7.5MB가 84ms만에 전송됩니다. 바이트 문제가 시간으로 드러나지 않죠. 그래서 무스로틀에서는 서버 응답(TTFB 513ms)이 병목처럼 보이고, Slow 4G에서는 전송이 98%를 차지합니다.
어느 쪽도 틀린 측정이 아닙니다
TTFB는 회선과 무관한 고정 비용이라 로컬에서 두드러지고, 전송량은 회선이 느릴수록 커져 실사용자에게 두드러집니다. 그래서 두 조건을 다 남기고, 각 개입의 근거를 각각의 조건에서 가져왔습니다.
전송 쪽 원인은 명확했습니다. 필요한 것보다 훨씬 큰 이미지를 받고 있었습니다.
| 항목 | 값 |
|---|---|
| 실제 표시 크기 | 332 × 415 CSS px |
| 표시에 필요한 픽셀 | 약 581 × 726 |
| 실제 원본 | 3840 × 2160 |
| Lighthouse 낭비 판정 | 7,388,963 B (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> |
next/image가 해주는 건 앞쪽입니다. 그럼 뒤쪽은 안 되는 걸까?
물어보지 말고 시켜보기
안 될 것 같다로 넘어가려다, 최적화 엔드포인트에 직접 요청해봤습니다. 원본이 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도 아무 값이나 안 되고deviceSizes·imageSizes에 있는 값이어야 합니다.
CSS로 자르면 되잖아의 함정
object-fit: cover로 화면상 4:5처럼 보이게 할 수는 있습니다. 하지만 그건 표시만 자르는 것이고, 내려받는 바이트는 16:9 전체 그대로입니다. 목표가 전송량 감소인데 이건 답이 안 됩니다.
같은 원본을 sharp에 넣으니 600 × 750(4:5)이 나왔습니다. next/image를 피한 게 아니라, 필요한 파일을 만들 수 있는 수단이 sharp뿐이었습니다.
후보 만들기
빌드 타임에 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
sharp를 고른 이유가 하나 더 있습니다. next/image가 런타임에 쓰는 것과 같은 라이브러리라, 미리 만든 후보와 next/image가 만들었을 결과의 인코딩 특성이 어긋나지 않습니다.
<picture>로 묶었습니다.
const PORTRAIT = {
media: "(max-width: 640px)",
widths: [600, 900],
sizes: "calc(100vw - 96px)",
} as const
// 브라우저는 조건이 맞는 첫 source를 고른다.
// 좁은 화면 후보를 먼저, 같은 화면 안에서는 압축률이 높은 포맷을 먼저.크롭 좌표를 103px 틀렸습니다
CSS에 이런 게 있었습니다.
object-position: 56% center;저는 이걸 가로 56% 지점을 중심에 둔다로 읽고 크롭 스크립트를 짰습니다.
const left = Math.round(sourceWidth * FOCAL_X - width / 2) // ❌만들어진 이미지를 눈으로 보니 구도가 어긋나 있었습니다. Lighthouse는 아무 문제 없다고 했습니다. LCP도, CLS도, 전송량도 전부 정상이었거든요.
object-position의 실제 의미는 다릅니다. 넘치는 부분을 56:44로 분배하는 것이었습니다.
const left = Math.round((sourceWidth - width) * FOCAL_X) // ✅103px 차이였습니다. 고친 뒤 원본을 CSS 규칙대로 잘라낸 것과 픽셀 대조를 해보니 평균 차이 2.47/255로 일치했습니다.
계측이 정상인데 잘못돼 있던 것
이 오류는 어떤 수치로도 안 잡힙니다. 사람이 눈으로 봐야 발견되고, 확정은 픽셀 대조로 하는 식이었습니다. 계측기를 늘려도 못 잡는 종류가 있다는 걸 처음 실감했습니다.
DPR — 두 계측이 다른 파일을 받았습니다
Lighthouse CLI로 재니 hero-portrait-600.avif(28.4 kB)를 받았는데, DevTools Performance 패널로 재니 hero-portrait-900.avif(54.4 kB)를 받았습니다.
처음엔 뭐가 잘못된 줄 알았는데, 계산해보니 둘 다 맞았습니다.
| 계측 | DPR | sizes 계산 폭 | 선택된 후보 |
|---|---|---|---|
| Lighthouse CLI | 1.75 | 316 × 1.75 = 553 | portrait-600 |
| DevTools (Retina) | 2 | 316 × 2 = 632 | portrait-900 |
DPR(Device Pixel Ratio) 은 CSS 픽셀 하나에 물리 픽셀이 몇 개 들어가는지입니다. 브라우저는 sizes로 계산한 CSS 폭에 DPR을 곱한 값 이상인 가장 작은 후보를 고릅니다.
두 계측이 서로 다른 파일을 받은 게 오히려 srcset이 동작한다는 증거였습니다. 어느 쪽이든 7,545 kB 대비 각각 266배 · 139배 작았고요.
여기까지의 결과
| 조건 | Before | After |
|---|---|---|
| Mobile / No throttling LCP | 701 ms | 79 ms |
| Mobile / Slow 4G LCP | 40,883 ms | 2,922 ms |
| Hero 전송 | 7,545 kB | 28.7 kB |
| 페이지 총 전송 | 8.0 MB | 485 kB |
Lighthouse 점수 100점. 다 끝난 줄 알았습니다.
그런데 지적이 하나 남아 있었습니다
After 리포트에 이게 남아 있었습니다.
Improve image delivery — Est savings of 53 KiB
/_next/image?url=%2Fimages%2Fproducts%2Fp21.jpg&w=828&q=75
상품 카드 이미지였습니다. 저는 이렇게 판단하고 넘어갔습니다.
개입 수단이 품질 저하뿐이다.
next/image는 이미 붙어 있어 남은 수단은quality를 낮추는 것인데, 이건품질을 낮춰 수치만 줄이는 것에 해당한다.
이 문장을 문서에 적어뒀습니다. 그리고 이 문장이 틀렸습니다.
질문 하나로 뚫렸습니다
리뷰를 준비하다 이런 질문을 받았습니다.
이미지 최적화는
next.config에서도 할 수 있지 않아?
확인해보니 next.config.ts가 비어 있었습니다. images.formats의 기본값은 ['image/webp']라 카드가 webp로 내려오고 있었고요. AVIF를 켜면 줄겠다 싶어 바꿔봤습니다.
결과는 반대였습니다.
| 대상 | webp | avif | 차이 |
|---|---|---|---|
p21.jpg | 72,321 B | 80,700 B | +8,379 B |
| 나머지 11장 | 97,667 B | 89,898 B | −7,769 B |
| 합계 | 169,988 B | 170,598 B | +610 B |
AVIF가 항상 작지는 않았습니다. 같은 q=75라도 webp와 AVIF의 품질 척도가 달라, 사진 특성에 따라 뒤집힙니다. p21 한 장이 나머지 11장의 절감을 통째로 먹었습니다. 되돌렸습니다.
그런데 이 삽질 덕에 원인을 보게 됐습니다. 왜 w=828을 받고 있지?
진짜 원인은 포맷이 아니라 sizes였습니다
<Image src={product.image} alt={product.name} width={400} height={400} loading="eager" />sizes가 없습니다.
sizes가 없으면 next/image는 레이아웃을 알 수 없어 width prop의 1x·2x로만 후보를 만듭니다.
width={400} → [400, 800] → 허용 폭 중 가장 가까운 [640, 828]
→ srcset="…w=640 1x, …w=828 2x"
DPR 1.75인 모바일은 2x(828)를, DPR 1인 데스크탑은 1x(640)를 고릅니다. 측정에서 관찰한 두 값이 정확히 이것이었습니다.
실제 카드는 412px 뷰포트 2열 그리드에서 184 CSS px입니다. DPR 1.75면 322px이면 충분한데 828px를 받고 있었던 거죠.
const CARD_SIZES = ["(max-width: 720px) 50vw", "(max-width: 960px) 34vw", "20vw"].join(", ")| 항목 | 전 | 후 |
|---|---|---|
| 요청된 폭 | w=828 | w=384 |
p21.jpg | 72,321 B | 47,501 B |
| 카드 12장 합 | 169,988 B | 129,510 B (−23.8%) |
| 페이지 총 전송 | 485,102 B | 445,484 B |
Hero에 한 것과 정확히 같은 종류의 문제였습니다. 필요한 것보다 큰 이미지를 받고 있었고, 품질을 낮추지 않고 고칠 수 있었습니다.
더 정확하게 쓴 sizes가 오히려 기능을 껐습니다
처음엔 여백과 gap까지 반영해 정확하게 썼습니다.
(max-width: 720px) calc(50vw - 22px), (max-width: 960px) calc(33.3vw - 24px), …
더 정확한데 결과는 더 나빴습니다. HTML의 srcset을 열어보니 후보가 640부터 시작하고 384가 아예 없었습니다.
next/image는 sizes 문자열에서 vw 값을 정규식으로 뽑아 후보 하한을 정하는데, 그 정규식이 공백이나 문자열 시작 뒤의 숫자만 인식합니다.
calc(50vw → "50"이 "(" 뒤라 안 잡힘
min(1200px, 100vw → "100"만 잡힘 → 하한 = 640 × 1.0
sizes 표기 | 인식된 vw | srcset 최솟값 |
|---|---|---|
calc(50vw - 22px) … | 100만 | 640 |
50vw, 34vw, 20vw | 20 | 128 |
순수 vw로 쓰고 약간 넉넉하게 잡는 쪽이 맞았습니다. 넉넉한 쪽은 과다 전송일 뿐이고, 모자란 쪽은 흐릿해지니까요.
트레이드오프와 한계
1. sharp는 빌드 산출물을 저장소에 넣습니다.
12개 파일 1,204 kB가 public/에 들어갑니다. next/image는 요청 시 만들어 캐시하니 저장소가 깨끗하죠. 대신 우리는 파일을 소유하게 되어 결과가 예측 가능해졌습니다. art direction 때문에 선택의 여지가 없었지만, 아니었다면 고민했을 부분입니다.
2. imageSizes를 더 조이면 어떻게 되는지는 못 재봤습니다.
카드가 필요로 하는 픽셀은 DPR마다 다릅니다.
| DPR | 필요 픽셀 | 고른 후보 |
|---|---|---|
| 1.75 | 322 | 384 |
| 2 | 368 | 384 |
| 3 | 552 | 640 |
DPR 2에서는 기본값 384가 이미 거의 맞습니다. 여유가 커 보이는 건 측정 환경인 DPR 1.75에서고요. 여기 맞춰 목록을 조이면 DPR 2 사용자가 더 작은 후보를 받아 흐려질 수 있다고 봤는데 — 320 같은 값을 넣었을 때 실제로 몇 바이트 줄어드는지는 재보지 못했습니다. 기본 목록에 없는 폭은 엔드포인트가 400을 내서, 재려면 config를 바꿔 다시 빌드해야 했습니다.
3. localhost는 프로덕션이 아닙니다.
네트워크 지연도, CDN도 없습니다. 무스로틀 79ms를 실사용자 경험으로 읽으면 안 됩니다. 같은 코드가 Slow 4G에서는 2,922ms였으니까요.
회고
Hero 하나로 7.5MB를 28KB까지 줄인 건 숫자로는 깔끔합니다. 그런데 이 작업에서 오래 남은 건 그 숫자가 아니었습니다.
하나는 안 될 것 같다와 시켜봤더니 안 되더라의 차이입니다.
next/image로 크롭이 안 된다는 건 처음부터 짐작하고 있었습니다. 그런데 실제로 curl로 세 번 요청해보니, 짐작보다 훨씬 구체적인 걸 알게 됐습니다. 받는 파라미터가 셋뿐이라는 것, h가 무시된다는 것, w가 허용 목록 안이어야 한다는 것. 마지막 사실은 나중에 sizes 이야기와 그대로 연결됐고요.
다른 하나는 선입견이 리포트를 못 읽게 만든다는 것입니다.
p21.jpg 지적은 Before에도, After에도, 같은 문구로 계속 떠 있었습니다. Hero가 7.4MB로 압도하던 동안엔 순위에 가려 있었고, Hero를 걷어낸 뒤에도 저는 이미 next/image를 쓰니까 최적화된 것 이라고 넘겨짚었습니다. 도구는 계속 말하고 있었는데 제가 안 들은 거죠.
그게 뚫린 계기가 next.config에서도 되지 않아?라는 질문 하나였습니다. 계측기를 늘려서가 아니라 질문을 받아서 보였습니다.
문장으로 적어둔 게 도움이 됐습니다
개입하지 않기로 한 근거를
개입 수단이 품질 저하뿐이다라고 문장으로 적어뒀기 때문에, 그 문장이 틀렸다는 것도 되짚을 수 있었습니다. 수치만 적어뒀다면 그냥 넘어갔을 겁니다.
마지막은 통념을 측정으로 확인해야 한다는 것입니다.
AVIF가 webp보다 작다는 거의 상식처럼 알고 있었는데, 이 페이지에서는 오히려 610B 늘었습니다. 측정 없이 적용했다면 악화된 채로 끝났을 변경이었습니다.
이미지 최적화라고 하면 포맷을 바꾸고 품질을 낮추는 걸 먼저 떠올렸는데, 정작 효과가 컸던 건
필요한 크기가 얼마인지 브라우저에게 정확히 알려주는 것이었습니다.