[Next.js] 이미지 성능 높이기

2026. 7. 29. 12:56·프로젝트
728x90

💡 읽기 전 필요한 지식 💡
- Next.js <Image> 컴포넌트가 이미지 최적화를 자동으로 해준다는 정도의 배경
- SVG(벡터)와 WebP(래스터/압축 포맷)가 서로 다른 이미지 포맷이라는 것
- 이 프로젝트(톨리)는 React Native + WebView로 Next.js 페이지를 감싼 하이브리드 앱이라, 웹 성능이 곧 앱 체감 속도로 이어진다

📖 읽고 나면 얻을 수 있는 것 📖
- SVG가 Next.js Image 최적화 대상에서 빠지는 이유를 이해하게 된다
- sharp로 SVG를 WebP로 직접 변환하는 스크립트를 만드는 법을 알게 된다
- priority와 <link rel="preload">의 차이, 언제 각각을 써야 하는지 알게 된다
- 프로덕션 JS 번들 크기를 개발 모드 수치만 보고 오판하지 않는 법을 알게 된다

 필자는 톨리란 프로젝트를 진행했는데,

 

[tolli #1] 무거운 성경암송 앱이 싫다

💡 읽기 전 필요한 지식 💡- 모바일 앱이 "기획 -> 개발 -> 출시"의 단계를 거친다는 정도의 큰 그림- 듀오링고 같은 학습 앱을 한 번이라도 써본 경험- "타겟 사용자"나 "문제 정의"라는 말이 어떤

bbin-guuuu.tistory.com

출시 전에 앱을 처음부터 다시 써보다가 온보딩에서 심각함을 느꼈다.

슬라이드를 넘길 때마다 캐릭터 이미지가 잠깐씩 깜빡이며 늦게 나타나는 이슈가 있었다.

개발 중엔 솔직히 MVP완성에 목표를 두고 있었기에 신경을 쓰지 못했는데, 완성된 앱을 사용자 입장에서 보니 거슬리기 시작했다.

 

그렇게 DevTools를 열어서 /onboarding/3 페이지(마지막 온보딩 슬라이드)에서 네트워크를 찍어보았다.

로컬 개발 서버, 캐시 없음 조건을 선택했을 때 총 4개의 리소스가 요청됐고 전체 전송량을 확인해보니 704KB였다.

이 4개의 리소스 중 하나인 tolli3.svg란 이미지가 700KB를 차지했고 로드에 64ms가 걸린 것을 확인할 수 있었다.

로컬에서 64ms가 걸린다면, 실기기에서 LTE나 3G로(극단적 상황으로?) 접속하게 되면 이미지 하나를 불러오는 데만 몇 초가 걸릴 수도 있겠다는 생각이 들었다.

온보딩에 쓰이는 파일들을 정리해보니 더 충격적이었던 것 같다...

파일 크기 사용 위치
eatingTolli.svg 1.7MB 온보딩
standingTolli_1.svg 1.7MB 온보딩
TolliLastStep.svg 1.7MB record-intro
hungryTolli_1.svg 1.4MB 온보딩
fullHappyTolli.svg 1.3MB 온보딩
fullTolli.svg 1.3MB 온보딩
tolli1.svg 1.1MB loading, step2-intro, recall-intro
timeSetTolli.svg 1.1MB 온보딩
timeTolli.svg 912KB 온보딩
온보딩 3 슬라이드 합계 ~11MB -

SVG는 벡터 포맷이라 오히려 가벼울 거라 막연히 생각했는데 전혀 아니었다는 것을 확인할 수 있다.


Next.js Image로 바꿔보기

첫 시도로 필자는 HTML의 기본 <img> 태그를 Next.js의 <Image> 컴포넌트로 바꾸어보았다.

Next.js Image는 WebP로의 자동 변환, 화면 크기에 맞는 해상도 조정, 그리고 지연 로딩을 자동으로 처리해준다.

 

<Image> – Nextjs 한글 문서

Optimize Images in your Next.js Application using the built-in `next/image` Component.

nextjs-ko.org

결과는 기대 이하였던 것 같다.

항목 Before After 변화
tolli3.svg 크기 700KB 700KB 동일
이미지 로드 시간  64ms 72ms 거의 동일
총 전송량  704KB 4.4MB 오히려 증가했다.
(cache disabled 옵션 체크 상태에서)

Next.js Image만으로는 SVG에 최적화 효과가 없었다.

찾아보니 SVG는 Next.js가 WebP로의 변환 자체를 건너뛴다고 한다.

이유는 벡터 포맷이라 변환 대상이 아니기 때문!

svgo를 사용하여 직접 SVG를 압축해보기도 했지만 변화율은 0%에 가까웠다.

 

원인을 찾으려 SVG파일을 직접 열어봤다.

그 결과 안에 base64로 인코딩된 PNG비트맵이 그대로 박혀 있었다.

즉, 확장자만 SVG였지 실제로는 비트맵으로 이루어진 덩어리였던 것이다.


SVG를 WebP로 직접 변환하기 (sharp 사용)

Next.js가 처리를 안 해준다면 필자가 직접 하면 된다고 생각해봤다.

sharp 라이브러리로 SVG를 래스터화한 뒤 WebP로 변환하는 스크립트를 클로드코드와 함께 짜보았다.

https://www.npmjs.com/package/sharp

density 144로 렌더링해서 2x 해상도를 유지하면서 quality 85로 압축해보았다.

 

결과는 만족스러웠다.

파일 Before After 감소율
tolli1.svg   23KB -97.9%
tolli2.svg 886KB 29KB -96.7%
tolli3.svg 908KB 25KB -97.3%
tolli-logo.svg 1,708KB 41KB -97.6%
tolli-terms.svg 1,204KB 25KB -98.0%
TolliLastStep.svg 1,708KB 56KB -96.7%
eatingTolli.svg 1,751KB 14KB -99.2%
fullHappyTolli.svg 1,328KB 23KB -98.3%
fullTolli.svg 1,328KB 17KB -98.7%
hungryTolli_1.svg 1,478KB 16KB -98.9%
standingTolli_1.svg 1,708KB 16KB -99.1%
timeSetTolli.svg 1,113KB 3KB -99.8%
timeTolli.svg 908KB 16KB -98.3%
star1.svg / star2.svg 1,328KB (각) 1KB (각) -99.9%
전체 합계 ~22MB ~387KB -98.2%

순수 벡터 아이콘인 icons/dropdown/에 있는 svg파일들과 soundBar로 시작하는 svg 등 4KB 이하 파일들은 변환하지 않았다.

이미 가볍고, 실제로 벡터라 확대해도 깨지지 않아야 하는 파일들이었다.

 

로드 시간도 같이 바뀐 것을 볼 수 있었다.

항목 Before After
이미지 크기 (tolli3 기준) 700KB 21.3KB (-97%)
로드 시간 72ms 9ms (-88%)
파일 포맷 SVG (base64 비트맵) WebP

혹시 몰라 프로덕션에서도 최적화가 되어있는지 build를 해보았다.

개발 모드에서의 1,398KB와는 다르게 프로덕션에선 102~168KB로 이미 꽤 최적화된 것을 볼 수 있었다.

찾아보니 Next.js가 빌드 시 트리쉐이킹을 자동으로 해준다고 한다.

Next.js 트리쉐이킹은
빌드 과정에서 사용하지 않는 코드를 제거해, 최종 번들 파일 크기를 줄이고 로딩 속도를 높이는 최적화 기술이다.

따라서 vercel 배포시에 최적화(CDN 캐싱, 엣지 최적화)까지 자동으로 해주기 때문에 지금 개선된 구조는 이미 양호한 상태라고 보았다.

그래서 번들 쪽 라우팅 성능은 지금 당장 손댈 필요가 없다고 판단했다!


그래도 늦게 뜬다...

파일 크기를 위와 같이 줄였는데도 UX상으로는 사진이 여전히 조금 깜빡이며 늦게 뜨는 것을 확인할 수 있었다.

이번엔 포맷이 아니라 로딩 타이밍이 문제였던 것 같다. (로드 시간 자체는 상당히 많이 줄였기 때문)

 

필자는 그 이후에 <Image>에 priority를 주어 우선순위를 부여해서 해결해봤다. 그 과정을 설명해보겠다.

Next.js의 <Image>의 기본값 자체는 lazy load이다.

뷰포트에 보일 때까지 로드를 시작하지 않는다는 뜻이다.

그러니까 이 서비스의 RN껍데기안의 웹뷰라는 구조를 본다면 페이지 진입 즉시 보여야하는 이미지인데도,

브라우저가 렌더링을 끝내고 나서야 요청을 보내고 있었던 것이다.

 

 

Next.js - Image Optimization

next.js에서는 이미지 최적화를 위해 Image 컴포넌트를 제공한다. Image 컴포넌트를 사용하면 다음과 같은 기능들을 이용하여 최적화된 이미지를 사용하여 성능을 향상할 수 있고, 더 나은 사용자 경

velog.io

위의 블로그를 확인해보았을 때 priority 속성에 대해 알게 되었다.

주의사항으로는 <Image priority>는 해당 태그가 DOM에 존재할 때만 작동한다고 한다.

브라우저는 HTML을 파싱하면서 <img>태그를 발견해야 그제서야 요청을 보낸다.

태그 자체가 없으면 <Image>에 priority속성을 주어도 브라우저는 그 이미지 존재 자체를 모른다.

 

그래서 상황을 두 갈래로 나누어 보았다.

상황 방법 이유
페이지 진입 즉시 DOM에 있는 이미지 priority 태그가 있으니 브라우저가 인식한다.
조건부 렌더링 또는 다음 페이지 이미지  <link rel="preload"> 태그가 아직 없으니 직접 브라우저에 알려줘야 한다.

priority

DOM에 바로 있는 이미지는 priority 하나만 추가하면 됐다.

// study/loading/page.tsx
<Image src="/tolli1.webp" alt="Tolli" width={228} height={228} priority />

// login/page.tsx
<Image src="/tolli-logo.webp" alt="tolli" width={744} height={744} priority />

조건부 렌더링 / 다음 페이지 이미지 preload

두갈래로 나눈 이유는 DOM에 없는 이미지 로드 타이밍을 개선하기 위해 preload를 생각하게 됐다.

 

조건부 렌더링되는 이미지는 priority의 케이스와 다른데,

completeRecording(녹음 완료 페이지)에서 FullTolli, Star1, Star2 이미지들은 3초뒤에 state가 바뀔 떄 그제서야 DOM에 나타난다.

즉, priority를 줘도 태그가 없는 동안엔 브라우저가 요청을 하지 않는다는 뜻이다.

그래서 useEffect에서 페이지 진입 시점에 <link rel="preload">를 직접 삽입해 보았다.

// study/[verseId]/completeRecording/page.tsx
useEffect(() => {
  const preloads = [Star1.src, Star2.src, FullTolli.src].map((src) => {
    const link = document.createElement('link');
    link.rel = 'preload';
    link.as = 'image';
    link.href = src;
    document.head.appendChild(link);
    return link;
  });
  return () => { preloads.forEach((l) => document.head.removeChild(l)); };
}, []);

온보딩 슬라이드도 마찬가지다.

슬라이드 1에는 슬라이드 2의 이미지가 현재 페이지 DOM에 아예 존재하지 않는 구조이다.

현재 슬라이드에 머무는 동안 다음 슬라이드에 이미지를 미리 요청해두고, 페이지를 떠날 때 cleanup으로 link를 제거해보았다.

// onboarding/[step]/page.tsx
const next = STEPS[step]; // 다음 슬라이드 데이터 (마지막이면 undefined)

useEffect(() => {
  if (!next) return;
  const link = document.createElement('link');
  link.rel = 'preload';
  link.as = 'image';
  link.href = next.image;
  document.head.appendChild(link);
  return () => { document.head.removeChild(link); };
}, [next]);

개선 이전 속도 - 15ms
개선 후 속도 - 12ms

위와 같이 개선 후에 3ms나 줄일 수 있었던 것을 볼 수 있다.

위의 사진도 온보딩1부터 3까지 진행한 데이터인데,

사진을 보면 다음 사진의 프리패칭이 올바르게 동작하는 것을 볼 수 있었고 속도자체도 체감상 많이 빨라졌다.

Disable cache 상태에서 매번 새로 받는 조건임을 감안하면,
실제 앱에서는 첫 로드 이후 캐시 히트로 훨씬 빠를 것이다.

마치며...

톨리와 같은 웹뷰 서비스는 웹 성능이 곧 앱의 체감 속도로 직결된다고 생각한다.

그래서 그런지 이번 작업을 진행하며 문서만 읽어서는 해결책이 잘 안잡히니 직접 네트워크 탭을 열어보는건 어떨까?

728x90

'프로젝트' 카테고리의 다른 글

[tolli #6] 웹뷰와 RN 사이의 UI 트러블슈팅 모음  (0) 2026.07.05
[tolli #5] 웹뷰에서 구글로그인 구현하기  (0) 2026.07.01
[tolli #4] AOS 애플로그인 트러블슈팅  (0) 2026.06.30
[tolli #3] switch로 쪼갠 화면이 협업 단위가 됐다  (0) 2026.06.22
[tolli #2] 왜 React Native + WebView 하이브리드였을까?  (0) 2026.06.20
'프로젝트' 카테고리의 다른 글
  • [tolli #6] 웹뷰와 RN 사이의 UI 트러블슈팅 모음
  • [tolli #5] 웹뷰에서 구글로그인 구현하기
  • [tolli #4] AOS 애플로그인 트러블슈팅
  • [tolli #3] switch로 쪼갠 화면이 협업 단위가 됐다
프론트 개발자 김현중
프론트 개발자 김현중
👋반갑습니다 저는 나눔을 실천하는 개발자 꿈나무 김현중입니다⌨️🚀
  • 프론트 개발자 김현중
    삥구의 개발블로그
    프론트 개발자 김현중
  • 전체
    오늘
    어제
    • 분류 전체보기 (118) N
      • React (39)
      • JavaScript (5)
      • TypeScript (5)
      • Next.js (13)
      • React Native (1)
      • 프로젝트 (7) N
      • 오픈소스 (1)
      • 우아한테크코스 (9)
      • 알고리즘 (5)
      • Swift (3)
      • 컴퓨터네트워크 (1)
      • Docker (1)
      • SQL (8)
      • Database (2)
      • 배포 (1)
      • Spring (9)
      • Git (1)
      • 회고 (1)
      • 컴퓨터그래픽스 (2)
      • Python (1)
      • Brew (1)
      • LangChain (2)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    appRouter
    database
    springboot
    Next.js
    회고
    프론트엔드
    javascript
    웹개발
    우테코
    frontend
    Backend
    톨리
    typescript
    java
    nextjs
    Spring
    ReactHooks
    우아한테크코스
    react
    데이터베이스
  • 최근 댓글

  • 최근 글

  • 250x250
  • hELLO· Designed By정상우.v4.10.1
프론트 개발자 김현중
[Next.js] 이미지 성능 높이기
상단으로

티스토리툴바