
기능을 개발하고 QA를 진행하는 중, 새로고침 이후 화면에 버튼이 보여 클릭했지만, 아무 반응이 없었어요. 몇 초가 지나서야 동작이 먹히기 시작했죠. 무언가 메인 스레드를 오래 점유하고 있다는 느낌이 들었어요. 무엇이 이렇게 초기 동작을 느리게 만드는지 확인이 필요했어요.
가장 먼저 빌드 후 번들 사이즈를 확인해 봤어요. 충격적이게도 대부분의 페이지의 FirstLoadJs의 크기가 500kb대에 가까웠어요. 권장 기준의 3배가 넘는 수준이었어요.
Next.js 빌드 출력에서 초록(Green)으로 표시되는 권장 기준은 130kB 미만이며, 빨간색 경고가 뜨는 임계값도 170kB
| 빌드 출력 색상 | First Load JS 크기 | 의미 |
|---|---|---|
| 🟢 초록 (Green) | < 130 kB | 양호 — 대부분 환경에서 빠르게 로드 |
| 🟡 노랑 (Yellow) | 130 ~ 170 kB | 주의 — 3G 환경에서 5~6초 로드 위험 |
| 🔴 빨강 (Red) | > 170 kB | 위험 — 6초 이상 로드, 사용자 이탈 가능 |
| ❌ 내 프로젝트 | 500+ kB | 권장치의 3배 이상 초과 |
여기서 제가 느꼈던 '클릭 먹통' 현상의 실마리를 찾을 수 있었어요. 아무리 SSR로 화면을 빠르게 그려준다고 해도, 그 뒤에 따라오는 Hydration 과정이 문제였던 거죠. 브라우저는 이 거대한 JS 파일을 단순히 받기만 하는 게 아니라, 파싱하고 실행한 뒤, SSR로 그려진 HTML에 이벤트를 연결하는 Hydration 과정까지 거쳐야 했거든요.
결국 사용자는 화면은 보고 있지만, 실제로는 아직 인터랙션할 수 없는 상태였어요.
| light house 지표 | First Load JS 크기 |
|---|---|
![]() | ![]() |
그렇다면 왜 번들 사이즈가 이렇게 커진 걸까요? 보통 번들이 비대해지는 원인은 크게 두 가지가 있어요.
가장 먼저 Next Bundle Analyzer를 돌려봤어요. 그런데 의외로 차트에는 눈에 띄게 거대한 덩어리가 보이지 않았어요. 특정 라이브러리가 범인이라면 차트의 한 칸이 엄청나게 커야 하는데, 다들 비슷비슷한 사이즈였죠.
거대 라이브러리가 후보에서 제외되자, 자연스럽게 트리 쉐이킹이 의심되었어요. 코드가 제대로 깎이지 않고 그대로 번들에 실려 가고 있다는 뜻이니까요.
그런데 문득 코드를 훑어보다 쎄한 느낌이 드는 지점을 발견했어요.
현재 프로젝트에서는 FSD 구조를 기반으로 코드를 관리하고 있었어요. 자연스럽게 각 레이어마다 ‘index.ts’ 배럴 파일을 두고 import 경로를 모아두는 방식도 사용하고 있었죠. 사용하는 입장에서는 import 문이 훨씬 깔끔해졌어요. 그런데 이 깔끔함이 번들러 입장에서는 전혀 다른 의미였어요.
FSD는 기능 단위로 모듈을 분해하고 계층화하여 복잡한 프런트엔드 프로젝트를 관리 가능하게 만드는 아키텍처예요.
FSD 규칙에 따라 폴더를 구성하다 보면 각 슬라이스(Slice)마다 index.ts를 두게 됩니다. 내부의 코드들을 이 파일 하나로 모아서 밖으로 내보내는 배럴 파일(Barrel File) 방식이죠. 이렇게 하면 상위 레이어에서 하위 모듈을 참조할 때 경로가 아주 깔끔해집니다.
// ✅ 깔끔한 참조의 예
import { Button, Input, Caption } from '@/shared/ui';
여러 파일을 일일이 찾아다닐 필요 없이 index.ts라는 하나의 창구만 통하면 되니, 생산성 면에서는 안 쓸 이유가 없었어요.
하지만 이 깔끔한 한 줄의 import 문이 번들 사이즈 폭발의 주요 원인 중 하나였어요. 저는 분명 Button, Input, Caption 세 가지만 불러왔지만, 실제 번들 결과물에는 @/shared/ui 안에 있는 사용하지 않는 수십 개의 컴포넌트들이 모두 포함되고 있었거든요.
이는 실제 파일로 직접 import를 할 때와 배럴 파일로 import할때의 빌드 사이즈를 비교해보니 알 수 있었어요.
처음엔 저도 Tree Shaking이 알아서 다 제거해줄 줄 알았어요. 하지만 실제로는 그렇지 않았어요. 트리 쉐이킹은 런타임이 아니라 정적 분석(Static Analysis)에 기반하기 때문이에요.
즉, 런타임에서 실제로 사용되는지와는 별개로, 컴파일 시점에서 안전하게 제거 가능하다고 판단되어야만 코드를 지울 수 있었어요. 배럴 파일을 통해 re-export된 모듈 중 단 하나라도 전역 변수를 설정하거나 외부 상태를 변경하는 코드가 있다면 번들러는 "이 파일 전체를 제거했다가 뭔가 망가질 수도 있다"고 판단해서 아무것도 못 지워요.
결국, 배럴 파일이 모든 파일의 '교차로' 역할을 하면서, 번들러가 의존성을 더 보수적으로 판단하게 만들고 있었어요. 특히 shared 레이어처럼 프로젝트 전반에서 공통으로 사용하는 영역일수록, 이 영향은 눈덩이 처럼 커져 First Load JS크기를 불필요하게 증가시키고 있었죠.
💡 잠깐! 배럴 파일(index.ts)은 정확히 어떻게 동작하나요?
배럴 파일의 원리는 간단합니다. 여러 폴더에 흩어진 개별 모듈들을 하나의 파일로 모아주는 역할을 해요.
- 동작 방식:
export { Button } from './Button'처럼 내부 파일들을 다시 내보내기(Re-export) 하여 외부에서 단일 진입점으로 접근하게 합니다.- 장점:
- 가독성:
import경로가 짧아지고 코드가 깔끔해집니다.- 유지보수: 파일 위치가 바뀌어도
index.ts만 수정하면 외부 참조를 일일이 바꿀 필요가 없습니다.
배럴 파일이 문제라는 건 알았어요. 그렇다면 배럴 파일을 없애면 될까요?
배럴 파일이 원인이라는 것을 알았지만 무턱대고 모든 배럴 파일을 지울 수는 없었어요. 배럴 파일을 걷어내면 import 경로가 전부 깨지고, 그걸 하나하나 고치는 건 리팩토링이 아니라 그냥 공사거든요. DX를 희생하지 않으면서 번들 사이즈를 줄일 방법이 필요했어요.
이때 package.json의 sideEffects속성을 알게되었어요.
앞서 말했듯, Webpack은 코드를 지울 때 매우 보수적이에요. 파일이 import되는 것만으로도 전역 상태를 바꾸거나 어딘가에 영향을 줄 수 있다고 판단하면, 실제로 사용되지 않더라도 함부로 삭제하지 못하죠. 이런 동작을 사이드 이펙트(Side Effect) 라고 해요. CSS를 전역으로 주입하거나, 전역 변수를 설정하는 코드가 대표적인 예예요.
package.json에 작성하는 "sideEffects": false는 번들러에게 보내는 강력한 신뢰의 메시지에요.
"우리 프로젝트의 파일들은 import만으로 이상한 짓을 하지 않아. 그러니 안 쓰는 코드가 보이면 고민하지 말고 과감하게 지워버려!"
이 설정 덕분에 번들 사이즈를 꽤 줄일 수 있었어요.
| light house 지표 | First Load JS 크기 |
|---|---|
![]() | ![]() |
하지만 특정 페이지들은 여전히 200kB를 넘나들고 있었어요. 직접 경로로 import했을 때보다 여전히 컸죠.
이유는 sideEffects: false의 구조적 한계 때문이었어요. 이 설정은 번들러가 이미 읽어 들인 코드 중에서 안 쓰는 걸 지워주는 방식이에요. 배럴 파일을 타고 딸려오는 불필요한 의존성을 애초에 읽지 않게 막아주지는 못하죠.
결국 sideEffects: false는 '읽고 난 뒤의 청소'는 잘 해줬지만, 애초에 불필요한 파일을 읽어오는 문제 자체는 막지 못했어요. 근본적인 해결은 다른 곳에 있었어요.
sideEffects 설정이 '이미 읽어 들인 코드'를 청소하는 사후 조치였다면, 애초에 파일을 읽지 않게 만들 수는 없을까요?
Webpack 같은 번들러는 import 문을 따라가며 프로젝트 전체의 의존성 그래프(Dependency Graph)를 만들어요. 문제는 번들러가 Button 하나를 찾으러 @/shared/ui/index.ts에 들어가는 순간부터였어요.
번들러는 그 안에 적힌 모든 export 문을 읽고 해석하면서, 사용하지도 않을 모듈들까지 의존성 그래프에 등록하기 시작해요. 그리고 이렇게 읽어 들인 방대한 의존성들 중 단 하나라도 트리쉐이킹에 실패하면, 그 코드는 그대로 최종 번들에 남게 되죠.
(https://webpack.js.org/concepts/dependency-graph/)
이 문제를 해결하기 위해 Next.js의 optimizePackageImports 옵션을 도입했어요. Next.js의 컴파일러인 SWC를 이용한 '사전 가공' 전략이에요.
공식 문서에서는 lucide-react, @mui/icons-material 같은 npm 패키지를 대상으로 설명하고 있어요. 그런데 저는 @/shared/ui, @/features/auth 같은 내부 경로(alias)에도 동일하게 적용했어요.
이게 가능한 이유는 SWC가 이미 tsconfig.json의 paths 설정을 인식하고 있기 때문이에요. SWC 입장에서 @/shared/ui는 실제로 어느 파일인지 알고 있고, 번들러가 그 파일을 열기 전에 import 문 자체를 가로채서 직접 경로로 바꿔버릴 수 있어요.
// SWC가 가로채서 고쳐 쓰는 과정
import { Button } from '@/shared/ui'
↓
import { Button } from '@/shared/ui/Button/Button'
다만 한 가지 짚어둘 점이 있어요. 내부 경로에
optimizePackageImports를 적용하는 건 문서화된 공식 사용 방법은 아니에요. 실제로 번들 사이즈가 줄어드는 효과를 확인했지만, Next.js 버전이 올라가면 동작이 바뀔 가능성도 있어요.
index.ts) 근처에도 가지 않아요. 입구에서 길을 틀어주니, 번들러가 쓸데없는 파일을 읽으며 고민할 필요가 없고, 최종 번들에 사용하지 않는 코드가 남을 확률 자체가 사라져요.중요한 건 이 작업이 '번들링 이후'가 아니라 번들러가 의존성 그래프를 만들기 전에 일어난다는 점이에요. sideEffects가 읽고 난 뒤 청소하는 방식이라면, optimizePackageImports는 애초에 읽을 파일 자체를 바꿔버리는 방식이죠.
| light house 지표 | First Load JS 크기 |
|---|---|
![]() | ![]() |
모든 설정을 마치고 다시 빌드를 돌렸을 때, 드디어 모든 페이지가 100kB대의 '그린 라이트'로 변한 것을 확인할 수 있었어요.
이번 경험을 통해 느낀 건, 성능 최적화는 단순히 숫자를 줄이는 작업이 아니라는 점이었어요. 현재 구조가 번들러에게 어떻게 해석되는지, 그리고 그 결과가 실제 사용자 경험에 어떤 영향을 주는지까지 연결해서 봐야 했어요.
특히 “편리한 구조”가 항상 “효율적인 런타임”으로 이어지는 것은 아니라는 점이 기억에 남아요. 이후부터는 기능 구현뿐 아니라, 이 구조가 번들 단계에서 어떤 비용으로 이어질지까지 함께 고려하는 시선을 기를 수 있는 경험이었어요.