CommonJS와 ESM — require와 import는 어떻게 다르게 동작하는가 (8편)
들어가며
1~7편에서 Node.js의 런타임, 이벤트 루프, 메모리, 스트림까지 추적했습니다. 이 모든 것의 시작점이 한 가지 빠져 있습니다. 코드를 불러오는 방식 — 모듈 시스템입니다.
// 방법 1: CommonJS
const express = require('express');
module.exports = { handler };
// 방법 2: ESM
import express from 'express';
export { handler };
둘 다 "다른 파일의 코드를 가져온다"는 목적은 같지만, 내부 동작이 근본적으로 다릅니다. 면접에서 "CommonJS와 ESM의 차이가 뭔가요?"라는 질문에 "require는 동기, import는 비동기"라고만 답하면 절반입니다. 왜 다르게 설계됐고, 그 차이가 실무에서 어떤 영향을 주는지까지 추적합니다.
CommonJS — Node.js의 원래 모듈 시스템
Node.js가 2009년에 탄생했을 때, JavaScript에는 공식 모듈 시스템이 없었습니다. 브라우저의 JS는 <script> 태그로 파일을 나열하는 것이 전부였습니다. Node.js는 서버에서 파일을 구조화하기 위해 CommonJS라는 모듈 규격을 채택했습니다.
require()의 동작 과정
const utils = require('./utils');
이 한 줄이 내부에서 하는 일:
require('./utils') 호출
↓
1. 경로 해석 (Resolution)
'./utils' → '/home/user/project/src/utils.js'
확장자 생략 시: .js → .json → .node 순서로 탐색
↓
2. 캐시 확인
require.cache에 이 경로가 있는가?
→ 있으면: 캐시된 module.exports 반환 (파일 다시 안 읽음)
→ 없으면: 다음 단계
↓
3. 파일 읽기 (동기)
fs.readFileSync()로 파일 내용을 읽음 — 블로킹
↓
4. 래핑 (Wrapping)
파일 내용을 함수로 감쌈:
(function(exports, require, module, __filename, __dirname) {
// 여기에 utils.js 내용이 들어감
});
↓
5. 실행
V8이 이 함수를 실행 → module.exports에 값이 할당됨
↓
6. 캐싱
require.cache[경로] = module 저장
↓
7. module.exports 반환
래핑 — 모듈이 격리되는 원리
Node.js는 파일 내용을 실행하기 전에 함수로 감쌉니다. 이 때문에 각 파일의 변수가 전역을 오염시키지 않습니다.
// utils.js에 이렇게 작성하면
const secret = 'abc';
module.exports = { getSecret: () => secret };
// Node.js가 실제로 실행하는 코드
(function(exports, require, module, __filename, __dirname) {
const secret = 'abc';
module.exports = { getSecret: () => secret };
});
// secret은 함수 스코프 안에 갇힘 → 다른 파일에서 접근 불가
__filename, __dirname이 마법처럼 사용할 수 있는 이유도 이 래핑 함수의 매개변수이기 때문입니다.
동기 로딩의 의미
3단계에서 fs.readFileSync()를 사용합니다. 파일을 읽을 때까지 다음 줄이 실행되지 않습니다.
console.log('1. require 전');
const utils = require('./utils'); // 여기서 멈춤 — utils.js를 읽고 실행할 때까지
console.log('2. require 후'); // utils.js 실행이 끝나야 이 줄이 실행됨
console.log(utils.getSecret());
// 출력: 1 → 2 → abc (항상 이 순서)
5편에서 bootstrap()의 import 해석이 동기라고 했던 것과 같은 이유입니다. 모듈 로딩은 서버 시작 시 한 번만 일어나므로, 동기여도 문제없습니다. 런타임 중에 매 요청마다 require를 호출하는 건 안티패턴입니다.
ESM — JavaScript의 공식 모듈 시스템
2015년 ES6에서 JavaScript의 공식 모듈 시스템이 표준화됐습니다. 브라우저와 Node.js 모두에서 동작하는 통합 규격입니다.
import의 동작 과정
import { getSecret } from './utils.mjs';
CommonJS와 근본적으로 다른 3단계 파이프라인으로 처리됩니다:
import { getSecret } from './utils.mjs'
↓
1단계: 구문 분석 (Parsing) — 코드 실행 전
소스 코드를 읽고 import/export 구문을 정적으로 분석
"이 파일은 ./utils.mjs에서 getSecret을 가져온다"를 파악
→ 아직 코드를 실행하지 않음
↓
2단계: 인스턴스화 (Instantiation)
export된 이름과 import하는 이름을 연결 (라이브 바인딩)
메모리에 빈 공간을 확보하고 양쪽을 같은 공간에 연결
→ 아직 값은 없음
↓
3단계: 평가 (Evaluation)
코드를 실행하여 export된 변수에 실제 값을 채움
→ 이제 getSecret에 값이 들어감
정적 분석이 가능한 이유
ESM의 import/export는 반드시 최상위에 위치해야 합니다. 조건문이나 함수 안에 넣을 수 없습니다.
// ✅ ESM — 최상위에서만 가능
import express from 'express';
// ❌ ESM — 불가능
if (condition) {
import express from 'express'; // SyntaxError
}
// ✅ CommonJS — 어디서든 가능
if (condition) {
const express = require('express'); // 런타임에 동적 로딩
}
이 제약 때문에 ESM은 코드를 실행하지 않고도 어떤 모듈이 어떤 모듈에 의존하는지 파악할 수 있습니다. 이것이 tree-shaking의 기반입니다.
라이브 바인딩 vs 값 복사
CommonJS와 ESM의 가장 실질적인 차이 중 하나입니다.
// counter.js (CommonJS)
let count = 0;
module.exports = { count, increment: () => ++count };
// main.js
const counter = require('./counter');
console.log(counter.count); // 0
counter.increment();
console.log(counter.count); // 0 ← 여전히 0! 값이 복사됐으므로
// counter.mjs (ESM)
export let count = 0;
export function increment() { count++; }
// main.mjs
import { count, increment } from './counter.mjs';
console.log(count); // 0
increment();
console.log(count); // 1 ← 변경이 반영됨! 같은 메모리를 참조하므로
| CommonJS | ESM | |
|---|---|---|
| 바인딩 | 값 복사 (snapshot) | 라이브 바인딩 (같은 메모리 참조) |
| 원본 변경 반영 | ❌ 반영 안 됨 | ✅ 실시간 반영 |
CommonJS에서 module.exports = { count }는 그 시점의 count 값을 객체에 복사합니다. 원본 count가 바뀌어도 복사본은 그대로입니다. ESM의 export는 변수 자체를 내보내므로, 원본이 바뀌면 import한 쪽에서도 바뀐 값이 보입니다.
모듈 캐시 — 한 번만 실행된다
CommonJS와 ESM 모두, 같은 모듈을 여러 번 import/require해도 코드는 한 번만 실행됩니다.
CommonJS 캐시
// 첫 번째 require — 파일 읽기 + 실행 + 캐싱
const a = require('./utils'); // utils.js 실행됨
// 두 번째 require — 캐시에서 반환
const b = require('./utils'); // utils.js 실행 안 됨. 캐시된 exports 반환
console.log(a === b); // true — 같은 객체
// 캐시 확인
console.log(require.cache);
// {
// '/absolute/path/to/utils.js': Module { exports: { ... }, ... }
// }
6편의 메모리 관점에서 보면, 캐시된 모듈 객체는 require.cache(전역 객체)에서 참조되므로 GC 대상이 아닙니다. 서버가 살아있는 한 Old Space에 머뭅니다.
ESM 캐시
ESM도 동일하게 캐싱합니다. 단, require.cache 대신 V8 내부의 모듈 맵에 저장되어 개발자가 직접 접근하거나 삭제할 수 없습니다.
순환 참조 — A가 B를 부르고 B가 A를 부르면
모듈 A가 B를 require하고, B가 다시 A를 require하는 상황입니다.
CommonJS의 순환 참조
// a.js
console.log('a.js 시작');
exports.done = false;
const b = require('./b'); // ← b.js 실행 시작
console.log('b.done =', b.done);
exports.done = true;
console.log('a.js 끝');
// b.js
console.log('b.js 시작');
const a = require('./a'); // ← a.js는 아직 실행 중 → 불완전한 exports 반환
console.log('a.done =', a.done);
exports.done = true;
console.log('b.js 끝');
실행 결과:
a.js 시작
b.js 시작
a.done = false ← a.js가 아직 끝나지 않은 시점의 값
b.js 끝
b.done = true
a.js 끝
실행 흐름 추적:
1. require('./a') → a.js 실행 시작
2. exports.done = false 설정
3. require('./b') → b.js 실행 시작
4. b.js에서 require('./a') → 캐시에 a가 있음 (아직 실행 중)
→ 현재까지의 a.exports 반환 = { done: false }
5. b.js 나머지 실행 → exports.done = true
6. a.js로 돌아와서 나머지 실행 → exports.done = true
CommonJS는 순환 참조 시 무한 루프에 빠지지 않습니다. 대신 불완전한 exports 객체를 반환합니다. a.done이 false인 이유는 a.js의 exports.done = true가 아직 실행되지 않았기 때문입니다.
ESM의 순환 참조
ESM은 라이브 바인딩이므로, 순환 참조 시 동작이 다릅니다.
// a.mjs
import { done as bDone } from './b.mjs';
export let done = false;
console.log('b.done =', bDone); // 이 시점에 bDone은?
done = true;
// b.mjs
import { done as aDone } from './a.mjs';
export let done = false;
console.log('a.done =', aDone); // 이 시점에 aDone은?
done = true;
ESM은 3단계(구문 분석 → 인스턴스화 → 평가) 중 인스턴스화 단계에서 양쪽의 export 변수를 미리 연결합니다. 하지만 평가 단계에서 아직 값이 할당되지 않은 변수에 접근하면 ReferenceError가 발생할 수 있습니다. 순환 참조 자체가 안티패턴이므로, 설계 단계에서 피하는 것이 최선입니다.
Tree-shaking — 사용하지 않는 코드 제거
ESM의 정적 분석 가능성이 만들어낸 가장 실질적인 이점입니다.
// utils.mjs — 3개 함수를 export
export function formatDate(date) { /* ... */ }
export function formatPrice(price) { /* ... */ }
export function formatName(name) { /* ... */ }
// app.mjs — 1개만 import
import { formatDate } from './utils.mjs';
번들러(webpack, esbuild 등)는 ESM의 import/export를 정적으로 분석해서, formatPrice와 formatName이 어디에서도 import되지 않음을 코드 실행 전에 파악할 수 있습니다. 최종 번들에서 이 함수들을 제거합니다.
// CommonJS — tree-shaking 불가능
const utils = require('./utils');
// utils 객체의 어떤 속성이 사용될지 실행 전에 알 수 없음
// require가 조건문 안에 있을 수도 있고, utils[dynamicKey]로 접근할 수도 있음
| CommonJS | ESM | |
|---|---|---|
| 분석 시점 | 런타임 (실행해봐야 앎) | 빌드 타임 (실행 전 분석 가능) |
| Tree-shaking | ❌ 불가능 | ✅ 가능 |
| 번들 크기 | 사용하지 않는 코드도 포함 | 사용하는 코드만 포함 |
7편에서 다룬 스트림처럼, tree-shaking도 "불필요한 것을 제거"하는 최적화입니다. 스트림이 메모리에서 불필요한 데이터를 제거한다면, tree-shaking은 번들에서 불필요한 코드를 제거합니다.
NestJS에서의 모듈 시스템
5편에서 서버 시작 시 import 해석이 동기로 일어난다고 했습니다. NestJS의 실제 상황을 정리합니다.
TypeScript → CommonJS 컴파일
// 개발자가 작성하는 코드 (TypeScript + ESM 문법)
import { Controller, Get } from '@nestjs/common';
import { BlogPostsService } from './blog-posts.service';
@Controller('blog-posts')
export class BlogPostsController {
constructor(private readonly service: BlogPostsService) {}
}
// tsc가 컴파일한 결과 (CommonJS)
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const common_1 = require("@nestjs/common");
const blog_posts_service_1 = require("./blog-posts.service");
let BlogPostsController = class BlogPostsController {
constructor(service) { this.service = service; }
};
exports.BlogPostsController = BlogPostsController;
NestJS에서 ESM 문법으로 작성하지만, 실제로 실행되는 코드는 CommonJS입니다. tsconfig.json의 "module": "commonjs" 설정에 의해 tsc가 import를 require로 변환합니다.
| 단계 | 모듈 시스템 | 이유 |
|---|---|---|
| 소스 코드 (.ts) | ESM 문법 | 타입스크립트 표준, IDE 지원 우수 |
| 컴파일 결과 (.js) | CommonJS | NestJS가 데코레이터 메타데이터에 의존, CommonJS가 안정적 |
| 실행 (Node.js) | CommonJS | require → 동기 로딩 → 데코레이터 순서 보장 |
프론트엔드(Next.js)와의 차이
NestJS (백엔드)
TypeScript → tsc → CommonJS (.js) → Node.js가 직접 실행
tree-shaking 불필요 (서버 코드는 번들 크기가 중요하지 않음)
Next.js (프론트엔드)
TypeScript → webpack/turbopack → ESM 번들 → 브라우저가 실행
tree-shaking 필수 (번들 크기 = 사용자 로딩 속도)
Node.js에서 ESM을 직접 쓰려면
Node.js 12부터 ESM을 네이티브로 지원합니다. 하지만 설정이 필요합니다.
| 방법 | 설정 | 비고 |
|---|---|---|
| 파일 확장자 | .mjs 사용 | 파일 단위 전환 가능 |
| package.json | "type": "module" 추가 | 프로젝트 전체를 ESM으로. .js가 ESM으로 해석됨 |
| CommonJS 유지 | "type": "module" 환경에서 .cjs 확장자 | 개별 파일만 CommonJS |
ESM에서 CommonJS 사용
// ESM에서 CommonJS 모듈 가져오기 — 가능
import express from 'express'; // express는 CommonJS지만 import 가능
// default import로 module.exports 전체를 가져옴
// CommonJS에서 ESM 모듈 가져오기 — 제한적
const { something } = await import('./esm-module.mjs');
// require()로는 불가능. 동적 import()만 가능 (비동기)
전체 비교 정리
| CommonJS | ESM | |
|---|---|---|
| 도입 | 2009년 (Node.js 탄생) | 2015년 (ES6 표준) |
| 문법 | require() / module.exports | import / export |
| 로딩 | 동기 (런타임) | 비동기 (정적 분석 후 실행) |
| 분석 시점 | 실행 시 | 파싱 시 (코드 실행 전) |
| 바인딩 | 값 복사 | 라이브 바인딩 |
| 동적 로딩 | require(variable) 가능 | import() 동적 임포트만 가능 |
| Tree-shaking | ❌ | ✅ |
| Top-level await | ❌ | ✅ |
| 순환 참조 | 불완전한 exports 반환 | 라이브 바인딩 (초기화 전 접근 시 에러) |
| 캐시 접근 | require.cache (조작 가능) | 내부 모듈 맵 (접근 불가) |
| __filename, __dirname | ✅ 래핑 함수 매개변수 | ❌ (import.meta.url로 대체) |
| Node.js 파일 확장자 | .js (기본) 또는 .cjs | .mjs 또는 "type": "module" |
정리
| 개념 | 핵심 |
|---|---|
| CommonJS | Node.js 원조. require는 동기 로딩 + 값 복사 + 런타임 분석 |
| ESM | JS 공식 표준. import는 정적 분석 + 라이브 바인딩 + tree-shaking 가능 |
| 래핑 함수 | CommonJS가 모듈을 격리하는 방식. __filename, __dirname의 출처 |
| 모듈 캐시 | 같은 모듈은 한 번만 실행. 캐시 객체는 GC 대상이 아님 (Old Space 상주) |
| 순환 참조 | CommonJS는 불완전 exports, ESM은 초기화 전 접근 에러. 설계로 피하는 것이 최선 |
| NestJS | ESM 문법으로 작성 → tsc가 CommonJS로 변환 → Node.js가 require로 실행 |
1편에서 os.hostname()을 추적하며 Node.js의 4개 레이어를 확인했습니다. 그 추적의 첫 단계인 require('os')가 어떻게 동작하는지를 이번 편에서 다룬 셈입니다. require든 import든, 모듈 시스템은 코드를 불러와서 격리된 스코프에서 실행하고, 결과를 캐싱하는 것입니다. 차이는 그 과정이 동기인지 비동기인지, 값을 복사하는지 참조를 연결하는지에 있습니다.
관련 글
Node.js 소스코드를 직접 열어봤습니다 — 런타임, V8, libuv의 실체 (1편)
면접 준비를 하다가 "Node.js가 뭔가요?"라는 질문에 제대로 답할 수 없다는 걸 깨달았습니다. 런타임이 뭔지, V8과 libuv가 각각 무슨 역할인지, 실제 Node.js GitHub 소스코드를 열어서 os.hostname() 한 줄이 OS까지 도달하는 과정을 추적해봤습니다.
Node.js만 있는 게 아니다 — Bun과 Deno, 같은 언어 다른 런타임
JavaScript를 실행하는 런타임은 Node.js만이 아닙니다. Bun과 Deno는 같은 언어를 다른 방식으로 실행합니다. 세 런타임의 설계 철학, 내부 구조, 실용적 차이를 정리합니다.
npm, yarn, pnpm — 패키지 매니저가 node_modules를 만드는 세 가지 방식
같은 package.json인데 npm, yarn, pnpm이 만드는 node_modules 구조가 다릅니다. nested에서 flat으로, 다시 symlink로 — 구조가 바뀌어 온 이유와 각 방식의 트레이드오프를 정리합니다.