Next.js 프로젝트를 빌드하면 .next/static/chunks/ 안에 여러 파일이 생긴다. 파일 이름에는 main-a3f9b2.js처럼 해시가 붙어 있다. 작성한 코드를 하나로 합치는 줄 알았는데, 왜 여러 조각으로 나뉘어 나올까?
각 조각을 청크(chunk)라고 한다. 빌드 도구가 청크를 나누는 기준과 캐시·로딩에 미치는 영향을 살펴본다. 직접 코드를 분리하는 방법과 청크를 지나치게 잘게 나눴을 때의 부담도 함께 다룬다.
1. 빌드 결과는 파일 하나가 아니다
우리가 쓰는 코드는 파일 하나가 아니다. import로 서로를 불러오는 파일이 수십, 수백 개 쌓여 있다. 브라우저는 이 상태를 그대로 못 읽는다. 그래서 빌드 도구(webpack, Turbopack 같은 것)가 이 파일들을 처음부터 끝까지 따라가며 하나로 엮어 배포용 파일을 만든다. 이 작업이 번들링이고, 나온 결과물이 번들이다.
여기까지가 대개 아는 데까지다. 그다음이 문제다. 번들러는 이걸 bundle.js 하나로 합쳐 주지 않는다. 전부를 하나의 거대한 파일로 합치지 않고 여러 조각으로 쪼개 놓는다. 그 조각 하나가 청크다.
Next.js에서 빌드하면 실제로 이렇게 생성된다.
.next/static/chunks/
├── main-[hash].js # 공통 런타임
├── framework-[hash].js # React, react-dom 등 프레임워크
├── pages/_app-[hash].js
├── [route]-[hash].js # 라우트별 코드
└── [number]-[hash].js # 공유 모듈 / 동적 import 청크
앞에서 본 알 수 없는 글자, -a3f9b2.js 부분은 파일 내용으로 만든 해시다. 내용이 안 바뀌면 이름도 그대로 나온다. 이름이 그대로면 브라우저는 예전에 받아 둔 걸 다시 쓴다. 반대로 그 파일 안의 코드를 한 줄이라도 고치면 해시가 바뀌고, 브라우저는 이름이 달라진 그 파일만 새로 받는다.
청크끼리는 서로를 참조하고 있다. 그래서 처음부터 전부 내려받지 않아도, 필요해지는 시점에 나머지를 불러올 수 있다.
2. 왜 굳이 나눠서 보낼까
한마디로 지금 이 화면에 필요한 코드만 내려받게 하려고 나눈다.
이사할 때 짐을 박스 하나에 다 넣으면 숟가락 하나 꺼내려고 박스 전체를 열어야 하는 것과 같다. 주문 상세 화면 하나를 보려고 들어왔는데 결제 모달, 정산 화면, 검색 화면 코드까지 전부 함께 내려오면 그만큼 기다려야 한다.
나눠 두면 네 가지가 달라진다.
| 이유 | 설명 |
|---|---|
| 초기 로딩 속도 | 첫 화면에 필요 없는 코드(예: 정산 모달, 주문 내역 다운로드 로직)를 처음부터 받지 않음 → JS 다운로드·파싱·실행 시간 단축 |
| 캐시 효율 | 잘 안 바뀌는 코드(framework 청크)와 자주 바뀌는 우리 코드(라우트 청크)를 분리 → 우리 코드만 배포해도 프레임워크 청크는 브라우저 캐시 재사용 |
| 병렬 다운로드 | HTTP/2 환경에서 여러 청크를 동시에 받을 수 있음 |
| 중복 제거 | 여러 페이지가 공통으로 쓰는 모듈을 별도 청크로 뽑아 한 번만 다운로드 |
표 1. 코드를 청크로 나눠서 얻는 네 가지
이 중 놓치기 쉬운 게 첫 줄이다. 다운로드가 끝나면 끝이라고 보기 쉬운데 그렇지 않다. 브라우저와 자바스크립트 엔진은 JS를 다운로드한 만큼이 아니라 파싱·컴파일·실행까지 비용을 낸다. 그래서 “안 쓰는 코드를 안 보내는 것”이 성능에 직접 붙는다. 받아만 놓고 안 쓰는 코드도 공짜가 아니라는 뜻이다.
3. 어디까지 알아서 나뉘고, 어디서 직접 나누나
절반은 프레임워크가 알아서 한다. Next.js App Router는 라우트 단위로 자동으로 청크를 나눈다. src/app/dashboard/orders/[orderId]/page.tsx는 사용자가 그 주소로 들어갈 때만 해당 청크가 로드된다. 따로 설정하지 않아도 여기까지는 된다.
나머지 절반은 개발자 몫이다. 무겁거나 특정 조건에서만 뜨는 컴포넌트는 직접 청크로 떼어낸다.
// 정적 import: 이 청크에 포함되어 항상 함께 로드됨
import { HeavyChart } from "./heavy-chart";
// 동적 import: 별도 청크로 분리 → 실제 렌더 시점에 로드
import dynamic from "next/dynamic";
const HeavyChart = dynamic(() => import("./heavy-chart"), {
loading: () => <Spinner />,
ssr: false, // 클라이언트에서만
});
import()가 여기서 한 번 걸린다. 평소 쓰는 import { X } from './x'는 파일 맨 위에만 적을 수 있는 문법이고, 뜻은 “이 파일을 쓸 거니까 같이 묶어 달라”에 가깝다. 그래서 항상 함께 내려온다. 반면 괄호를 붙인 import('./heavy-chart')는 함수를 부르는 것처럼 생겼고, 실제로 그 줄이 실행되는 순간에 파일을 가져온다. 번들러는 이 괄호를 보고 여기부터는 따로 떼도 되겠다고 판단해 별도 청크로 만든다. 가져오는 데 시간이 걸리니 결과는 Promise로 나오고, 그동안 보여 줄 화면이 필요하다. 위 코드의 loading: () => <Spinner />가 그 자리다.
이렇게 떼어낼 만한 곳은 대개 셋이다.
- 풀모달(
src/app/dashboard/(modal)/)처럼 특정 플로우에서만 여는 무거운 화면 - 차트/에디터/PDF 뷰어 같은 큰 서드파티 라이브러리
- 조건부로만 뜨는 모달·드로어
셋의 공통점은 하나다. 대부분의 사용자가 그 화면을 안 연다는 것. 안 열 사람에게까지 미리 보낼 이유가 없다.
4. 코드를 안 고쳤는데 왜 에러가 뜰까
에러 수집 도구에 ChunkLoadError가 쌓이는데, 그날 배포에서 손댄 적 없는 화면인 경우가 있다. 코드를 뒤져서는 원인이 안 나온다.
원인은 1절의 해시에 있다. 배포를 하면 청크 파일명의 해시가 바뀐다. 그런데 사용자가 배포 전에 열어 둔 탭은 화면을 그대로 띄워 둔 채로 예전 파일명을 기억하고 있다. 그 탭에서 동적 import가 걸린 화면을 처음 열면, 브라우저는 이제 서버에 없는 파일을 요청한다. 그래서 ChunkLoadError가 난다.
고치는 코드가 따로 있는 게 아니다. 오래된 탭이 남아 있는 한 이 상황은 계속 생긴다. 그래서 보통 Error Boundary에서 이 에러를 잡아 새로고침을 유도한다. 에러 수집 도구에서 자주 보이는 패턴이라 검색하면 사례가 금방 나온다.
5. 그럼 안 나누면 어떻게 되나
청크 분할이 전혀 없다면, 그러니까 모든 코드가 하나의 거대한 bundle.js 라면 세 가지가 한꺼번에 온다.
첫째, 첫 페이지 진입이 느려진다. 주문 상세 페이지 하나 보려는데 결제·정산·검색 코드까지 전부 다운로드하고 파싱해야 한다.
둘째, 캐시가 무력화된다. 버튼 텍스트 한 줄만 고쳐도 번들 전체의 해시가 바뀌어서, 사용자는 React까지 포함해 전체를 다시 받는다.
셋째, Time to Interactive가 늦어진다. 메인 스레드가 거대한 JS를 파싱하는 데 묶여 있는 동안은 화면이 떠 있어도 버튼을 눌러도 반응이 없다. 그 시간이 길어진다.
6. 그럼 잘게 쪼갤수록 좋은가
여기까지 읽으면 잘게 쪼갤수록 좋겠다는 결론으로 가기 쉽다. 그런데 너무 잘게 쪼개도 역효과가 난다.
- 요청 수(HTTP 오버헤드) 증가
- 청크마다 붙는 부트스트랩 코드 중복
- waterfall(A 청크를 받아야 B가 필요한 줄 아는) 발생 시 오히려 지연
셋 중에 마지막이 제일 성가시다. 청크를 순서대로 하나씩 받게 되면, 파일을 나눠서 아낀 시간을 기다리는 데 도로 쓴다. 나눈 게 이득이 아니라 손해가 되는 경우다.
그래서 번들러는 “너무 크지도 너무 잘지도 않게” 자동으로 균형을 맞추고(webpack splitChunks), 개발자는 무거운 부분에만 dynamic import로 수동 개입하는 게 실무 표준이다. 정리하면, 기본값을 믿고 두다가 눈에 띄게 무거운 화면만 손으로 떼어내는 쪽이다.
참고: 같은 이름을 쓰는 다른 청크들
“청크”라는 말은 프론트엔드 밖에서도 자주 쓰인다. 검색하다 다른 뜻의 글로 빠지기 쉬워서 같이 적어 둔다.
- 스트리밍 청크 — HTTP 응답을 조각으로 나눠 보내는 것 (RSC 스트리밍 렌더,
Transfer-Encoding: chunked). Next.js App Router 서버 렌더링도 이걸 씀 - 배열/데이터 chunking —
lodash.chunk처럼 리스트를 N개씩 나누는 것 (대량 API 배치 처리 등) - RAG/LLM 청크 — 긴 문서를 임베딩용으로 자르는 텍스트 조각
이름은 같지만 셋 다 이 글의 번들 청크와는 다른 이야기다.
그래서 이 조각들은 누가 무슨 기준으로 나눈 것인가
.next/static/chunks/ 안에 파일이 여러 개 있는 이유는, 빌드 도구가 지금 이 화면에 필요한 것만 내려보내려고 코드를 미리 조각으로 나눠 뒀기 때문이다.
이걸 알고 나면 두 가지가 달라진다. 빌드 결과를 볼 때 파일 개수와 크기를 한 번 보게 되고, ChunkLoadError가 뜨면 코드부터 뒤지지 않고 배포 시점을 먼저 의심하게 된다.

답글 남기기