🏎️

Next.js 최적화 (Gloddy)

Created
Apr 4, 2024 07:01 AM
설명
Front-end 팀원으로 합류하게된 Gloddy 에서 진행한 Next.js 성능 최적화 사항들을 정리합니다.
Status
진행중
Tags
Next.js
Optimization
Gloddy
Select
Dev Log
👀
Front-end 팀원으로 합류하게된 Gloddy 에서 진행한 Next.js 성능 최적화 사항들을 정리합니다.

최적화는 왜 해야하나요?

 
 
Gloddy는 어떤 최적화 작업을 진행했을까요?
우선 Gloddy에서 사용중인 라이브러리중 더 용량이 작은 라이브러리로 대체가 가능하거나 불필요한 라이브러리르 제거하기 위해 번들을 분석했습니다.
번들을 쉽게 파악하기위해 시각화 도구 Bundle Analyzer를 사용했습니다.
Bundle Analyzer client.html
Bundle Analyzer client.html

불필요한 라이브러리 제거

가장 먼저 불필요한 라이브러리 제거 작업을 진행했습니다.
기존 코드를 열심히 살펴보고 고민해본 결과 다음 2개의 라이브러리가 제거 대상이 되었습니다.

sentry

Sentry의 경우 번들의 크기가 커서 제거한 이유보다는 Gloddy에서 아직 도입할 필요성이 충분치 않다고 판단되어 제거했습니다.
사용자 트래픽이 많지 않고 당장의 Gloddy 규모가 그렇게 크지 않아 도입은 다음을 기약했습니다.

loadash

loadash는 사용하는 부분이 딱 하나 debounce 를 위해 사용하고 있었습니다.
이런 부분은 낭비라고 생각이 되었고 loadash를 제거하고 직접 만들어 사용하기로 결정했습니다.
작성한 useDebounce, useDebounceCallback 훅

useDebounce

특정 함수의 값의 변화를 추적하는 debounce 훅 입니다.
import { useEffect, useState } from 'react'; interface UseDebounceProps<T> { target: T; delay: number; } const useDebounce = <T>({ target, delay }: UseDebounceProps<T>) => { const [debouncedValue, setDebouncedValue] = useState(target); useEffect(() => { const handler = setTimeout(() => { setDebouncedValue(target); }, delay); return () => { clearTimeout(handler); }; }, [target]); return debouncedValue; }; export default useDebounce;

useDebounceCallback

특정 함수의 실행을 추적하는 훅입니다.
'use client'; import { useCallback, useState } from 'react'; type CallbackFunction = (...params: unknown[]) => void; interface UseDebouncedCallbackProps { target: CallbackFunction; delay: number; } const useDebouncedCallback = ({ target, delay }: UseDebouncedCallbackProps) => { const [timerId, setTimerId] = useState<NodeJS.Timeout | null>(null); return useCallback( (...params: Parameters<CallbackFunction>) => { if (timerId) { clearTimeout(timerId); } const newTimerId = setTimeout(() => { target(...params); }, delay); setTimerId(newTimerId); }, [target, delay, timerId] ); }; export default useDebouncedCallback;
 

라이브러리 변경

Swiper

Gloddy 에서는 여러장의 이미지를 넘기며 볼 수 있도록 swiper 라이브러리를 사용했는데요.
현재 사용하고있는 기능에 비해 번들 사이즈가 너무 크다고 판단했고 Bundlephobia를 사용해 동일한 기능을 제공하면서 사이즈가 훨씬 작은 react-glider를 선택해 적용했습니다.

use-places-autocomplete

이 라이브러리는 주소지의 자동완성을 위한 라이브러리로 모임을 생성할 때 모임 위치를 정하기 위해서 사용합니다.
notion image
이 라이브러리의 교체 이유는 용량의 문제는 아니였고 제가 생각하는 단점 때문에 교체하게 되었습니다.
이 라이브러리를 사용하려면 구글 Map의 스크립트를 가져와야 했는데요.
<Script defer src={`https://maps.googleapis.com/maps/api/js?key=${GOOGLE_API_KEY}&libraries=places&callback=initMap`} />
Next.js의 Script는 스크립트를 단 1번만 가져오는걸 보장해주고, 사용 시 스크립트가 없어 에러가 나는 상황도 방지해 주는 장점이 있습니다.
하지만 어느 페이지를 열더라도 초기 로딩 시 구글 Map 스크립트를 가져와야 했고 용량은 대략 75kb 였습니다.
사진에 보이는 모임 생성 페이지에서만 이 스크립트를 사용하기에 이걸 동적으로 가져오고 싶었고 react-google-autocomplete 라이브러리를 발견해 교체 했습니다.
이 라이브러리의 usePlacesService 훅은 사용시 스크립트를 가져오기 때문에 제가 정의한 문제의 해결책이 되었습니다.
const googleMapsScriptUrl = `${googleMapsScriptBaseUrl}?key=${apiKey}&libraries=${libraries}${languageQueryParam}`; ... const handleLoadScript = useCallback( () => loadGoogleMapScript(googleMapsScriptBaseUrl, googleMapsScriptUrl), [googleMapsScriptBaseUrl, googleMapsScriptUrl] );
react-google-autocomplete 라이브러리의 usePlacesService 코드

코드 스플리팅

react-datepicker

react-datepicker의 경우 큰 사이즈를 가지고 있어 필요한 순간에 동적으로 가져올 수 있도록해 페이지의 초기 로드 JS 크기를 줄일 수 있었습니다.
notion image
const DatePicker = dynamic(() => import('react-datepicker').then((mod) => { require('react-datepicker/dist/react-datepicker.css'); return mod; }) );

@react-google-maps/api

이 라이브러리는 MapView 컴포넌트에서 사용하고 있었는데요.
마찬가지로 이 컴포넌트가 필요한 순간에 동적으로 가져올 수 있도록 했습니다.
notion image
const MapView = dynamic(() => import('@/components/MapView/MapView'));

Tab, Funnel

Gloddy는 Funnel 컴포넌트나 Tab 컴포넌트와 같이 하위 요소가 큰 용량을 차지할 수 있는 컴포넌트가 존재합니다.
Tab 컴포넌트
Tab 컴포넌트
<Funnel> <Funnel.Step name="main"> <MainStep/> </Funnel.Step> <Funnel.Step name="meetDate"> <MeetDateStep onDone={prevStep} /> </Funnel.Step> </Funnel>
2단계로 이루어진 Gloddy 그룹 생성 절차
특히나 Funnel의 경우 하위 컴포넌트가 거의 하나의 페이지나 다름이 없어 초기 로딩시에 많은양의 JS를 다운받아야 하는데요.
이런 컴포넌트들에 코드 스플리팅을 적용해 페이지를 작은 단위로 나누었습니다.
const MainStep = dynamic(() => import('./main/MainStep')); const MeetDateStep = dynamic(() => import('./meetDate/MeetDateStep'));

이미지 최적화

초기 이미지 로드 시간 줄이기

Next.js의 Image는 다양한 해상도에 맞는 사이즈, 포맷 등이 최적화된 이미지들을 제공하는 기능이 있습니다.
이를 위해 Image는 초기 이미지 로드 시 다양한 크기의 이미지 srcSet을 만들게 됩니다.
notion image
 srcSet은 next.config.js의 images.imageSizes와 images.deviceSizes 에 의해 지정되게 되기 때문에 Next.js 의 기본 설정에 따라 많은 srcSet 만들어진걸 볼 수 있습니다.
module.module.exports = { images: { imageSizes: [16, 32, 48, 64, 96, 128, 256, 384], deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840], }, };
이로인해 최초로 페이지를 로드하게 되는 사용자는 Next.js 서버에서 이미지 생성이 완료될 때까지 기다려야 하기 때문에 오랜 시간 대기해야 할 수 있습니다.
또, Gloddy의 경우 모바일 환경만을 생각해 제작했기 때문에 최대 넓이는 450px으로 위처럼 많은 이미지를 만들 필요가 없습니다.
notion image
그래서 deviceSizes를 최대 넓이인 450으로 제한하고 이에 맞추어 이미지들을 생성할 수 있도록 했습니다.
deviceSizes: [450], imageSizes: [16, 32, 48, 64, 96, 128, 256, 450], minimumCacheTTL: 6000, formats: ['image/webp'],
마지막으로 Image 컴포넌트를 사용할 때 sizes prop에 기본 출력 크기를 넘겨주어 적절한 이미지가 렌더링 되도록 했습니다.
<Image src={images[0]} alt="이미지" sizes={'90px'} fill />

이미지 캐시

처음 이미지 요청 이후 캐시가 만료될 때까지 캐시 된 이미지가 제공되어 첫 번째 요청보다 굉장히 빠르게 이미지를 가져올 수 있습니다.
그래서 Next.js 옵션에 minimumCacheTTL 옵션에 1시간 가량의 캐시를 설정했습니다.
이미지가 캐싱 되는 기간은 next.config.js의 images.minimumCacheTTL 구성 또는 CDN에서 응답한 이미지의 Cache-Control 헤더 중 더 큰 것으로 정의된다고 합니다.
1시간으로 설정한 이유는 커뮤니티나 모임의 경우 이미지가 바뀔 수 있다고 생각해 짧은 TTL을 설정했지만 실제 운영중에 이미지가 자주 바뀌지 않는다는 판단이 생기면 TTL을 여유롭게 설정할 것 같습니다.

최종 결과

📈
최종적인 결과입니다! 평가 도구는 크롬 개발자 도구 성능 통계를 사용해 평가했고 캐시 사용 X, 빠른 3G 환경에서 비교한 결과입니다.

Dom Content Loaded: 평균 1.3초 감소

FCP: 평균 1.2초 감소

LCP: 평균 0.4초 감소

공용 번들 크기 141 → 81.7 (42.06% 감소)

notion image
notion image

이미지 로드 크기 70% 감소

notion image
notion image

페이지별 FIRST LOAD JS 35% 감소

notion image
notion image
notion image
notion image
notion image