본문으로 건너뛰기
Engineering#Next.js#AVIF#WebP#이미지최적화#성능최적화

Next.js 이미지 최적화, AVIF를 끄고 WebP만 남긴 이유

N100 서버에서 재보니 AVIF 인코딩이 WebP보다 최대 약 7배 느렸습니다. Next.js 이미지 최적화의 포맷, 캐시 TTL, 원본 크기를 조정하고 운영에서 놓친 설정 파일까지 바로잡은 과정입니다.

Ian2 min read11

좋은 포맷이 빠른 포맷은 아니다

플린랩스 홈페이지는 Next.js 14로 만들었고, Docker Compose로 Intel N100 기반 저전력 서버에 올려 운영합니다. 회사 사이트 정도의 트래픽에는 전력과 비용 면에서 충분하다고 판단했습니다.

이미지 로딩을 손보면서 next.config.mjs에 AVIF를 추가하려 했습니다. AVIF는 사진에서 WebP보다 용량이 작게 나오니 켜지 않을 이유가 없어 보였습니다. 결론부터 말하면, 우리는 AVIF를 켜지 않기로 했습니다.

원인은 요청 시점 인코딩

Next.js의 <Image>는 빌드할 때가 아니라 첫 요청이 들어올 때 원본을 변환하고, 결과를 캐시에 저장합니다. 변환 비용을 첫 방문자가 그대로 기다리는 구조입니다.

Next.js 이미지 요청 흐름: 캐시가 없으면 요청 시점에 인코딩한다
javascript
images: {
  formats: ['image/avif', 'image/webp'], // 검토했던 설정
}

브라우저가 Accept: image/avif를 보내면 Next.js는 AVIF로 인코딩합니다. 문제는 AVIF 인코딩이 WebP보다 CPU를 훨씬 많이 쓴다는 점입니다.

N100에서 직접 재보니

운영 중인 web 컨테이너 안에서 Next.js가 /_next/image에 쓰는 것과 같은 변환 함수로 1856×2304px PNG 한 장을 너비별로 인코딩했습니다.

N100 인코딩 시간: 640px WebP 1.1초 AVIF 1.8초, 1080px 1.0초 대 3.5초, 1920px 1.1초 대 7.6초

WebP는 너비와 상관없이 1초 남짓이었지만, AVIF는 너비가 커질수록 느려져 1920px에서 7.6초가 걸렸습니다. 결과물은 AVIF가 확실히 작았습니다(1920px 기준 36KB 대 88KB). 하지만 첫 방문자가 이미지 한 장에 7초 넘게 기다리는 대가로는 너무 비쌉니다.

참고로 이 컨테이너에는 sharp가 설치돼 있지 않아 Next.js 내장 변환기로 측정했습니다. sharp를 쓰면 절대 시간은 달라질 수 있지만, AVIF가 더 무겁다는 방향은 같습니다.

우리가 바꾼 세 가지

1. 포맷은 WebP만

javascript
images: {
  formats: ['image/webp'],
  minimumCacheTTL: 31536000, // 1년
}

우리 팀의 원칙은 **"요청 시점에 인코딩하는 구조에서 서버 CPU가 약하면 AVIF는 켜지 않는다"**입니다. CDN에서 변환하거나 빌드 단계에서 미리 만들어 두는 구조라면 판단이 달라질 수 있습니다.

2. 캐시 TTL 1년

minimumCacheTTL의 기본값은 60초입니다. 원본에 캐시 헤더가 없으면 최적화된 이미지가 1분 만에 만료되고, 다음 요청에서 다시 인코딩됩니다.

minimumCacheTTL 60초는 만료마다 재인코딩, 1년은 처음 한 번만 인코딩

우리 정적 이미지는 내용이 바뀌면 파일명도 바꾸기 때문에 1년 캐시를 걸어도 안전합니다.

3. 원본부터 작게

인물 사진 원본은 1856px PNG였지만, 실제로는 화면 절반 너비 슬롯이라 1920px 화면에서도 최대 약 960px로 표시됩니다.

bash
sips -Z 1200 portrait.png

인물 사진 원본 크기 비교: PNG 1856px 2.9MB, PNG 1200px 807KB, WebP 69KB

새 사진은 커밋 전에 1200px 이하로 줄이는 것을 팀 규칙으로 두었습니다.

함정: 빠진 설정 파일

여기서 끝난 줄 알았는데, 블로그에 외부 커버 이미지를 올리자 "url" parameter is not allowed 400 오류가 났습니다. 확인해 보니 운영 Docker 이미지에 next.config.mjs가 아예 복사되지 않고 있었습니다.

dockerfile
# next start는 실행 시점에 next.config를 읽는다
COPY --from=build /app/apps/web/next.config.mjs ./

next start는 빌드 산출물이 아니라 실행 디렉터리의 설정 파일을 읽습니다. 파일이 없으니 remotePatterns도, 1년 캐시도 적용되지 않은 채 기본값으로 돌고 있었습니다. 설정을 바꿨다면 운영에서 실제 응답 헤더로 확인해야 한다는 걸 배웠습니다.

마치며

  • Next.js 이미지 최적화는 요청 시점에 비용이 생깁니다. 포맷은 서버 CPU에 맞춰 골라야 합니다.

  • N100에서는 AVIF가 WebP보다 최대 약 7배 느렸고, 우리는 WebP만 쓰기로 했습니다.

  • minimumCacheTTL을 늘려 같은 이미지를 반복해서 인코딩하지 않게 합니다.

  • 설정은 코드에 있는 것과 운영에 적용된 것이 다를 수 있습니다. 응답으로 확인하세요.

벤치마크 기사 속 수치보다, 우리 서버에서 직접 잰 숫자를 믿어야 한다는 걸 다시 배웠습니다.

플린랩스와 함께

함께 미래를 만들 준비가 되셨나요?

기획·설계부터 구축, 운영까지 — 플린랩스가 전 과정을 함께합니다.

인천, 북위 37.53도 동경 126.73도