"어제까지 잘 되던 빌드가 왜 터지죠?"
평화로운 오후였습니다. 잘 쓰던 오픈소스 유틸리티 라이브러리의 버전을 1.2.0에서 1.3.0으로 올렸을 뿐인데, 갑자기 Next.js 빌드가 빨간색 에러를 뿜어내며 멈췄습니다.
SyntaxError: Named export 'foo' not found. The requested module 'awesome-lib' is a CommonJS module, which may not support all module.exports as named exports.
CommonJS modules can always be imported via the default export, for example using:
import pkg from 'awesome-lib';
const { foo } = pkg;
"아니, import { foo } from 'awesome-lib' 이렇게 쓰는 게 표준 아니었어?"
공식 문서를 봐도 분명히 Named Export를 지원한다고 되어 있는데, 내 프로젝트에서만 에러가 납니다.
로컬 개발 서버(npm run dev)에서는 잘 돌아가는데, 프로덕션 빌드(npm run build)에서만 터지는 환장할 상황.
이 에러 로그 뒤에는 자바스크립트 생태계의 가장 거대하고 지루한 전쟁, CommonJS(CJS)와 ES Modules(ESM)의 전쟁이 숨어 있습니다.
전쟁의 서막 - 두 개의 세계
자바스크립트는 태초에 모듈 시스템이 없었습니다. 그러다 Node.js가 등장하면서 서버 사이드 자바스크립트를 위해 CommonJS(CJS)라는 표준을 만들었습니다. 우리가 아는 require()와 module.exports입니다.
// CJS (Node.js의 언어)
const React = require('react');
module.exports = function app() { ... }
반면, 브라우저와 모던 웹 진영은 "우리도 공식 표준이 필요해"라며 ES6(2015년)에서 ES Modules(ESM)을 발표했습니다. import와 export입니다.
// ESM (브라우저/모던 웹의 언어)
import React from 'react';
export default function app() { ... }
문제는 이 둘이 서로 호환되지 않는다는 점입니다.
- CJS는 동기(Synchronous) 로딩입니다. 파일을 다 읽을 때까지 멈춥니다.
- ESM은 비동기(Asynchronous) 로딩입니다. 정적 분석(Static Analysis)이 가능해서 트리 쉐이킹(Tree Shaking)에 유리합니다.
Next.js는 기본적으로 웹 프레임워크니까 ESM을 지향합니다. 하지만 Node.js 환경(서버) 위에서 돌아가기도 하고, 수만 개의 npm 패키지들이 여전히 CJS로 작성되어 있습니다. Next.js는 이 둘을 아슬아슬하게 섞어서 쓰고 있는 "하이브리드" 괴물입니다.
에러의 원인: "Dual Package Hazard"
가장 흔한 문제는 라이브러리 제작자가 "친절하게" 두 가지 버전을 모두 제공하려고 할 때 발생합니다. 이를 Dual Package라고 합니다.
예를 들어 awesome-lib의 package.json을 봅시다.
{
"name": "awesome-lib",
"main": "./dist/index.js", // CJS 버전
"module": "./dist/index.mjs" // ESM 버전
}
이론적으로는 Next.js가 알아서 똑똑하게 "나는 모던하니까 module 필드에 있는 ESM 버전을 써야지!"라고 판단해야 합니다.
하지만 Webpack 설정, Next.js 버전, 그리고 type: module 설정에 따라 이 선택 로직이 꼬이는 경우가 발생합니다.
특히 Next.js가 Server Components를 도입하면서 상황이 더 복잡해졌습니다.
- Server Components는 Node.js 런타임에서 돕니다. -> CJS를 선호할 수 있음.
- Client Components는 브라우저 리소스입니다. -> ESM을 선호함.
만약 라이브러리의 ESM 버전(index.mjs)에는 export const foo = ...가 있는데, CJS 버전(index.js)에는 module.exports = { foo: ... }가 미묘하게 다르게 구현되어 있다면?
빌드 도구가 CJS 파일을 가져와 놓고는 ESM 문법(import { foo })으로 해석하려 할 때, 저 위의 Named export not found 에러가 터지는 것입니다.
해결책 1 - 라이브러리 소비자의 대처법 (Next.js 설정)
우리가 라이브러리 코드를 고칠 순 없으니, Next.js 설정을 고쳐야 합니다.
1. transpilePackages 옵션 (가장 확실함)
Next.js 13.1부터 도입된 이 옵션은, 지정한 패키지를 Next.js의 빌드 파이프라인(Babel/SWC)으로 강제로 가져와서 다시 컴파일하게 만듭니다. 즉, "이 패키지(CJS)를 내 프로젝트 소스코드인 척하고 다시 빌드해!"라고 명령하는 겁니다. 그러면 Next.js가 알아서 ESM으로 변환하거나 호환성을 맞춰줍니다.
/** @type {import('next').NextConfig} */
const nextConfig = {
transpilePackages: ['awesome-lib', 'old-legacy-ui'],
};
module.exports = nextConfig;
대부분의 CJS 호환성 문제는 이 한 줄로 해결됩니다.
2. Default Import 후 구조 분해 할당
에러 로그가 친절하게 알려준 방법입니다. Named Import가 안 먹히면, 통째로 가져온(Default Import) 뒤에 꺼내 쓰면 됩니다.
// ❌ 에러 발생
import { foo } from 'awesome-lib';
// ✅ 해결
import pkg from 'awesome-lib';
const { foo } = pkg;
모양새는 좀 빠지지만, 당장 빌드를 성공시켜야 한다면 가장 빠른 방법입니다. 다만, 이렇게 하면 트리 쉐이킹(Tree Shaking)이 안 될 수 있습니다. 즉, foo 하나만 쓰는데 라이브러리 전체 코드가 번들에 포함될 수 있습니다.
3. Dynamic Import 사용 (ssd)
만약 이 라이브러리가 브라우저에서만 쓰이는 거라면, 아예 서버 사이드 렌더링(SSR)에서 제외해버리는 것도 방법입니다.
import dynamic from 'next/dynamic';
const AwesomeComponent = dynamic(
() => import('awesome-lib').then(mod => mod.AwesomeComponent),
{ ssr: false }
);
이렇게 하면 서버 빌드 타임에 Node.js가 이 파일을 해석하려 들지 않으므로, CJS 충돌을 회피할 수 있습니다.
해결책 2 - 라이브러리 제작자의 대처법 (exports 필드)
만약 여러분이 사내 라이브러리를 만드는 입장이라면? 제발 main과 module 필드에 의존하지 마세요. 그건 구시대의 유물입니다.
Node.js 12.16부터 표준화된 Conditional Exports (exports) 필드를 써야 합니다.
{
"name": "my-library",
"exports": {
".": {
"import": "./dist/index.mjs", // ESM 환경(import)에서 쓸 파일
"require": "./dist/index.js", // CJS 환경(require)에서 쓸 파일
"default": "./dist/index.js" // 그 외(Fallback)
}
}
}
이 exports 필드는 "정확히 어떤 환경에서 어떤 파일을 가져가야 하는지" 명시적으로 선언하는 엄격한 규칙입니다.
Next.js와 같은 최신 도구들은 main 필드보다 exports 필드를 최우선으로 봅니다. 이 필드만 잘 설정되어 있어도 "Dual Package Hazard"의 99%는 예방할 수 있습니다.
.mjs와 .cjs 확장자의 비밀 깊이 들여다보기
파일 확장자만 봐도 이 파일의 정체성을 알 수 있습니다.
.mjs: "나는 무조건 ESM이야.import/export쓸 거야. Node.js야 토달지 마.".cjs: "나는 무조건 CJS야.require쓸 거야.".js: "나는package.json의type필드에 따라 달라져." (기본값은 CJS)
최근 많은 라이브러리들이 .js 대신 .mjs, .cjs를 명시적으로 사용하는 추세입니다. 빌드 도구의 추론(Guesswork)을 없애고 확실하게 모듈 타입을 지정하기 위해서죠.
만약 여러분의 프로젝트가 package.json에 "type": "module"을 선언했다면, 프로젝트 내의 모든 .js 파일은 ESM으로 취급됩니다. 이때 갑자기 require()를 쓰는 레거시 설정 파일(next.config.js 등)이 있다면 에러가 납니다. 그럴 땐 설정 파일 확장자를 next.config.cjs로 바꿔주면 됩니다.
ESM이 미래다 (하지만 CJS는 좀비다)
자바스크립트 생태계는 명확하게 ESM(ES Modules)으로 이동하고 있습니다.
Deno나 Bun 같은 새로운 런타임들은 애초에 CJS를 레거시 취급합니다.
React 생태계도 ESM-only 패키지(예: node-fetch v3, d3 등)가 늘어나고 있습니다.
하지만 10년 넘게 쌓인 npm 생태계의 유산 때문에, CommonJS는 아마 앞으로도 10년은 더 살아남아 우리를 괴롭힐 겁니다. 좀비처럼요.
우리가 할 수 있는 최선은:
- 새로 코드 짤 때는 무조건 ESM 문법(
import/export)을 쓴다. - 외부 라이브러리 고를 때 ESM 지원 여부를 확인한다.
- 에러가 터지면 당황하지 말고
transpilePackages를 켠다. - 라이브러리 만들 때는
exports필드를 정성스럽게 작성한다.
이 전쟁에서 승리하는 방법은, 적(CJS)을 알고 나(ESM)를 아는 것입니다.