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

빌드노트

같은 패키지인데 import와 require가 다른 파일을 읽는다

같은 Node 패키지도 import와 require가 서로 다른 진입점을 고를 수 있다. 실제 선택 경로와 합성 기능 값을 비교하고, 조건 키 순서와 default의 적용 범위를 확인해 지원하는 호출 방식으로 맞춘다.

Node.js에서 같은 패키지 이름을 썼는데 import로 읽은 기능과 require()로 읽은 기능이 다르면, 설치된 버전과 함께 package.json의 exports를 확인한다. 하나의 설치본도 호출 방식에 따라 서로 다른 진입 파일을 제공할 수 있다. 두 호출이 모두 성공했다는 사실만으로 같은 구현을 읽었다고 판단할 수는 없다. Node.js의 조건부 exports 설명 (새 탭에서 열림)

호출한 파일 옆에서 선택 경로를 확인한다

아래는 Node.js v22.23.2 기준의 조회다. 패키지를 사용하는 실제 JavaScript 파일과 같은 폴더에 check-entry.mjs를 만들고, route-probe를 자신이 사용하는 정확한 패키지 이름으로 바꾼다. 하위 경로로 가져오고 있다면 그 경로도 포함한다. 두 조회의 기준 위치를 같게 두고 import 쪽과 require 쪽이 고르는 파일을 비교한다.

import { createRequire } from 'node:module';
import { fileURLToPath } from 'node:url';
const require = createRequire(import.meta.url);
console.log('import:', fileURLToPath(import.meta.resolve('route-probe')));
console.log('require:', require.resolve('route-probe'));

실제 앱과 같은 Node 실행 옵션으로 node ./check-entry.mjs를 실행한다. createRequire(import.meta.url)은 이 파일 위치를 기준으로 하는 require 함수를 만든다. 두 경로 모두 파일 경로 문자열로 출력하도록 import 쪽의 file URL을 변환했다. createRequire의 기준 위치 (새 탭에서 열림)

뒤의 합성 패키지에서는 import 쪽이 entry.mjs, require 쪽이 entry.cjs로 끝났고 종료 코드는 0이었다. 경로가 다르면 각 파일이 속한 패키지의 package.json을 열어 exports 매핑과 대조한다. 루트 이름의 설정은 exports 자체에 있거나 exports["."] 안에 있을 수 있다.

이 코드는 파일을 실행하지 않고 해석 경로를 조회한다. require.resolve()도 로딩 대신 파일 이름을 반환하는 API다. 특히 import.meta.resolve()는 대상 파일이 없어도 file URL을 반환할 수 있으므로, 출력이 나왔다고 실제 로딩까지 성공한 것은 아니다. 조회가 실패하면 오류가 가리킨 패키지 이름과 기준 위치부터 확인한다. require.resolve 설명 (새 탭에서 열림), import.meta.resolve의 범위와 주의점 (새 탭에서 열림)

같은 이름에서 true와 false를 읽었다

Windows 11, PowerShell 7.6.6, Node.js v22.23.2에서 새 로컬 패키지로 비교했다. 실제 서비스의 장애 기록이 아니라 별도 재현이다. 외부 패키지를 설치하거나 기존 node_modules를 고치지 않았다. 패키지 안에서 자기 이름으로 가져오는 self-reference를 사용했다. 이 방식에도 exports가 적용된다. 패키지 이름으로 자기 자신 참조하기 (새 탭에서 열림)

직접 비교하려면 Node.js가 설치된 환경에서 새 빈 폴더를 열고 다음 내용을 package.json으로 저장한다. route-probe라는 같은 이름에 두 진입 파일을 연결하는 설정이다. 기존 앱의 설정을 덮어쓰지 않는다.

{
  "name": "route-probe",
  "private": true,
  "type": "module",
  "exports": {
    "import": "./entry.mjs",
    "require": "./entry.cjs",
    "default": "./fallback.cjs"
  }
}

같은 폴더에 아래 세 파일을 만든다. supportsBatch는 분기별 기능 차이를 눈으로 구별하기 위해 넣은 합성 값이다.

  • entry.mjs: export default { label: 'esm-entry', supportsBatch: true };
  • entry.cjs: module.exports = { label: 'cjs-entry', supportsBatch: false };
  • fallback.cjs: module.exports = { label: 'fallback-entry', supportsBatch: false };

그 폴더의 PowerShell에서 다음 두 명령으로 실제 값을 읽는다. --input-type은 -e로 전달한 코드의 모듈 형식을 명시하며, 각 exit는 바로 앞 명령의 종료 코드다.

node --input-type=module -e "import api from 'route-probe'; console.log(api.label, api.supportsBatch);"
"exit=$LASTEXITCODE"
node --input-type=commonjs -e "const api = require('route-probe'); console.log(api.label, api.supportsBatch);"
"exit=$LASTEXITCODE"

실제 출력은 차례로 esm-entry true, exit=0, cjs-entry false, exit=0이었다. 앞의 check-entry.mjs도 이 폴더에 두고 실행하면 두 파일 경로를 비교할 수 있다. 별도의 경로 기록용 예제에서는 진입 파일 자신이 내보낸 위치와 조회 경로도 일치했다. 패키지 이름이나 버전을 바꾸지 않아도 선택된 파일과 관찰값이 달라진 것이다.

"import" 조건은 정적 import와 import()에, "require" 조건은 require()에 대응한다. 이 조건 이름이 대상 파일의 모듈 형식을 정하는 것은 아니다. 호출 방식에 따른 조건 선택 (새 탭에서 열림)

같은 분기 설정에서 호출 파일도 바꿔 확인했다. .cjs 파일 안의 require()는 cjs-entry를 읽었지만, 그 파일의 async 함수 안에서 실행한 await import()는 esm-entry를 읽었다. .mjs 파일에서 createRequire()로 만든 require 함수도 cjs-entry를 읽었다. 따라서 호출 파일의 확장자만 보고 분기를 단정하면 안 된다. 빌드 결과를 실행하는 앱이라면 최종 JavaScript에서 실제로 어떤 호출을 하는지도 확인한다.

default를 먼저 쓰면 두 호출 모두 그 파일을 고른다

조건 객체는 키 순서가 의미를 가진다. default는 항상 맞는 조건이므로 마지막에 둔다. 여기의 조건 키 default와 ESM 문법의 export default는 별개다. 조건 키의 우선순위 (새 탭에서 열림)

진입 파일의 내용은 그대로 유지하고 설정만 바꿔 새 Node 프로세스로 비교했다.

  • default를 import, require보다 앞에 놓으면 두 호출 모두 fallback.cjs를 읽었다. 출력 값은 모두 fallback-entry, false였다.
  • import를 먼저 두고 require 항목만 지우면 import는 계속 entry.mjs를 읽었다. require는 뒤의 default로 넘어가 fallback.cjs를 읽었다.
  • 두 조건 앞에 "node": "./node.cjs"를 추가하면 두 호출 모두 node.cjs를 읽었다. 이 파일의 합성 값은 node-entry, false였다.

위 비교는 전부 종료 코드 0이었다. 로딩 오류가 없어도 앞선 조건이 의도한 분기를 가릴 수 있었다. Node 전용 분기 안에서 import와 require를 나누려는 경우도 확인했다. "node"의 값을 파일 문자열 대신 {"import":"./entry.mjs","require":"./entry.cjs"}로 바꾸고, 바깥에는 node와 마지막 default만 남겼다. 다시 각각 esm-entry, cjs-entry를 읽었다. 중첩 조건 설명 (새 탭에서 열림)

fallback을 오류 복구로 해석해서도 안 된다. 별도 대조에서는 첫 import가 가리키는 곳만 존재하지 않는 ./absent.mjs로 바꿨다. default의 파일은 그대로 있었지만 실제 import는 ERR_MODULE_NOT_FOUND와 종료 코드 1로 끝났다. require는 계속 entry.cjs를 읽고 정상 종료했다. 선택된 import 대상이 없다는 이유로 뒤의 default 파일을 다시 시도하지 않았다. 같은 구성에서 import.meta.resolve()만 실행하면 없는 absent.mjs의 경로를 출력하고 종료 코드 0이었다. 경로 조회와 실제 로딩을 따로 확인해야 하는 이유다.

필요한 기능을 제공하는 지원 경로로 맞춘다

외부 패키지라면 그 버전이 어떤 호출 방식에서 필요한 기능을 제공하는지 문서와 실제 선택 파일을 함께 확인한다. 위 예제의 true가 필요하다면 require 쪽의 false를 재설치만으로 바꿀 근거는 없다. CommonJS에서도 import()로 import 분기를 선택할 수 있음을 확인했지만, 실제 코드에 적용하려면 비동기 결과를 받는 호출 흐름까지 맞춰야 한다. 패키지가 제공하는 공식 진입점과 호출 방식을 사용하고 설치된 내부 파일을 직접 고치는 방식에 기대지 않는다.

자신이 관리하는 패키지라면 두 진입점이 약속한 API를 각각 내보내는지 확인한다. 조건이 맞는데 한쪽에서 필요한 export가 빠졌다면 해당 진입 파일을 수정하고 두 호출을 다시 실행한다. 반대로 두 조회가 같은 파일을 가리킨다면, 이번 실험의 분기 차이만으로 증상을 설명할 수 없으므로 그 파일의 export와 실행 입력을 확인한다.

직접 검증한 범위는 위 Node 버전에서 기본 해석 규칙을 쓰는 로컬 JavaScript 실행이다. 사용자 정의 로더나 추가 조건 없이 정적 import, 동적 import, require, createRequire를 실행했다. 번들러·TypeScript의 경로 선택, 다른 Node 버전과 운영체제, 레지스트리 배포본은 실행하지 않았다. 이 글에서 비교한 것은 공개된 같은 패키지 이름의 조건별 진입점이며, 비공개 하위 경로 차단이나 상대 import의 확장자 생략은 별도 문제다.

← 전체 글 보기