본문 바로가기
빌드노트 검색

빌드노트

type: module을 켠 뒤 설정 파일의 require가 실패한다

type을 module로 바꾸면 기존 .js 설정도 ESM으로 해석될 수 있다. 같은 CommonJS 설정의 js/cjs/mjs 실행과 export 값을 비교하고, 가까운 package.json 경계를 확인해 설정만 CommonJS로 남길지 ESM 문법으로 옮길지 판단한다.

package.json에 "type": "module"을 추가한 뒤 기존 JavaScript 설정에서 ReferenceError: require is not defined in ES module scope가 날 수 있다. 그 설정 파일도 ESM으로 해석되는데 내용은 CommonJS 문법으로 남아 있기 때문이다. 도구가 지원한다면 설정만 .cjs로 유지하거나, 설정의 불러오기와 내보내기를 모두 ESM 문법으로 바꾼다.

오류가 가리킨 설정 파일부터 확인한다

오류 스택에 나온 자신의 설정 파일을 열어 확장자와 require, module.exports 사용을 확인한다. .js라면 그 파일이 있는 폴더에서 위로 올라가며 처음 만나는 package.json의 type을 읽는다. 터미널을 연 폴더나 저장소 최상위의 설정만 보는 것으로는 부족하다. 하위 폴더에 다른 package.json이 있을 수 있다.

Node.js가 직접 읽는 JavaScript 파일에서는 다음처럼 판단한다. Node.js v22.23.2의 모듈 형식 결정 규칙 (새 탭에서 열림)

  • .js와 가장 가까운 "type": "module": ESM으로 해석한다.
  • .js와 가장 가까운 "type": "commonjs": CommonJS로 해석한다.
  • .cjs는 CommonJS, .mjs는 ESM이다. 이 두 확장자는 type 값에 좌우되지 않는다.

이번 글은 type을 명시한 경우를 다룬다. type이 없는 .js를 무조건 같은 방식으로 판단하지는 않는다. Node.js 버전에 따른 문법 감지 조건도 있으므로, 그런 파일은 실행 버전과 문법 감지 규칙 (새 탭에서 열림)을 함께 확인한다.

CommonJS 설정만 남겨도 되는가

예를 들어 오류가 난 설정 내용이 아래 두 줄이라면, 기존 문법을 유지하는 선택은 이 내용을 tool.config.cjs로 저장하는 것이다. tool.config는 설명용 이름이며 실제 파일명은 사용하는 도구의 규칙에 맞춘다.

const path = require('node:path');
module.exports = { output: path.posix.join('dist', 'assets') };

먼저 그 도구의 현재 버전이 해당 .cjs 설정 이름을 찾는지 확인한다. 실행 옵션이나 스크립트가 옛 .js 경로를 지정했다면 새 경로로 바꿔야 한다. Node.js가 .cjs를 읽을 수 있다는 사실만으로 도구가 자동으로 그 파일을 선택한다고 판단할 수는 없다. 설정 하나를 CommonJS로 남기려는 경우라면, 프로젝트 전체의 type을 되돌리기 전에 이 선택이 가능한지 확인한다.

도구가 ESM 설정을 지원하고 요구하는 내보내기가 기본 export라면, 같은 값을 아래처럼 내보낼 수 있다. "type": "module" 범위의 .js 또는 도구가 지원하는 .mjs에 넣는다.

import path from 'node:path';
export default { output: path.posix.join('dist', 'assets') };

require 한 줄만 import로 고치고 module.exports를 남기면 전환이 끝나지 않는다. 아래 별도 실험에서도 그렇게 바꾼 파일은 ReferenceError: module is not defined in ES module scope로 실패했다. module.exports 한 줄만 있는 파일도 같은 오류였다. ESM에는 CommonJS의 require, exports, module 변수가 기본으로 주어지지 않는다. ESM과 CommonJS의 차이 (새 탭에서 열림)

내보낼 값이 객체인지 함수인지, 기본 export인지 다른 형식인지는 도구의 설정 계약을 따른다. 위 두 줄은 이 글의 작은 객체 설정을 옮긴 예이며 모든 도구의 설정을 그대로 대체하는 코드는 아니다.

같은 내용을 세 확장자로 실행했다

Windows 11, PowerShell 7.6.6, Node.js v22.23.2에서 새 합성 패키지로 비교했다. 실제 프로젝트 장애 기록이 아닌 별도 재현이다. 외부 패키지는 설치하지 않았고 Node 내장 모듈 node:path만 사용했다. path.posix.join을 쓴 것은 Windows에서도 설정 값을 dist/assets로 고정하기 위해서다.

직접 비교하려면 Node.js가 설치된 환경의 새 빈 폴더에 package.json을 만들고 내용으로 {"type":"module"}을 넣는다. 위 CommonJS 두 줄을 동일하게 tool.config.js, tool.config.cjs, tool.config.mjs 세 파일에 저장한다. 기존 프로젝트의 설정을 덮어쓰지 않고, 이 새 폴더의 PowerShell에서 다음을 실행하면 확장자에 따른 차이를 볼 수 있다.

node ./tool.config.js
"exit=$LASTEXITCODE"
node ./tool.config.cjs
"exit=$LASTEXITCODE"
node ./tool.config.mjs
"exit=$LASTEXITCODE"

각 exit는 바로 앞 명령의 종료 코드다. 실제 결과는 다음과 같았다.

  • .js: ReferenceError: require is not defined in ES module scope, you can use import instead, exit=1.
  • .cjs: 출력 없이 exit=0.
  • .mjs: .js와 같은 require 오류, exit=1.

.js 오류에는 이 파일의 확장자와 "type": "module"이 있는 package.json 경로도 나왔다. .mjs로 이름만 바꿔서는 CommonJS 코드가 ESM 문법으로 변환되지 않았다.

출력이 없었다고 설정 값이 비었다는 뜻은 아니다. 이 파일은 값을 내보낼 뿐 출력하지 않는다. .cjs가 내보낸 값을 확인하려면 같은 폴더에서 다음처럼 읽는다.

node --input-type=module -e "import config from './tool.config.cjs'; console.log(JSON.stringify(config));"
"exit=$LASTEXITCODE"

실제 출력은 {"output":"dist/assets"}와 exit=0이었다. ESM에서 CommonJS를 기본 import로 읽으면 module.exports 값을 받는다. CommonJS와의 상호 운용 (새 탭에서 열림)

ESM 전환도 같은 방식으로 확인했다. 위 ESM 두 줄을 esm.config.js와 esm.config.mjs에 각각 저장하고, 바로 앞 명령의 import 대상을 그 이름으로 바꿔 실행했다. 두 파일 모두 같은 JSON과 exit=0을 출력했다. 이때 --input-type=module은 -e로 전달한 한 줄의 실행 형식을 정한다. 가져오는 파일의 형식을 강제로 바꾸는 옵션이 아니다. .cjs는 그대로 CommonJS로 읽혔다. 한 줄 입력의 모듈 형식 (새 탭에서 열림)

여기서 JSON이 다르다면 내보낸 값과 읽는 export를 확인한다. 새 오류가 설정 안의 다른 파일이나 플러그인을 가리킨다면 그 파일도 따로 살펴야 한다. 단순히 확장자를 다시 바꾸는 것만으로 해결됐다고 볼 수 없다.

하위 폴더에서는 가장 가까운 package.json이 달랐다

같은 실험에서 inherited/tool.config.js에도 CommonJS 두 줄을 넣었다. inherited에는 package.json을 만들지 않았다. 상위의 "type":"module"이 적용돼 node ./inherited/tool.config.js는 같은 require 오류와 종료 코드 1이었다.

반면 legacy/package.json에는 {"type":"commonjs"}를 넣고 legacy/tool.config.js에 동일한 두 줄을 저장했다. 상위 패키지 폴더에서 node ./legacy/tool.config.js를 실행해도 출력 없이 종료 코드 0이었다. 그 파일을 기본 import로 읽은 값도 {"output":"dist/assets"}였다. legacy/tool.config.mjs에 같은 CommonJS 내용을 넣으면 다시 require 오류와 종료 코드 1이었다. 현재 작업 폴더보다 대상 파일의 확장자와 가까운 패키지 경계를 확인해야 한다는 차이가 드러났다. 패키지 범위와 확장자 (새 탭에서 열림)

하위 package.json을 추가하면 그 범위의 다른 .js에도 영향을 준다. 설정 하나를 고치려고 새 패키지 경계를 만드는 대신 .cjs 유지가 가능한지 먼저 보는 이유다. 도구가 하위 폴더의 설정을 탐색하는지는 이번 실험으로 확인하지 않았다.

검증한 범위는 사용자 정의 로더 없이 Node.js가 이 작은 설정 파일을 실행하고 export 값을 읽는 동작이다. 특정 빌드 도구의 설정 자동 탐색, 플러그인 로딩, 실제 빌드는 실행하지 않았다. Node에서 원하는 값을 읽었다면 다음 확인 대상은 해당 도구의 버전별 설정 문서와 실제로 선택된 파일 경로다. 이 글의 증상은 상대 import의 확장자 누락이 아니라, 설정 파일 자체의 모듈 형식과 그 안의 문법이 어긋난 경우다.

← 전체 글 보기