
이글은 프로젝트를 진행하면서 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점으로 성능이 향상되었다.