본문으로 건너뛰기
KYH
  • Blog
  • About

joseph0926

React와 TypeScript로 문제를 해결하며 배운 것들을 기록합니다.

HomeBlogAbout

© 2026 joseph0926. All rights reserved.

javascriptesmmodule-system

ESM은 비동기인데 어떻게 정적 분석이 가능한가

ESM vs CJS 비교에서 가장 먼저 든 의문. 비동기 로딩과 정적 분석은 다른 시점의 이야기이며, ESM 로딩 3단계를 이해하면 모순이 아님을 알 수 있습니다.

Feb 10, 20261 min read
ESM은 비동기인데 어떻게 정적 분석이 가능한가

ESM은 비동기인데 어떻게 정적 분석이 가능한가

면접에서 "ESM과 CJS의 차이가 뭔가요?"라는 질문을 받았습니다. "ESM은 비동기이고, CJS는 동기적입니다"라고 답했습니다. 틀린 답은 아니었지만, 돌아와서 정리하다 보니 한 가지가 걸렸습니다.

항목CJSESM
로딩동기비동기
정적 분석제한적가능

비동기로 로드하는데, 정적 분석이 가능하다? 비동기라면 실행 시점에 모듈을 가져온다는 뜻이고, 정적 분석이란 실행 전에 구조를 파악한다는 뜻입니다. 모순처럼 보였습니다.

이 글은 그 의문에서 시작합니다.


무엇이 비동기이고, 무엇이 정적인가

모순을 풀려면 "비동기"와 "정적 분석"이 각각 무엇을 가리키는지 분리해야 합니다.

"비동기"가 가리키는 것: 모듈 파일을 네트워크나 디스크에서 가져오는 동작입니다. a.js, b.js, c.js를 동시에 fetch할 수 있다는 뜻입니다. 이건 런타임에 일어납니다.

"정적 분석"이 가리키는 것: 코드를 실행하지 않고, import/export 문만 읽어서 "어떤 모듈이 어떤 모듈을 필요로 하는지" 파악하는 것입니다. 이건 코드 실행 전에 일어납니다.

둘은 대상이 다릅니다.

[평가(Evaluation) 전]
  모듈 파일 로드(비동기 I/O) ↔ 소스 파싱 ↔ import/export 분석
  (그래프를 따라 반복되며 의존성 그래프 완성)

[평가(Evaluation)]
  모든 모듈 준비 후 코드 실행

정적 분석은 "어떤 모듈을 가져와야 하는지" 파악하는 것이고, 비동기 로딩은 "그 모듈 파일을 실제로 가져오는 것"입니다. 시점이 다르니까 모순이 아닙니다.

그러면 비동기 로딩은 실제로 어떤 차이를 만들까요? 로드하는 데 각각 1초, 5초, 3초 걸리는 모듈 3개를 가져온다고 가정해봅시다.

CJS (순차):  [=a 1초=][=====b 5초=====][===c 3초===]  → 총 9초

ESM (병렬):   [=a 1초=]
             [=====b 5초=====]                      → 총 5초
             [===c 3초===]

CJS의 require()는 동기 함수이므로 a가 끝나야 b를 가져오고, b가 끝나야 c를 가져옵니다. ESM은 정적 분석으로 필요한 모듈을 미리 파악했기 때문에, 세 파일을 동시에 요청할 수 있습니다. 가장 느린 모듈(5초)이 전체 시간을 결정합니다.

이 차이는 네트워크 I/O가 병목인 브라우저 환경에서 특히 큽니다. Node.js는 로컬 파일 I/O라 체감 차이가 작습니다.


ESM이 정적 분석을 가능하게 하는 두 가지 제약

ESM에서 정적 분석이 가능한 이유는 import와 export에 각각 문법적 제약이 걸려 있기 때문입니다.

import(정적 import): 파일 최상단에만 올 수 있다

// ESM — 항상 최상단
import { a } from './a.js';

// CJS — 조건문 안에서도 가능
if (condition) {
  const a = require('./a.js');
}

여기서 말하는 import는 정적 import 선언문입니다. 정적 import는 파일 최상단에만 올 수 있어서, 코드를 실행하지 않아도 "이 모듈이 어떤 모듈을 가져오는지"가 확정됩니다. 반면 import()는 표현식이므로 조건문/함수 안에서 사용할 수 있습니다.

export: 조건문 안에 넣을 수 없다

// ESM — 항상 확정
export { a, b };

// CJS — 런타임에 결정
module.exports = condition ? { a } : { b };

export는 모듈 top-level에서만 선언되고 조건문 안에 들어갈 수 없어서, "이 모듈이 무엇을 내보내는지"가 확정됩니다.

여기서 흔히 오해하는 부분이 있습니다. import와 export 모두 "조건문 안에 못 들어간다"고 생각하기 쉬운데, 정확히 말하면 규칙이 다릅니다.

  • import: "조건문 불가"가 아니라 **"(정적 import 선언문은) 최상단에만 올 수 있다"**는 위치 제약
  • export: **"모듈 top-level에서만 선언 가능하고 조건문 안에는 못 들어간다"**는 구문 제약

결과적으로 둘 다 정적으로 확정된다는 점은 같지만, 제약의 성격이 다릅니다.

Tree-shaking이 가능하려면 이 양쪽이 모두 정적이어야 합니다. "누가 뭘 가져오는지"와 "누가 뭘 내보내는지"가 둘 다 빌드 타임에 확정되어야 미사용 코드를 안전하게 제거할 수 있기 때문입니다.

그러면 CJS도 "require()를 조건문 안에 쓰지 않는다"는 규칙을 정하면 정적 분석이 가능하지 않을까요? import 쪽은 부분적으로 가능합니다. 하지만 두 가지 한계가 있습니다. 첫째, 그 규칙은 린트 레벨의 약속이라 우회할 수 있습니다. ESM은 언어 문법 자체가 거부합니다. 둘째, require 쪽을 막아도 export 쪽(module.exports = condition ? ...)은 여전히 동적입니다. 한쪽만 정적으로는 부족합니다.


ESM 로딩 3단계: Construction → Instantiation → Evaluation

지금까지 "정적 분석"과 "비동기 로딩"이 다른 시점이라는 것, 그리고 ESM의 문법적 제약이 정적 분석을 가능하게 한다는 것을 확인했습니다. 이제 ESM이 모듈을 로드할 때 실제로 어떤 순서로 동작하는지 살펴봅시다.

ESM은 모듈을 3단계로 로드합니다. 건물의 배관 공사에 비유하면 이해가 쉽습니다.

1. Construction   — 설계도를 그린다 (어떤 방이 어떤 방과 연결되는지)
2. Instantiation  — 설계도대로 배관을 연결한다 (아직 물은 안 틂)
   ════════════════  장벽 (모든 배관 연결 완료, 누수 점검)
3. Evaluation     — 수도꼭지를 연다 (수원지부터 순서대로)

Construction: 엔트리 파일부터 시작해서 import 문을 읽고, 필요한 모듈 파일을 찾아 모듈 그래프를 만듭니다. 설계도를 그리는 단계입니다. 이때 파일을 실제로 가져오는 동작(fetch)은 병렬/비동기로 일어납니다. 앞서 말한 "정적 분석"이 바로 이 단계에 해당합니다.

Instantiation: 그래프가 완성되면, 각 모듈의 export와 import를 연결합니다. 설계도대로 배관을 잇는 단계입니다. 이름이 맞는지 확인하고, 메모리 슬롯을 할당합니다. 아직 값은 채우지 않습니다. 배관은 깔았지만 물은 아직 흐르지 않는 상태입니다.

여기서 장벽이 생깁니다. 모든 배관이 연결되고 누수가 없는지 확인될 때까지 물을 틀지 않습니다.

Evaluation: 장벽을 넘으면, 의존성이 없는 말단 모듈(leaf)부터 코드를 실행하며 값을 채워 나갑니다. 수원지(leaf)에서 물을 틀면 배관을 따라 각 방(모듈)으로 물이 흘러가는 것과 같습니다. a.js가 아무것도 import하지 않는다면 a.js가 먼저 실행되고, a.js에 의존하는 모듈이 그 다음에 실행됩니다.

이 3단계를 처음 의문과 연결하면 이렇게 됩니다.

의문의 키워드해당 단계시점
정적 분석Construction코드 실행 전
비동기 로딩Construction 내 파일 fetch런타임

"정적 분석"과 "비동기 로딩"은 같은 Construction 단계 안에서 각각 다른 일을 합니다. import 문을 파싱해서 그래프를 구축하는 건 정적이고, 그 그래프에 따라 파일을 가져오는 건 비동기입니다.


정적 분석이 가능하면 뭐가 좋은가

대표적인 이점이 Tree-shaking입니다. 모듈에서 사용하지 않는 export를 번들에서 제거하는 최적화입니다.

// lib.js
export const a = 1;
export const b = 2;
export const c = 3;

// main.js
import { a } from './lib.js';
console.log(a);

import와 export가 모두 정적으로 확정되어 있으므로, 번들러는 빌드 타임에 "b와 c는 어디에서도 import되지 않는다"는 것을 알 수 있습니다. 최종 번들에서 b와 c를 제거합니다.

sideEffects: 번들러에게 주는 힌트

Tree-shaking에는 한 가지 주의점이 있습니다. 미사용 export라도 그 모듈이 로드될 때 전역에 영향을 주는 코드(side effect)가 있으면 함부로 제거할 수 없습니다.

// polyfill.js — export는 없지만, 로드만으로 전역을 수정한다
Array.prototype.sum = function () {
  return this.reduce((a, b) => a + b, 0);
};

그래서 package.json에 "sideEffects": false라는 설정이 있습니다. "이 패키지의 모듈들은 부작용이 없으니, 미사용이면 제거해도 된다"는 선언입니다.

문제는 이게 사람의 판단이라는 점입니다. 위처럼 폴리필이 포함된 패키지에 "sideEffects": false를 잘못 선언하면, 번들러가 폴리필을 제거해서 런타임에 sum is not a function 에러가 발생할 수 있습니다. 부작용이 있는 파일은 명시적으로 보호해야 합니다.

{
  "sideEffects": ["./src/polyfill.js", "*.css"]
}

barrel file이 Tree-shaking을 방해하는 이유

라이브러리에서 흔히 사용하는 barrel file(index.ts에서 모든 모듈을 re-export하는 패턴)도 Tree-shaking에 영향을 줍니다.

// index.ts (barrel file)
export * from './Button';
export * from './Modal';
export * from './Table';

export *는 실제로 어떤 심볼을 내보내는지 명시하지 않아서, 번들러가 분석해야 할 범위가 넓어집니다. 체인 중간에 side effect나 CJS 모듈이 섞이면 번들러는 보수적으로 판단해서 코드를 남기게 됩니다. 가능하면 명시적으로 re-export하는 것이 안전합니다.

export { Button } from './Button';
export { Modal } from './Modal';

실무에서 겪은 번들 비대화

이전 프로젝트에서 ModalProvider로 모달을 중앙 관리한 적이 있습니다. zustand로 어떤 모달을 열지 상태로 관리하고, Provider가 모든 모달 컴포넌트를 정적으로 import하는 구조였습니다.

// ModalProvider.tsx
import AlertModal from './AlertModal';
import ConfirmModal from './ConfirmModal';
import SettingsModal from './SettingsModal';
// ... 20개 이상의 모달

const registry = { alert: AlertModal, confirm: ConfirmModal, ... };

빌드 결과 모든 모달이 번들에 포함되어 크기가 크게 늘었습니다. 어떤 모달이 열릴지는 런타임 상태(zustand의 type 값)로 결정되기 때문에, 번들러는 빌드 타임에 "이 모달은 안 쓰인다"고 판단할 수 없었습니다.

개선은 정적 import를 동적 import로 바꾸는 것이었습니다.

const registry = {
  alert: () => import('./AlertModal'),
  confirm: () => import('./ConfirmModal'),
};

이렇게 하면 모달이 실제로 열릴 때만 해당 모듈을 로드합니다. 정적 분석의 한계(런타임 분기는 판단 불가)를 동적 import로 우회한 것입니다.


CJS는 왜 어렵다고 하는가

여기까지 읽으면 자연스럽게 반대 질문이 떠오릅니다. CJS는 왜 정적 분석이 어렵다고 할까요?

require()는 일반 함수입니다. 조건문 안에서 호출할 수 있고, 변수를 인자로 넘길 수도 있습니다.

// CJS — 런타임에 결정
const name = condition ? 'a' : 'b';
const mod = require(`./${name}.js`);

module.exports도 마찬가지입니다. 어떤 값이든, 어떤 시점에든 할당할 수 있습니다.

// CJS — 런타임에 결정
if (process.env.NODE_ENV === 'production') {
  module.exports = require('./prod.js');
} else {
  module.exports = require('./dev.js');
}

가져오는 쪽과 내보내는 쪽이 모두 런타임에 결정될 수 있으므로, 코드를 실행하기 전에 모듈 간 관계를 완전하고 안전하게 확정하기 어렵습니다. 문자열 리터럴 require() 같은 단순 케이스는 부분 분석이 가능하지만, 일반적으로는 Tree-shaking 최적화 여지가 ESM보다 작습니다.


마무리

처음 의문으로 돌아갑시다.

ESM은 비동기인데 어떻게 정적 분석이 가능한가?

"비동기"와 "정적 분석"은 같은 대상을 가리키는 말이 아니었습니다.

  • 정적 분석: import/export 문을 파싱해서 모듈 간의 관계를 파악하는 것 (코드 실행 전)
  • 비동기 로딩: 그 관계에 따라 모듈 파일을 실제로 가져오는 것 (런타임)

ESM은 import와 export에 문법적 제약을 걸어서 빌드 타임에 구조를 확정했고, 그 덕분에 파일을 병렬로 가져올 수 있고 미사용 코드를 제거할 수 있습니다. 비동기와 정적 분석은 모순이 아니라, 정적 분석이 가능하기 때문에 비동기 로딩이 효율적으로 동작하는 관계입니다.