
"이게 왜 안 되지?" 라는 말을 하루에도 수십 번 중얼거리며 터미널 에러 메시지를 노려보고 있다면, 당신은 아마도 React Native와 최신 자바스크립트 생태계 사이의 간극과 싸우고 있을 가능성이 높다. (그게 나다..) 특히 그 중심에는 CommonJS라는, 마치 고대 유물처럼 React Native가 고수하고 있는 모듈 시스템이 있다.
(👨🏻🏫 : 처음에는 CommonJS 는 JS 의 대명사 같은 느낌인 줄 알았고, ESM은 조금 최신 JS 겠구나~ 싶었습니다. 하하, 얼마나 순진했던가요...
안다고 크게 나아질까 싶으면서도... 그래도 왜 맞는지는 알고 맞는 게 좋지 않을까 싶어서 잘 정리해봤습니다.)
오늘은 React Native가 여전히 CommonJS에 의존하면서 발생하는 다양한 문제점과 그 해결 방법에 대해 깊이 있게 살펴보려 한다. 이 글을 통해 당신이 겪고 있는 많은 "이상한" 오류들의 근본 원인을 이해하고, 더 효율적인 해결책을 찾는 데 도움이 되길 바란다.
React Native는 여전히 CommonJS 모듈 시스템을 사용하며, 이는 최신 ESM 기반 라이브러리와 호환성 문제를 일으킨다.Metro 번들러의 제한된 기능은 최신 자바스크립트 생태계와의 통합을 어렵게 만든다.babel-plugin-transform-modules, metro-react-native-babel-preset 커스터마이징, 또는 ESM 지원 라이브러리 사용이 있다.React Native의 아키텍처 변화와 함께 Hermes 엔진의 발전으로 점진적인 개선이 이루어지고 있다. (블로그에 추가적으로 정리하겠습니다)CommonJS 호환성을 확인하고, 필요한 경우 변환 도구를 활용해야 한다.자바스크립트 모듈 시스템의 역사는 복잡하다. 브라우저에서 모듈 시스템이 없던 시절, Node.js는 CommonJS라는 모듈 시스템을 도입했다. 이 시스템은 require()와 module.exports를 사용하여 모듈을 가져오고 내보내는 방식이다.
*// CommonJS 방식*
const React = require('react');
module.exports = MyComponent;
반면, 최신 자바스크립트는 ECMAScript Modules(ESM)이라는 표준 모듈 시스템을 채택했으며, 이는 import와 export 구문을 사용한다.
*// ESM 방식*
import React from 'react';
export default MyComponent;
CommonJS와 ESM은 각각 다른 환경과 요구사항에 맞게 사용된다.
CommonJS는 주로 다음과 같은 상황에서 사용된다:
(👨🏻🏫 :
React를 사용하더라도,React Native를 사용하더라도,eslint.config.js등과 같은 설정 파일은CommonJS로 이루어져있는 경우가 많아요. 왜 그럴까요? 이 또한 재미있는데 추후에 블로그로 작성해볼게요)
ESM은 다음과 같은 상황에서 주로 사용된다:
CommonJS와 ESM은 완전히 다른 모듈 시스템이다. 단순히 버전이 업그레이드된 관계가 아니라, 설계 철학과 작동 방식이 근본적으로 다르다.
CommonJS는 동기적(synchronous)으로 모듈을 로드하는 반면, ESM은 비동기적(asynchronous)으로 로드한다. 이는 실행 환경과 성능에 큰 영향을 미친다.CommonJS는 require()와 module.exports를 사용하고, ESM은 import와 export 구문을 사용한다.ESM은 정적 구조를 가지고 있어 빌드 타임에 모듈 간의 의존 관계를 파악할 수 있다. 반면 CommonJS는 런타임에서만 모듈 관계를 파악할 수 있다.*// CommonJS - 동적 로딩 가능*
const moduleName = 'math';
const math = require(`./utils/${moduleName}`);
*// ESM - 정적 경로만 가능*
import math from './utils/math.js'; *// 동적 경로 불가능*
(👨🏻🏫 : 두 시스템은 마치 자동차와 비행기의 차이와 같답니다. 둘 다 이동 수단이지만, 작동 원리와 사용 환경이 완전히 다르죠!)
두 시스템은 완전히 다르지만, Node.js에서는 두 시스템 간의 상호 운용성을 위한 방법을 제공한다:
import 구문으로 가능하다.import() 함수를 통해 비동기적으로 가능하다.*// ESM에서 CommonJS 모듈 사용*
import add from './add.cjs';
*// CommonJS에서 ESM 모듈 사용*
(async function() {
const add = (await import('./index.mjs')).default;
add(1, 2);
})();
Node.js 23부터는 특정 조건 하에서 require()를 통해 ESM 모듈을 로드할 수 있게 되었지만, 이는 실험적 기능이다.
결론적으로, CommonJS와 ESM은 단순한 버전 차이가 아닌 완전히 다른 모듈 시스템이며, 각각의 장단점과 사용 사례가 있다. 현재 자바스크립트 생태계는 ESM으로 점진적으로 이동하고 있지만, CommonJS는 여전히 널리 사용되고 있으며 앞으로도 상당 기간 지원될 것이다.
React Native는 Metro라는 자체 번들러를 사용하며, 이 Metro는 여전히 CommonJS를 기본 모듈 시스템으로 사용한다. 이는 React Native가 2015년에 출시되었을 때 ESM이 아직 널리 채택되지 않았던 시기적 배경이 있다.
(👨🏻🏫 : 마치 오래된 집에 살면서 전기 배선을 완전히 교체하기 어려운 것처럼, React Native도 기반 구조를 한번에 바꾸기 어렵답니다!)
React Native의 번들링 시스템인 Metro는 웹 생태계에서 널리 사용되는 Webpack, Vite, Rollup 등과 비교했을 때 몇 가지 중요한 제한사항을 가지고 있다.
Metro는 기본적으로 ESM 구문을 지원하지만, 내부적으로는 이를 CommonJS로 변환하여 처리한다. 이 과정에서 ESM의 일부 고급 기능들이 제대로 지원되지 않는 경우가 있다.
*// 이런 동적 import는 Metro에서 문제가 될 수 있다*
const module = await import(`./locales/${language}.js`);
최신 Node.js와 웹 생태계에서는 package.json에서 "exports" 필드를 통해 조건부 내보내기를 지원한다. 이를 통해 동일한 패키지가 환경에 따라 다른 버전의 코드를 제공할 수 있다.
*// package.json*
{
"exports": {
"import": "./esm/index.js",
"require": "./cjs/index.js",
"react-native": "./native/index.js"
}
}
하지만 Metro는 이러한 조건부 내보내기를 완전히 지원하지 않아, 최신 라이브러리를 사용할 때 문제가 발생한다.
출처: Metro 공식 문서
CommonJS 기반 시스템을 사용하면서 발생하는 실제 문제들을 살펴보자.
많은 최신 자바스크립트 라이브러리들이 ESM만 지원하거나 ESM을 기본으로 제공하면서, React Native 프로젝트에 통합하기 어려워졌다.
Error: Unable to resolve module 'modern-esm-only-library' from 'App.js'
이런 오류 메시지는 React Native 개발자에게 너무나 친숙하다. 🥲
Vitest와 같은 최신 테스트 도구들은 ESM을 기본으로 사용하며, React Native 프로젝트에 통합하려면 추가적인 설정이 필요하다.
*// vitest.config.js*
export default {
test: {
deps: {
inline: ["react-native"]
}
}
}
하지만 이런 방식으로도 완벽한 호환성을 보장하기 어렵다.
타입스크립트를 사용할 때도 모듈 시스템 간의 차이로 인한 복잡성이 증가한다.
*// tsconfig.json*
{
"compilerOptions": {
"module": "ESNext",
"moduleResolution": "node",
"esModuleInterop": true
}
}
이러한 설정은 타입스크립트 컴파일러가 ESM 구문을 이해하도록 하지만, 실행 시에는 여전히 Metro가 CommonJS로 변환하는 과정이 필요하다.
React Native에서 CommonJS와 관련된 문제를 해결하기 위한 몇 가지 방법을 살펴보자.
babel-plugin-transform-modules 같은 플러그인을 사용하여 ESM 전용 라이브러리를 CommonJS로 변환할 수 있다.
(👨🏻🏫 : 특정 라이브러리를 사용해야할 때, 항상 설치에서 끝나는 게 아니라, 추가적인 작업을 해야하는 이유가 이런 이유였던 것이다..!)
*// babel.config.js*
module.exports = {
plugins: [
['babel-plugin-transform-modules', {
'modern-esm-library': {
transform: 'modern-esm-library/dist/cjs/index.js',
},
}],
],
};
출처: babel-plugin-transform-modules
Metro 설정을 커스터마이징하여 특정 패키지를 처리하는 방법을 변경할 수 있다.
*// metro.config.js*
module.exports = {
resolver: {
extraNodeModules: {
'esm-only-package': require.resolve('./path-to-commonjs-version')
}
}
};
출처: Metro 설정 문서
가능하다면, CommonJS를 지원하는 대체 라이브러리를 선택하는 것도 좋은 방법이다. npm 패키지를 설치하기 전에 해당 패키지가 CommonJS를 지원하는지 확인하자.
*# package.json을 확인하여 "type": "module"이 없는지 확인*
npm view some-package
React Native 팀도 이러한 문제를 인식하고 있으며, 장기적으로는 ESM 지원을 개선하기 위한 노력을 기울이고 있다.
React Native의 새로운 아키텍처인 "New Architecture"는 내부 구조를 현대화하는 과정의 일부이며, 이는 장기적으로 모듈 시스템 개선으로 이어질 수 있다.
React Native의 자바스크립트 엔진인 Hermes도 지속적으로 발전하고 있으며, ESM 지원을 개선하기 위한 작업이 진행 중이다.
(👨🏻🏫 : Hermes가 처음 나왔을 때는 기능이 제한적이었지만, 이제는 점점 더 강력해지고 있답니다. 인내심을 가지고 기다려 봅시다!)
React Native 커뮤니티에서도 이 문제를 해결하기 위한 다양한 도구와 방법을 개발하고 있다. 예를 들어, react-native-esbuild와 같은 프로젝트는 Metro 대신 esbuild를 사용하여 더 나은 ESM 지원을 제공하려는 시도이다.
(👨🏻🏫 : 현재는 토스 개발자 이신 거 같은데 해당 라이브러리를 만드는 과정을 들여다보시면, 진짜 대단한 노력이 들어간다는 사실을 알 수 있습니다….! 되려, 이 분은 토스에서 다음과 같은 방식의 성공 사례를 보고서, 과감하게 시도해보았다고 하네요. )
React Native의 CommonJS 의존성은 현대 자바스크립트 생태계와의 통합을 어렵게 만드는 요소 중 하나이다. 그러나 이러한 제한 속에서도 효과적으로 개발하기 위한 방법들이 존재한다.
React Native의 CommonJS 의존성은 단기간에 해결되기 어려운 문제이지만, 개발자로서 이러한 제한을 이해하고 적절히 대응한다면 여전히 생산적인 개발이 가능하다. 모바일 앱 개발의 여정에서 이러한 도전은 우리를 더 나은 개발자로 성장시키는 기회가 될 수 있다. 💪
🙇🏻 글 내에 틀린 점, 오탈자, 비판, 공감 등 모두 적어주셔도 됩니다. 감사합니다..! 🙇🏻
너무 재밌게 잘 읽었습니다