들어가며
최근 외주를 통해 프리랜서로 업무를 맡게 되었습니다. 마침 성능 최적화에 대해 공부중이었고 이를 적용해서 사이트를 분석 및 개선해볼 수 있었습니다.
환경
- Next.js v12.3 Page router
- React.js v18.2.0
Page Speed Insight와 LightHouse를 통해 확인하며 개발했고, 최종적인 느린 4G 네트워크의 모바일 환경에서 성능 지표 점수를 약 52%를 개선하였습니다. 기존 38점이던 지표에서 58점으로 개선하여 나쁨에서 개선 필요 단계로 상승할 수 있었습니다.
아직 부족한 부분이 많지만, 개선 작업의 효과가 있어 공부한 내용을 Next.js 환경에 적용한 내용을 공유하려고 합니다.
Page Speed Insight에서는 모바일 환경을 기본적으로 느린 4G 네트워크로 제한하고 있어, 지하철에서 사용할 때와 같이 느린 네트워크 환경에서의 사용자 경험을 생각해 볼 수 있었습니다.
성능 지표 향상을 위해 크게 세 가지 전략을 도입해보았습니다.
- 이미지 최적화
- 첫 페이지 로딩에 필요한 이미지 주소를 SSR을 통해 미리 가져온다.
- Image 컴포넌트의 sizes, priority, loading 등 전략을 적절히 사용한다.
- 첫 로딩 스크립트 최적화
- 외부 타사 스크립트 Script 컴포넌트의 id, stratiges 전략을 적절히 사용한다.
- 외부 모듈 제거, 불필요한 컴포넌트 동적 import
- 첫 로딩 시 불필요한 컴포넌트 dynamic import를 사용하여 지연 로드
- 외부 모듈 삭제, 직접 구현하여 최초 JS 파일 크기 축소
Next.js Image 컴포넌트 최적화
Next.js의 Image 컴포넌트는 이미지 최적화를 위한 다양한 속성들을 제공합니다. 이번 글에서는 Image 컴포넌트의 주요 속성들을 살펴보고, sizes, priority, lazy loading
속성을 활용해 LCP(Largest Contentful Paint) 성능을 개선한 사례를 공유하고자 합니다.
SSR 활용하기 - prefetchQuery
getServerSideProps
함수를 통해, 이미지 주소를 알아오는 GET 요청을 페이지 로딩 전에 먼저 받아올 수 있도록 수정하였습니다.
이를 통해, 페이지가 로드될 때 데이터를 미리 가져오고 LCP 지표를 개선할 수 있게 됩니다.
Next.js와 tanstack query를 활용하여 데이터를 가져오기 때문에, prefetchQuery 함수를 사용하여 가져올 수 있습니다.
export async function getServerSideProps(context) {
const queryClient = new QueryClient();
await queryClient.prefetchQuery([QUERY_KEY], () => getAPI());
return {
props: {
dehydratedState: dehydrate(queryClient),
},
};
}
props를 통해 내려주기보다, dehydrate를 사용하면 클라이언트-서버 간의 상태 동기화가 이루어져 캐시 시스템을 통합하여 사용할 수 있습니다.
즉, stale time, gc time같은 캐시 설정을 그대로 활용하면서 자동으로 재검증할 수 있게 됩니다.
Next.js Image 컴포넌트의 주요 속성들
1. width/height 속성
명시적인 크기를 지정할 때 사용합니다. 정적으로 가져온 이미지나 fill 속성이 있는 이미지를 제외하고는 필수입니다.
- 이미지의 종횡비를 유추
- CLS(Cumulative Layout Shift) 방지
<Image
src="/product.jpg"
alt="상품 이미지"
width={146}
height={146}
/>
2. sizes 속성
sizes
속성은 브라우저에게 이미지가 표시될 크기에 대한 힌트를 제공합니다.
그리고 Next.js가 자동으로 생성하는 img
태그의 srcset
과 함께 작동하여 반응형 이미지를 구현합니다.
브라우저가 이미지 로딩 전에 필요한 정확한 크기를 알 수 있게되어, 적절한 크기의 이미지를 선택할 수 있도록 합니다. 따라서, 불필요하게 큰 이미지를 다운로드하지 않게 됩니다.
// (max-width: 768px) 에서는 뷰포트 너비를 100% 사용하고
// 그 이상에서는 1200px로 제한
<Image
src="/banner.jpg"
alt="배너 이미지"
sizes="(max-width: 768px) 100vw, 1200px"
/>
// Next.js가 생성하는 HTML 예시
<img
alt="배너 이미지"
sizes="100vw"
srcset="
/next/image?url=/banner.jpg&w=640&q=75 640w,
/next/image?url=/banner.jpg&w=750&q=75 750w,
/next/image?url=/banner.jpg&w=828&q=75 828w,
/next/image?url=/banner.jpg&w=1080&q=75 1080w,
/next/image?url=/banner.jpg&w=1200&q=75 1200w,
/next/image?url=/banner.jpg&w=1920&q=75 1920w
"
src="/next/image?url=/banner.jpg&w=1920&q=75"
/>
위 예시처럼 srcset에는 이미지 여러 버전을 각 너비와 함께 명시합니다. sizes 속성은 이 뷰포트에서 이 너비로 이미지가 그려질 것 이라는 힌트를 제공합니다.
브라우저는 뷰포트의 너비와 sizes를 종합하여 가장 적절한 srcset 이미지를 골라서 다운로드하게 됩니다.
LightHouse에서 Properly size images
경고가 발생했다면, sizes 속성을 통해 해결할 수 있습니다.
저도 적절한 sizes를 제공하고 이 경고를 해결할 수 있었습니다.
3. priority와 loading 속성의 관계
priority
와 loading
속성은 이미지 로딩 전략을 결정하는 중요한 속성입니다.
실제로 priority를 주는 이미지는 페이지 내에서 정말 중요한 이미지(예: LCP에 영향을 주는 Hero 이미지) 1~2개 정도에만 쓰는 것이 권장됩니다.
priority가 설정되면 내부적으로 <link rel="preload">
로 처리되므로 여러 이미지에 priority를 과도하게 사용하면 네트워크 리소스가 몰리거나 역효과가 날 수 있습니다.
priority={true}
: 이미지 사전 로드- 페이지 진입 시점에 즉시 다운로드
- 뷰포트 위치와 관계없이 다운로드
- HTML의
<head>
에<link rel="preload">
추가 - LCP(Largest Contentful Paint) 이미지에 사용
loading="lazy"
:- 뷰포트에 가까워질 때만 다운로드
- 스크롤 위치 기반으로 동작
- 초기 페이지 로드 최적화
주의할 점은 loading="lazy"를 설정하면 priority 속성은 무시됩니다.
If the loading property is also used and set to lazy, the priority property can't be used. The loading property is only meant for advanced use cases. Remove loading when priority is needed.
따라서, 첫 페이지에서도 뷰포트에 가장 먼저 들어오는 이미즈는 priority
를 설정하고
뷰포트에 포함되지 않는 이미지는 loading=lazy
를 설정하므로서 최적화를 진행할 수 있습니다.
Next.js Script 컴포넌트
외부 스크립트를 다운로드 하거나, 필요한 스크립트를 실행하는데에 필요한 Script 컴포넌트에 대해 알아보겠습니다.
Strategy
<Script
src="example.js"
strategy="lazyOnload"
/>
strategy는 여러 전략을 제공합니다.
beforeInteractive
: 페이지 상호작용 전 로드. 페이지 상호 전에 로드하므로 페이지 로드를 차단할 수 있어 필요한 경우에만 사용하도록 합니다. html head태그 내에 삽입되게 됩니다.afterInteractive
: (기본값) 페이지 상호작용 후 로드. html body 태그에 삽입되게 됩니다.lazyOnload
: 모든 리소스 후 지연 로드. 모든 메인 리소스와 상호작용이 끝난 뒤에 로드하기 때문에, 성능에는 유리하지만, 스크립트 로딩이 늦어지는 만큼 실제로 써야 하는 시점과 타이밍을 꼭 고려해야 합니다.worker
: (Next 12.3버전에서 실험적) web worker에서 로드
기존 프로젝트에서는 defer와 async가 혼재되어 있거나, 심지어 일부 스크립트에는 로딩 전략이 설정되지 않아 rendering blocking 요소로 인해 HTML 파싱을 방해하여 성능이 저하될 가능성이 있었습니다.
Next.js의 Script 컴포넌트는 기본적으로 afterInteractive
전략을 사용하며, defer
와 동일하게 HTML body 태그에 스크립트를 추가하고, 페이지 DOM 로딩 후 실행되도록 보장합니다.
HTML파싱을 방해하지 않으므로 FCP를 개선할 수 있고, 일관된 로딩 전략으로 로딩 순서를 예측할 수 있습니다.
일부 태그는 lazyOnLoad 전략을 통해 리소스 로드 완료 후에 실행되어 네트워크 병목을 최소화할 수 있습니다.
이벤트 처리
onLoad, onError와 같이 이벤트 핸들러를 부착할 수 있어 디버깅 시 유용합니다.
<Script
src="example.js"
onLoad={() => console.log('로드 완료!')}
onError={(e) => console.error('로드 실패', e)}
/>
Inline script id 속성
각 인라인 스크립트를 추적하고 최적화하기 위해 id를 부여하면 이미 로드된 스크립트의 중복 실행을 방지할 수 있습니다.
next/script components with inline content require an id attribute to be defined to track and optimize the script.
중복된 id를 주면 뒤에 선언된 동일 id 스크립트는 무시됩니다.
<Script id="sc-id">{`console.log("1-script")`}</Script>
<Script id="sc-id">{`console.log("2-script")`}</Script> // 자동으로 무시됨
불필요한 외부 라이브러리 삭제, 직접 구현
위와 같이 첫 로딩시 필요한 리소스의 대부분의 용량이 js에 해당했고, 라이트하우스의 진단에도 기본 스레드 작업 최소화하기(JS 페이로드를 줄이면 도움이 될 수 있다고 명시) 첫 로딩 시 필요한 청크의 크기를 줄여보고자 노력해보았습니다.
@next/bundle-analyzer
를 통해 각 번들 파일의 크기와 그 내부에 어떤 파일이 큰 용량을 차지하는지 알아볼 수 있습니다.
이 중, 공통으로 필요한 js파일에 _app.tsx
페이지의 청크가 포함되는데 이 페이지에 사용하는 컴포넌트를 살펴보았습니다.
+ First Load JS shared by all 174 kB
├ chunks/framework-4ed89e9640adfb9e.js 45.3 kB
├ chunks/main-e6f84a61209f39b9.js 29.5 kB
├ chunks/pages/_app-eb55a50bda085b73.js 93 kB // ✅
├ chunks/webpack-2812e16367159de4.js 1.88 kB
└ css/a2e6fe3e27497148.css 3.87 kB
Loading 컴포넌트라는 애니메이션이 포함된 컴포넌트의 사이즈가 가장 컸는데,
이 컴포넌트는 lottie 라이브러리를 사용하여 애니메이션을 보여주는 컴포넌트였고 첫 로딩시에는 로직 상 로딩 컴포넌트가 불필요함에도 이 라이브러리가 포함되어 큰 청크가 만들어지고 있었습니다.
처음에는 이 컴포넌트를 dynamic import로 변경하여 청크에서 제외하였지만, 코드를 다시 살펴보니 이 컴포넌트에서만 유일하게 lottie 라이브러리를 사용 중이었습니다.
따라서 근본적으로 청크를 가볍게 만들기 위해서, Lottie 라이브러리를 제거하고 css 애니메이션으로 구현하여 기능을 유지하면서 번들 사이즈를 줄일 수 있게 되었습니다.
실제로 절반 가량의 사이즈가 감소되었습니다.
+ First Load JS shared by all 127 kB
├ chunks/framework-4ed89e9640adfb9e.js 45.3 kB
├ chunks/main-e6f84a61209f39b9.js 29.5 kB
├ chunks/pages/_app-f83be073744bc5ba.js 46.2 kB // 93kb에서 약 47% 감소 ✅
├ chunks/webpack-a3d899559bb2fe17.js 1.93 kB
└ css/a2e6fe3e27497148.css 3.87 kB
이 외에도 dynamic import를 적용하여 덩치가 큰 컴포넌트를 SSR에서 제외하려고 살펴보고 진행했습니다.
다만, 몇몇 컴포넌트는 SEO에 중요한 heading 태그가 포함되어있었습니다. SEO에 필요한 컴포넌트가 CSR만으로 렌더되면 검색 엔진 인덱싱에 누락 위험이 있어 리팩토링이 필요해보여 진행하지 않았습니다.
SEO가 중요한 프로젝트라면 확인 후 진행하는 것이 좋을 것 같습니다.
처음으로 B2C 서비스의 버그 수정과 SEO 대응과 함께 코드를 분석해보며 새로운 점들을 많이 배울 수 있었습니다.
제한적인 개발 환경이어서 아쉬움이 남지만 외부 모듈(트래킹, 분석, 광고 등)을 더 줄일 수 없을까 고민을 하며 Partytown같은 새로운 개념에 대해 접해볼 수 있었습니다.
Ref
- Tanstack query - ssr
- Next.js Image 공식 문서
- Web Vitals - LCP
- MDN - sizes 속성
- https://nextjs.org/docs/pages/api-reference/components/script#afterinteractive
- https://nextjs.org/docs/pages/building-your-application/optimizing/scripts
- https://velog.io/@iberis/%EC%9B%B9-%EC%B5%9C%EC%A0%81%ED%99%94-Lighthouse-CLI-%EC%84%B1%EB%8A%A5-%EC%A7%80%ED%91%9C#%EF%B8%8F-%EC%82%AC%EC%9A%A9-%EB%B0%A9%EB%B2%95
- https://www.daleseo.com/partytown/
- https://reactnext-central.xyz/blog/nextjs/image-component#%EB%8F%99%EC%A0%81-%EC%9D%B4%EB%AF%B8%EC%A7%80-%EC%86%8C%EC%8A%A4%EC%99%80-%EC%9B%B9-%EC%84%B1%EB%8A%A5%EC%97%90-%EB%AF%B8%EC%B9%98%EB%8A%94-%EC%98%81%ED%96%A5
- https://www.seocomponent.com/blog/why-lighthouse-mobile-score-is-bad/
'Web Development' 카테고리의 다른 글
웹 성능 최적화에 앞서 알아야할 내용 (0) | 2025.01.05 |
---|---|
2024 회고 - 경력 면접과 커피챗, 번아웃과 취미🎹, 체력관리, 재태크 (9) | 2024.12.22 |
🚧 Nestjs 에러 로깅 (0) | 2024.11.26 |
어? 이거 설치한적없는데? 👻 패키지 매니저 알아보기 - npm, yarn, yarn berry, pnpm (2) | 2024.11.24 |
Vanilla-extract 라이브러리 번들사이즈 60% 최적화하기(feat. 내가 Anti-pattern이라니..) (3) | 2024.11.10 |