의심한 적 없던 q=100, 직접 눈으로 확인하기로 했다
입사한 지 얼마 안 됐을 때 동적 이미지 컴포넌트를 맡아 구현했는데 품질 값을 몇으로 설정할지 고민됐었다. 조금 줄여도 육안으로 구분이 잘 안 돼 낮게 잡았는데 리뷰를 해준 시니어분께서 이커머스 특성상 이미지는 무조건 고화질이어야 한단 주장에 따라 q=100으로 설정했다. 여기서 q는 이미지 리사이징에 쓰는 sharp 모듈의 quality 파라미터다. 당시 나는 깊게 생각지 않았고 그 뒤로 3년이 흘렀다.
이 설정을 다시 들여다보게 된 건 최근 nugu 몰을 App Router로 전환하며 성능 개선을 진행하면서였다.
next/image를 쓸 수 없는 환경
nugu 몰은 Next.js로 만들어져 있지만 next/image를 쓰지 않았다. 이미지 인프라가 Next.js 전용일 수 없었기 때문이다. 어드민은 같은 모노레포 안에 Svelte로 만들어져 있었는데, next/image는 Next.js 서버의 이미지 최적화 엔드포인트에 종속된 컴포넌트라 Svelte 쪽에서는 쓸 방법이 없었다. 그래서 어느 프레임워크에서든 동일하게 쓸 수 있도록 AWS 람다 기반 이미지 리사이저를 두고, 각 서비스가 자체 이미지 컴포넌트로 이를 호출하는 구조로 설계됐다. next/image가 해주는 화면 크기별 srcset 생성, lazy 로딩, 품질 압축 같은 일들을 전부 책임져야 했다.
성능 개선을 하며 이 구조를 따져보니 두 가지가 눈에 들어왔다. 하나는 img의 loading이 eager로 기본값이 설정돼 400곳이 넘는 이미지 사용처의 99%가 스크롤 아래 이미지까지 즉시 로드하고 있었다는 것. next/image는 lazy인데 정반대의 기본값이었다. 다른 하나가 q=100이었다.
loading은 명백히 고쳐야 할 문제였다. 그런데 q=100은 버그가 아니라 과거 의사결정이었다. 뒤집으려면 근거가 필요했다.
업계 표준 도구의 기본값은 75였다
next/image의 기본 품질이 75라는 사실을 알게 됐다. Next.js 소스의 find-closest-quality.ts를 보면 quality를 지정하지 않았을 때 이렇게 처리한다.
const q = quality || 75공식 문서도 같은 값을 명시한다. 수많은 커머스가 올라가 있는 프레임워크가, 아무 설정 없이 쓰면 이미지를 75로 압축하고 있었다.
표준 도구 기본값이 75라면, 100과 75의 차이가 실제로 눈에 보이긴 할까? 직접 확인해봤다.
직접 눈으로 비교하기
비교 도구는 이미지 처리 라이브러리 sharp를 썼다. next/image의 이미지 최적화 서버(image-optimizer.ts)가 내부에서 쓰는 인코더가 바로 sharp다. 즉 next/image의 q=75와 sharp의 q=75는 같은 인코더의 같은 값이다. 그리고 우리 AWS 람다 이미지 리사이저가 내부에서 쓰는 모듈도 sharp였다.
이 sharp로 고전 테스트 이미지(학부 이후로 오랜만에..)인 Lenna(512×512)를 WebP q=100부터 q=50까지 인코딩해 봤다. 파일 크기부터 차이가 컸다.
| quality | 파일 크기 | q=100 대비 |
|---|---|---|
| 100 | 131.0KB | 100% |
| 90 | 62.1KB | 47% |
| 85 | 41.3KB | 32% |
| 75 | 22.4KB | 17% |
| 60 | 17.7KB | 14% |
| 50 | 15.2KB | 12% |
q=100에서 q=85로만 내려도 파일이 3분의 1이 됐다. q=75면 6분의 1이었다. 속눈썹, 머리카락의 결, 피부의 그라데이션처럼 압축 아티팩트가 드러나기 쉬운 얼굴 부분을 잘라 3배 확대했다.

q=100. 속눈썹 한 가닥, 머리카락의 결까지 모두 살아 있었다.

q=85. 3배 확대한 상태로 비교해도 q=100과 구분이 어려웠다. 파일은 3분의 1이었다.

q=75. 속눈썹도 가닥이 뭉개져 또렷함이 떨어졌다. 확대해서 들여다보면 보이지만, 실제 화면 크기에서는 알아채기 힘든 수준이었다.

q=50. 여기서는 뚜렷하게 차이가 보였다. 얼굴 윤곽이 뭉개졌다.
같은 테스트를 Google Chrome Labs가 만든 브라우저 이미지 압축 도구 Squoosh에서도 진행했다. 화면 가운데 슬라이더를 드래그하면 원본과 압축본을 실시간으로 오가며 비교할 수 있다. 결과는 sharp로 본 것과 같았다. (직접 재현해 보고 싶다면 아무 이미지나 올려 quality를 내려보면 된다.)
그래서 85로 갔다
가장 고민됐던 건 85와 75 사이였다. next/image 기본값에 맞춰 75까지 가면 절감 폭이 가장 컸다. 하지만 nugu는 옷을 파는 곳이라, 이미지 속 원단의 질감, 니트의 짜임 같은 디테일이 구매에 영향을 줄 거라 봤다. 100과 비교하면 75부터 미세한 결이 무뎌지기 시작하는 게 보여, 그보다는 높여야겠다고 생각했다. 시각적으로 q=100과 구분이 안 되면서 파일은 3분의 1인 q=85가 절충점이라 판단했다.
img loading도 기본값만 eager에서 lazy로 뒤집었고, eager는 필요한 곳에서만 명시적으로 opt-in하는 구조로 바꿨다. 메인 배너, 상품 상세의 첫 이미지와 같은 LCP 후보들은 명시적으로 eager를 유지했다. autoplay 스와이퍼 컴포넌트도 예외로 뒀다. 곧 노출될 슬라이드가 lazy면 슬라이드가 변경되는 순간 빈 화면이 잠깐 보이기 때문이다.
전송량은 줄었지만 점수는 떨어졌다...?
배포 후 초기 로드 이미지 전송량은 홈 −57%, 상품 상세(임의의 상품) −95%, 디렉터 페이지 −70%로 줄었다. LCP도 전 라우트에서 16~29% 개선됐다. 효과가 있었다.
그런데 신기하게 Lighthouse 점수는 좋아지지 않았다. 홈만 오르고, 상품 상세와 디렉터 페이지는 오히려 떨어졌다. 전송량을 95% 줄였는데 점수가 내려간다는 게 이해되지 않았다.
원인은 TBT(Total Blocking Time)였다. Lighthouse 점수 산정 방식에서 TBT는 가중치 30%로 단일 항목 최대다. 그 TBT가 측정 조건(slow-4G)에서 수 배로 뛰면서, LCP 개선분(가중치 25%)을 전부 상쇄하고도 남았다.
대역폭 해방의 역설
처음엔 새로운 JS가 추가됐나 의심했는데 아니었다. JS 전송량은 거의 동일했다. 그런데 메인스레드에서 실행된 작업량은 늘어 있었다.
메커니즘은 이랬다. 변경 전에는 느린 네트워크에서 이미지 수십 MB가 대역폭을 독점하고 있었다. JS는 찔끔찔끔 도착했고, 채널톡이나 GTM 같은 서드파티 스크립트는 측정이 끝날 때까지 실행을 다 마치지 못했다. 이 때문에 메인스레드가 한가해 보였던 것이다. 이미지를 줄이자 대역폭이 비었고, JS가 일찍, 빽빽하게 도착해 연속으로 실행되면서 롱태스크가 밀집했다.
즉 상당 부분은 새로 생긴 비용이 아니라, 숨어 있던 JS 비용이 측정 윈도 안으로 들어온 것이다. 이미지가 비켜주자, 그동안 이미지 뒤에 가려져 있던 진짜 비용이 드러났다.
대증요법에서 원인요법으로
돌아보면 이번 작업은 대증요법이었다. 성능을 개선하겠다며 가장 눈에 띄는 비용(이미지 전송량)부터 줄였다. 그리고 그 처치가 효과를 내는 순간, 그 뒤에 가려져 있던 메인스레드를 붙잡는 JS 실행 비용이 드러났다.
이미지 전송량 −57~95%, LCP 개선은 측정 조건과 무관하게 실사용자에게 이득이다. 하지만 Lighthouse 점수가 말해준 건 다음에 진료해야 할 곳은 이미지가 아니라 JS라는 것이었다. q=100이라는 오래된 설정 하나를 의심한 것이 결과적으로 다음 환부를 찾아준 셈이다.
의사결정에는 유통기한이 있다. 아마존 제프 베조스는 의사결정을 두 종류로 나눴다. 되돌릴 수 없는 결정(one-way door)은 신중하게, 되돌릴 수 있는 결정(two-way door)은 가벼운 절차로 빠르게 내리고 틀렸다면 다시 문을 열고 나오면 된다고. q=100은 전형적인 two-way door였다. 설정값 하나만 바꾸면 언제든 되돌릴 수 있는 결정이었는데, 한 번 닫힌 뒤로 3년간 아무도 다시 열어보지 않았다. 결정 당시에는 옳았더라도, 그 근거를 다시 확인하지 않은 채 몇 년이 지나면 "옳은 결정"이 아니라 "아무도 안 건드린 결정"이 된다. 다음 의심 대상은 이미 정해졌다.