0407

개발

[checky] React + Vite에서 초기 로딩 느려서 뜯어본 이야기

| 서론

안녕하세요, 팡일입니다.

오늘은 checky 서비스의 초기 로딩 속도를 개선했던 경험을 공유해보려고 합니다.

기능은 잘 돌아가는데 첫 진입이 묘하게 무겁게 느껴졌습니다. 그래서 별 생각 없이 vite build를 돌려봤더니, 이런 경고가 보이더라고요.

Some chunks are larger than 500 kB after minification

이 경고를 시작으로 번들 구조를 다시 뜯어보고, 최적화 → 배포 → 실제 에러 → 재설계까지 겪은 이야기를 정리해보겠습니다.

| 빌드 결과를 보니 문제는 생각보다 단순했다

빌드 결과를 까보니 구조는 이렇게 되어 있었습니다.

  • 홈 페이지

  • 관리자 페이지

  • React, Firebase 같은 공용 라이브러리

이게 전부 하나의 JS 파일에 묶여 있던 것이죠. 즉, 사용자가 첫 화면만 보는데도 “나중에 쓸지도 모르는 코드”까지 모두 다운로드하고 있었습니다.

그래서 메인 번들은 1.1MB (gzip 343KB) 크기를 갖게 되었고, Vite에서 500KB 경고 발생하게 된 것이었습니다.

| “처음엔 필요한 것만 받게 하자”

여기서 목표는 하나였습니다, “첫 진입에서 진짜 필요한 것만 받게 만들자”

그래서 크게 두 가지 접근을 선택했습니다.

1) 라우트 단위 코드 스플리팅

기존에는 모든 페이지가 앱 시작 시 로드됐는데, 위의 사진처럼, 이를 React.lazy + Suspense로 분리했습니다.

  • 홈은 홈 진입할 때만 로드

  • 관리자 페이지는 실제로 접근할 때만 로드

결과적으로 초기 진입 시 다운로드 범위가 확 줄었습니다.

2) 라이브러리는 따로 분리

React나 Firebase 같은 라이브러리는 크고, 자주 바뀌지 않고, 한 번 받아두면 캐시 효과가 큽니다.

그래서 Vite의 manualChunks 옵션으로 분리했습니다. (이 옵션은 Rollup 번들링을 직접 설계할 수 있게 해줍니다)

처음엔 이렇게 나눴습니다:

  • react-vendor

  • firebase

  • vendor

| 그런데… 배포 후 흰 화면이 떴다

배포 후에 이런 오류가 발생했습니다.

JavaScript
vendor-*.js: Uncaught TypeError: Cannot read properties of undefined (reading 'useLayoutEffect')

원인을 확인해보니, react-vendor와 vendor가 서로를 import하는 순환 구조가 만들어져 있었고, React가 완전히 초기화되기 전에 참조되면서 런타임 오류가 난 것이었습니다.

react-vendor → vendor → react-vendor으로 순환되는 것이었죠.

| 해결: React 분리 대신 “큰 라이브러리 단위”로 재설계

그래서 방향을 바꿨습니다. react-vendor 분리는 철회하고, 대신 큰 라이브러리 단위로 안정적으로 분리하는 것으로 결정했습니다.

위의 사진처럼, 최종 구조는 이렇게 정리했습니다.

  • firebase

  • charts (recharts)

  • router

  • react-query

  • vendor

이렇게 하면 순환 의존 없이 안정적으로 분리할 수 있고, 여전히 초기 로딩은 크게 개선되었습니다.

Update_At : 260425조금 더 설명을 추가해보면 다음과 같습니다. manualChunks라는 설정은 번들 파일을 라이브러리 반뒤로 분리하여, 캐싱 효율 및 초기 로딩 성능을 개선하기 위한 설정인데요?node_modules 기반으로만 분리하며, 변경이 적은 라이브러리는 별도 chunk로 분리하여 캐싱을 유지할 수 있습니다. 이를 통해 초기 번들 크기가 감소하는 효과를 볼 수 있죠,단, chunk를 너무 많이 나누면, 오히려 요청 수 증가로 인해 성능 저하가 일어날 수 있습니다. 그렇기에, 큰 라이브러리 위졸만 분리하는 것이 핵심입니다.특히, 위의 코드에서 return vendor의 의미는, 나머지 모든 외부 라이브러리를 공통 chunk로 묶어서 관리한다는 점까지 이해하면, 더욱 manualChunks 구조를 이해하실 수 있습니다.

| 결과는 이렇게 나왔다

결과는 꽤 극적이었습니다. 특히 느린 네트워크 환경이나 첫 방문 사용자에게서 이 차이는 더 크게 체감될 수 있다고 느꼈습니다.

1) 메인 JS 번들 (초기 진입 기준)

  • 변경 전: 1,103.34 kB (gzip 343.46 kB)

  • 변경 후: 35.26 kB (gzip 10.70 kB)

2) 분리된 청크

  • firebase: 335.28 kB (gzip 103.72 kB)

  • charts: 189.29 kB (gzip 50.78 kB)

  • router: 35.28 kB (gzip 12.77 kB)

  • react-query: 33.16 kB (gzip 9.97 kB)

  • vendor: 421.70 kB (gzip 135.71 kB)

이를 통해 단일 500KB 경고도 사라졌고, 실제 첫 로딩 속도도 체감될 만큼 개선되었습니다.

| 왜 Firebase는 완전히 lazy-load 하지 않았을까?

이러한 방법을 사용한 점으로 인해서 몇 가지 트레이오프도 있었는데요?

  • 라우트 처음 들어갈 때는 네트워크 요청이 하나 더 생긴다 → 그래서 Suspense fallback으로 로딩 상태를 보여줬다

  • Firebase를 끝까지 쪼개지는 않았다 → 구조 안정성을 우선했다

명확한 정답이 있는 문제는 아니라고 느껴져서 여러 자료를 찾아보고, 실제 사용 사례를 알아보면서,ChatGPT에게도 조언을 구해본 결과, 이번에는 완전 lazy-load 대신 별도 청크 분리로 캐시 + 병렬 다운로드 이점을 확보하는 전략을 선택했습니다.

| 결론: “한 번에 다 받게 하지 말자”

이번 최적화의 핵심은 한 줄로 정리됩니다,

“한 번에 다 받게 하지 말고, 필요한 순간에 필요한 것만 받게 하자.”

페이지는 페이지대로, 라이브러리는 라이브러리대로 나누니 초기 로딩은 빨라지고, 배포 안정성도 좋아졌습니다. 무엇보다 직접 운영 환경에서 에러를 겪고 해결하면서 성능 최적화는 수치뿐 아니라 안정성까지 함께 봐야 한다는 걸 배웠습니다.