Vercel의 React Best Practices 톺아보기[2]
2. 번들 크기 최적화 - 매우 중요
2.1 Avoid Barrel File Imports (배럴 파일 import 피하기)
- 영향도: 매우 중요 (CRITICAL / import에 200~800ms 소요, 빌드 지연)
배럴이 아닌 실제 소스 파일에서 직접 가져오세요. 그래야 쓰지도 않는 모듈이 수천 개까지 딸려 오는 일을 막을 수 있습니다. 배럴 파일이란 여러 모듈을 다시 export하는 진입점으로, 예를 들어 export * from './module'를 쓰는 index.js 같은 파일을 말합니다.
유명한 아이콘·컴포넌트 라이브러리 배럴에는 최대 수천~만 단위 re-export가 들어 있는 경우가 많습니다. React 패키지 중 일부는 그걸 import하는 것만으로도 200~800ms가 들어가 개발 체감과 프로덕션 첫 기동에도 모두 영향을 줍니다.
트리 셰이킹이 잘 안 먹히는 이유: 라이브러리를 번들 밖 external로 두면 번들러가 그래프를 줄여 주기 어렵습니다. 반대로 번들에 넣어 트리 셰이킹을 켜면, 전체 모듈 그래프를 분석해야 해서 빌드가 눈에 띄게 느려질 수 있습니다.
잘못된 예 (라이브러리 전체가 딸려 옴):
import { Check, X, Menu } from "lucide-react";
// dev에서 모듈 1,583개 로드, 약 2.8s 추가 등
// 첫 로딩마다 런타임 200~800ms 부담
import { Button, TextField } from "@mui/material";
// dev에서 모듈 2,225개 로드, 약 4.2s 추가 등올바른 예 — Next.js 13.5+ (권장):
// next.config.js — 빌드 시 배럴 import를 자동으로 최적화
module.exports = {
experimental: {
optimizePackageImports: ["lucide-react", "@mui/material"],
},
};// 평소 쓰던 import 그대로 — Next가 직접 import로 바꿔 줌
import { Check, X, Menu } from "lucide-react";
// TypeScript 타입·에디터 자동완성 유지, 경로는 손으로 안 짬배럴 import 비용은 없애면서도 TypeScript 타입 안전성과 에디터 자동완성을 그대로 쓸 수 있어 이 방식이 권장됩니다.
올바른 예 — 직접 import (Next.js가 아닌 프로젝트):
import Button from "@mui/material/Button";
import TextField from "@mui/material/TextField";
// 쓰는 것만 로드TypeScript 주의: 일부 라이브러리는 깊은 경로용
.d.ts를 넣어 주지 않습니다. 특히lucide-react는lucide-react/dist/esm/icons/check처럼 가져오면 타입이 암묵적any로 잡혀strict나noImplicitAny에서 오류가 날 수 있습니다. 가능하면optimizePackageImports를 쓰고, 직접 경로를 쓸 거면 그 하위 경로에 타입이 붙어 있는지 라이브러리 쪽을 먼저 확인하세요.
이런 최적화를 쓰면 dev 부팅은 대략 15~70% 빠르고, 빌드는 약 28%, 첫 기동은 약 40% 빨라지는 등의 보고가 있으며 HMR도 체감상 빨라집니다.
HMR(Hot Module Replacement) - 개발 서버에서 파일을 저장했을 때 페이지 전체를 새로고침하지 않고, 바뀐 모듈만 런타임에 갈아 끼우는 방식
자주 해당되는 라이브러리: lucide-react, @mui/material, @mui/icons-material, @tabler/icons-react, react-icons, @headlessui/react, @radix-ui/react-*, lodash, ramda, date-fns, rxjs, react-use.
참고: How we optimized package imports in Next.js
2.2 Conditional Module Loading (조건부 모듈 로딩)
- 영향도: 높음 (HIGH / 필요할 때만 대용량 데이터를 로드)
무거운 데이터, 모듈 전부를 한꺼번에 받아 오지 말고, 실제로 그 기능을 쓰는 순간에만 import()로 나누어 로드하세요.
예시 (애니메이션 프레임 지연 로드):
function AnimationPlayer({
enabled,
setEnabled,
}: {
enabled: boolean;
setEnabled: React.Dispatch<React.SetStateAction<boolean>>;
}) {
const [frames, setFrames] = useState<Frame[] | null>(null);
useEffect(() => {
// 브라우저에서만 동적 import — SSR 서버 번들에 끼지 않게
if (enabled && !frames && typeof window !== "undefined") {
import("./animation-frames.js")
.then((mod) => setFrames(mod.frames))
.catch(() => setEnabled(false));
}
}, [enabled, frames, setEnabled]);
if (!frames) return <Skeleton />;
return <Canvas frames={frames} />;
}typeof window !== 'undefined'는 브라우저에서만 이 분기를 타게 해서, 위 동적 import가 SSR용 서버 번들에 섞이지 않도록 돕습니다. 서버에서는 window가 없으니 해당 import()는 클라이언트에서만 실행되고, 서버 번들 크기와 빌드 속도를 최적화합니다.
2.3 Defer Non-Critical Third-Party Libraries (중요하지 않은 타사 라이브러리는 나중에 사용하세요.)
- 영향도: 중간 (MEDIUM / 하이드레이션 이후 로드)
분석, 로깅, 에러 트래킹 같은 건 사용자 조작을 막지 않아도 됩니다. 하이드레이션이 끝난 뒤에 로드하세요.
잘못된 예 (초기 번들을 부담스럽게 함):
import { Analytics } from "@vercel/analytics/react";
export default function RootLayout({ children }) {
return (
<html>
<body>
{children}
<Analytics />
</body>
</html>
);
}올바른 예 (하이드레이션 이후 로드):
import dynamic from "next/dynamic";
const Analytics = dynamic(
() => import("@vercel/analytics/react").then((m) => m.Analytics),
{ ssr: false } // 서버 HTML에는 안 실리고 클라이언트에서만
);
export default function RootLayout({ children }) {
return (
<html>
<body>
{children}
<Analytics />
</body>
</html>
);
}2.4 Dynamic Imports for Heavy Components (무거운 컴포넌트는 동적 import 하세요.)
- 영향도: 매우 중요 (CRITICAL / TTI, LCP에 직접 영향)
첫 화면에 꼭 필요하지 않은 큰 컴포넌트는 next/dynamic으로 지연 로드하세요.
잘못된 예 (Monaco가 메인 청크에 ~300KB까지 묶임):
import { MonacoEditor } from "./monaco-editor";
function CodePanel({ code }: { code: string }) {
return <MonacoEditor value={code} />;
}올바른 예 (Monaco는 필요할 때만 로드):
import dynamic from "next/dynamic";
const MonacoEditor = dynamic(
() => import("./monaco-editor").then((m) => m.MonacoEditor),
{ ssr: false } // 에디터는 보통 클라이언트 전용
);
function CodePanel({ code }: { code: string }) {
return <MonacoEditor value={code} />;
}2.5 Preload Based on User Intent (사용자 의도에 맞춰서 미리 로드하세요.)
- 영향도: 중간 (MEDIUM / 체감 대기 시간 줄임)
필요해지기 직전에 무거운 번들을 미리 받아 두면 체감 대기 시간을 줄일 수 있습니다.
예시 (호버/포커스 시 preload):
function EditorButton({ onClick }: { onClick: () => void }) {
// 사용자 입력 직전(호버/포커스)에 미리 로드
const preload = () => {
if (typeof window !== "undefined") {
void import("./monaco-editor");
}
};
return (
<button onMouseEnter={preload} onFocus={preload} onClick={onClick}>
Open Editor
</button>
);
}예시 (기능 플래그가 켜졌을 때 preload):
function FlagsProvider({ children, flags }: Props) {
useEffect(() => {
// 기능이 켜진 사용자에게만 선로딩
if (flags.editorEnabled && typeof window !== "undefined") {
void import("./monaco-editor").then((mod) => mod.init());
}
}, [flags.editorEnabled]);
return (
<FlagsContext.Provider value={flags}>{children}</FlagsContext.Provider>
);
}typeof window !== 'undefined' 검사로 preload용 동적 import가 서버 번들에 포함되지 않게 막을 수 있어, 서버 번들 크기와 빌드 속도를 최적화합니다.