이글은 프로젝트를 진행하면서 LightHouse 기반 성능최적화를 다룬 내용입니다.
먼저 최적화를 진행 하기 전 처참한 성능..
먼저, 성능최적화를 위해 폰트최적화를 먼저 진행했다.
url
로 받아와 폰트를 렌더링하고 있었는데,FOLT
, 웹 폰트 로딩이 완료되기 전까지 사용자에게 텍스트가 보이지 않는 현상이 발생했다.💡 여기서 FOLT(Flash of Invisible Text)란?
웹 페이지가 로드되는 동안 웹 폰트가 아직 완전히 로드되지않았을때 발생하는 현상
이때, 브라우저는 해당 텍스트를 눈에 보이지 않는 상태로 두는데, 사용자 입장에선 웹 폰트가 로드되기 전까지 페이지의 일부 또는 전체 텍스트가 보이지 않는 상태가 되므로 좋지 않은 UX를 제공할 수 있다.
1. 먼저 폰트의 형식 중 가장 압축률이 좋은 WOFF2
폰트로 바꿔서 적용했다.
WOFF2
폰트는 WOFF
폰트에 비해 압축률이 30% 정도 더 좋아 성능상의 이점이 존재한다.2. font-display : swap
적용
font-display : swap
속성을 적용함으로써, 웹 폰트가 로딩되는 시간동안 시스템폰트로 대체해서 보여주며, 최종적으로 FOLT
현상을 방지시켰다.@layer base {
html {
font-family: Pretendard;
src: url("https://cdn.jsdelivr.net/gh/Project-Noonnu/noonfonts_2107@1.1/Pretendard-Regular.woff2")
format("woff2");
font-size: 1rem;
font-display: swap;
@screen tablet {
font-size: 0.875rem;
}
@screen mobile {
font-sizep: 0.75rem;
}
}
}
리액트와 같은 Single Page Application
은 프로젝트 빌드 시 하나의 JavaScript
파일로 번들링된다.
Single Page Appliction
이므로 코드 분할을 통해 성능적으로 이점을 챙길 수 있게된다.동적 import와 lazy 함수
먼저, 프로젝트 내부에선 모든 컴포넌트가 모여있는 곳이 routes 파일이므로 해당 파일을 허브로 만들었다.
import
부분을 lazy
함수와 동적 import
로 매핑해서 리팩토링하였다.동적 import
import() 함수는JavaScript
의 동적 임포트 기능이다.
이로 인해 모듈을 비동기적으로 불러올 수 있다
lazy 함수
React에서 제공하는 lazy함수는 컴포넌트를 동적 임포트하여 불러온다.
해당 함수는 Promise를 리턴하는 함수를 인자로 받으며, 해당 Promise가 resolve 될 경우 컴포넌트를 렌더링할 수 있다
import {lazy} from "react";
const RootLayout = lazy(() => import("@/layout/RootLayout"));
const Cart = lazy(() => import("@/views/Cart"));
const Customer = lazy(() =>
const CustomerCreate = lazy(() =>
import {Route} from "react-router-dom";
import {createRoutesFromElements} from "react-router-dom";
import {createBrowserRouter} from "react-router-dom";
const router = createBrowserRouter(
createRoutesFromElements(
<Route path="/" element={<RootLayout />}>
<Route index element={<Main />} />
<Route path="/signUp" element={<SignUp />} />
<Route path="/findStore" element={<FindStore />} />
</Route>
)
);
export default router;
이후, routes
부분을 import
구문과 lazy
함수로 래핑한후, 상위 컴포넌트(여기선 App.jsx)를Suspense
로 래핑하고, fallback
컴포넌트를 부여해야한다.
Suspense
컴포넌트의 children
으로 매핑했던 RouterProvider
를 자식으로 넣는다.fallback
은 컴포넌트가 lazy
함수로 인해 지연로딩 될 때 대체해서 보여줄 UI
를 담아준다.import {QueryClient, QueryClientProvider} from "@tanstack/react-query";
import {Suspense} from "react";
import {RouterProvider} from "react-router-dom";
import {HelmetProvider} from "react-helmet-async";
import {Toaster} from "react-hot-toast";
import router from "./routes/routes";
import JijoSpinner from "./components/JijoSpinner";
function App() {
return (
<>
<HelmetProvider>
<QueryClientProvider client={queryClient}>
<Suspense fallback={<JijoSpinner />}>
<RouterProvider router={router}></RouterProvider>
</Suspense>
</QueryClientProvider>
</HelmetProvider>
<Toaster />
</>
);
}
export default App;
여기서 텍스트 압축이란?
데이터를 저장하거나 전송할 때 사용되는 기술이다.
텍스트 파일의 크기를 효과적으로 압축해, 저장공간을 절약하고, 네트워크를 통한 데이터 전송속도 또한 향상시킨다.
결과적으로 텍스트압축 또한 성능상의 이점을 가져다준다.
텍스트 압축을 위해 vite 플러그인인 vite-plugin-compression 라이브러리가 제공된다.
vite.config.js
내부에 해당 플러그인을 추가함으로써, Vite 환경에서 빌드 시 생성되는 Assets 파일들을 압축해, 서버로부터 클라이언트로 전송 시 필요한 파일 크기들을 줄여줄 수 있게된다.plugins
배열 내부에 viteCompression
을 넣고있다.alogoritm
속성은 압축 알고리즘을 지정하고 기본적으로 gzip
이 사용된다.ext
인데, 해당 속성은 압축된 파일의 확장자를 지정한다. gzip
알고리즘 사용시에는 .gz
라는 확장자명을 사용하게 된다.
import react from "@vitejs/plugin-react";
import {defineConfig} from "vite";
import viteCompression from "vite-plugin-compression";
// https://vitejs.dev/config/
export default defineConfig({
plugins: [
react(),
jsconfigPath(),
viteCompression({
// 압축 알고리즘 지정, 기본적으로는 'gzip'을 사용
algorithm: "gzip",
// 압축된 파일의 확장자를 '.gz'로 설정
ext: ".gz",
}),
],
css: {
devSourcemap: true,
modules: {
generateScopedName: isDev
? "[name]_[local]__[hash:base64:5]"
: "[hash:base64:4]",
},
},
});
여기서 청크란?
청크(Chunck)는 웹 애플리케이션의 코드 또는 리소스를 작은 단위로 나눈것을 의미한다.
일반적으로 청크는 Webpack, Rollup, Vite와 같은 모던 빌드 도구에 의해 생성되고, 이 도구들은 애플리케이션의 자바스크립트, 스타일시트, 이미지, 폰트등을 분할하여 청크로 만들게 된다.
vite
환경에선 해당 라이브러리로 번들 사이즈를 분석할 수 있다.
pnpm i -D rollup-plugin-visualizer
vite.config.js
에서 빌드의 plugin
에서 설치한 visualizer
라이브러리를 추가한다.
// import * as path from 'node:path'
import react from "@vitejs/plugin-react";
import {defineConfig} from "vite";
import {env} from "node:process";
import {splitVendorChunkPlugin} from "vite";
import jsconfigPath from "vite-jsconfig-paths";
import {createHtmlPlugin} from "vite-plugin-html";
import {loadEnv} from "vite";
import viteCompression from "vite-plugin-compression";
import {visualizer} from "rollup-plugin-visualizer";
// https://vitejs.dev/config/
export default defineConfig({
plugins: [
react(),
jsconfigPath(),
splitVendorChunkPlugin(),
viteCompression(),
],
build: {
rollupOptions: {
plugins: [
visualizer({
open: true,
gzipSize: true,
brotliSize: true,
}),
],
},
},
});
이후 빌드시 visualizer
라이브러리에 의해 현재 코드들이 어떤식으로 분리가 되었는지 확인할 수 있다.
기존 코드분할로 분리된 청크파일들
기본적으로 vite
는 자동적으로 파일들의 청크를 진행해주지만, 추가적으로 더 디테일하게 manualChunks
옵션을 통해 라이브러리 청크를 진행할 수 있다.
rollupOptions.output.manualChunks
에서 관심사별로 라이브러리 분리 후 추가manualChunks
옵션은 Rollup
번들러의 설정 옵션 중 하나로, 이를 통해 개발자가 직접 청크(chunk)를 어떻게 분할할지 정의할 수 있다.
export default defineConfig({
plugins: [
react(),
jsconfigPath(),
viteCompression(),
],
// 빌드 시, 청크 파일 생성 매뉴얼 구성
build: {
rollupOptions: {
plugins: [
visualizer({
open: true,
gzipSize: true,
brotliSize: true,
}),
],
output: {
manualChunks: {
react: ["react", "react-dom", "zustand", "@tanstack/react-query"],
reactRouter: ["react-router-dom"],
animations: ["framer-motion", "gsap", "aos"],
swiperBundle: ["swiper"],
},
},
},
},
});
마지막으로 최적화를 진행할 부분은 이미지이다.
LazyImage
라는 컴포넌트를 만들어 프로젝트 내 모든 <img />
태그에 적용시켰다.Lazy Image 컴포넌트
import { useState } from "react";
import { useEffect } from "react";
import { useRef } from "react";
function LazyImage({ src, alt, className, width = "auto", height = "auto" }) {
const [isLoading, setLoading] = useState(false);
const imgRef = useRef(null);
const observer = useRef();
const intersectionObserver = (entries, io) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
io.unobserve(entry.target);
setLoading(true);
}
});
};
useEffect(() => {
observer.current = new IntersectionObserver(intersectionObserver);
imgRef.current && observer.current.observe(imgRef.current);
}, []);
return (
<div className="w-full">
<img width={width} height={height} ref={imgRef} src={isLoading ? src : null} alt={alt} className={className} />
</div>
);
}
export default LazyImage;
WebP 이미지 포멧으로 얻을 수 있는 성능상 이점은?
JPEG,PNG,GIF
포멧에 비해 상당히 작은 파일 크기를 가지면서도 동일하거나 더 나은 이미지 품질을 제공할 수 있게된다.물론 LazyImage
컴포넌트와 WebP 이미지 사용과 달리 이미지 최적화 기법은 무수히 많이 존재한다.
위의 5가지 방법들을 모두 수행한 후 빌드 후 LightHouse에서 성능점수를 다시 확인해보았다.
98점으로 성능이 향상되었다.