본문으로 건너뛰기

프론트엔드

ESM과 CommonJS의 export 모델 차이 — named/default export, UMD, 모듈 최상위 this

2026. 09. 07. 월요일 오후 9시 0분

라이브러리를 Rollup으로 번들하면 아래 세 경고를 자주 만납니다.

(!) Mixing named and default exports
(!) Unresolved dependencies
(!) "this" has been rewritten to "undefined"

셋 다 빌드를 멈추지 않기 때문에 그냥 넘어가기 쉽습니다. 하지만 원인은 하나로 모입니다. ESM과 CommonJS가 "모듈이 무엇을 내보내는가"를 다르게 정의하기 때문입니다.

이번 글에서는 경고 세 개를 차례로 재현하면서 두 모듈 시스템의 export 모델 차이를 정리하겠습니다. 글에 나오는 출력은 전부 Node 22.20.0, Rollup 4.63.1, TypeScript 7.0.2로 작은 패키지를 만들어 직접 돌려본 결과입니다.


두 모듈 시스템의 export 모델

경고를 보기 전에 바탕부터 짚겠습니다. 두 시스템의 차이는 문법이 아니라 내보내기의 단위에 있습니다.

CommonJS에서 모듈이 내보내는 것은 module.exports라는 값 하나입니다.

// lib.cjs — 내보내는 것은 이 객체 하나뿐이다.
module.exports = {
  createMap: (container) => ({ container }),
  version: '1.0.0',
};

여기서 createMap은 모듈이 내보낸 이름이 아니라 내보낸 객체의 프로퍼티 이름입니다. 받는 쪽은 객체를 통째로 받아서 열어봅니다.

const lib = require('./lib.cjs'); // 객체 하나를 받는다
lib.createMap; // 그 객체의 프로퍼티를 꺼낸다

ESM에서 모듈이 내보내는 것은 이름이 붙은 여러 개의 바인딩입니다.

// lib.mjs — 이름이 붙은 바인딩을 여러 개 내보낸다.
export const createMap = (container) => ({ container });
export const version = '1.0.0';

여기서 createMap은 객체의 프로퍼티가 아니라 모듈이 직접 내보낸 이름입니다. default는 그중 이름을 따로 붙이지 않은 것에 해당하는 예약된 이름 하나이고, CommonJS의 module.exports와 같은 개념이 아닙니다.

export default { createMap, version }; // 'default'라는 이름으로 내보낸다

정리하면 이렇습니다.

CommonJSESM
내보내기 단위module.exports 값 하나이름 붙은 바인딩 여러 개
이름의 정체내보낸 객체의 프로퍼티모듈이 직접 내보낸 이름
default개념 자체가 없음예약된 이름 하나

값 하나와 이름 여럿의 차이입니다. 아래 세 경고는 전부 이 비대칭에서 나옵니다. 이름 여럿을 값 하나에 담으려면 어딘가에서 결정을 내려야 하는데, 소스만 봐서는 그 결정을 확정할 수 없을 때 번들러가 경고를 남깁니다.


named와 default를 함께 내보낼 때

첫 번째 경고를 재현하겠습니다. 엔트리 모듈이 named export와 default export를 함께 내보내는 흔한 형태입니다.

// src/main.js
export function createMap(container) {
  return { container, kind: 'map' };
}
 
export const version = '1.0.0';
 
export default { createMap, version };

이 파일을 UMD 형식으로 번들합니다.

// rollup.config.mjs
export default {
  input: 'src/main.js',
  output: [
    {
      format: 'umd',
      name: 'Cartis',
      file: 'dist/cartis.js',
    },
  ],
};
(!) Mixing named and default exports
The following entry modules are using named and default exports together:
src/main.js
 
Consumers of your bundle will have to use chunk.default to access their default
export, which may not be what you want. Use `output.exports: "named"` to disable
this warning.

Rollup에는 output.exports라는 옵션이 있고 기본값은 auto입니다. 엔트리 모듈이 무엇을 내보내는지 읽어서 출력 형태를 추론하는 모드입니다. named와 default가 같이 있으면 추론 근거가 둘로 갈리므로, Rollup이 한쪽을 고른 뒤 골랐다는 사실을 알립니다.

auto가 무엇을 추론했는지 확인하기

무엇을 골랐는지는 출력물에 그대로 드러납니다.

// dist/cartis.js — auto가 추론한 결과
(function (global, factory) {
  typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) :
  typeof define === 'function' && define.amd ? define(['exports'], factory) :
  (global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.Cartis = {}));
})(this, (function (exports) { 'use strict';
 
  function createMap(container) {
    return { container, kind: 'map' };
  }
 
  const version = '1.0.0';
 
  var main = { createMap, version };
 
  exports.createMap = createMap;
  exports.default = main;
  exports.version = version;
 
  Object.defineProperty(exports, '__esModule', { value: true });
 
}));

exports 객체에 프로퍼티를 하나씩 붙이는 방식이고, default도 그중 하나로 붙었습니다. 실제로 require해 보면 이렇게 나옵니다.

const m = require('./dist/cartis.js');
console.log(Object.keys(m)); // [ 'createMap', 'default', 'version' ]
console.log(typeof m.createMap); // 'function'
console.log(typeof m.default); // 'object'

output.exports: 'named'를 명시하고 다시 번들한 뒤 두 결과물을 diff로 비교하면 한 글자도 다르지 않습니다. auto는 named로 추론하고 있었습니다. 경고 메시지가 "named"를 권한 이유도 같습니다.

named 모드와 default 모드의 출력 차이

default로 지정하면 왜 안 되는지 보겠습니다. 위 엔트리 그대로 output.exports: 'default'를 주면 경고가 아니라 에러가 납니다.

[!] RollupError: "default" was specified for "output.exports", but entry module
"src/main.js" has the following exports: "createMap", "default" and "version"

default 모드는 default export가 유일한 export일 때만 성립합니다. 그 하나를 통째로 module.exports에 대입하는 방식이라 다른 이름들은 갈 곳이 없습니다.

여기서 조건은 "내보낼 것이 하나"가 아닙니다. named export 하나만 내보내는 엔트리로 바꿔 봐도 똑같이 에러가 납니다.

[!] RollupError: "default" was specified for "output.exports", but entry module
"src/named-only.js" has the following exports: "createMap"

module.exports에 통째로 대입할 대상은 default export뿐이라, 이름이 붙은 export는 하나여도 대입할 자리가 없습니다. default export만 있는 엔트리로 바꿔 번들하면 출력 형태가 달라집니다.

// exports: 'default'로 번들한 결과 — 첫 줄부터 다르다
(function (global, factory) {
  typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() :
  typeof define === 'function' && define.amd ? define(factory) :
  (global = typeof globalThis !== 'undefined' ? globalThis : global || self, global.Cartis = factory());
})(this, (function () { 'use strict';
 
  function createMap(container) {
    return { container, kind: 'map' };
  }
 
  var defaultOnly = { createMap, version: '1.0.0' };
 
  return defaultOnly;
 
}));

factory(exports)module.exports = factory()로 바뀌었습니다. 앞에서 본 값 하나와 이름 여럿의 차이가 코드에 그대로 드러납니다.

exports: 'named'exports: 'default'
CJS 경우factory(exports)module.exports = factory()
소비하는 쪽require('lib').createMaprequire('lib')가 곧 그 값
조건이름이 여럿이어도 됨default export가 유일한 export여야 함

두 소비 방식은 서로 호환되지 않습니다. named로 번들한 결과를 default처럼 쓰면 require('lib') 자체를 값으로 착각하게 되고, 반대도 마찬가지입니다. 번들 하나가 두 방식을 동시에 만족시킬 수 없어서 Rollup이 한쪽을 골라야 합니다.

해결은 단순합니다. 이미 named로 번들되고 있으니 그대로 명시합니다.

// rollup.config.mjs
output: [
  {
    format: 'umd',
    name: 'Cartis',
    file: 'dist/cartis.js',
    exports: 'named', // 추론에 맡기지 않고 못 박는다
  },
],

결과물은 그대로이고 경고만 사라집니다. 값이 달라지지 않는데도 명시하는 이유는, 추론에 기대는 동안에는 엔트리의 export가 바뀔 때 소비 방식도 함께 바뀌기 때문입니다. named export를 정리하다 default 하나만 남으면 auto는 이번엔 default로 추론하고, require('lib').createMap을 쓰던 코드가 전부 깨집니다. 명시해 두면 이런 변화가 에러로 먼저 잡힙니다.


UMD 래퍼가 나누는 세 가지 경우

앞의 출력물에는 아직 설명하지 않은 네 줄이 있습니다. 그 네 줄이 UMD(Universal Module Definition)의 정체이고, 나머지 두 경고를 읽으려면 먼저 짚어야 합니다.

(function (global, factory) {
  typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) :
  typeof define === 'function' && define.amd ? define(['exports'], factory) :
  (global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.Cartis = {}));
})(this, (function (exports) { 'use strict';
  // ... 실제 코드 ...
}));

하는 일은 실행 환경을 확인하고 그 환경에 맞게 자신을 등록하는 것입니다. 삼항 연산자 두 개로 세 가지 경우를 나눕니다.

  • exportsmodule이 있으면 CommonJS 환경입니다. exports에 프로퍼티를 붙입니다.
  • define.amd가 있으면 AMD 로더가 있는 환경입니다. define으로 등록합니다.
  • 둘 다 아니면 브라우저입니다. globalThis.Cartis에 붙입니다.

output.name에 적은 'Cartis'가 브라우저인 경우에 쓰이는 전역 변수 이름입니다. <script> 태그로 불러갔을 때 window.Cartis로 접근하게 됩니다.

여기까지 보면 앞의 경고가 다시 읽힙니다. UMD는 ESM 모듈을 이름을 값 하나의 프로퍼티로만 담을 수 있는 두 세계(CommonJS와 브라우저 전역)로 내려보내는 포장입니다. 두 세계에도 이름을 여럿 둘 수는 있지만, exports.createMap처럼 값 하나에 붙는 프로퍼티여야 합니다. nameddefault 중 하나를 고르는 일은, 그 값에 이름을 여럿 붙일지 값 하나를 통째로 대입할지 정하는 일이었습니다.

format: 'esm'으로만 번들하면 이 경고는 아예 뜨지 않습니다. ESM 결과물은 named와 default를 둘 다 그대로 내보낼 수 있어 고를 것이 없기 때문입니다. 이 경고는 ESM 자체의 문제가 아니라 ESM을 CommonJS로 옮길 때 생기는 문제입니다.


번들에 포함하지 않는 외부 모듈

두 번째 경고입니다. 라이브러리가 다른 패키지를 가져다 쓰는 상황을 만들어 보겠습니다.

// src/with-dep.js
import { loadTiles } from 'cartis';
 
export function createMap(container) {
  return { container, tiles: loadTiles() };
}

cartis는 이 프로젝트가 직접 설치한 패키지가 아니라, 소비하는 쪽이 따로 설치해서 쓰는 외부 모듈입니다. 이 상태로 번들하면 경고가 두 개 뜹니다.

(!) Unresolved dependencies
cartis (imported by "src/with-dep.js")
 
(!) Missing global variable name
Use "output.globals" to specify browser global variable names corresponding to
external modules:
cartis (guessing "cartis")

번들러의 기본 임무는 import를 따라가 그 파일의 내용을 결과물 안에 집어넣는 것입니다. cartis는 디스크에서 찾을 수 없으니 집어넣지 못했고, 그래서 첫 번째 경고가 나옵니다.

그리고 집어넣지 않기로 하는 순간 다음 문제가 생깁니다. 앞에서 본 세 가지 경우 중 브라우저에서는 전역 변수 이름이 필요한데, cartis가 전역에서 무슨 이름으로 존재하는지 알 방법이 없습니다. 그래서 이름을 추측했다는 두 번째 경고가 따라옵니다.

출력물을 보면 무슨 뜻인지 분명해집니다.

(function (global, factory) {
  typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports, require('cartis')) :
  typeof define === 'function' && define.amd ? define(['exports', 'cartis'], factory) :
  (global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.Cartis = {}, global.cartis));
})(this, (function (exports, cartis) { 'use strict';

import 문 한 줄이 경우마다 require('cartis'), AMD 의존성 배열, global.cartis로 각각 다르게 번역됐습니다. 결과물 안에 코드를 넣지 않고 바깥에 맡길 때 번들러가 하는 일입니다.

해결은 이 모듈이 외부 모듈이라고 알려주는 것입니다.

// rollup.config.mjs
export default {
  input: 'src/with-dep.js',
  external: ['cartis'], // 번들에 넣지 말고 그대로 남겨라
  output: [
    {
      format: 'umd',
      name: 'Cartis',
      file: 'dist/with-dep.js',
      exports: 'named',
      globals: { cartis: 'CartisCore' }, // 브라우저에서는 이 전역 이름으로 찾아라
    },
  ],
};

두 옵션은 짝입니다. external이 번들에 넣지 말라는 지시이고, globals가 그렇다면 브라우저에서 어디서 찾을지에 대한 답입니다. 둘 다 주면 경고가 사라지고 브라우저인 경우가 global.CartisCore로 바뀝니다.

이 경고는 tsconfig.jsonpaths로 모노레포 안의 패키지 경로를 잡아둔 상태에서 특히 헷갈립니다.

{
  "paths": {
    "cartis": ["../../packages/cartis/dist/index"]
  }
}

paths가 있으면 TypeScript는 import ... from 'cartis'를 문제없이 검사합니다. 하지만 paths타입 검사기에게만 알려주는 지도여서 Rollup은 이 설정을 읽지 않습니다. 타입 검사는 통과하는데 번들 단계에서만 못 찾는 상태가 이렇게 만들어집니다.

여기서 길이 둘로 갈립니다. 이 글처럼 소비하는 쪽이 따로 설치할 외부 모듈이라면 external로 알려주면 됩니다. 반대로 번들에 포함하고 싶다면 rollup-plugin-typescript-paths 같은 플러그인을 붙여 Rollup도 paths를 읽게 만들어야 합니다. 모노레포 글에서 패키지를 연결하는 방식을 다룬 적이 있는데, 그 연결은 도구마다 따로 설정해야 합니다.


ESM의 모듈 최상위 this

세 번째 경고는 소스에 this를 쓰지 않았는데도 뜹니다.

(!) "this" has been rewritten to "undefined"
ts/out-ES2015/main.js
1: var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
                    ^

두 시스템의 최상위 this

경고의 근거부터 확인하겠습니다. 같은 내용을 확장자만 바꿔 실행해 봤습니다.

// mod.mjs
console.log('ESM 최상위 this:', this);
// mod.cjs
console.log('CJS 최상위 this:', this, '/ this === module.exports:', this === module.exports);
ESM 최상위 this: undefined
CJS 최상위 this: {} / this === module.exports: true

CommonJS에서 모듈 최상위의 thismodule.exports입니다. 모듈이 함수로 감싸여 실행되므로 this가 그 함수를 호출한 객체를 가리킵니다. 반면 ES 모듈 명세는 모듈 최상위의 thisundefined로 정하고 있습니다. 앞에서 본 export 모델 차이의 연장선입니다. ESM에는 module.exports에 해당하는 값이 없으니 this가 가리킬 대상도 없습니다.

번들러는 소스를 ES 모듈로 읽습니다. 그래서 최상위에 this가 보이면 명세대로 undefined로 바꾸고, 바꿨다는 사실을 알립니다.

this를 쓴 것은 TypeScript 헬퍼

소스에 this가 없는데 경고가 뜨는 이유는 TypeScript가 넣은 코드 때문입니다. 아래처럼 평범한 async 함수를 두고,

// ts/src/main.ts
export async function loadTiles(url: string): Promise<string[]> {
  const res = await fetch(url);
  const body = await res.json();
 
  return body.tiles;
}

targetES2015로 잡아 컴파일하면 이런 결과가 나옵니다.

var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
    function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
    return new (P || (P = Promise))(function (resolve, reject) {
        function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
        function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
        function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
        step((generator = generator.apply(thisArg, _arguments || [])).next());
    });
};
export function loadTiles(url) {
    return __awaiter(this, void 0, void 0, function* () {
        const res = yield fetch(url);
        const body = yield res.json();
        return body.tiles;
    });
}

asyncawait는 ES2017에 들어간 문법입니다. 그보다 낮은 타깃으로 컴파일하면 TypeScript가 제너레이터로 흉내 내는 __awaiter 헬퍼를 만들어 넣습니다. 그 첫 줄에 (this && this.__awaiter)가 있습니다. 이미 정의된 헬퍼가 있으면 재사용하고 없으면 새로 만든다는 fallback 패턴이고, 여기 쓰인 this가 모듈 최상위의 this입니다.

정리하면 이렇습니다. TypeScript가 낮은 타깃을 맞추려고 넣은 헬퍼의 this를, Rollup이 ES 모듈 명세대로 undefined로 바꿉니다. 소스에 this가 한 번도 등장하지 않아도 경고가 뜨는 이유입니다.

이 경고가 동작을 깨뜨리는가

결과물을 열어보면 이렇게 바뀌어 있습니다.

var __awaiter = (undefined && undefined.__awaiter) || function (thisArg, _arguments, P, generator) {

이 번들을 실제로 불러다 실행하면 정상 동작합니다.

globalThis.fetch = async () => ({ json: async () => ({ tiles: ['a.png', 'b.png'] }) });
 
const lib = require('./dist/ts-ES2015.js');
lib.loadTiles('https://example.com/tiles').then((r) => console.log('결과:', r));
// 결과: [ 'a.png', 'b.png' ]

undefined && undefined.__awaiter는 앞이 falsy라 뒤를 평가하지 않고 undefined가 되고, 그러면 || 뒤의 함수 정의가 쓰입니다. "있으면 재사용, 없으면 새로 만들기"가 "항상 새로 만들기"로 바뀔 뿐입니다.

그래서 이 경고는 버그 신고가 아니라 소스의 의미가 달라졌다는 통보에 가깝습니다. 다만 헬퍼를 바깥에서 주입받도록 설계한 코드였다면 조용히 깨졌을 것이고, 경고는 그 가능성을 알려줍니다.

target을 올려 헬퍼를 없애기

경고를 끄는 옵션(context)도 있지만, 원인을 없애는 편이 낫습니다. targetES2017로 올리면 asyncawait가 네이티브로 지원되므로 TypeScript가 변환할 이유 자체가 사라집니다.

{
  "compilerOptions": {
    "target": "ES2017"
  }
}

같은 소스를 다시 컴파일한 결과입니다.

export async function loadTiles(url) {
    const res = await fetch(url);
    const body = await res.json();
    return body.tiles;
}

헬퍼가 통째로 사라졌고, 이 상태로 번들하면 경고도 나오지 않습니다. 결과물도 가벼워집니다.

타깃번들 크기경고
ES20151,346 바이트"this" has been rewritten to "undefined"
ES2017539 바이트없음

async 함수 하나짜리 예제라 감소 폭이 크게 나왔습니다. 헬퍼는 파일마다가 아니라 번들당 한 번 들어가므로 실제 프로젝트에서 이 비율만큼 줄지는 않지만, 쓰지 않을 변환 코드가 빠지는 것은 분명합니다.

타깃을 올려도 되는지는 지원 범위의 문제입니다. MDN 기준으로 async 함수는 2017년 4월부터 주요 브라우저에서 모두 쓸 수 있습니다. 최신 브라우저를 대상으로 하는 라이브러리라면 ES2017은 보수적인 선택에 속합니다.


정리

경고 세 개의 원인은 하나였습니다. ESM은 이름 붙은 바인딩 여럿을 내보내고, CommonJS는 값 하나를 내보냅니다. 번들러가 전자를 후자로 옮길 때 소스만으로 결정할 수 없는 지점이 생기고, 그 지점마다 경고가 붙습니다.

경고원인해결
Mixing named and default exports이름 여럿과 default를 값 하나에 담는 방법이 둘이라 모호함output.exports'named'로 명시
Unresolved dependencies번들에 넣지 않을 모듈인데 그 사실을 알려주지 않음external로 선언하고 globals로 전역 이름 지정
"this" has been rewritten to "undefined"ESM 최상위 thisundefined인데 TS 헬퍼가 this를 씀targetES2017로 올려 헬퍼를 없앰
  • output.exports는 결과물이 바뀌지 않더라도 명시하는 편이 낫습니다. 추론에 맡기면 엔트리의 export가 바뀔 때 소비 방식도 함께 바뀝니다.
  • UMD는 실행 환경을 확인해 자신을 등록하는 래퍼입니다. nameddefault의 선택도, globals 설정도 각 경우에 무엇을 넣을지 정하는 일입니다.
  • tsconfig.jsonpaths는 타입 검사기에게만 알려주는 지도입니다. 번들러는 이 설정을 읽지 않습니다. 외부 모듈로 둘 것이면 external을, 번들에 포함할 것이면 paths를 해석하는 플러그인을 따로 붙여야 합니다.
  • 모듈 최상위 this는 CommonJS에서 module.exports, ESM에서 undefined입니다. 이 차이 때문에 소스에 쓰지 않은 this에 대한 경고를 받을 수 있습니다.
  • 세 번째 경고는 확인해 보면 동작에는 문제가 없습니다. 그래도 원인을 없애면 결과물이 작아지고 다음에 같은 경고로 시간을 쓸 일도 줄어듭니다.

마치며

ESM과 CommonJS의 차이는 흔히 importrequire라는 문법 차이로 소개됩니다. 하지만 실제로 다른 것은 모듈이 무엇을 내보내는지에 대한 모델이고, 그 차이가 번들 결과물의 첫 네 줄부터 헬퍼 함수의 this까지 곳곳에 드러납니다.

package.json exports 글에서 조건부 진입점으로 importrequire에 다른 파일을 내주는 방법을 다뤘는데, 파일을 두 벌 만들어야 하는 이유도 여기에 있습니다. 두 시스템은 같은 파일을 다르게 읽는 것이 아니라, 애초에 다른 것을 기대합니다.

빌드 로그의 경고도 같은 관점으로 읽을 수 있습니다. 대부분은 무시해도 되는 잔소리도 아니고 당장 고쳐야 할 버그도 아닙니다. 도구가 대신 내린 결정을 알리는 기록입니다. 그 결정이 의도와 맞는지는 확인해야 알 수 있습니다. 긴 글 읽어주셔서 감사합니다.