Menu

CommonJS vs ES Modules: require와 import 차이 정리

자바스크립트에 두 가지 모듈 시스템이 공존하는 이유, 그리고 Node 프로젝트에서 require와 import 중 무엇을 써야 할지 정리했습니다.

하나의 언어, 두 가지 모듈 시스템

JavaScript에는 원래 모듈 시스템이라는 개념 자체가 없었습니다. 이 빈자리를 채운 게 2009년 Node가 도입한 CommonJS(require, module.exports)였고, 한동안 Node 코드는 다들 이 모습이었죠. 그러다 2015년, 언어 차원에서 표준 모듈 시스템인 ES Modules(import, export)가 생겼고, 이제는 브라우저와 Node 모두 이를 지원합니다.

그래서 실무에서는 두 방식이 섞여 돌아다닙니다. 아래는 똑같은 작은 모듈을 두 방식으로 각각 작성한 예시입니다:

index.js
Output
Click Run to see the output here.
index.js
Output
Click Run to see the output here.

같은 기능, 다른 포장일 뿐입니다. 이 페이지의 나머지 부분은 어떤 포장이 언제 중요한지, 그리고 어떤 상황에 무엇을 골라야 하는지를 다룹니다.

문법 차이 한눈에 보기

실제로 매일 마주치는 차이는 엽서 한 장이면 충분합니다:

// CommonJS
const fs = require("fs");
const { readFile } = require("fs/promises");

module.exports = something;
module.exports.name = value;
exports.name = value;
// ES Modules
import fs from "fs";
import { readFile } from "fs/promises";

export default something;
export const name = value;
export { name };

require는 그냥 평범한 함수 호출이다. 반면 import문(statement) 이라서, 모듈 최상단에서만 쓸 수 있고 경로도 반드시 문자열 리터럴이어야 한다. 이 제약은 괜히 걸어놓은 게 아니다. 바로 이 덕분에 ESM이 CommonJS로는 못 하는 일들을 할 수 있는 것이다.

핵심 차이: 정적 분석 vs 동적 실행

CommonJS는 require()가 적힌 줄이 실행될 때 평가된다. 그래서 if 블록 안에 넣을 수도 있고, 경로를 런타임에 계산할 수도 있고, 조건부로 모듈을 불러올 수도 있다:

index.js
Output
Click Run to see the output here.

ES Modules는 정적(static) 이다. 엔진이 코드를 실행하기 전에 모든 import 문을 먼저 파싱해서 의존성 그래프를 만들고, 필요한 것들을 미리 다 해결해버린다. 이래서 경로가 반드시 리터럴 문자열이어야 하고, import도 최상위에서만 쓸 수 있는 거다.

그 대신 얻는 이점이 크다. 도구들이 코드를 실행하지 않고도 전체 모듈 그래프를 훑어볼 수 있다는 것. 번들러가 사용하지 않는 export를 제거하는 트리 셰이킹(tree-shaking)을 할 수 있는 이유도, 에디터가 정확한 자동 완성을 띄워주는 이유도, 브라우저가 모듈을 병렬로 가져올 수 있는 이유도 전부 여기서 나온다.

ESM 환경에서 진짜로 동적으로 모듈을 불러와야 할 때는 import()를 쓰면 된다. 함수처럼 호출하는 표현식이고, Promise를 반환한다:

index.js
Output
Click Run to see the output here.

Node가 파일별로 모듈 시스템을 판단하는 방식

하나의 Node 프로젝트 안에서도 두 가지 모듈 시스템을 섞어 쓸 수 있습니다. Node는 다음 두 가지를 보고 각 파일이 어떤 시스템을 따르는지 결정합니다.

  • 파일 확장자: .mjs는 무조건 ESM, .cjs는 무조건 CommonJS로 처리됩니다.
  • 가장 가까운 package.json"type" 필드: 값이 "module"이면 .js 파일은 ESM으로, "commonjs"(지정하지 않았을 때의 기본값)이면 CJS로 동작합니다.
// package.json
{
    "name": "my-app",
    "type": "module"
}

"type": "module"을 지정하면 같은 패키지 안의 평범한 hello.jsimport/export를 쓰게 됩니다. 여기에 hello.cjs 파일을 하나 추가하면, 그 파일만 require를 사용할 수 있죠. 이런 방식 덕분에 프로젝트를 점진적으로 마이그레이션하거나, 라이브러리가 두 가지 형식을 동시에 제공할 수 있습니다.

초보자가 자주 걸리는 함정 하나: ESM 파일 안에서는 requiremodule.exports가 아예 존재하지 않습니다. 손에 익은 대로 무심코 썼다간 ReferenceError를 만나게 됩니다.

상호 운용: CommonJS와 ESM 섞어 쓰기

실무에서는 ESM 파일에서 CommonJS 패키지를 불러오거나, 반대로 CommonJS에서 ESM 패키지를 불러와야 하는 상황이 자주 생깁니다. 그런데 이 둘의 규칙은 서로 대칭적이지 않습니다.

ESM에서 CommonJS 불러오기는 별문제 없이 바로 됩니다. CJS의 module.exports 객체가 그대로 default export로 들어옵니다:

index.js
Output
Click Run to see the output here.
// app.mjs
import greet from "./greet.cjs";
console.log(greet("Rosa"));

CommonJS 모듈에서 named import는 경우에 따라 동작합니다. Node가 named export를 정적으로 분석하려고 시도하긴 하지만, 확실하게 쓰려면 default로 가져온 뒤 구조 분해 할당으로 꺼내 쓰는 편이 안전합니다.

import pkg from "./utils.cjs";
const { parse, stringify } = pkg;

CommonJS에서 ESM을 불러오는 건 정말 골치 아픈 방향이다. ES 모듈을 require()로 가져오려고 하면 ERR_REQUIRE_ESM 에러가 터진다. 유일한 탈출구는 동적 import()인데, 이건 CJS 환경에서도 동작하고 Promise를 반환한다:

index.js
Output
Click Run to see the output here.

최신 Node(22+)에서는 특정 조건을 만족하면 ESM도 동기 require()로 불러올 수 있게 됐지만, 어디서든 안전하게 쓰려면 역시 동적 import()가 정답입니다.

그 외에 알아두면 좋은 동작 차이

문법 말고도 두 모듈 시스템이 미묘하게 다르게 동작하는 지점들이 있는데, 가끔 이런 부분에서 발목을 잡힙니다.

index.js
Output
Click Run to see the output here.

몇 가지 더 짚고 넘어가자면:

  • 최상위 this의 동작. CJS에서는 thismodule.exports를 가리키지만, ESM에서는 undefined다. ESM은 언제나 strict mode로 동작한다.
  • __dirname__filename. CJS에서는 따로 설정할 필요 없이 바로 쓸 수 있다. 반면 ESM에서는 import.meta.url을 활용해 직접 만들어 써야 한다:
import { fileURLToPath } from "node:url";
import { dirname } from "node:path";

const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
  • import 경로의 파일 확장자. ESM에서는 상대 경로를 쓸 때 확장자를 반드시 붙여야 한다("./utils"가 아니라 "./utils.js"). CJS는 이 부분이 관대한 편이다.
  • 라이브 바인딩 vs 스냅샷. ESM의 import는 내보내는 모듈의 변수에 대한 살아있는 참조다. 반면 CJS는 모듈이 로드되는 시점에 module.exports에 할당된 값의 복사본을 넘겨준다. 평소 코드에서는 거의 체감하지 못하지만, 순환 의존성이 엮이면 이야기가 달라진다.

어떤 걸 써야 할까?

새 프로젝트라면 답은 ES Modules다. package.json"type": "module"을 넣고 뒤돌아보지 말자. ESM은 자바스크립트 언어 표준이고, 브라우저와 Node에서 똑같이 동작하며, 최상위 await을 지원하고, 요즘 나오는 도구들도 ESM을 전제로 설계되어 있다.

CommonJS를 계속 쓰는 게 합리적인 경우는 이렇다.

  • 기존 CJS 코드베이스를 유지보수 중이고, 아직 마이그레이션 비용을 들일 시점이 아닐 때.
  • 아주 오래된 Node 버전이나 ESM을 못 쓰는 사용자까지 지원해야 하는 라이브러리를 배포할 때.
  • 꼭 필요한 의존성이 CJS로만 배포되고 interop가 지저분할 때. (요즘은 드물지만 여전히 있다.)

그렇다고 해도 결국 ESM 코드를 계속 읽게 된다. 최근 몇 년간 npm에 올라온 패키지들은 거의 다 ESM 쪽으로 기울고 있기 때문이다. 둘 다 읽을 줄 아는 건 선택이 아니라 기본이고, 본인이 실제로 작성하는 쪽의 관용구는 확실히 몸에 익혀야 한다.

빠르게 점검하는 체크리스트

새 파일을 열었을 때 이렇게 자문해 보자.

  • 이 파일은 import/export를 쓰는가, 아니면 require/module.exports를 쓰는가? 섞어 쓰지 말 것.
  • 가장 가까운 package.json"type" 값은 무엇인가?
  • 패키지를 import 하는 경우, 그 패키지의 package.json을 확인하자. ESM으로 배포되는가, CJS인가, 둘 다인가?
  • ERR_REQUIRE_ESM 에러가 떴다면, CJS에서 ESM을 로드하려 한 상황이다. 동적 import()로 바꾸거나 호출하는 쪽을 ESM으로 옮기자.

Node에서 겪는 모듈 관련 혼란의 90%는 이 네 가지 중 하나다.

다음: npm 기초

모듈은 코드를 여러 파일로 쪼개는 수단이다. 다음 단계는 다른 사람이 짜둔 코드를 가져다 쓰는 것, 바로 npm의 역할이다. 패키지 설치, semver 범위 표기법, 그리고 실제로 매일 쓰게 되는 npm 워크플로우까지 차근차근 살펴보자.

자주 묻는 질문

자바스크립트에서 require와 import는 뭐가 다른가요?

require는 CommonJS 방식입니다. 동기로 동작하고, 호출되는 시점에 실행되며, 모듈이 module.exports에 할당한 값을 그대로 돌려줍니다. 반면 import는 ES Modules 문법이고, 정적(static)이라 파일 상단으로 끌어올려진 뒤 코드 실행 전에 먼저 분석됩니다. 이 둘은 this가 가리키는 값, 순환 참조가 해석되는 방식, top-level await 허용 여부에서도 차이가 납니다.

새 Node 프로젝트에는 CommonJS와 ES Modules 중 뭘 써야 하나요?

ES Modules를 쓰세요. package.json"type": "module"을 넣고 import/export로 작성하면 됩니다. ESM은 공식 표준이고 브라우저와 Node 양쪽에서 동작하며 top-level await도 지원합니다. 다만 오래된 패키지나 도구에는 여전히 CommonJS가 많이 남아 있어서, 직접 작성하진 않아도 읽을 일은 생깁니다.

한 프로젝트 안에서 require와 import를 섞어 써도 되나요?

규칙만 지키면 가능합니다. .mjs 파일이나 "type": "module"이 설정된 패키지는 ESM으로, .cjs"type": "commonjs"는 CJS로 동작합니다. ESM에서는 CommonJS 모듈을 import할 수 있고, 이때 module.exports가 default export로 잡힙니다. 반대로 CommonJS에서는 ESM 모듈을 require()로 바로 불러올 수 없어서 동적 import()를 써야 하고, 이건 Promise를 반환합니다.

Coddy로 코딩 배우기

시작하기