ESM과 모듈 그래프
1. 모듈이 왜 필요할까
모듈 이전의 브라우저 JavaScript 는 파일을 나눠도 하나의 전역 스코프를 같이 썼습니다.
파일 사이의 의존 관계는 HTML 의 script 태그 순서에만 있었습니다.
<script src="/js/format.js"></script>
<script src="/js/cart.js"></script>
// 전역에 올라갑니다. 다른 파일이 같은 이름을 쓰면 덮어씁니다
function formatPrice(price) {
return `${price.toLocaleString()}원`;
}
// format.js 가 먼저 실행됐다는 보장은 script 태그 순서뿐입니다
document.querySelector("#total").textContent = formatPrice(32000);
cart.js 만 읽어서는 formatPrice 가 어디서 왔는지 알 길이 없습니다. 찾으려면 다른 파일을 전부 뒤져야 합니다.
태그 순서를 바꾸면 formatPrice is not defined 로 깨집니다. 어떤 파일이 먼저 실행돼야 하는지를 HTML 의 태그 순서만 알고 있기 때문입니다.
두 파일이 같은 이름으로 함수를 선언하면 나중에 실행된 쪽이 먼저 것을 덮어씁니다. 모든 파일이 전역 스코프 하나를 같이 쓰기 때문입니다.
1.1 CommonJS
Node.js 는 파일 하나를 모듈 하나로 두고 require 로 가져오고 module.exports 로 내보내는 방식을 먼저 자리잡게 했습니다.
지금도 Node.js 의 기본 방식이고 npm 패키지 상당수가 이 형식으로 배포됩니다.
const { formatPrice } = require("./format");
const renderCart = (total) => `합계 ${formatPrice(total)}`;
module.exports = { renderCart };
require 는 함수 호출입니다. 코드를 실행해야 무엇을 가져오는지 정해집니다.
조건문 안에서 부를 수도 있고 경로를 변수로 만들 수도 있습니다. 그래서 파일을 실행하지 않고는 의존 관계를 확정하지 못합니다.
1.2 ESM
ESM(ECMAScript Modules)은 ES2015 에 들어온 언어 표준 모듈입니다. import 와 export 가 언어 문법이라서 브라우저가 알아듣습니다.
브라우저는 import 문의 경로를 보고 그 파일을 네트워크로 받아 와 실행합니다. require 를 모르는 브라우저가 CommonJS 를 돌리려면 번들러가 먼저 변환해야 합니다. ESM 은 그 단계가 없습니다.
import { formatPrice } from "./format";
export const renderCart = (total: number) => `합계 ${formatPrice(total)}`;
| 구분 | CommonJS | ESM |
|---|---|---|
| 문법 | require / module.exports | import / export |
| 출처 | Node.js 의 관례 | ECMAScript 2015 언어 표준 |
| 의존 관계가 정해지는 때 | 코드를 실행하면서 | 코드를 실행하기 전, 파싱만으로 |
| 로딩 | 동기. require 가 돌아올 때까지 멈춤 | 비동기. 네트워크로 받아도 됨 |
| 가져온 값 | module.exports 객체. 값은 그 시점의 복사본 | 내보낸 자리를 그대로 연결 |
| 브라우저 | 번들러가 변환해야 실행됨 | script type="module" 로 바로 실행 |
| Node 에서 구분 | .cjs 또는 "type": "commonjs" | .mjs 또는 "type": "module" |
1.3 정적 구조
ESM 은 import 와 export 를 쓸 수 있는 자리를 모듈의 최상위로 못박고 경로도 문자열 리터럴만 받습니다.
if (import.meta.env.DEV) {
import { log } from "./log"; // SyntaxError. 블록 안에는 못 둡니다
}
const path = "./themes/" + theme;
import theme from path; // SyntaxError. 경로는 문자열 리터럴만
같은 일을 하려면 함수처럼 부르는 import() 를 씁니다. 이쪽은 문이 아닌 식이라 자리와 경로에 제약이 없습니다.
if (import.meta.env.DEV) { // Vite 가 넣어 주는 값. 개발 서버에서만 true
const { log } = await import("./log"); // 블록 안에서도 됩니다
}
const theme = await import(`./themes/${name}.ts`); // 경로에 변수도 됩니다
MDN 은 이렇게 적습니다.
importdeclarations can only be present in modules, and only at the top-level (i.e., not inside blocks, functions, etc.).
import선언은 모듈 안에서만, 그것도 최상위에서만 쓸 수 있습니다.
이 제약 덕분에 파일을 실행하지 않고 파싱만 해도 무엇을 가져오고 내보내는지 전부 드러납니다.
2. 브라우저에서 ESM 바로 쓰기
Vite 로 만든 React 프로젝트의 index.html 은 script 태그 하나로 앱 전체를 시작합니다.
<!doctype html>
<html lang="ko">
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
type="module" 은 이 파일을 ES 모듈로 다루라는 표시입니다. 이 표시가 없으면 브라우저는 import 문을 만나는 순간 SyntaxError 를 냅니다.
모듈로 읽히는 스크립트는 일반 스크립트와 몇 가지가 다릅니다.
| 항목 | 일반 스크립트 | 모듈 스크립트 |
|---|---|---|
| 다운로드 | 받는 동안 HTML 파싱을 멈춤 | HTML 파싱과 나란히 받음. import 한 파일도 같이 |
| 실행 시점 | 받자마자 | HTML 파싱이 끝난 뒤. defer 와 같음 |
| 최상위 선언 | 전역 스코프에 올라감 | 모듈 스코프. 전역을 더럽히지 않음 |
| strict mode | 직접 선언해야 함 | 항상 켜짐 |
| 같은 파일을 두 번 넣으면 | 두 번 실행 | 한 번만 실행 |
최상위 await | 불가 | 가능 |
MDN 의 설명입니다.
Modules are only executed once, even if they have been referenced in multiple
<script>tags.여러
script태그에서 참조해도 모듈은 한 번만 실행됩니다.
같은 파일을 두 번씩 넣고 콘솔을 보면 차이가 드러납니다.
<script src="/classic.js"></script>
<script src="/classic.js"></script>
<script type="module" src="/analytics.js"></script>
<script type="module" src="/analytics.js"></script>
classic.js 최상위 코드 실행 (2회)
analytics.js 최상위 코드 실행 (1회)
브라우저는 모듈을 URL 로 식별해 하나만 둡니다. 두 번째 태그는 이미 있는 모듈을 그대로 쓰므로 다시 받지도, 최상위 코드를 다시 돌리지도 않습니다. 일반 스크립트는 캐시에서 꺼내 오더라도 태그마다 처음부터 다시 실행합니다.
2.1 패키지 이름은 브라우저가 못 찾습니다
브라우저가 해석하는 경로는 ./, ../, / 로 시작하는 것과 전체 URL 뿐입니다.
"react" 처럼 이름만 적은 것(bare specifier)은 어디서 받을지 몰라 오류가 납니다.
Uncaught TypeError: Failed to resolve module specifier "react".
Relative references must start with either "/", "./", or "../".
브라우저에서 직접 쓰려면 import map 으로 이름과 URL 을 짝지어 줍니다.
<script type="importmap">
{ "imports": { "react": "https://esm.sh/react@19" } }
</script>
Vite 개발 서버는 다른 길을 택합니다. 브라우저에 보내기 전에 이름을 실제 경로로 바꿔 둡니다.
// 내가 쓴 코드
import { useState } from "react";
// 개발 서버가 브라우저에 보내는 코드 (요지만 남김)
import react from "/node_modules/.vite/deps/react.js?v=2db34275";
const useState = react["useState"];
node_modules/.vite/deps 는 Vite 가 의존성을 ESM 으로 바꿔 미리 번들해 둔 폴더입니다. React 처럼 CommonJS 로 배포된 패키지도 이 과정을 거쳐야 브라우저가 실행합니다.
배포 빌드에서는 이 치환이 없습니다. React 코드가 번들 파일 안에 들어가서 import 문 자체가 사라집니다.
3. 모듈 그래프
모듈 그래프는 모듈을 노드로, import 를 간선으로 놓은 그래프입니다.
진입점(entry)에서 시작해 import 를 따라가면 그려집니다.
장바구니 앱의 파일 다섯 개입니다.
import { createRoot } from "react-dom/client";
import App from "./App";
createRoot(document.getElementById("root")!).render(<App />);
import Header from "./components/Header";
import Cart from "./components/Cart";
export default function App() {
return (
<>
<Header />
<Cart />
</>
);
}
import { formatPrice } from "../utils/format";
export default function Header() {
return <header>무료 배송 기준 {formatPrice(50000)}</header>;
}
import { formatPrice } from "../utils/format";
export default function Cart() {
const total = 32000;
return <p>합계 {formatPrice(total)}</p>;
}
export const formatPrice = (price: number) => `${price.toLocaleString()}원`;
format.ts 는 두 컴포넌트가 가져옵니다. 그래도 그래프에서 노드는 하나입니다.
3.1 모듈 맵: URL 하나에 모듈 하나
브라우저는 모듈을 URL 로 식별하고 모듈 맵에 URL 당 레코드 하나를 둡니다.
같은 URL 을 어디서 몇 번 import 하든 가져오기도 한 번, 실행도 한 번입니다.
import "./analytics";
import "./analytics";
import { track } from "./analytics";
// analytics.ts 는 한 번만 가져오고 한 번만 실행됩니다
모듈 최상위에서 만든 값은 앱 전체에서 하나라는 뜻입니다.
// 어디서 import 하든 같은 Map 입니다. 모듈은 앱에서 한 번만 평가되기 때문입니다
const cache = new Map<string, Promise<unknown>>();
export const fetchJson = (url: string) => {
if (!cache.has(url)) {
cache.set(url, fetch(url).then((res) => res.json()));
}
return cache.get(url)!;
};
Cart.tsx 와 Header.tsx 가 각각 fetchJson 을 가져가도 cache 는 공유됩니다.
싱글턴 패턴을 따로 만들 필요가 없는 이유입니다. 테스트에서 모듈 상태가 남아 서로 간섭하는 이유이기도 합니다.
3.2 DevTools 에서 그래프 보기
Vite 개발 서버는 소스 파일을 하나로 묶지 않습니다. TypeScript 를 JavaScript 로 바꾸고 경로를 손본 뒤 import 문은 남긴 채 파일 단위로 보냅니다.
그러면 브라우저가 자기 모듈 로더로 import 경로의 파일을 하나씩 요청합니다. 번들러 대신 브라우저의 모듈 기능을 그대로 쓰는 것이라 네이티브 ESM 이라고 부릅니다.
Network 탭을 열면 요청 하나가 모듈 하나입니다. 요청 목록이 곧 모듈 그래프입니다.
번들하지 않는 이유는 속도입니다. 서버를 켤 때 전체를 묶지 않으니 바로 뜹니다. 파일을 고치면 그 파일과 그것을 import 한 모듈만 브라우저가 다시 받습니다. Vite 의 HMR 이 프로젝트 크기와 상관없이 빠른 이유입니다.
브라우저는 main.tsx 를 받아 파싱해야 App.tsx 가 필요하다는 것을 알고 App.tsx 를 받아야 그 아래를 압니다.
그래프 깊이만큼 네트워크 왕복이 생깁니다.
Vite 가 개발에서는 네이티브 ESM 을 쓰면서 배포 빌드는 번들하는 이유가 이것입니다.
shipping it in production is still inefficient due to additional network round trips from nested imports.
중첩된 import 가 네트워크 왕복을 더 만들기 때문에 배포 환경에서 그대로 내보내는 것은 여전히 비효율적입니다.
Vite 7 까지는 개발 서버의 사전 번들은 esbuild 가, 배포 빌드는 Rollup 이 나눠 맡았습니다. 2026년 3월에 나온 Vite 8 부터는 둘 다 Rolldown 하나가 맡습니다. Rolldown 은 Rollup 과 같은 플러그인 API 를 가진 Rust 번들러입니다. Rollup 보다 10배에서 30배 빨라서 esbuild 가 맡던 자리까지 하나로 합칠 수 있었습니다.
4. 모듈이 실행되기까지: 로딩 → 링크 → 평가
script type="module" 을 만난 브라우저는 세 단계를 거쳐 코드를 실행합니다.
ECMAScript 명세에서는 Load, Link, Evaluate 라고 부릅니다.
세 단계는 모듈 하나씩이 아니라 그래프 전체가 한 단계씩 거칩니다. 파일을 전부 받아 그래프를 만들고, 전부 연결하고, 그다음에야 실행을 시작합니다. 로딩은 진입점에서 아래로 내려가며 파일을 찾고, 평가는 맨 아래 의존성부터 위로 올라오며 실행합니다.
| 단계 | 하는 일 | 끝나면 생기는 것 | 여기서 나는 오류 |
|---|---|---|---|
| 로딩 | 경로 해석, 파일 가져오기, 파싱. import 목록을 뽑아 다음 파일로 | 모듈 레코드와 모듈 그래프 | 404, 파싱 SyntaxError |
| 링크 | export 마다 메모리 자리를 만들고 import 를 그 자리에 연결. 값은 아직 비어 있음 | 바인딩이 연결된 모듈 환경 | does not provide an export named |
| 평가 | 의존성부터 최상위 코드를 실행해 자리를 값으로 채움 | 값이 든 바인딩, 실행된 부수 효과 | ReferenceError 등 런타임 오류 |
4.1 링크: 이름이 맞는지 먼저 전부 검사
없는 이름을 가져오면 평가에 들어가기 전에 멈춥니다.
console.log("이 줄은 실행되지 않습니다");
import { formatMoney } from "./utils/format"; // format.ts 에 없는 이름
SyntaxError: The requested module './utils/format' does not provide an export named 'formatMoney'
첫 줄의 console.log 는 찍히지 않습니다. 링크는 평가보다 앞 단계라서 그래프 전체의 이름 검사가 끝나야 첫 줄이 실행됩니다.
링크 단계에서 함수 선언은 미리 만들어집니다. const, let, class 는 자리만 생기고 값은 평가 때 들어갑니다.
4.2 평가: 의존성이 먼저, 그리고 한 번만
평가 순서는 각 파일의 최상위 코드가 실제로 실행되는 순서입니다. 로딩 순서와 반대입니다.
main.ts 가 app.ts 를, app.ts 가 cart.ts 를, cart.ts 가 utils.ts 를 가져온다고 합시다.
로딩은 main 에서 시작해 app, cart, utils 순으로 찾아 내려갑니다. 실행은 utils, cart, app, main 순으로 올라옵니다.
모듈의 코드를 실행하려면 그 모듈이 import 한 값이 먼저 준비돼 있어야 하기 때문입니다. main.ts 는 app.ts 가 끝나야 돌 수 있고, app.ts 는 cart.ts 가 끝나야 돕니다.
utils.ts 를 세 파일이 가져와도 모듈 맵에 레코드가 하나라서 한 번만 실행됩니다.
5. Live Binding: import 한 값은 복사본일까
로그인 토큰을 모듈 변수에 두고 바꾸는 함수를 함께 내보냅니다.
export let token: string | null = null;
export const setToken = (next: string) => {
token = next;
};
import { token, setToken } from "../auth/token";
console.log(token); // null
setToken("abc");
console.log(token); // "abc"
두 번째 console.log 가 "abc" 입니다. 복사본이었다면 null 이 그대로 찍혔을 것입니다.
import 로 가져온 이름은 token.ts 가 만든 변수의 자리를 그대로 가리킵니다. 이것을 라이브 바인딩이라고 부릅니다.
가져온 쪽에서 값을 바꾸는 것은 막혀 있습니다.
token = "xyz"; // TypeError: Assignment to constant variable.
token.ts 에서는 let 인데 메시지는 상수에 할당했다고 합니다. 가져온 쪽의 token 은 그 변수를 들여다보는 읽기 전용 이름이라 const 와 같은 취급을 받기 때문입니다.
값을 바꾸는 길은 token.ts 가 내보낸 setToken 뿐입니다.
TypeScript 는 컴파일 단계에서 Cannot assign to 'token' because it is an import 로 먼저 잡습니다.
5.1 CommonJS 는 값을 복사합니다
같은 코드를 CommonJS 로 쓰면 결과가 다릅니다.
let token = null;
const setToken = (next) => {
token = next;
};
module.exports = { token, setToken }; // 이 시점의 token 값 null 이 객체에 들어갑니다
const { token, setToken } = require("./token.cjs");
console.log(token); // null
setToken("abc");
console.log(token); // null. token.cjs 의 변수는 바뀌었지만 복사본은 그대로입니다
| 구분 | ESM | CommonJS |
|---|---|---|
| 가져오는 것 | 내보낸 변수의 자리 | module.exports 객체 |
| 내보낸 쪽이 변수를 재할당하면 | 가져온 쪽도 새 값을 봄 | 객체에 복사된 옛 값 그대로 |
| 가져온 쪽에서 재할당 | TypeError | 자기 변수라서 됨. 내보낸 쪽과 무관 |
5.2 export default 는 예외
export default 뒤에 식을 쓰면 그 시점의 값이 나갑니다.
let count = 0;
export default count; // 지금의 0 을 내보냅니다. count 변수와 연결되지 않습니다
export { count }; // 이쪽은 count 변수의 자리를 내보냅니다
export const increment = () => {
count += 1;
};
import defaultCount, { count, increment } from "./counter";
increment();
console.log(defaultCount, count); // 0 1
defaultCount 는 counter.ts 에 없는 이름입니다. export default 는 이름 없이 내보내므로 가져오는 쪽이 이름을 정합니다.
변수 자체를 default 로 내보내려면 export { count as default } 로 씁니다.
6. 순환 의존성
A 가 B 를 가져오고 B 가 다시 A 를 가져오면 순환입니다. 그래프에 고리가 생깁니다.
import { greeting } from "./greeting";
export const userName = "길동";
console.log(greeting);
import { userName } from "./user";
export const greeting = `안녕하세요, ${userName}님`;
main.ts 가 user.ts 를 가져오면 이 오류가 납니다.
ReferenceError: Cannot access 'userName' before initialization
user.ts 를 실행하려면 의존성인 greeting.ts 가 먼저 실행돼야 합니다.
그런데 greeting.ts 는 최상위에서 userName 을 바로 읽습니다. user.ts 는 아직 실행 전이라 userName 에 값이 없습니다.
greeting.ts 를 먼저 가져와도 마찬가지입니다. 이번에는 user.ts 가 먼저 실행되어 아직 없는 greeting 을 읽습니다.
두 파일이 서로의 값을 최상위에서 바로 읽는 한 어느 쪽을 먼저 가져와도 터집니다.
순환 자체가 오류를 내지는 않습니다. 먼저 실행되는 쪽이 아직 값이 없는 변수를 최상위에서 바로 읽을 때만 터집니다. 함수 안에서 나중에 읽으면 그 함수를 부르는 시점에 값이 준비돼 있어 괜찮습니다.
6.1 고치는 방법
의존 방향을 한쪽으로 만듭니다. greeting.ts 가 user.ts 를 가져오는 대신 필요한 값을 인자로 받습니다.
export const greet = (name: string) => `안녕하세요, ${name}님`;
import { greet } from "./greeting";
export const userName = "길동";
console.log(greet(userName)); // 안녕하세요, 길동님
greeting.ts 는 이제 아무것도 가져오지 않습니다. 고리가 사라져 어느 쪽을 먼저 가져와도 같은 결과입니다.
7. 정적 구조가 주는 것: 트리 쉐이킹
format.ts 에 함수가 세 개 있고 앱은 하나만 씁니다.
export const formatPrice = (price: number) => `${price.toLocaleString()}원`;
export const formatDate = (date: Date) => date.toLocaleDateString("ko-KR");
export const formatPhone = (phone: string) => phone.replace(/(\d{3})(\d{4})(\d{4})/, "$1-$2-$3");
import { formatPrice } from "../utils/format";
빌드 결과에는 formatPrice 만 남습니다. 번들러가 쓰이지 않는 export 를 떨어내는 일을 트리 쉐이킹(Tree Shaking)이라고 부릅니다.
가능한 이유는 ESM 의 정적 구조입니다. 실행하지 않아도 어느 이름을 가져가는지 파싱만으로 확정됩니다.
그래서 패키지가 CommonJS 로 배포돼 있으면 번들러가 구조를 못 보고 통째로 넣습니다. lodash 는 전체가 들어가고 ESM 으로 배포된 lodash-es 는 쓴 함수만 들어가는 차이가 여기서 납니다.
모듈 최상위에서 전역을 건드리는 파일도 함부로 못 뺍니다. package.json 의 sideEffects 에 그런 파일을 적어 두면 나머지는 안심하고 떨어냅니다.
{
"sideEffects": ["*.css", "./src/polyfills.ts"]
}
8. 동적 import 와 코드 분할
import() 는 함수처럼 부르는 식입니다. 어디서든 부를 수 있고 Promise 를 돌려줍니다.
const handleExport = async () => {
const { exportToExcel } = await import("./utils/excel"); // 버튼을 누른 뒤에야 받습니다
exportToExcel(rows);
};
| 구분 | 정적 import | 동적 import() |
|---|---|---|
| 쓰는 자리 | 모듈 최상위 | 조건문, 함수, 이벤트 핸들러 어디든. 일반 스크립트와 CommonJS 에서도 |
| 경로 | 문자열 리터럴 | 식. 변수로 조합 가능 |
| 가져오는 때 | 그래프를 만들 때 전부 | 호출된 그때 |
| 돌려주는 것 | 바인딩 | 모듈 네임스페이스 객체로 이행하는 Promise |
| 번들러가 보는 것 | 같은 조각에 포함 | 조각을 나누는 지점 |
8.1 번들러는 import() 에서 조각을 나눕니다
Vite 와 Webpack 모두 import() 를 만나면 그 모듈과 그 아래 그래프를 별도 파일로 뺍니다.
dist/assets/index-BxT4e2c1.js 142.11 kB 첫 화면이 받는 조각
dist/assets/excel-D9kz0a3m.js 318.40 kB import() 가 불릴 때 받는 조각
첫 화면은 index 조각만 받습니다. excel 조각은 handleExport 가 처음 불릴 때 요청합니다.
8.2 React.lazy 와 Suspense
React 에서는 컴포넌트 단위로 조각을 나눕니다. lazy 에 import() 를 넘기고 Suspense 로 감쌉니다.
import { lazy, Suspense, useState } from "react";
// 컴포넌트 밖에서 선언합니다. 안에서 만들면 렌더링마다 새 컴포넌트 타입이 되어 state 가 날아갑니다
const SalesChart = lazy(() => import("../components/SalesChart"));
export default function Report() {
const [showChart, setShowChart] = useState(false);
return (
<>
<button onClick={() => setShowChart(true)}>차트 보기</button>
{showChart && (
<Suspense fallback={<p>차트를 불러오는 중</p>}>
<SalesChart />
</Suspense>
)}
</>
);
}
React 공식 문서가 적어 둔 조건 두 가지입니다.
load가 돌려주는 Promise 는.default가 컴포넌트인 객체로 이행해야 합니다. 그래서SalesChart.tsx는export default여야 합니다.lazy는 모듈 최상위에서 선언합니다. 컴포넌트 안에서 만들면 렌더링마다 state 가 초기화됩니다.
이름 있는 export 를 쓰려면 then 에서 모양을 맞춥니다.
const SalesChart = lazy(() =>
import("../components/charts").then((m) => ({ default: m.SalesChart })),
);
load 는 처음 렌더링을 시도할 때 한 번만 불립니다. 결과는 React 가 캐시해 두므로 showChart 를 껐다 켜도 다시 받지 않습니다.
나눌 만한 곳은 라우트, 차트나 에디터처럼 무거운 라이브러리, 모달처럼 열어야 보이는 UI 입니다.
작은 컴포넌트까지 나누면 조각마다 네트워크 왕복이 생겨 요청 수만 늘어납니다. 첫 화면에 꼭 필요한 것은 정적 import 로 둡니다.
9. 정리
- ESM 은
import와export를 최상위에 리터럴 경로로만 쓰게 하는 정적 구조입니다. 실행 없이 파싱만으로 의존 관계가 드러납니다. - 모듈 그래프는 진입점에서
import를 따라 그린 그래프입니다. 브라우저는 URL 당 모듈 하나만 두므로 모듈 최상위의 값은 앱에서 하나입니다. - 모듈은 로딩, 링크, 평가 순서로 실행됩니다. 없는 이름은 링크에서 잡히고 값은 평가에서 채워집니다.
- 평가는 의존성이 먼저입니다. 가져온 모듈이 이 파일의 코드보다 먼저 실행됩니다.
import한 이름은 내보낸 변수의 자리를 가리키는 라이브 바인딩입니다.export default 식은 그 시점의 값입니다.- 순환 의존성은 먼저 실행되는 쪽이 아직 값이 없는 변수를 최상위에서 읽을 때
ReferenceError를 냅니다. 의존 방향을 한쪽으로 만들면 사라집니다. - 트리 쉐이킹은 정적 구조 위에서만 됩니다. CommonJS 패키지와 부수 효과 모듈은 통째로 들어갑니다.
import()는 Promise 를 돌려주는 식이고 번들러의 분할 지점입니다. React 에서는lazy와Suspense로 씁니다.