
[Trouble Shooting] Pages Router에서만 터진 "Module not found" — CJS/ESM exports의 함정
우리 도메인에 Pages Router로 작성한 코드를 빌드하는데 다음과 같은 에러가 발생했다.
> Module not found: Package path ./thumbnail is not exported from package ... design-system-kit
같은 위젯인데 App Router 프로젝트에서는 멀쩡하게 빌드가 잘 되었는데, Pages Router에서만 빌드시 에러가 발생했다.
📍 원인 분석 1 — 패키지가 어떻게 빌드되어 있었나
search-header-widget@1.1.0 패키지는 CJS/ESM 듀얼 포맷으로 빌드가 되도록 설정이 되어있다. 그런데 일부 패키지들에 한해 다음과 같이 external 설정이 되어있었다.
// package.json
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.es.js" // ESM
"require": "./dist/index.cjs.js" // CJS
},
"./server": {
"types": "./dist/server.d.ts",
"import": "./dist/server.es.js",
"require": "./dist/server.cjs.js"
}
}
// vite.config.js
export default defineConfig({
build: {
rollupOptions: {
external: (id: string) =>
[ '@design-system-kit'].some((p) => id === p || id.startsWith(`${p}/`)),
},
},
});
external이란 패키지 번들에 external 패키지 코드를 포함시키지않고 사용처에서 해당 패키지를 호출하란 뜻인데 예를들어 design-system-kit과 그 서브패스(design-system-kit/thumbnail 등)를 위젯 번들에 포함시키지 않고, 소비자 쪽 require/import 방식에 그대로 위임하는 방식이다.
search-header-widget은 CJS/ESM을 모두 산출물로 갖고 있으니 문제가 없었으나. 문제는 external로 빼놓은 design-system-kit@0.3.0 쪽의 exports 맵이었다.
"exports": {
"./thumbnail": {
"import": "./dist/thumbnail.js", // ESM
"types": "./dist/thumbnail.d.ts"
},
"./dialog": {
"import": "./dist/thumbnail.js", // ESM
"types": "./dist/thumbnail.d.ts"
},
"./utils": {
"import": "./dist/thumbnail.js", // ESM
"types": "./dist/thumbnail.d.ts"
},
...
}
문제가 되었던 ./thumbnail, ./dialog, ./utils 서브패스에 import 조건만 있고 require 조건이 없었다.
📍 원인 분석 2 — App Router는 왜 안 터졌나
Next.js 내부(packages/next/src/build/webpack-config-rules/resolve.ts)를 열어보면 서버 컴파일러의 module resolution 우선순위를 아래와 같이 정의해두고 있다. (우리 프로젝트가 쓰는 Next.js v15.5.18 기준 원본 소스)
// https://github.com/vercel/next.js/blob/v15.5.18/packages/next/src/build/webpack-config-rules/resolve.ts#L13-L34
const mainFieldsPerCompiler = {
// For default case, prefer CJS over ESM on server side. e.g. pages dir SSR
[COMPILER_NAMES.server]: ['main', 'module'],
[COMPILER_NAMES.client]: ['browser', 'module', 'main'],
// For bundling-all strategy, prefer ESM over CJS
'server-esm': ['module', 'main'],
}
export function getMainField(compilerType: CompilerNameValues, preferEsm: boolean) {
if (compilerType === COMPILER_NAMES.edgeServer) {
return edgeConditionNames
} else if (compilerType === COMPILER_NAMES.client) {
return mainFieldsPerCompiler[COMPILER_NAMES.client]
}
// Prefer module fields over main fields for isomorphic packages on server layer
return preferEsm
? mainFieldsPerCompiler['server-esm']
: mainFieldsPerCompiler[COMPILER_NAMES.server]
}
주석에 그대로 적혀있듯, 서버 컴파일러의 기본값은 pages 디렉토리의 SSR을 대상으로 CJS(main)를 ESM보다 우선하도록 명시적으로 설계되어 있다. Pages Router는 이 기본 설정(getMainField(compilerType, false))을 그대로 쓰기 때문에 서버 빌드에서 main 필드, 즉 CJS 산출물이 우선 resolve된다.
반면 App Router는 웹팩 레이어(layer)별로 별도 모듈 규칙을 갖는다. packages/next/src/build/webpack-config.ts에서 hasAppDir일 때만 추가되는 module rule을 보면, appBrowserLayerLoaders와 appSSRLayerLoaders가 적용되는 규칙에 각각 resolve.mainFields: getMainField(compilerType, true)가 명시되어 있다.
// https://github.com/vercel/next.js/blob/v15.5.18/packages/next/src/build/webpack-config.ts#L1720-L1752
...(hasAppDir
? [
{
test: codeCondition.test,
issuerLayer: shouldUseReactServerCondition,
exclude: asyncStoragesRegex,
use: appServerLayerLoaders,
},
{
test: codeCondition.test,
resourceQuery: new RegExp(
WEBPACK_RESOURCE_QUERIES.edgeSSREntry
),
use: appServerLayerLoaders,
},
{
test: codeCondition.test,
issuerLayer: WEBPACK_LAYERS.appPagesBrowser,
// Exclude the transpilation of the app layer due to compilation issues
exclude: browserNonTranspileModules,
use: appBrowserLayerLoaders,
resolve: {
mainFields: getMainField(compilerType, true),
},
},
{
test: codeCondition.test,
issuerLayer: WEBPACK_LAYERS.serverSideRendering,
exclude: asyncStoragesRegex,
use: appSSRLayerLoaders,
resolve: {
mainFields: getMainField(compilerType, true),
},
},
]
: []),
여기서 눈여겨볼 건 preferEsm 값이 사용자나 next.config.js 설정에서 오는 게 아니라, Next.js 소스 코드에 true라는 리터럴 값으로 그냥 박혀있다는 점이다. 우리가 뭘 설정해서 켜는 옵션이 아니라 Next.js 팀이 App Router 레이어용으로 미리 정해둔 값이다.
그리고 이 규칙 블록 자체는 hasAppDir(=!!appDir)가 true일 때만 module rules에 추가된다. appDir는 프로젝트에 app/ 디렉토리가 있는지를 나타내므로, App Router를 쓰는 프로젝트라면(=app/ 디렉토리가 존재하기만 하면) 별도로 켜고 끌 수 있는 옵션 없이 이 규칙과 preferEsm: true가 자동으로 적용된다.
반대로 Pages Router만 있는 프로젝트는 hasAppDir가 false라 이 블록 자체가 생성되지 않고, 앞서 본 기본 규칙(getMainField(compilerType, false), mainFieldsPerCompiler[server] = ['main', 'module'])만 적용되어 CJS가 우선한다.
즉 "Pages Router가 Node라서 무조건 CJS를 강제한다"기보다는, Next.js가 pages 디렉토리의 서버사이드 렌더링을 기본 케이스로 두고 CJS 우선 정책을 깔아뒀고, App Router는 레이어 단위로 이 기본값을 ESM 우선으로 오버라이드해뒀다는 것이 정확한 원인이다. 그 결과 같은 위젯을 두고도 다음처럼 갈렸다.
- App Router →
mainFields: ['module', 'main']→ ESM 산출물(index.es.js) 선택 →import조건과 매칭 → 정상 동작 - Pages Router →
mainFields: ['main', 'module']→ CJS 산출물(index.cjs.js) 선택 → 내부의require("@yanolja-org/design-system-kit/thumbnail")→ exports 맵에 require 조건이 없음 → 매칭 실패 → 빌드 에러
같은 코드인데 어느 라우터로 빌드하느냐에 따라 애초에 다른 산출물이 선택되고 있었던 것이다. App Router에서 재현이 안 됐던 이유이자, Pages Router에서만 터진 이유다. 해결방안은 여러가지가 있었다.
🔍 대안 분석 — external을 제거하는 것 외에 고려한 방법들
1. design-system-kit의 exports 맵에 require 조건 직접 추가
가장 먼저 떠올린 방법이다. 문제가 exports 맵에 require 조건이 없는 것이었으니, 조건만 채워주면 되지 않을까 생각했다.
// 시도해본 방향
"exports": {
"./thumbnail": {
"import": "./dist/thumbnail.js",
"require": "./dist/thumbnail.cjs", // 추가
"types": "./dist/thumbnail.d.ts"
}
}
그런데 design-system-kit의 dist를 열어보니 CJS 산출물 자체가 존재하지 않았다. vite 빌드가 ESM 단일 포맷으로만 구성되어 있었고, package.json에도 "type": "module"이 없어서 .js 확장자에 ESM 문법이 그대로 담겨있는 상태였다.
즉 exports 맵만 고쳐서 끝날 문제가 아니라, vite 빌드에 CJS 포맷 출력을 새로 추가하는 작업까지 같이 가야 했다. 가장 근본적인 방법이긴 한데, design-system-kit 저장소의 빌드 파이프라인 자체를 건드려야 해서 영향 범위가 컸다.
2. Next.js transpilePackages에 등록
Next.js에는 특정 패키지를 external 처리하지 않고 직접 트랜스파일하도록 강제하는 transpilePackages 옵션이 있다. "이 패키지는 Next가 알아서 처리해줘"라는 기대로 등록해봤다.
// next.config.js
const nextConfig = {
transpilePackages: ['@yanolja-org/design-system-kit'],
}
하지만 효과가 없었다. Next.js 내부(packages/next/src/build/handle-externals.ts)의 makeExternalHandler 함수를 열어보면 순서가 이렇게 짜여있다. (동일하게 v15.5.18 원본 소스 기준)
// https://github.com/vercel/next.js/blob/v15.5.18/packages/next/src/build/handle-externals.ts#L265-L289
const resolveResult = await resolveExternal(
dir,
config.experimental.esmExternals,
context,
request,
isEsmRequested,
getResolve,
isLocal ? resolveNextExternal : undefined,
)
// ...
const { res, isEsm } = resolveResult
// If the request cannot be resolved we need to have
// webpack "bundle" it so it surfaces the not found error.
if (!res) {
return
}
// https://github.com/vercel/next.js/blob/v15.5.18/packages/next/src/build/handle-externals.ts#L328-L338
// If a package should be transpiled by Next.js, we skip making it external.
// It doesn't matter what the extension is, as we'll transpile it anyway.
if (transpiledPackages && !resolvedExternalPackageDirs) {
resolvedExternalPackageDirs = new Map()
// We need to resolve all the external package dirs initially.
for (const pkg of transpiledPackages) {
const pkgRes = await resolveExternal(/* ... */)
if (pkgRes.res) {
resolvedExternalPackageDirs.set(pkg, path.dirname(pkgRes.res))
}
}
}
transpiledPackages(=transpilePackages 설정값) 체크는 함수 뒷부분, 즉 resolveExternal으로 exports 조건 매칭이 이미 끝난 뒤에야 등장한다. design-system-kit/thumbnail처럼 exports 맵에 require 조건이 없는 서브패스는 앞단의 resolveExternal 호출에서 이미 resolve에 실패하고 webpack이 그 자리에서 "Module not found"를 던져버리기 때문에, transpilePackages가 관여할 차례 자체가 오지 않는다.
transpilePackages는 "resolve에 성공한 모듈을 번들링에서 제외하지 않는다"는 뒷단의 결정에만 관여하는 옵션이라, exports 맵 자체가 안 맞아서 resolve가 실패하는 이 문제는 막아주지 못했다.
3. dynamic(..., { ssr: false })로 완전히 클라이언트 전용 처리
원인을 조사하는 동안 임시로 적용해뒀던 방법이다. 서버 번들의 모듈 그래프에서 위젯을 아예 제외시키면, Pages Router 서버 컴파일 시점의 CJS 기준 resolve 자체가 일어나지 않아 에러가 발생하지 않는다.
// 우회책으로 적용했던 코드
const SearchWidget = dynamic(
async () => {
const { SearchWidget } = await import('@search-header-widget')
return SearchWidget
},
{ ssr: false },
)
위젯이나 design-system-kit 어느 쪽도 건드리지 않고 소비 코드만으로 막을 수 있다는 게 장점이다. 하지만 런타임에만 에러가 발생하지않는 임시방편이고 다른 페이지에서 이 위젯을 SSR과 함께 쓰거나 CI/CD 빌드과정을 거치게되면 동일하게 에러가 발생한다.
세 가지를 두고 보니 1번(exports 맵 정비)이 가장 근본적이지만 외부 저장소의 빌드 설정까지 건드려야 해서 비용이 컸고, 2번은 기술적으로 통하지 않았고, 3번은 원인을 남긴 채 증상만 가리는 방법이었다. 결국 external 자체를 없애서 resolve가 필요 없게 만드는 방법이 가장 확실했다.
💡 해결 — 1.2.0에서 뭘 바꿨나
해결은 의외로 단순했다. rollupOptions.external에 있던 @design-system-kit을 제거했다.
// vite.config.js
// AS-IS
export default defineConfig({
build: {
rollupOptions: {
external: (id: string) =>
[ '@design-system-kit'].some((p) => id === p || id.startsWith(`${p}/`)),
},
},
});
// TO-BE
export default defineConfig({
build: {
rollupOptions: {
external: (id: string) =>
[].some((p) => id === p || id.startsWith(`${p}/`)),
},
},
});
이제 빌드 시 design-system-kit 소스가 위젯 산출물에 직접 인라인(번들링)된다. 소비자 쪽 webpack이 design-system-kit의 서브패스 exports를 다시 resolve할 필요가 없어져서 Pages Router의 CJS 우선 resolve 경로에서도 정상 빌드된다.
회고
첫째, dual 포맷(CJS/ESM)으로 배포하는 패키지는 external로 뺀 의존성의 exports 맵까지 같이 책임져야 한다. import 조건만 있고 require 조건이 없는 의존성을 external로 분리하면, 소비자가 CJS로 resolve하는 순간 터진다.
둘째, 같은 코드도 App Router와 Pages Router는 module resolution 전략이 다르다. 한쪽에서 재현이 안 된다고 문제가 없는 게 아니다. 어느 컴파일러가 어떤 필드를 우선하는지 먼저 확인했어야 했다.
셋째, 문제를 우회하는 방법은 늘 여러 개가 있다. 하지만 그중 "원인을 없애는 방법"과 "증상만 가리는 방법"은 구분해서 판단해야 한다. transpilePackages나 ssr: false 같은 선택지들은 당장의 에러는 지워주지만, 근본 원인인 exports 맵 불일치는 그대로 남아있어 언젠가 다른 형태로 다시 터질 수 있다.
'Frontend > TroubleShooting' 카테고리의 다른 글
| [Trouble Shooting] useEffect도 useMemo도 답이 아니었다 — 한 박자 늦는 state (1) | 2026.06.24 |
|---|---|
| [Trouble Shooting] bingbot으로 인한 잘못된 API 요청 차단하기 (1) | 2025.12.16 |
| [Trouble Shooting] React Query의 QueryClient를 공통모듈에서 싱글톤으로 관리하기 (0) | 2025.12.03 |
| [TroubleShooting] 공백때문에 예약이 실패한다고? - 정규표현식 검증 불일치 이슈 해결기 (0) | 2025.10.20 |
| [TroubleShooting] 예약 실패 → PDP 새로고침, 브라우저 간 메시지 통신으로 해결하기 (0) | 2025.09.28 |
댓글