팀 솔루션 프로젝트 버전 마이그레이션을 진행하며 다짐했던 한 가지가 있습니다.
처참한 lighthouse 지표 개선하기..😄
팀 내부 솔루션 프로젝트는 고객사가 주로 폐쇄망 환경이고, 외부에서 접근을 할 수 없는 구조이기 때문에 SEO는 크게 고려하지 않아도 되었습니다.
따라서 Performance 점수를 집중적으로 올리기로 다짐했었습니다. 첫번째로 로딩 성능 개선을 목표로 시작했습니다.
초기 AS-IS 버전을 V1 , TO-BE 버전을 V2 라고 부르겠습니다.
AS-IS (V1)
다음은 모든 페이지 중 Performance 점수가 가장 낮았던 V1 대시보드 화면의 Core Web Vitals 점수입니다.
V1 Score

위의 사진에서 제일 먼저 보이다시피 FCP(First Contentful Paint) 는 12.9s,
LCP(Largest Contentful Paint)는 13.4s .. 매우 높은 수치 입니다.
V1 의 수치를 먼저 표로 정리해보겠습니다.
| FCP | LCP | TBT | CLS | SpeedIndex |
|---|---|---|---|---|
| 12.9s | 13.4s | 150ms | 0s | 12.9s |
- FCP 는 첫 번째로 컨텐츠가 렌더링 되는 시점, 즉 사용자의 눈에 컨텐츠가 보이기 시작하는 시점입니다.
- LCP 는 뷰포트에서 가장 큰 컨텐츠가 렌더링되는 시점입니다.
- TBT 는 Total Blocking Time 즉, Javascript 실행으로 인한 렌더링 차단 시간입니다.
- CLS 는 페이지 로드 중 예상치 못한 레이아웃 이동을 말합니다. 요소가 깜빡이거나, 폰트가 갑자기 적용된다거나 하는 현상을 말합니다.
- SpeedIndex는 컨텐츠가 시각적으로 표시되는 속도를 의미합니다.
V1 Lighthouse Treemap
그리고 아래의 V1 Treemap을 보겠습니다.

거대한 보라색 두부입니다. 눈으로만 봐도 잘게 으깨고 싶게 생겼습니다.
저렇게 거대한 자바스크립트를 리소스를 불러오니 로딩 성능이 최악이였던 것 같습니다.
여러가지 수치를 분석 후, 로딩 최적화 계획을 크게 두 가지로 세웠습니다.
1. 사용자가 접속한 페이지에 필요한 리소스만 가져오게 하기
2. 로드 되는 블록 리소스의 크기를 파편화하여 렌더 블로킹 시간을 줄이기1. 사용자가 접속한 페이지에 필요한 리소스만 가져오게 하기
🚀️ node_modules 의존성 chunk 분리하기
먼저, node_modules에 설치되어 있는 패키지를 쪼개주겠습니다. 만약 쪼개주지 않는다면 추후 빌드타임 때, 메인 index 자바스크립트로 같이 빌드되어 번들 사이즈가 커질 것입니다.
여기서 의문점이 들었습니다.
어떤 기준으로 쪼개야 하는걸까? 👀️
만약 모든 패키지를 쪼갠다면, 서버로 매우 많은 리소스 요청을 보내야하기 때문에, 오히려 서버 응답 지연으로 로딩 성능이 떨어질 것이라 생각했습니다. 라이브러리는 대부분 변경되지 않는 코드 조각들이기에, 코어 관련 라이브러리나 UI 패키지 라이브러리 그리고 차트 라이브러리 같은 사이즈가 큰 것들 위주로 쪼개는게 효율적일 것이라 생각이 들었습니다.
import type { OutputOptions } from 'rollup';
import packages from './package.json';
export const generateBuildChunks = (mode: string): OutputOptions['manualChunks'] => {
if (mode === 'development') {
return undefined;
}
const allDependencies = Object.keys(packages.dependencies);
const radixPackages = allDependencies.filter((key) => key.startsWith('@radix-ui/'));
const dndPackages = allDependencies.filter((key) => key.startsWith('@dnd-kit/'));
const chunks: OutputOptions['manualChunks'] = {
vendor: ['react', 'react-dom'],
state: ['@tanstack/react-query', 'zustand'],
router: ['@tanstack/react-router'],
ui: ['clsx', 'class-variance-authority', 'cmdk', 'lucide-react', 'vaul', ...radixPackages],
utils: ['dayjs', 'axios', 'uuid'],
i18n: ['i18next', 'react-i18next'],
ag_grid: ['ag-grid-community', 'ag-grid-react'],
g6: ['@antv/g6'],
workflow: ['@[packageScope]/workflow'],
react_grid_layout: ['react-grid-layout'],
dnd: dndPackages,
};
return chunks;
};
rollupOptions: {
output: {
manualChunks: isDev
? undefined
: {
...generateBuildChunks(mode),
},
}
}
위와 같이 manualChunks 옵션에 들어갈 유틸을 생성해주었습니다. 비교적 변경사항이 적은 라이브러리거나, 사이즈가 큰 것들 위주로 쪼개주었고, 라이브러리의 관심사별로 자바스크립트 파일을 생성하도록 구성하였습니다.
🚀️ Page 컴포넌트 단위로 코드 스플리팅
admin 성격의 솔루션 프로젝트에서는 페이지별 성능이 매우 중요했습니다. 또한, 각 계정의 권한에 맞는 페이지가 각각 따로 있었기에, 모든 페이지에 코드 스플리팅을 적용하여 페이지 별로 로드되는 JavaScript 파일의 크기를 줄이기로 결정하였습니다.
흔히 알고 계시는 코드 스플리팅 방식은 아래와 같이 lazy, Suspense 컴포넌트를 활용하는 방법입니다.
import React, { Suspense } from 'react';
const HomePage = React.lazy(() => import('./pages/HomePage'));
//...그러나 팀 내에서는 라우터 코어로 Tanstack Router를 사용 중입니다.
Tansatack Router에서는 코드 스플리팅을 보다 쉽게 적용할 수 있는 방법을 제공합니다.
import { createRoute, lazyRouteComponent } from '@tanstack/react-router';
export const dashboardRoute = createRoute({
getParentRoute: () => monitoringRoute,
path: PATH.dashboard,
component: lazyRouteComponent(() => import('@/pages/monitoring/Dashboard'), 'DashboardPage'),
wrapInSuspense: true,
});위와 같이 lazyRouteComponent 메서드를 사용하여 코드 스플리팅을 진행해주었고, Suspense 의 fallback 옵션은 최상위 라우트의 defaultPendingComponent 옵션으로 아래와 같이 설정해주었습니다.
export const router = createRouter({
routeTree,
defaultPendingComponent: () => <Spinner />,
defaultNotFoundComponent: () => <NotFoundPage />,
//...
});2. 로드 되는 블록 리소스의 크기를 파편화하여 렌더 블로킹 시간 줄이기
🚀️ 메인 스레드 블로킹 개선하기
성능 개선을 진행하던 중 아래와 같은 파일이 눈에 들어왔습니다.
import '@[packageScope]/workflow/dist/workflow.css';
import { AllCommunityModule, ModuleRegistry, ValidationModule } from 'ag-grid-community';
import 'ag-grid-community/styles/ag-grid.css';
import 'ag-grid-community/styles/ag-theme-alpine.css';
import dayjs from 'dayjs';
import customParseFormat from 'dayjs/plugin/customParseFormat';
import duration from 'dayjs/plugin/duration';
import relativeTime from 'dayjs/plugin/relativeTime';
import timezone from 'dayjs/plugin/timezone';
import utc from 'dayjs/plugin/utc';
import i18next from 'i18next';
import LanguageDetector from 'i18next-browser-languagedetector';
import 'react-grid-layout/css/styles.css';
import { initReactI18next } from 'react-i18next';
import 'react-resizable/css/styles.css';
import 'react-table-mapping/styles';
import resources from 'virtual:i18next-loader';
import { useRequestHeaderStore } from '@/store/useRequestHeaderStore';
import { ACCEPT_LANGUAGE } from './constants';
import './lib/tailwind.css';
ModuleRegistry.registerModules([AllCommunityModule, ValidationModule]);
dayjs.extend(utc);
dayjs.extend(timezone);
dayjs.extend(relativeTime);
dayjs.extend(customParseFormat);
dayjs.extend(duration);
i18next
.use(initReactI18next)
.use(LanguageDetector)
.init({
resources: {
en: { translation: resources.en },
ko: { translation: resources.ko },
},
fallbackLng: ACCEPT_LANGUAGE.KO,
detection: {
order: ['cookie'],
caches: ['cookie'],
},
debug: __DEV__,
interpolation: {
escapeValue: false,
},
})
.then(() => {
const { setAcceptLanguage } = useRequestHeaderStore.getState();
setAcceptLanguage({ acceptLanguage: i18next.language as (typeof ACCEPT_LANGUAGE)[keyof typeof ACCEPT_LANGUAGE] });
});
렌더링 성능을 생각하지 않고 편하게 작업하려고 앱 초기 진입 시 필요한 모듈들은 모두 import 한 모습의 파일입니다.
다시 자세히 살펴보니, 다국어 설정과 관련된 초기화 코드가 렌더링 성능을 크게 저하시키지 않을까 라는 의문이 들었습니다.
이를 개선하기 위해 다국어 설정을 별도의 파일로 분리하여 다음과 같이 비동기로 로드하도록 하였습니다.
//i18n.ts
import i18next from 'i18next';
import LanguageDetector from 'i18next-browser-languagedetector';
import { initReactI18next } from 'react-i18next';
import resources from 'virtual:i18next-loader';
import { useRequestHeaderStore } from '@/store/useRequestHeaderStore';
import { ACCEPT_LANGUAGE } from './constants';
import './lib/tailwind.css';
const initI18n = async () => {
await i18next
.use(initReactI18next)
.use(LanguageDetector)
.init({
resources: {
en: { translation: resources.en },
ko: { translation: resources.ko },
},
fallbackLng: ACCEPT_LANGUAGE.KO,
detection: {
order: ['cookie'],
caches: ['cookie'],
},
debug: __DEV__,
interpolation: {
escapeValue: false,
},
});
const { setAcceptLanguage } = useRequestHeaderStore.getState();
setAcceptLanguage({ acceptLanguage: i18next.language as (typeof ACCEPT_LANGUAGE)[keyof typeof ACCEPT_LANGUAGE] });
};
initI18n();
export default i18next;
//preload 파일
import '@[packageScope]/workflow/dist/workflow.css';
import { AllCommunityModule, ModuleRegistry, ValidationModule } from 'ag-grid-community';
//...
export { initI18n } from 'i18n' 다음으로 CSS 파일이 약 4000kb 용량의 블록 리소스로 동작하여 초기렌더링 성능을 저하시킨다는 점을 발견하였습니다.
팀 내에서는 tailwindcss v4 을 사용하고 있었습니다.
먼저 다국어 설정 로직이 있던 파일에서 css 파일들을 각 별도의 필요한 페이지에서 lazy import 해주었습니다.
import { useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import 'react-table-mapping/styles';
export type DataMapperForm = Extract<InterfaceProcessAdapterUnion, { type: 'DATA.MAPPER' }>;
const NeedReactTableMappingCss = ({ processInfo }: DataMapperInfoProps) => {
return //...
}그 과정에서 먼저 로드된 tailwind 관련 스타일을 사내 라이브러리의 css가 덮어쓰는 이슈가 생겼습니다.
- 아래와 같이 tailwind css를 구성하고 있는 css파일에서 layer의 우선순위롤 명시하여 같이 로드해주었습니다. 그리고 문제가 있는 css import 문에 layer(vendor) 라는 키워드를 붙혀주어 import 해주었습니다.
@layer vendor, base, components, utilities;
@import '@[packageScope]/workflow/dist/workflow.css';
@config '../../tailwind.config.js';
@import 'tailwindcss';
@import './global.css';
@import './color.css';@layer vendor, base, components, utilities 라고 명시해주면 스타일 우선순위가 utilities 부터 vendor 순으로 내림차순입니다.
스타일 오버라이드는 해결되었지만 같은 위치에서 import를 하다보니, 같은 파일에 포함되어 여전히 용량이 커, 본질적인 문제는 해결되지 않았습니다.
- 두 번째로 별도의 css파일을 생성하여 아래와 같이 작성해주었습니다.
/* workflow-base.css */
@import '@[packageScope]/workflow/dist/workflow.css' layer(base);
그리고 해당 css 파일을 필요한 곳에 lazy import 시켜주었습니다.
그 결과,

아래와 같이 css 파일이 분리된 것을 확인할 수 있었습니다!
TO-BE (V2)
마지막으로 V2를 빌드하고 preview 해보겠습니다.
V2 Score

위의 사진에서 제일 먼저 보이다시피 FCP(First Contentful Paint) 는 1.4s,
LCP(Largest Contentful Paint)는 2.0s ..
V2 의 수치를 먼저 표로 정리해보겠습니다.
| FCP | LCP | TBT | CLS | SpeedIndex |
|---|---|---|---|---|
| 1.4s | 2.0s | 0ms | 0 | 1.5s |
V1의 표를 다시 한 번 확인해보겠습니다.
| FCP | LCP | TBT | CLS | SpeedIndex |
|---|---|---|---|---|
| 12.9s | 13.4s | 150ms | 0 | 12.9s |
V1 대비 매우 크게 개선된 것을 볼 수 있었습니다.
V2 Lighthouse Treemap

위는 V2의 Lightouse Treemap입니다.
첫번째 사진과 비교했을 때, 리소스들이 파편화되어있는 것을 확인할 수 있었습니다.
결과적으로 1개의 js 리소스 -> 100여개의 js 리소스 로 번들 사이즈를 최적화하였습니다.

느낀 점 🤵
말로만 들었던 성능 최적화를 직접 경험하며, 놀라운 점도 많았고 매우 뿌듯했던 것 같습니다.
개선 전 V1의 성능 지표를 보았을 때, FCP 12.9s, LCP 13.4s와 같은 높은 수치가 정말 놀라웠습니다. 이 수치들이 의미하는 바는, 사용자가 페이지를 처음 로딩할 때까지 많은 시간이 소요되고, 그 과정에서 사용자 경험이 크게 저하될 수 있다는 것이었고, 실제로도 개발 과정에서 사용자가 불편하겠다 라는 생각을 종종 하곤 했지만, 바쁘다는 핑계로 성능최적화를 뒤로 미루고 이러한 제품을 사용자에게 제공하고 있었다는 사실이 매우 부끄러웠습니다.
다음 릴리즈 때, 더 만족할 사용자들을 생각하며 이번 포스팅을 마치도록 하겠습니다! 방문하신 분들 긴 글 읽어주셔서 감사합니다 😄