홈시리즈

© 2026 Ki Chang. All rights reserved.

본 블로그의 콘텐츠는 CC BY-NC-SA 4.0 라이선스를 따릅니다.

☕후원하기소개JSON Formatter러닝 대기질개인정보처리방침이용약관

© 2026 Ki Chang. All rights reserved.

콘텐츠: CC BY-NC-SA 4.0

☕후원하기
소개|JSON Formatter|러닝 대기질|개인정보처리방침|이용약관

CommonJS와 ESM — require와 import는 어떻게 다르게 동작하는가 (8편)

정기창·2026년 3월 4일

들어가며

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 ← 변경이 반영됨! 같은 메모리를 참조하므로
CommonJSESM
바인딩값 복사 (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]로 접근할 수도 있음
CommonJSESM
분석 시점런타임 (실행해봐야 앎)빌드 타임 (실행 전 분석 가능)
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)CommonJSNestJS가 데코레이터 메타데이터에 의존, CommonJS가 안정적
실행 (Node.js)CommonJSrequire → 동기 로딩 → 데코레이터 순서 보장

프론트엔드(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()만 가능 (비동기)

전체 비교 정리

CommonJSESM
도입2009년 (Node.js 탄생)2015년 (ES6 표준)
문법require() / module.exportsimport / export
로딩동기 (런타임)비동기 (정적 분석 후 실행)
분석 시점실행 시파싱 시 (코드 실행 전)
바인딩값 복사라이브 바인딩
동적 로딩require(variable) 가능import() 동적 임포트만 가능
Tree-shaking❌✅
Top-level await❌✅
순환 참조불완전한 exports 반환라이브 바인딩 (초기화 전 접근 시 에러)
캐시 접근require.cache (조작 가능)내부 모듈 맵 (접근 불가)
__filename, __dirname✅ 래핑 함수 매개변수❌ (import.meta.url로 대체)
Node.js 파일 확장자.js (기본) 또는 .cjs.mjs 또는 "type": "module"

정리

개념핵심
CommonJSNode.js 원조. require는 동기 로딩 + 값 복사 + 런타임 분석
ESMJS 공식 표준. import는 정적 분석 + 라이브 바인딩 + tree-shaking 가능
래핑 함수CommonJS가 모듈을 격리하는 방식. __filename, __dirname의 출처
모듈 캐시같은 모듈은 한 번만 실행. 캐시 객체는 GC 대상이 아님 (Old Space 상주)
순환 참조CommonJS는 불완전 exports, ESM은 초기화 전 접근 에러. 설계로 피하는 것이 최선
NestJSESM 문법으로 작성 → tsc가 CommonJS로 변환 → Node.js가 require로 실행

1편에서 os.hostname()을 추적하며 Node.js의 4개 레이어를 확인했습니다. 그 추적의 첫 단계인 require('os')가 어떻게 동작하는지를 이번 편에서 다룬 셈입니다. require든 import든, 모듈 시스템은 코드를 불러와서 격리된 스코프에서 실행하고, 결과를 캐싱하는 것입니다. 차이는 그 과정이 동기인지 비동기인지, 값을 복사하는지 참조를 연결하는지에 있습니다.

Node.jsCommonJSESM모듈requireimport면접 준비

관련 글

Node.js 소스코드를 직접 열어봤습니다 — 런타임, V8, libuv의 실체 (1편)

면접 준비를 하다가 "Node.js가 뭔가요?"라는 질문에 제대로 답할 수 없다는 걸 깨달았습니다. 런타임이 뭔지, V8과 libuv가 각각 무슨 역할인지, 실제 Node.js GitHub 소스코드를 열어서 os.hostname() 한 줄이 OS까지 도달하는 과정을 추적해봤습니다.

관련도 89%

Node.js만 있는 게 아니다 — Bun과 Deno, 같은 언어 다른 런타임

JavaScript를 실행하는 런타임은 Node.js만이 아닙니다. Bun과 Deno는 같은 언어를 다른 방식으로 실행합니다. 세 런타임의 설계 철학, 내부 구조, 실용적 차이를 정리합니다.

관련도 89%

npm, yarn, pnpm — 패키지 매니저가 node_modules를 만드는 세 가지 방식

같은 package.json인데 npm, yarn, pnpm이 만드는 node_modules 구조가 다릅니다. nested에서 flat으로, 다시 symlink로 — 구조가 바뀌어 온 이유와 각 방식의 트레이드오프를 정리합니다.

관련도 88%