Node.js 런타임과 모듈 - ESM import 와 CommonJS require 차이 (Node.js API 1단원)
이 단원에서 배우는 것
이 책은 설치 없이 Node.js 표준 라이브러리만으로 작은 메모 API를 처음부터 끝까지 만든다. 서버를 띄우기 전에 먼저 할 일이 있다. 지금 내 코드를 실행하는 것이 무엇이고, 파일끼리 코드를 어떻게 주고받는지 아는 것이다. 이 두 가지가 흐리면 뒤에서 만나는 오류 대부분이 "왜 되는지 모르는데 되는 코드"로 남는다.
- 브라우저 자바스크립트와 Node.js 런타임이 무엇이 같고 무엇이 다른지 직접 출력해서 확인한다.
- ES 모듈(
import/export)로 파일을 나누고, 예전 방식인 CommonJS(require/module.exports)를 읽을 수 있게 된다. - 둘을 섞을 때 나는 오류와
__dirname대신 쓰는import.meta.dirname을 익힌다.
준비물은 Node.js 20.11 이상이다. 이 책의 모든 출력은 Node.js 26.4에서 실제로 실행해 얻었고, 같은 명령을 20.18에서도 돌려 종료 코드가 같은지 확인했다. 오류 메시지의 세부 문구와 줄 번호는 버전마다 조금씩 다를 수 있다.
문제 상황
자바스크립트 기본서를 마치고 인터넷에서 찾은 Node 예제를 붙여 넣었더니 이런 일이 생긴다.
- 브라우저에서 잘 되던
document.querySelector가document is not defined로 멈춘다. - A 블로그는
const fs = require('fs'), B 블로그는import fs from 'fs'라고 쓴다. 둘을 한 파일에 섞었더니require is not defined가 난다. - 예제에 있는
__dirname을 쓰니 역시 정의되지 않았다고 한다.
셋 다 "Node.js라는 실행 환경"과 "모듈 방식 두 가지"를 구분하면 한 번에 풀린다. 이 단원의 예제는 ch01 폴더를 만들어 그 안에 저장한다.
완성 코드
1. 런타임 확인: hello.mjs
// hello.mjs — 지금 코드를 실행하는 것이 브라우저가 아니라 Node.js 라는 것을 확인한다
import { basename } from 'node:path';
console.log('명령행 인자:', process.argv.slice(2));
console.log('이 파일 이름:', basename(import.meta.filename));
console.log('작업 폴더 이름:', basename(process.cwd()));
console.log('window 가 있나?', typeof window !== 'undefined');
console.log('document 가 있나?', typeof document !== 'undefined');
console.log('fetch 가 있나?', typeof fetch === 'function');
console.log('process 가 있나?', typeof process === 'object');
2. ES 모듈로 파일 나누기: lib/greet.mjs, main.mjs
// lib/greet.mjs — 밖에서 쓸 것만 export 로 내보낸다
export const DEFAULT_NAME = '방문자';
export function greet(name = DEFAULT_NAME) {
return `안녕하세요, ${name}님`;
}
function secret() { // export 하지 않은 함수는 이 파일 안에서만 보인다
return '비밀';
}
// main.mjs — 다른 파일의 함수를 import 로 가져온다
import { greet, DEFAULT_NAME } from './lib/greet.mjs';
import * as greetModule from './lib/greet.mjs';
console.log(greet('지민'));
console.log(greet());
console.log('기본 이름:', DEFAULT_NAME);
console.log('모듈이 내보낸 이름들:', Object.keys(greetModule));
3. CommonJS 모듈과 다리 놓기: counter.cjs, old-main.cjs, bridge.mjs
// counter.cjs — 예전 방식(CommonJS)으로 만든 모듈
let count = 0;
function increase() {
count += 1;
return count;
}
module.exports = { increase };
// old-main.cjs — CommonJS 에서는 require 로 가져온다
const { increase } = require('./counter.cjs');
const path = require('node:path');
increase();
console.log('카운트:', increase());
console.log('이 파일 이름:', path.basename(__filename));
// bridge.mjs — ES 모듈에서 CommonJS 모듈을 가져올 수도 있다
import counter from './counter.cjs';
import { increase } from './counter.cjs';
console.log('기본 가져오기:', typeof counter.increase);
console.log('이름 붙여 가져오기:', increase());
console.log('같은 함수인가?', counter.increase === increase);
4. 일부러 틀린 코드: mixed.mjs, where.mjs
// mixed.mjs — ES 모듈 안에서 require 를 쓰면 어떻게 될까
const fs = require('node:fs');
console.log(typeof fs.readFileSync);
// where.mjs — ES 모듈에서 __dirname 대신 쓰는 값
import { basename } from 'node:path';
console.log('__dirname 이 있나?', typeof __dirname !== 'undefined');
console.log('import.meta.dirname 끝부분:', basename(import.meta.dirname));
console.log('import.meta.url 앞부분:', import.meta.url.slice(0, 8) + '...');
줄별 해설
런타임이란: 엔진 + 주변 도구
자바스크립트 문법(변수, 함수, Promise)을 해석하는 것은 엔진이다. Node.js는 크롬과 같은 V8 엔진을 쓴다. 그래서 문법은 똑같다. 다른 것은 엔진 바깥에 붙은 도구 상자다. 브라우저는 화면을 다루는 window, document를 주고, Node.js는 파일·네트워크·프로세스를 다루는 process, node:fs, node:http를 준다. hello.mjs의 typeof window !== 'undefined'는 "그런 이름이 있는가"를 오류 없이 묻는 방법이다. 없는 변수를 그냥 읽으면 ReferenceError가 나지만 typeof는 'undefined'를 돌려준다.
그림 1-1. 브라우저와 Node.js 런타임 비교
| 기능 | 브라우저 | Node.js |
|---|---|---|
| 화면 다루기 | document · DOM | 없음 |
| 파일 읽기·쓰기 | 사용자가 고른 파일만 | node:fs |
| HTTP 서버 열기 | 없음 | node:http |
| 요청 보내기 | fetch | fetch (18부터 기본) |
| 실행 인자·환경변수 | 없음 | process.argv · process.env |
fetch는 원래 브라우저 기능이었지만 Node.js 18부터 기본으로 들어 있다. 이 책에서 서버에 요청을 보낼 때 따로 설치 없이 fetch를 쓰는 이유다.
console.log('명령행 인자:', process.argv.slice(2));
process.argv는 실행할 때 준 단어들의 배열이다. 0번은 node 실행 파일 경로, 1번은 스크립트 경로라서 slice(2)로 잘라야 내가 준 인자만 남는다. 뒤 단원에서 포트나 파일 이름을 받을 때 쓴다.
import { basename } from 'node:path';
Node.js에 들어 있는 모듈은 node:를 앞에 붙여 부를 수 있다. 붙이지 않아도 되지만, 붙이면 "npm으로 설치한 같은 이름의 패키지"와 헷갈릴 일이 없고 읽는 사람도 표준 라이브러리임을 바로 안다. 이 책은 항상 붙인다.
ES 모듈: export한 것만 밖으로 나간다
.mjs 확장자는 "이 파일은 ES 모듈이다"라는 표시다. ES 모듈에서는 파일 하나가 자기만의 범위를 가진다. greet.mjs의 secret 함수는 export를 붙이지 않았으므로 다른 파일에서 볼 수 없다. main.mjs에서 import * as greetModule로 모듈 전체를 받아 이름 목록을 찍어 보면 DEFAULT_NAME과 greet 두 개만 나온다.
import 경로의 ./lib/greet.mjs는 확장자까지 정확히 적어야 한다. 브라우저 번들러(웹팩, Vite)는 확장자를 알아서 찾아 주지만 Node.js의 ES 모듈은 찾아 주지 않는다. ./lib/greet라고 쓰면 파일을 찾지 못했다는 오류가 난다.
CommonJS: require와 module.exports
Node.js는 ES 모듈이 표준이 되기 전부터 자체 모듈 방식을 썼다. 그것이 CommonJS다. module.exports에 넣은 객체가 밖으로 나가고, 가져가는 쪽은 require() 함수의 반환값으로 받는다. 오래된 프로젝트, 많은 npm 패키지, 설정 파일 예제에 아직 널리 남아 있어서 읽을 줄은 알아야 한다. .cjs 확장자는 "이 파일은 CommonJS다"라는 표시다.
increase();
console.log('카운트:', increase());
첫 번째 호출로 1, 두 번째 호출로 2가 되므로 카운트: 2가 찍힌다. 모듈 안의 let count는 모듈이 처음 로드될 때 한 번만 만들어지고, 그 뒤로는 같은 모듈을 다시 불러도 같은 값을 공유한다. 연습 문제 2번에서 다시 확인한다.
bridge.mjs는 ES 모듈에서 CommonJS 파일을 가져온다. 이 방향은 잘 된다. module.exports 객체 전체가 기본 가져오기(import counter)로 들어오고, Node.js가 코드를 훑어 increase 같은 이름도 이름 붙여 가져오기로 꺼내 준다. 두 방식으로 받은 함수가 같은 함수인지 비교하면 true다.
그림 1-2. ES 모듈과 CommonJS
| 항목 | ES 모듈 | CommonJS |
|---|---|---|
| 내보내기 | export | module.exports |
| 가져오기 | import ... from | require() |
| 파일 표시 | .mjs 또는 "type": "module" | .cjs 또는 type 없음 |
| 현재 파일 경로 | import.meta.dirname | __dirname |
최상위 await | 된다 | 안 된다 |
| 없는 이름 가져오기 | 실행 전에 SyntaxError | 실행 중 undefined |
섞으면 나는 오류와 __dirname
mixed.mjs는 ES 모듈인데 require를 부른다. ES 모듈에는 require, module, exports, __filename, __dirname이 없다. 이 다섯은 CommonJS가 파일마다 몰래 넣어 주던 변수였기 때문이다. 대신 ES 모듈에서는 import.meta에 현재 파일 정보가 있다.
import.meta.url—file:///...형식의 주소. 모든 버전에서 쓸 수 있다.import.meta.filename,import.meta.dirname— 보통 경로 문자열. Node.js 20.11부터 있다. 이 책이 20.11 이상을 요구하는 이유다.
실제 실행 결과
ch01 폴더 안에서 실행했다.
$ node hello.mjs 메모 --verbose
명령행 인자: [ '메모', '--verbose' ]
이 파일 이름: hello.mjs
작업 폴더 이름: ch01
window 가 있나? false
document 가 있나? false
fetch 가 있나? true
process 가 있나? true
브라우저 전용 이름인 window, document는 없고 fetch, process는 있다. 명령행 인자 메모 --verbose가 배열로 들어왔다.
$ node main.mjs
안녕하세요, 지민님
안녕하세요, 방문자님
기본 이름: 방문자
모듈이 내보낸 이름들: [ 'DEFAULT_NAME', 'greet' ]
$ node old-main.cjs
카운트: 2
이 파일 이름: old-main.cjs
$ node bridge.mjs
기본 가져오기: function
이름 붙여 가져오기: 1
같은 함수인가? true
bridge.mjs에서는 increase()를 처음 불렀으므로 1이다. old-main.cjs와는 다른 프로세스라서 값을 공유하지 않는다. 공유는 "한 번 실행되는 프로그램 안에서"의 이야기다.
$ node mixed.mjs
file:///home/me/node-book/ch01/mixed.mjs:2
const fs = require('node:fs');
^
ReferenceError: require is not defined in ES module scope, you can use import instead
at file:///home/me/node-book/ch01/mixed.mjs:2:12
at ModuleJob.run (node:internal/modules/esm/module_job:447:25)
at async node:internal/modules/esm/loader:646:26
at async asyncRunEntryPointWithESMLoader (node:internal/modules/run_main:101:5)
Node.js v26.4.0
오류 메시지가 해결책까지 알려 준다. "ES 모듈 범위에는 require가 없으니 import를 써라." 종료 코드는 1이다. 여러 줄 오류 중에서 먼저 볼 곳은 파일 이름과 줄 번호(mixed.mjs:2), 그리고 ReferenceError:로 시작하는 줄이다. 아래 at ... 줄들은 Node.js 내부 호출 기록이라 대개 건너뛰어도 된다. 출력의 /home/me/node-book은 검증에 쓴 임시 폴더 경로를 바꿔 적은 것이다.
$ node where.mjs
__dirname 이 있나? false
import.meta.dirname 끝부분: ch01
import.meta.url 앞부분: file:///...
실무에서 자주 틀리는 것
1. .js 파일의 모듈 방식을 운에 맡긴다
확장자가 .js이면 Node.js는 가장 가까운 package.json의 "type" 값을 본다. "module"이면 ES 모듈, 없거나 "commonjs"이면 CommonJS로 읽는다. 최근 버전은 import 문법을 보고 알아서 ES 모듈로 다시 읽어 주기도 하지만, 버전과 설정에 따라 결과가 달라진다. 이 책은 헷갈릴 여지를 없애려고 .mjs/.cjs를 쓴다. 새 프로젝트를 .js로 만든다면 package.json에 "type": "module"을 적어 두는 것을 기본으로 삼는다(11단원에서 완성한다).
2. 브라우저용 코드를 서버에서 돌린다
localStorage, document, alert은 서버에 없다. 반대로 node:fs는 브라우저에 없다. "이 코드는 어디서 실행되나?"를 먼저 묻는 습관을 들인다. 프론트엔드 개발자도 빌드 도구, 테스트, 서버 사이드 렌더링에서 Node 쪽 코드를 매일 만난다.
3. __dirname을 만들려고 복잡한 코드를 복사한다
예전 글에는 fileURLToPath(new URL('.', import.meta.url)) 같은 코드가 흔하다. 20.11 이상이면 import.meta.dirname 한 줄이면 된다. 3단원에서 "실행 위치와 무관하게 파일 옆의 데이터 파일을 찾는" 데 이 값을 쓴다.
4. 확장자 없는 import 경로
import { greet } from './lib/greet'는 Node.js ES 모듈에서 실패한다. 번들러 환경에서 옮겨 온 코드에서 가장 흔한 오류다. 경로는 확장자까지 적는다.
연습 문제
main.mjs첫 줄을import { greet, secret } from './lib/greet.mjs';로 바꾸고 실행하면 어떻게 되는가? 오류가 나는 시점은secret()을 부를 때인가, 그 전인가?- ES 모듈 파일 하나에서
counter.cjs를import로 두 번 가져와(한 번은 기본, 한 번은 이름 붙여) 각각increase()를 한 번씩 부르면 두 번째 호출 결과는 1인가 2인가? old-main.cjs를 ES 모듈new-main.mjs로 옮겨 적어라. 출력은 같아야 한다.
정답과 해설
- 파일이 실행되기 전에 실패한다. ES 모듈은 코드를 실행하기 전에
import/export연결부터 확인하므로,greet.mjs가secret을 내보내지 않는다는 사실을 첫 줄에서 알아챈다. 메시지는SyntaxError: The requested module './lib/greet.mjs' does not provide an export named 'secret'이고console.log는 하나도 찍히지 않는다(검증 스크립트로 확인). 밖에서 쓰려면greet.mjs에서export function secret()으로 바꿔야 한다. 없는 이름을 실행 도중에야 알게 되는 CommonJS와 다른 점이다. - 2다. 모듈은 처음 한 번만 실행되고 캐시되므로 두
import는 같은 모듈 객체와 같은count를 가리킨다. 뒤 단원에서 저장소를 모듈로 만들 때 "모듈 수준 변수는 프로그램 전체가 공유한다"는 사실을 이용한다. 반대로 그 공유 때문에 테스트끼리 값이 섞일 수 있어서, 10단원에서는 저장소를 함수(createMemoStore)로 매번 새로 만든다. - 아래처럼 쓴다. CommonJS 모듈은 기본 가져오기로 받거나 이름을 꺼내 받을 수 있고,
__filename대신import.meta.filename을 쓴다. 검증 스크립트가 이 코드를 실제로 실행해카운트: 2와이 파일 이름: new-main.mjs를 확인했다.import { increase } from './counter.cjs'; import path from 'node:path'; increase(); console.log('카운트:', increase()); console.log('이 파일 이름:', path.basename(import.meta.filename));
다음 단원에서는 Node.js가 "기다리는 일"을 어떻게 처리하는지 본다. 서버는 파일과 네트워크를 끝없이 기다리는 프로그램이라, 이 감각이 없으면 서버 코드를 읽을 수 없다.