오늘은 스낵게임 개발 중 node 버전을 변경하면서 에셋 최적화 파이프라인이 깨졌던 경험과 해결 과정을 공유하려고 합니다. 문제가 발생했던 환경은 다음과 같습니다.
- Node.js 20.19.0
- pnpm 10.10.0
- assetpack 0.8.0
개발을 이어가던 중 또 pnpm assets 스크립트에서 에러가 났습니다.
제가 ‘또’라고 하는 건 최근에 pnpm assets 스크립트가 안 돌아갔던 적이 있기 때문입니다.
패키지 버전도 패키지 매니저도 건드리지 않았는데 어떻게 이런 일이 생기나 싶어 에러 메시지를 확인했습니다.

이번에는 /static 폴더를 읽으려고 했는데 그런 폴더가 없다는 내용이었습니다.
당연히 없지. 안 만들었으니까.
왜 만들지도 않은 폴더를 읽으려고 했는지 알아보겠습니다.
assetpack을 사용하려면 반드시 프로젝트 루트에 .assetpack.js 파일을 만들어야 합니다. (공식 문서)
이 파일에 각종 설정을 지정할 수 있는데, 스낵게임에서 사용한 설정 파일은 이렇습니다.
export default {
entry: './raw-assets',
output: './public/assets/',
cache: false,
plugins: {
/** 각종 플러그인들. . . */
},
};
해석하자면 raw-assets 폴더에 있는 에셋을 최적화해서 public/assets 폴더에 저장한다는 의미입니다.

이전 글에서 assetpack의 이미지 에셋 최적화 과정을 이런 그림으로 표현한 적이 있는데요.
여기서 왼쪽의 원본들이 raw-assets 폴더, 스프라이트 이미지가 public/assets 폴더에 위치한다고 생각하시면 됩니다.
만약 설정 파일에서 entry/output을 따로 지정하지 않으면 assetpack의 기본 설정이 적용됩니다.
// @assetpack/core/dist/es/index.js
const defaultConfig = {
entry: './static',
output: './dist',
...
};
이 지점에서 에러 메시지의 /static 은 제가 설정한 entry가 아니라 default 값임을 알 수 있습니다. 만들지도 않은 폴더를 읽으려고 했던 건 설정한 config 값이 제대로 읽히지 않았다는 뜻이겠네요.
왜 config 파일이 읽히지 않나 고민해봤는데, 제가
패키지 버전도 패키지 매니저도 건드리지 않았는데!
라고 했지만 진짜 아무런 변화도 없는데 멀쩡한 Assetpack이 안 돌아갈리는 없습니다.
생각해보면 저는 종종 nvm(node version manager)를 사용해서 노드 버전을 변경할 때가 있습니다.
문제가 생겼던 환경은 node 20.19.0이고, node 20.9.0를 사용 중인 노트북에서는 정상적으로 동작하는 걸 보고 여기가 원인이라는 확신이 234098245% 들었습니다.
그래서
assetpack/cli 코드로 들어가서 .assetpack.js 파일이 어떻게 로드되는지 보겠습니다.
예전엔 node_modules 내부를 보려면 식은땀부터 났는데 이제는 조금 익숙해졌습니다.
아래는 config 파일 로드와 관련된 부분만 발췌한 코드입니다.
// @assetpack/cli/dist/index.js
async function main() {
const configPath = options.config ?
path.resolve(process.cwd(), options.config)
: await findUp('.assetpack.js', { cwd: process.cwd() });
if(!configPath) { /** 설정 파일 없을 때 에러 처리 */ }
let config;
let AssetPack;
// We try to load cjs first, if that fails we try to load esm
try {
config = require(configPath);
AssetPack = require('@assetpack/core').AssetPack;
} catch (error) {
if (error.code === 'ERR_REQUIRE_ESM') {
const esmLoader = dynamicImportLoader();
const urlForConfig = pathToFileURL(configPath);
config = (await esmLoader(urlForConfig)).default;
AssetPack = (await esmLoader('@assetpack/core')).AssetPack;
// ...
}
}
}
configPath 변수에 .assetpack.js 파일의 경로를 저장
먼저 cjs 방식으로 로드
ERR_REQUIRE_ESM 에러가 생기면 esm 방식으로 다시 로드
ERR_REQUIRE_ESM에러
ES 모듈을 require()로 불러오려고 시도할 때 생기는 에러입니다.
스낵게임은 package.json에 type: module을 명시했기 때문에, .assetpack.js 파일도 ES 모듈로 동작합니다. 따라서 아래처럼 파일 로드가 이뤄졌을 것으로 예상됩니다.
try {
config = require(configPath); // 💥 ERR_REQUIRE_ESM 발생
// ...
} catch (error) {
if (error.code === 'ERR_REQUIRE_ESM') {
const esmLoader = dynamicImportLoader();
const urlForConfig = pathToFileURL(configPath);
config = (await esmLoader(urlForConfig)).default; // ESM 방식으로 로드됨
AssetPack = (await esmLoader('@assetpack/core')).AssetPack;
...
}
}
이렇게 config 파일의 경로를 찾고, 순차적으로 로드하는 구조까지는 파악이 끝났습니다.
다음으로 Node.js의 변경사항이 이 과정에 어떤 영향을 미쳤는지 살펴볼 차례입니다.
릴리즈 노트를 보면, Node.js 20.19.0 버전에서는 require(esm) 기능이 기본 활성화되었습니다
이전까지는 별도 플래그 없이 CommonJS의 require()로 ES 모듈을 불러오면 ERR_REQUIRE_ESM 에러가 발생했지만, 20.19.0부터는 이게 가능해진 것입니다.
아주아주 간단한 코드를 만들어 변경된 동작을 알아보겠습니다.
Assetpack을 사용할 때처럼 esm으로 config 파일을 정의하고, 이를 require로 불러오는 코드입니다.
// esm-module.js
export default {
entry: './raw-assets',
output: 'public/assets'
}
// cjs-require-esm.cjs
const path = './esm-module.js';
const testModule = require(path);
console.log(testModule)
| Node 20.18.2 | Node 20.19.0 |
|---|---|
![]() | ![]() |
ERR_REQUIRE_ESM 에러 발생 | 정상적으로 모듈 불러옴 |
여기서 하나 주의할 점은, require(esm) 기능이 활성화되면서 파일 로드는 성공했지만 바로 값을 사용할 수는 없다는 점입니다. testModule.default로 접근해야 실제로 export했던 객체를 쓸 수 있습니다.
이쯤에서 다시 assetpack 코드를 보면
try {
config = require(configPath); // ✅ ERR_REQUIRE_ESM 발생 X
AssetPack = require('@assetpack/core').AssetPack;
} catch (error) {
if (error.code === 'ERR_REQUIRE_ESM') {
// ...
}
}
Node 20.19.0 이상 환경에서는config = require(configPath); 로 모듈을 읽고 바로 사용했기 때문에, config 변수에 실제 설정값이 아닌 네임스페이스 객체가 들어갔을 것입니다.
설정된 config가 무시되는 현상이 생기는 원인까지 파악했고, 제가 assetpack 코드를 로컬에서 알아서 수정해서 쓸 순 없으므로.. 이 문제가 해결된 버전을 찾아봤습니다.
Assetpack 1.2.3 버전부터 config 파일을 읽어오는 방식을 다음과 같이 변경했습니다.
// before
config = require(configPath) as AssetPackConfig;
// after
const configModule = require(configPath);
config = (configModule.__esModule ? configModule.default : configModule);
require()로 파일을 읽었을 때, __esModule 필드를 확인해 ES 모듈이면 .default로 접근하도록 수정된 것을 알 수 있습니다.
여기까지 봤을 때 선택할 수 있는 해결 방안은 두 가지입니다.
Assetpack 0.8.0을 사용하려면 Node 20.19.0 미만으로 제한하기
Node 20.19.0 이상 환경에서 동작을 위해 Assetpack 버전 업데이트하기
우선 두 방법을 각각 시도해봤을 때 모두 문제가 해결되지만, 저는 두 번째 방법을 택하려고 합니다.
.assetpack.js에서 불러와야 했지만,겸사겸사 package.json을 심플하게 유지할 수 있다는 장점이 매력적이었습니다.
개발 환경의 변화가 예상치 못한 곳에서 문제를 일으키는 걸 보고,
로컬 환경 관리에 주의를 기울여야겠다는 생각을 또 한번 했습니다.
프로젝트 관리할 때 패키지 매니저나 노드 버전을 따로 적어본 적이 몇 번 없는데,
이렇게 환경 문제로 헤매고 나니 package.json에 노드 버전의 범위를 명시하는 걸 습관화해야겠단 생각이 듭니다. . .
package.json에 노드 버전 명시하기
package.json에 engine 필드를 추가하면 그 외 환경에서 실행할 때 경고 문구를 볼 수 있습니다.
저처럼 노드 버전을 바꾸는 경우 다시 프로젝트에 맞는 버전으로 변경하는 걸 까먹지 않을 수 있습니다 (っ ᵔ◡ᵔ)っ