
이번에도 돌아온 프로젝트의 마무리 시간.
최적화 타임!
지난번 프로젝트에서는 Lighthouse로 최적화를 진행해보았다면,
이번에는 그 전단계에서 진행하는 최적화 과정을 가져볼까 합니다.
요 방법은 제가 채용 공고들을 보다가 알게 되었으며..
처음 도전해보는 것이기 때문에 헤맬 수 있음에 유의하시고..
그럼 들어가겄습니다.
저는 이번 프로젝트에서 프론트엔드를 맡았고, 제가 맡은 페이지들이 꽤 많았습니다.
프론트엔드 전체 공통 UI 레이아웃부터 게임화면에 들어가기까지의 페이지를 제가 맡았거든요.
아 그래서 제가 혼자서 많은걸 했다는 건가요? >> 전혀 아닙니다.
게임화면 페이지 구현, 채점 결과 페이지 구현 등 다른 페이지도
정말 만만치 않았기 때문에 팀원들 모두 고생하며 개발했지요..
아무튼 이렇게 프로젝트가 어떻게 완성이 되었고,
자체적으로 리팩토링 과정을 거친 후에 최적화 시간을 가지게 되었습니다.
제가 최적화할 방법은 바로바로
Vitest + RTL + Vite tree-shaking
입니다.
최적화 들어가기에 앞서, 각 최적화 방법에 대해 간단히 알아보도록 하겠습니다.
1️⃣ Vitest
Vitest는 Vite 기반으로 작동하는 자바스크립트 단위 테스트 프레임워크입니다.
이전에는 Vite에서만 사용이 가능했지만 현재는 React와 js 프로젝트 어디서나 사용이 가능합니다.
테스트 실행 속도 + 리소스 사용량 + 테스트 구조를 개선하는 작업으로,
단순히 "테스트가 통과한다"가 아닌
빠르게 실행되고, 불필요한 연산이 없고, CI에서도 부담이 적은 테스트 구조로 만드는 것이라고 볼 수 있습니다.
Vite 기반으로 하는 React, Vue, Svelte 프로젝트를 개발할 때,
단위 테스트 (Unit Test)를 간편하게 설정하고 실행하고 싶을 때 쓰면 좋다고 합니다.
Jest는 빌드와 테스트 설정을 따로 나누어서 관리해야하지만,
Vitest는 Vite를 통해 프로젝트의 기본 빌드 설정을 그대로 이용할 수 있기 때문이죠!
2️⃣ RTL (React Testing Library)
RTL(React Testing Library)은
사용자가 웹 페이지를 사용하는 방식과 유사하게
React UI 컴포넌트의 동작을 테스트하도록 설계된 테스팅 도구입니다.
DOM 구조를 검사하는 것이 아닌,
사용자가 실제로 보는 것, 클릭하는 것, 입력하는 것을 기준으로 테스트를 하는 것!
컴포넌트 내부가 아니라 "사용 경험"을 테스트하는 라이브러리라고 하네욥..
RTL을 사용하는 이유는 내부 구조가 바뀌어도 테스트가 깨지지 않기 때문에
실무에서 많이 사용한다고 하네요.. (채용공고에서 봤음)
RTL 기본 동작 구조는
1. 렌더링 (가상 DOM 생성)
2. 사용자 행동 시뮬레이션 (실제 사용자처럼 클릭)
3. 결과 검증 (화면 변화 확인)
이렇게 3단계로 볼 수 있습니다.
3️⃣ Vite Tree-shaking
Vite Tree-shaking은 사용하지 않는 코드를 번들에서 제거하는 최적화 기술로,
최종 배포 JS 용량을 줄이는 과정입니다.
이건 다음 글에서 할 작업
자 잠시 제가 이해하기 위해서 개념 정리 시간을 가져보았구요! ^^
이제 진짜 최적화로 들어가보겠습니다!

.
.
.
.
.
📍 Vitest 환경 설정📍

프론트엔드 폴더로 이동해서

npm install -D vitest @vitest/coverage-v8 jsdom
명령어를 실행하여 vitest 패키지를 설치해봅니다.
설치 후 package.json의 devDependencies에
vitest, @vitest/coverage-v8, jsdom이 추가되었는지 확인해봅니다.



확인했죠? 그럼 이제
frontend/ 루트에 vitest.config.ts 파일을 생성해보겠습니다!

여기서는 jsdon 환경, @alias 설정, .test.tsx 파일 설정을 추가해주었어요.
그런데
왜 vitest.config.ts를 따로 만들었는지..?
>> vite.config.ts에는 Tailwind 플러그인, proxy 설정 등 빌드 전용 설정이 들어가있어서
깔끔하게 테스트 설정을 분리하는 것이 좋습니다.
그리고 Vitest는 vitest.config.ts가 있으면 이걸 우선적으로 사용한다고 하네여
그 다음 frontend/tsconfig.app.json에 들어가서
types 배열에 "vitest/globals"를 추가합니다.

이렇게 해두면 describe, it, expect, vi 등을 import 없이 사용할 경우에 TypeScript가 타입을 인식할 수 있슴다
자 또 그 다음!
frontend/package.json에 스크립트를 추가합니다.

명령어를 알아볼까요?
npm test # watch 모드 (파일 변경 시 자동 재실행)
npm run test:run # 1회 실행 후 종료(CI용)
npm run test:coverage # 커버리지 리포트 포함 실행
이 명령어 중 npm test, npm run test:run까지 실행해보겠습니다.


아직 테스트 파일을 생성하지 않았기 때문에 위와 같이 뜨는 것이 정상입니데이
이제 Vitest 세팅은 끝났고, RTL 설정과 예제 테스트를 진행해보겠어요
📍RTL 설정 + 예제 테스트📍
이 과정에서는
3개의 컴포넌트들을 테스트 해보려고 합니다.
여전히 frontend/ 폴더에서
다음 명령어로 RTL 패키지를 설치합니다.
npm install -D @testing-library/react @testing-library/jest-dom @testing-library/user-event

그리고 frontend/src/ 안에 setupTests.ts 파일을 생성해줍니다.
그리고 요렇게만 적어줍니다.

@testing-library/jest-dom/vitest 연동하면
DOM matcher 사용 가능하게 할 수 있는데 이제 그러니까
이 한 줄이 이제 toBeInTheDocument(), toHaveTextContent() 같은
RTL 커스텀 matcher를 Vitest에 등록해줍니다.
그 다음 이제 진짜 예제 테스트를 해보겟슴다
frontend/src/components/common/ 안에
ErrorMessage.test.tsx 파일을 생성해줍니다.
현재 지금 제 프로젝트에 있는 ErrorMessage.tsx가 있는데, 이걸 활용해서 테스트 파일을 만들고 테스트 해보는 겁니다.

요래 씁니다.
코드 내용을 설명하자면,
ErrorMessage 컴포넌트에 message prop 전달 시 "오류 발생"과 메시지 텍스트가 렌더링되는지
확인해보는것입니다.
이제 테스트 돌려볼게용
npm run test:run 명령어 ㄱㄱ
실행했을 때, pass가 나와야 성공
기다려봅니다...




아놔;;;
.... 어 잠만

잘못 썼네 ^^


다시 npm run test:run하면요...

당연히 "성공"입니다.
왜냐하면 "당연"하기 때문이죠
여기선 1 passed 나오면 된겁니다
자, 다음
테스트 파일 하나 더 만들어보겠습니다.
frontend/src/components/routes/에서
PrivateRoute.test.tsx 파일을 생성해줍니다.
현재 프로젝트에서 PrivateRoute.tsx 파일이
useStore에서 isLogin을 읽고, 미로그인 시 /login으로 리다이렉트되게 코드가 써있는데요?
요걸 테스트해보는거죠
코드는 다음과 같이 작성해줍니다

핵심 패턴을 말해보자면:
useStore.setState() >> Zustand는 Provider 없이 직접 상태 주입이 가능
MemoryRouter >> 테스트에서 브라우저 히스토리 없이 라우팅 테스트
beforeEach 초기화 >> 테스트 간 상태 격리
그러니까 미로그인 시 보호된 페이지 접근 차단 / 로그인 시 자식 라우트 정상 렌더링
이걸 확인하는 겁니다.
이렇게 쓰면 다음 테스트 파일 또 생성
frontend/src/pages/home/ 안에
HomePage.test.tsx 만들고 코드 작성
HomePage.tsx는 VHSEffect, VHSStyles 컴포넌트를 불러와서 css 효과를 보여주는데,
start 버튼 클릭 시 로그인 여부에 따라 다른 경로로 이동합니다.
로그인 시 > 모드 선택 페이지 (게임 설정 시작)
비로그인 시 > 로그인 페이지 (로그인을 해야 게임 가능)
네 또 그래서 여기도 테스트 코드를 다음과 같이 작성해주었어욥


이건 이제
비로그인 상태에서 Start 클릭 -> /login 이동 / 로그인 상태에서 start 버튼 클릭 -> /selection/mode 이동
이 과정을 보는거죠.
이렇게 테스트 파일 3개를 작성했는데,
그냥 세팅을 했다.. 요정도입니다.
그럼 이제 npm run build 로 사이즈 확인 한번 해볼게요.....
이미 하기 전부터 저는 저희 프로젝트가 정리되지 않은 상태라는 것을 알고 있기 때문에
약간 떨리네염..

.
.
.

와 많다
이렇게 보면 모르겠으니까 이미지로 볼게요
슬쩍 봤을 때 수상하게 사이즈가 큰 녀석들이 있는데... 일단 자세히 들여다 볼까요



.....
게임 플젝이라 이미지랑 오디오가 있었는데 이것도 크고 일단 index 저녀석 너무 크네요
1MB 넘는 파일들은
ts.work-....js >> 7,010KB (~7MB)
index-3FUXjhcE.js >> 4,660KB (~4.6MB)
css.worker-...js >> 1,030KB (~1MB)
이미지 / 오디오 파일들은
5,281KB (~5.3MB) / 5,011KB(~5MB) / 4,253KB(~4.2MB) / 1,815KB(~1.8MB)
메인으로 들어가는 배경음이랑 메인 이미지 2개가 엄청 크네용..
그리고 지금 저희가 개발하면서 쓴 monaco-editor도 번들 크기가 많이 크고,
배경음 + 고해상도 PNG,
code-splitting이 안된 메인 js 때문에 엄청 커진 상황입니다.
그러니까 지금은
최적화 하기 딱 좋다...^^
그렇게 볼 수 있겠네요 후후
지금 메인 번들(index.js)이 4.6MB (gzip 1.27MB)로 매우 매우 큽니다.. Vite가 직접 경고하고 있어요

이제 시각적으로 저 4.6MB안에 뭐가 들어가있는지를 확인해볼게요
rollup-plugin-visualizer 설치해보겠습니다.

그리고 vite.config.ts에 이 내용도 추가합니다.

이제 다시 npm run build 합니다

뭐가 많죠?
크게 5가지 녀석들이 크다고 볼 수 있습니다.

일단 게임화면에서 사용하는 monaco-editor가 많이 많이 컸고,
모나코 이친구는 그리고 게임에서만 쓰는데 왜 메인부터 불러와졌던 걸까요? (이유는 알고 있어요 저도)
그리고 framer-motioin + gsap 애니메이션도 사용하는 페이지에서만 불러져야 하는데 왜.. 불러지는걸까요..
그건 바로바로 lazy import를 하지 않았기 때문이죠!
제 블로그 읽으신 분들 중 lighthouse 최적화 진행한 글 읽으신 분들은
(과연 몇명이나 될까요? 조회수 한자리수인데 ㅋ)
저번에도 lazy import 안해서 이렇게 최적화 햇던 거 같은데 이 사람 이거 또하네??
싶으실 수 있겟지만 (제 스스로 저에게 한 말입니다 사실..)
원래 프로젝트 하다보면
급해서 그럴 수 있다고 생각합니다.

아직 배우는 단계니까 이해해주시길?
다시 본론으로 들어가서요 최적화 진짜 해볼게요
먼저 App.tsx에서 무거운 페이지들은 React.lazy() + Suspense로 변경해보겠습니다.


다른건 안바꿨어요 초기 진입페이지 (LoginPage, SignupPage, HomePage)는 별로 무겁지 않으니까요?
그리고 이렇게 감싸주었어여

📍 변경포인트📍
1. import 부분 => 무거운 페이지 13개를 lazy()로 교체, useEffect import에 lazy, Suspense 추가
2. JSX 부분 - <Routes> 바깥을 <Suspense> 감싸기
자 바꿨으니 다시 빌드해서 확인해볼게요

stats.html도 이렇게 뿅! 나옵니다

뭐가 달라졌을까~요?
전후 비교해서 설명해볼게예
핵심부터 설명하면 (두괄식)
1. 초기 로딩(index) 번들이 크게 줄었음

➡️ 개선 폭
* Rendered: -4,212.90 kB (약 -90.4%)
* Gzip: -1,124.08 kB (약 -88.5%)
결론: 초기 진입에 필요한 JS가 약 1/10 수준으로 감소.

근데 이렇게 빠진 녀석들이 어디로 갔는지?
GamePage로 분리되었습니다.
2. Lazy import / Code splitting

➡️ 의미
이전에는 index에 들어가 있던 무거운 코드(monaco/editor 계열)가
GamePage로 이동해서 “필요할 때만 다운로드” 되는 구조가 됨.

그래서 빌드 경고도 index가 아니라 GamePage 청크가 500kB 초과로 뜨는 게 정상인것이죠!
GamePage에는 정말 많은것이 구현되어있기 때문에 어쩔수가 없습니다.. (팀원이 고생했음 ㅠ)
3. CSS 변화: “index CSS가 줄고, GamePage CSS가 생김” (초기 CSS 분리)
Render 사이즈 70% 감소
그러니까 CSS 다이어트 성공했습니다.
(위고비 X, 마운자로(이거 맞나)X.
번들 다이어트함)


재밌는 부분은
index CSS가 이전에 206.24kB였는데
나누고 난 뒤에는
index CSS 60.77 + GamePage CSS 146.36 = 207.13kB
원래는 하나의 거대한 덩어리에서
2개로 나눠서 부담하게 만들었다... 그 말 입니다.ㅋ
초기 렌더 비용이 줄었어요!
그리고 추가로 분리된 청크들을 보자면요,
stomp-DsTm_-xG.js : 72.64 kB (gzip 23.07)
InvitationCodePage-7g9NhjR0.js : 76.83 kB (gzip 31.16)
stats.html이 보기 어렵지만, 청크가 더 자잘하게 많이 된것을 보면
스플리팅이 잘 된 것이라고 볼 수 있기 때문에!
잘햇다.. 그말이죠

이제 vite.config.ts에서 visualizer import와 plugins 배열 안에서 visualizer({...}) 지워주고,,
.gitignore에 stats.html 추가해주겠습니다.
다했으니까 다른 팀원이 최적화 할 수 잇게 넘겨주려구요 ㅋ
여기까지 일단 하고 다음 글에서
이미지 최적화,
폰트/오디오 최적화,
framer-motion 트리쉐이킹
이렇게 진행해보도록 하겠습니다!
생각보다 한번에 수치가 화아아악! 떨어져서 재밌네용..
그럼 다음 글에서 만나요!

'Project' 카테고리의 다른 글
| [React+Vite+TypeScript] try-catch! 이미지/오디오/폰트 최적화 및 vite 번들 최적화 (8) | 2026.02.16 |
|---|---|
| [Jenkins][React+TypeScript] eslint가 배포 빌드에 영향을 미치는 점.. (0) | 2026.01.27 |
| [Vue.js] 프론트엔드 프로젝트 성능 측정 01 - 💡Lighthouse (5) | 2026.01.05 |
| [Django + Vue.js] 1. AI API 기반 도서 추천 및 실시간 모임 서비스 앱 <BOOKLUV> - 프로젝트 명세서 및 주요 서비스 설명 (2) | 2026.01.02 |