
CJS 라이브러리 호환 문제 해결하기
"Named export not found" 에러의 원인인 CommonJS와 ES Modules의 충돌을 파헤칩니다. package.json의 exports 필드부터 transpilePackages 설정까지 완벽 가이드.

"Named export not found" 에러의 원인인 CommonJS와 ES Modules의 충돌을 파헤칩니다. package.json의 exports 필드부터 transpilePackages 설정까지 완벽 가이드.
버튼을 눌렀는데 부모 DIV까지 클릭되는 현상. 이벤트는 물방울처럼 위로 올라갑니다(Bubbling). 반대로 내려오는 캡처링(Capturing)도 있죠.

전처리(Preprocessing), 컴파일(Process), 어셈블리(Assembly), 링킹(Linking)의 4단계를 해부한다. 정적 라이브러리와 동적 라이브러리의 차이까지.

데이터를 수정했는데 페이지에 계속 예전 값이 나오는 유령 같은 현상. Next.js 13+의 강력한(그리고 사악한) 캐싱 메커니즘을 4계층으로 분석하고, React Query와의 차이점, 그리고 실제 디버깅 전략을 공유합니다.

함수가 선언될 때의 렉시컬 환경(Lexical Environment)을 기억하는 현상. React Hooks의 원리이자 정보 은닉의 핵심 키.

평화로운 오후였습니다. 잘 쓰던 오픈소스 유틸리티 라이브러리의 버전을 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() { ... }
문제는 이 둘이 서로 호환되지 않는다는 점입니다.
Next.js는 기본적으로 웹 프레임워크니까 ESM을 지향합니다. 하지만 Node.js 환경(서버) 위에서 돌아가기도 하고, 수만 개의 npm 패키지들이 여전히 CJS로 작성되어 있습니다. Next.js는 이 둘을 아슬아슬하게 섞어서 쓰고 있는 "하이브리드" 괴물입니다.
가장 흔한 문제는 라이브러리 제작자가 "친절하게" 두 가지 버전을 모두 제공하려고 할 때 발생합니다. 이를 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를 도입하면서 상황이 더 복잡해졌습니다.
만약 라이브러리의 ESM 버전(index.mjs)에는 export const foo = ...가 있는데, CJS 버전(index.js)에는 module.exports = { foo: ... }가 미묘하게 다르게 구현되어 있다면?
빌드 도구가 CJS 파일을 가져와 놓고는 ESM 문법(import { foo })으로 해석하려 할 때, 저 위의 Named export not found 에러가 터지는 것입니다.
우리가 라이브러리 코드를 고칠 순 없으니, Next.js 설정을 고쳐야 합니다.
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 호환성 문제는 이 한 줄로 해결됩니다.
에러 로그가 친절하게 알려준 방법입니다. Named Import가 안 먹히면, 통째로 가져온(Default Import) 뒤에 꺼내 쓰면 됩니다.
// ❌ 에러 발생
import { foo } from 'awesome-lib';
// ✅ 해결
import pkg from 'awesome-lib';
const { foo } = pkg;
모양새는 좀 빠지지만, 당장 빌드를 성공시켜야 한다면 가장 빠른 방법입니다. 다만, 이렇게 하면 트리 쉐이킹(Tree Shaking)이 안 될 수 있습니다. 즉, foo 하나만 쓰는데 라이브러리 전체 코드가 번들에 포함될 수 있습니다.
ssd)만약 이 라이브러리가 브라우저에서만 쓰이는 거라면, 아예 서버 사이드 렌더링(SSR)에서 제외해버리는 것도 방법입니다.
import dynamic from 'next/dynamic';
const AwesomeComponent = dynamic(
() => import('awesome-lib').then(mod => mod.AwesomeComponent),
{ ssr: false }
);
이렇게 하면 서버 빌드 타임에 Node.js가 이 파일을 해석하려 들지 않으므로, CJS 충돌을 회피할 수 있습니다.
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(ES Modules)으로 이동하고 있습니다.
Deno나 Bun 같은 새로운 런타임들은 애초에 CJS를 레거시 취급합니다.
React 생태계도 ESM-only 패키지(예: node-fetch v3, d3 등)가 늘어나고 있습니다.
하지만 10년 넘게 쌓인 npm 생태계의 유산 때문에, CommonJS는 아마 앞으로도 10년은 더 살아남아 우리를 괴롭힐 겁니다. 좀비처럼요.
우리가 할 수 있는 최선은:
import/export)을 쓴다.transpilePackages를 켠다.exports 필드를 정성스럽게 작성한다.이 전쟁에서 승리하는 방법은, 적(CJS)을 알고 나(ESM)를 아는 것입니다.