본문 바로가기
개발 일지

[ 개선 ] Lighthouse 경고 하나씩 없애보기 2: Duplicated JavaScript 줄이기

by CODESIGN 2026. 5. 24.
반응형


Lighthouse에서 로고 이미지 관련 경고를 해결한 뒤, 다음으로 마주한 경고는 JavaScript 관련 항목이었다.

Duplicated JavaScript
Est savings of 118 KiB


처음에는 “공통 코드가 여러 번 번들에 들어간 건가?”라고 생각했다.  
하지만 확인해 보니 실제 문제는 조금 달랐다.

이번 글은 `Duplicated JavaScript` 경고를 확인하고, 라우트 단위 lazy loading으로 초기 번들을 줄여 Lighthouse 경고를 제거한 과정이다.

 

1. Lighthouse 경고



Lighthouse는 다음과 같은 메시지를 보여줬다.

Duplicated JavaScript
Remove large, duplicate JavaScript modules from bundles


대략 이런 파일들이 표시됐다.

index.jsx
...NextYearPlanPage/index.jsx
...ManageReportForm/index.jsx
...PlanningOperationalServicesPage/index.jsx
...ManageReportPage/index.jsx
...ServiceOperationPage/index.jsx

 



여기서 눈에 띈 건 `index.jsx?t=...` 형태였다.

Vite 개발 서버에서 파일 경로 뒤에 붙는 `?t=`는 HMR을 위한 timestamp다.

즉, 이 경고는 개발 서버에서 Lighthouse를 돌렸을 가능성이 있었다.

개발 서버는 production 번들과 다르게 동작한다.

- 모듈을 개별적으로 제공한다
- HMR 코드가 붙는다
- 최적화된 chunk splitting 결과가 아니다
- Lighthouse 경고가 실제 배포 상태와 다를 수 있다

그래서 먼저 실제 production build 기준으로 확인해야 했다.

 

2. production 번들 기준으로 중복 확인



먼저 sourcemap을 포함해서 빌드했다.

 

npm run build -- --sourcemap



그리고 `dist/assets/*.js.map` 파일을 분석해서 같은 원본 source가 여러 JS chunk에 중복 포함되는지 확인했다.

확인 결과는 다음과 같았다.

app duplicates: 0
shared duplicates: 0
features duplicates: 0
node_modules duplicates: 0

 


즉, production 번들 기준으로는 공통 코드가 여러 청크에 중복 포함된 문제는 없었다.

Lighthouse에서 보였던 `Duplicated JavaScript`는 개발 서버 측정 영향일 가능성이 컸다.

하지만 여기서 다른 문제가 보였다.

3. 진짜 문제: 메인 번들이 너무 큼



중복 모듈은 없었지만, production build 결과에서 메인 JS가 매우 컸다.

index-*.js
3,584 KiB
gzip 1,052 KiB


초기 진입 시 gzip 기준 약 1MB의 JavaScript를 받아야 하는 상태였다.

원인을 보면 라우터 구조가 문제였다.

기존 라우터는 모든 페이지를 정적으로 import 하고 있었다.

import { ManageInquiriesPage } from '../../pages/admin/ManageInquiriesPage.jsx'
import { ManageMembersPage } from '../../pages/admin/ManageMembersPage.jsx'
import { ReportPage } from '../../features/report-evaluation/ui/ReportPage.jsx'
import { EvaluationPage } from '../../pages/user/EvaluationPage.jsx'
import ServiceOperationMenu from '../../pages/user/ServiceOperationPage/index.jsx'
import NextYearPlanPage from '../../pages/user/NextYearPlanPage/index.jsx'



이런 구조에서는 사용자가 첫 화면에 들어와도 관리자 페이지, 평가 페이지, 보고서 페이지, 입력 페이지 코드가 함께 초기 번들에 포함될 수 있다.

즉, 지금 당장 보지 않는 화면의 코드까지 먼저 다운로드하고 있었다.

 

4. 해결 방향: 라우트 단위 lazy loading



SPA에서 이런 문제를 줄이는 가장 일반적인 방법은 라우트 단위 code splitting이다.

모든 컴포넌트를 lazy loading 할 필요는 없다.  
하지만 페이지 단위는 lazy loading을 적용하는 경우가 많다.

특히 이런 화면은 lazy loading 대상이 된다.

- 관리자 화면
- 보고서 출력 화면
- 평가 입력 화면
- 문의 관리 화면
- 에디터나 PDF 출력처럼 무거운 라이브러리를 쓰는 화면
- 로그인 후에만 접근하는 화면

그래서 라우터의 정적 import를 `React.lazy` 기반 동적 import로 변경했다.

## 5. lazyNamed 유틸 추가

프로젝트의 page wrapper들은 대부분 default export가 아니라 named export를 사용하고 있었다.

예를 들면 이런 형태다.

export { EvaluationPage } from '../../features/user/EvaluationPage'


`React.lazy`는 기본적으로 default export를 기대한다.  
그래서 named export를 lazy로 불러오기 위한 작은 helper를 만들었다.

const lazyNamed = (loader, exportName) =>
  lazy(() =>
    loader().then(module => ({
      default: module[exportName]
    }))
  )


그리고 라우트 fallback도 추가했다.

const RouteFallback = () => (
  <div style={{ display: 'flex', justifyContent: 'center', padding: '48px 0' }}>
    <Spin />
  </div>
)

const withSuspense = (Component, props) => (
  <Suspense fallback={<RouteFallback />}>
    <Component {...props} />
  </Suspense>
)


처음에는 `fallback={null}`로 둘 수도 있지만, 느린 네트워크에서는 페이지가 비어 보일 수 있다.

그래서 `antd`의 `Spin`을 사용해 작은 로딩 UI를 보여주도록 했다.

 

6. 라우터 import 변경


기존 정적 import를 제거하고 lazy import로 바꿨다.

기존:

import { EvaluationPage } from '../../pages/user/EvaluationPage.jsx'
import { ReportListPage } from '../../pages/admin/ReportListPage.jsx'
import { ReportPage } from '../../features/report-evaluation/ui/ReportPage.jsx'



변경 후:

const EvaluationPage = lazyNamed(
  () => import('../../pages/user/EvaluationPage.jsx'),
  'EvaluationPage'
)

const ReportListPage = lazyNamed(
  () => import('../../pages/admin/ReportListPage.jsx'),
  'ReportListPage'
)

const ReportPage = lazyNamed(
  () => import('../../features/report-evaluation/ui/ReportPage.jsx'),
  'ReportPage'
)



default export를 사용하는 페이지는 그대로 `lazy`를 사용했다.

const NextYearPlanPage = lazy(() =>
  import('../../pages/user/NextYearPlanPage/index.jsx')
)

const ServiceOperationMenu = lazy(() =>
  import('../../pages/user/ServiceOperationPage/index.jsx')
)

 


7. 라우트 element 변경


각 라우트의 element도 `withSuspense`로 감쌌다.

기존:

{
  path: '/evaluationPage/:id',
  element: <EvaluationPage />
}



변경 후:

{
  path: '/evaluationPage/:id',
  element: withSuspense(EvaluationPage)
}



props가 필요한 경우에는 두 번째 인자로 넘겼다.

기존:

{
  path: '/reportEvaluationPage/:id',
  element: <ReportPage evaluationMode />
}



변경 후:

{
  path: '/reportEvaluationPage/:id',
  element: withSuspense(ReportPage, { evaluationMode: true })
}



하위 라우트들도 동일하게 적용했다.

{
  path: 'serviceOperation/:districtId',
  element: withSuspense(ServiceOperationMenu)
}


8. 빌드 결과 비교


변경 후 다시 빌드했다.

npm run build


빌드는 정상적으로 성공했다.

그리고 메인 JS 크기가 크게 줄었다.

변경 전
index-*.js 3,584 KiB
gzip 1,052 KiB

변경 후
index-*.js 1,443 KiB
gzip 463 KiB

gzip 기준으로 약 `589 KiB`가 줄었다.

라우트별로 별도 chunk도 생성됐다.

예를 들면:

ReportListPage-*.js
ManageInquiriesPage-*.js
EvaluationPage-*.js
ServiceOperationPage 관련 chunk
ReportPage-*.js
TiptapEditorPanel-*.js



이제 첫 진입 시 모든 페이지 코드를 한 번에 받지 않고, 실제로 해당 라우트에 접근할 때 필요한 chunk를 추가로 받게 됐다.

 

9. preview 서버에서 확인



production build 결과를 기준으로 확인하기 위해 preview 서버도 실행했다.

npm run preview -- --host 127.0.0.1



그리고 루트 경로가 정상 응답하는지 확인했다.

GET /
200 OK


개발 서버가 아니라 production preview 기준으로 확인하는 것이 중요하다.

Lighthouse도 가능하면 `npm run dev`가 아니라 아래 흐름으로 측정하는 것이 좋다.

npm run build
npm run preview

그리고 Lighthouse는 preview 주소에서 돌린다.

http://localhost:4173

10. 결과



라우트 단위 lazy loading 적용 후 Lighthouse에서 `Duplicated JavaScript` 경고가 제거됐다.

정리하면 이번 작업의 흐름은 다음과 같다.

1. Lighthouse에서 Duplicated JavaScript 경고 확인
2. dev server 측정 가능성 확인
3. sourcemap build로 실제 중복 모듈 확인
4. production 기준 중복 source는 0개 확인
5. 대신 메인 번들이 너무 큰 문제 발견
6. 라우터의 정적 import를 React.lazy로 변경
7. Suspense fallback 추가
8. 메인 JS gzip 1,052 KiB → 463 KiB 감소
9. Lighthouse 경고 제거



이번 작업에서 배운 점


`Duplicated JavaScript` 경고가 나온다고 해서 항상 실제로 코드가 중복 포함된 것은 아니었다.

특히 Vite dev server에서 Lighthouse를 돌리면 HMR과 개발용 모듈 로딩 방식 때문에 production과 다른 경고가 나올 수 있다.

그래서 먼저 확인해야 할 것은 이것이다.

지금 Lighthouse를 dev server에서 돌렸는가?
production preview에서 돌렸는가?

그리고 실제 production 빌드에서 중복이 없다면, 다음으로 볼 것은 초기 번들 크기다.

이번 프로젝트에서는 중복 제거보다 라우트 단위 lazy loading이 더 효과적이었다.

 

마무리



이번 개선은 단순히 Lighthouse 경고 하나를 없앤 작업이 아니라, SPA 초기 로딩 구조를 바꾼 작업이었다.

페이지가 많고 기능이 큰 React 앱에서는 라우트 단위 lazy loading이 꽤 자연스러운 최적화 지점이다.

처음부터 모든 컴포넌트를 잘게 쪼갤 필요는 없지만, 다음과 같은 상황이라면 적용할 만하다.

 

- 메인 JS가 너무 크다
- 관리자/사용자/보고서/에디터 화면이 한 앱에 섞여 있다
- 첫 화면에서 쓰지 않는 기능 코드가 많다
- Lighthouse에서 JavaScript 관련 경고가 나온다
- Vite build에서 chunk size warning이 나온다

 

이번 작업으로 초기 JS 다운로드량은 크게 줄었다.  

 

반응형

댓글