들어가며

[진행한 작업에 대한 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 이미지 다운로드 시간 그 자체였습니다.

Network 탭에서 hero-original.jpg가 45.12초
같은 화면 안에서 raw img(7,546 kB)와 next/image 경유(72.3 kB)가 104배 차이로 나란히 찍혔습니다.

이 글은 이 이미지를 28KB로 줄인 이야기입니다. 그리고 다 줄였다고 믿은 뒤에도 놓치고 있던 것에 대한 이야기이기도 합니다.


문제의 원인 파악하기

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

구간No throttlingSlow 4G
Time to first byte513 ms (73%)530 ms
Resource load delay16 ms10 ms
Resource load duration89 ms~40,000 ms (98%)
Element render delay107 ms110 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

세 번의 요청으로 두 가지가 확정됐습니다.

  1. 이 엔드포인트가 받는 건 url · w · q 세 개뿐입니다. 폭만 줄이고 비율은 원본을 따라갑니다.
  2. 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)를 받았습니다.

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

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

DPR(Device Pixel Ratio) 은 CSS 픽셀 하나에 물리 픽셀이 몇 개 들어가는지입니다. 브라우저는 sizes로 계산한 CSS 폭에 DPR을 곱한 값 이상인 가장 작은 후보를 고릅니다.

두 계측이 서로 다른 파일을 받은 게 오히려 srcset이 동작한다는 증거였습니다. 어느 쪽이든 7,545 kB 대비 각각 266배 · 139배 작았고요.


여기까지의 결과

조건BeforeAfter
Mobile / No throttling LCP701 ms79 ms
Mobile / Slow 4G LCP40,883 ms2,922 ms
Hero 전송7,545 kB28.7 kB
페이지 총 전송8.0 MB485 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를 켜면 줄겠다 싶어 바꿔봤습니다.

결과는 반대였습니다.

대상webpavif차이
p21.jpg72,321 B80,700 B+8,379 B
나머지 11장97,667 B89,898 B−7,769 B
합계169,988 B170,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=828w=384
p21.jpg72,321 B47,501 B
카드 12장 합169,988 B129,510 B (−23.8%)
페이지 총 전송485,102 B445,484 B

Hero에 한 것과 정확히 같은 종류의 문제였습니다. 필요한 것보다 큰 이미지를 받고 있었고, 품질을 낮추지 않고 고칠 수 있었습니다.

더 정확하게 쓴 sizes가 오히려 기능을 껐습니다

처음엔 여백과 gap까지 반영해 정확하게 썼습니다.

(max-width: 720px) calc(50vw - 22px), (max-width: 960px) calc(33.3vw - 24px), …

더 정확한데 결과는 더 나빴습니다. HTML의 srcset을 열어보니 후보가 640부터 시작하고 384가 아예 없었습니다.

next/imagesizes 문자열에서 vw 값을 정규식으로 뽑아 후보 하한을 정하는데, 그 정규식이 공백이나 문자열 시작 뒤의 숫자만 인식합니다.

calc(50vw            →  "50"이 "(" 뒤라 안 잡힘
min(1200px, 100vw    →  "100"만 잡힘  →  하한 = 640 × 1.0
sizes 표기인식된 vwsrcset 최솟값
calc(50vw - 22px)100만640
50vw, 34vw, 20vw20128

순수 vw로 쓰고 약간 넉넉하게 잡는 쪽이 맞았습니다. 넉넉한 쪽은 과다 전송일 뿐이고, 모자란 쪽은 흐릿해지니까요.


트레이드오프와 한계

1. sharp는 빌드 산출물을 저장소에 넣습니다.

12개 파일 1,204 kB가 public/에 들어갑니다. next/image는 요청 시 만들어 캐시하니 저장소가 깨끗하죠. 대신 우리는 파일을 소유하게 되어 결과가 예측 가능해졌습니다. art direction 때문에 선택의 여지가 없었지만, 아니었다면 고민했을 부분입니다.

2. imageSizes를 더 조이면 어떻게 되는지는 못 재봤습니다.

카드가 필요로 하는 픽셀은 DPR마다 다릅니다.

DPR필요 픽셀고른 후보
1.75322384
2368384
3552640

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 늘었습니다. 측정 없이 적용했다면 악화된 채로 끝났을 변경이었습니다.

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