개요
오늘날의 자바스크립트 생태계에서는 기능을 모듈로 분리하여 구현하고 실제 배포 시에는 번들링 과정을 거치게 된다.
React Native는 기본적으로 Metro 번들러를 통해 번들링을 수행하는데, 이번 글에서는 React Native 프로젝트 코드가 어떻게 번들링 되는지 살펴보려고 한다.
번들링(Bundling)
번들링이란, 여러 모듈과 파일을 단일 파일로 병합하여 최적화하는 과정을 의미한다. 물론 번들을 조금 더 잘게 나눌 수도 있지만 목적은 여러 모듈과 파일을 묶는 것이다.
자바스크립트 번들러는 대부분 비슷한 과정을 거쳐 번들링을 수행하게 된다. 모듈을 탐색하고 코드를 변환한 후 마무리 작업을 거치게 되는데, React Native의 기본 번들러인 Metro 번들러도 동일한 작업들을 수행한다.
모듈 해석(Module Resolve)
모듈 해석 (Module Resolve)이란, 코드 내의 참조(import
)가 어느 모듈(파일)을 참조하는지 찾아 로드하는 과정을 의미한다.
아래와 같이 라이브러리를 사용하기 위해 import
했다고 가정하자.
react-native-reanimated
가 어디에 위치하고 있는지 어떻게 찾는 것일까? 기본적으로는 NodeJS 의 Resolve 알고리즘에 따라 프로젝트 내에 위치한 node_modules
폴더 안에서 모듈을 탐색하게 된다. 이후 Metro 번들러는 모듈 package.json
파일의 react-native
, main
, browser
필드에 정의된 파일을 진입점(Entry Point)으로써 참조한다.
Metro 번들러의 경우 아래의 resolveMainFields 구성을 변경하여 어느 필드를 진입점으로 사용할지 커스터마이징이 가능하다.
react-native-reanimated 라이브러리를 예시로 확인해보면 package.json
파일에 아래와 같은 정보가 포함된 것을 확인할 수 있다.
Metro 가 모듈을 해석할 때 react-native
, main
, browser
에 해당하는 파일을 우선적으로 참조하게 되는데, 만약 react-native
필드가 없다면 main
필드, 이마저도 없다면 browser
필드를 참조하는 방식으로 진행된다.
주입(Inject)
React Native의 경우 모듈뿐만 아니라 기본적인 실행 환경을 구성하기 위해 몇 가지 코드를 주입하고 있다. 여기에는 호환성 혹은 기능 확장을 위한 폴리필(Polyfill) 그리고 초기화 코드가 포함된다.
폴리필(Polyfill)
폴리필(Polyfill)이란, 기본적으로 지원하지 않는 기능을 제공하기 위한 코드 조각이다.
Metro 구성에 따라 기본적으로 @react-native/js-polyfills 가 주입되는데 이는 metro-config 에서 확인할 수 있다. 현재의 React Native에서는 세 가지 폴리필이 존재하는데 한 번 간단히 살펴보자.
Object.entries, Object.values 를 지원하기 위한 폴리필이다.
console.log
에 대한 포맷 개선 그리고 네이티브 로깅을 위한 기능을 제공한다.
React Native 에는 자바스크립트 컨텍스트에 몇 가지 네이티브 기능을 노출시키는데, 그중 하나가 global.nativeLoggingHook이다.
이를 통해 console.log
메시지를 네이티브 로그에 함께 기록할 수 있도록 한다. 이에 대한 자세한 내용은 다음 글에서 자세히 알아볼 예정이니 지금은 지나가도록 하자.
폴리필이 적용되었다면, console
객체에 _isPolyfilled
속성을 true
로 설정하는데, 이는 네이티브(Android, iOS) 환경의 디버그 모드에서 기록되는 콘솔 메시지를 터미널에 노출시키기 위한 플래그 값으로써 사용된다. 이 역시 다음 글에서 자세히 알아볼 예정이다.
앱 실행 중 에러가 발생할 경우 핸들러를 통해 에러를 처리하기 위한 기능을 제공한다.
React Native 초기화 시 전역 에러 핸들러를 등록 하는 과정을 거치는데, 런타임에 예기치 못한 에러가 발생할 경우 등록된 에러 핸들러에게 위임하여 처리한다.
Metro 모듈
Metro 를 통해 번들링된 코드 내에서 모듈을 처리하기 위해 폴리필이 추가로 주입된다. 이 코드는 앞서 살펴본 폴리필과 동일하게 코드 최상단에 포함된다.
metro-config 에서 폴리필을 확인할 수 있고, 폴리필 코드는 metro-runtime 에서 확인 가능하다.
초기화
폴리필 뿐만 아니라 코드를 본격적으로 실행하기에 앞서 React Native 초기화 작업을 진행한다.
초기화 코드는 InitializeCore.js이며, 모듈 실행 전 우선 처리될 수 있도록 Metro 구성에 포함되어있다.
변환(Transform)
해석된 모듈들과 주입 코드들에 대해 알아보았다. 이 코드들은 이제 React Native 실행 환경에 맞게 변환 과정을 거치게 된다.
코드 변환은 Babel 을 통해 처리되는데, React Native 환경에 맞는 구성 프리셋(metro-react-native-babel-preset)이 존재하며 아래와 같이 수많은 플러그인들로 구성되어있다.
Hermes 엔진을 사용 중인가, 클래스가 코드에 존재하는가, 비동기 함수가 코드에 존재하는 등등 우리가 작성한 코드를 기준으로 변환이 필요한지 확인한 후 변환이 필요한 경우에 extraPlugins
에 플러그인을 추가하여 코드를 변환한다.
위 과정은 각 모듈마다 개별적으로 수행되는데, 모듈이 1000개가 있다고 하면 위의 작업이 1000번 수행된다. 각 모듈별로 Babel 플러그인 변환 과정을 모두 거치기 때문에 Metro 번들러의 성능이 썩 좋지 못하다.
React Native 팀에서도 이를 인지하고 있고, 현재 Babel 대신 성능이 월등히 좋은 Rust 기반의 swc를 사용할 수 있도록 개선하는 작업(#948)을 진행하고 있다.
왜 이렇게 많은 플러그인이 필요한지 의구심이 들 수 있는데 React Native 의 환경적인 요인 때문이다.
React Native 내부에서 사용하는 Flow 구문도 제거가 필요하고, Hermes 엔진의 경우 제대로된 ES6 사양을 지원하지 않으며 몇 가지 버그들로 인해 코드 변환은 반드시 필요한 상황이다.
예를 들면, Hermes 에서는 const
, let
키워드를 지원하긴 하지만, 블록 스코프를 완전하게 지원하지 않는다(#575).
이러한 버그를 해결하기 위해서 @babel/plugin-transform-block-scoping
플러그인을 사용하는 등 근본적인 원인 해결 대신 플러그인으로 대응하고 있다는 느낌이 든다. (필자 본인은 이러한 대처가 문제 해결보단 회피에 가깝다고 본다)
물론 Hermes 엔진 개발팀에서 이를 인지하고 있고 작업 중이긴 하지만, 블록 스코프 버그를 포함한 다른 버그들이 언제 수정되어 릴리즈 될지 알 수 없는 상황이다. (위 이슈에서는 2024년도 중에 릴리즈 될 예정이라고 한다)
번들 살펴보기
React Native 프로젝트 경로에서 아래 명령어를 입력하면 번들을 직접 생성할 수 있다. 번들을 생성하고 내부를 살펴보도록 하자.
npx react-native bundle \
--platform <ios|android> \
--dev true \
--minify false \
--bundle-output bundle.js \
--sourcemap-output budnle.js.map \
--assets-dest <path> \
--reset-cache \
--verbose
지금부터 코드를 살펴보려고 하는데, 번들 코드를 그대로 가져오면 가독성이 좋지 않기 때문에 일부 코드 형식을 보기 쉽게 변경하려고 한다. 실제 코드와 동일하니 참고하길 바란다.
전역 변수
번들 최상단을 보면 전역 변수들이 선언되어있는 것을 확인할 수 있다. 번들 실행 시작 시간을 확인하기 위한 변수, 개발 모드 여부를 나타내는 변수, 환경 변수 등 여러가지 변수가 가장 먼저 선언되고 초기화 된다.
폴리필
이어서 Metro 폴리필 코드를 확인할 수 있다. 폴리필은 전역 객체를 매개변수로 갖고 있는 즉시 실행 함수(IIFE, Immediately Invoked Function Expression)로 래핑 되어있으며, 자신만의 범위를 갖고 캡슐화 되어있다.
여기서 참고할 부분은 metroRequire
그리고 define
함수이다. 잠시 뒤 모듈에 대해 살펴볼텐데 이 함수들을 통해 모듈을 정의하고, 참조하게 된다.
코드를 더 살펴보면 @react-native/polyfills
의 폴리필 코드들도 확인할 수 있다.
이처럼 전역 변수 그리고 폴리필들은 코드의 최상단에 위치하고 있으며, 번들 실행 시 가장 먼저 평가된다.
모듈 정의
폴리필 코드 이후부터는 모듈 정의 코드들이 위치하고 있다. 모든 모듈은 __d
라는 함수로 래핑되어있는데 이는 조금 전 살펴본 Metro 폴리필 내의 define
이다.
내부 코드를 보면 조금 복잡해 보일 수 있는데, 한 가지 사실만 기억하자. Metro 번들러가 변환한 모든 모듈에는 고유한 ID(숫자)를 갖고 있으며 해당 ID를 통해 모듈간 서로 참조(require)한다.
index.js
모듈의 ID 는 0이고, [1, 783, 18, 836, 5540]
배열은 모듈 의존성을 나타낸다. 여기에는 index.js
모듈에서 참조하고 있는 다른 모듈의 ID 가 포함되며 내부에서는 _dependencyMap
을 통해 모듈 ID 값을 참조한다.
_dependencyMap
의 모듈 ID를 추적해보면 정의되어있는 모듈의 실체를 확인할 수 있다.
모듈 참조
앞서 살펴본 __d
와 동일하게, metroResolve
함수가 __r
이라는 전역 프로퍼티로 정의되어있는데 이를 통해 정의한 모듈들을 참조할 수 있다.
번들 코드 가장 아래로 내려가보면 모듈을 참조하고 있는 모습을 확인할 수 있다.
Metro 구성을 변경하지 않았다면 기본적 총 2개의 모듈을 참조하고 있을 것이다.
첫 번째는 React Native 초기화를 담당하는 InitializeCore.js
이고, 두 번째는 프로젝트 코드의 Entry Point 인 index.js
이다.
정리해보면 번들 코드가 실행되면 가장 먼저 전역 변수와 폴리필이 적용되고, 모듈들이 정의된 다음, 초기화 코드를 가장 먼저 실행 시킨 후 우리의 애플리케이션 코드가 실행된다.
사전 컴파일(Pre-compilation)
이 섹션은 Hermes 엔진을 사용하고 있을 때 해당하는 내용이다. JSC 혹은 다른 엔진을 사용하고 있다면 본 과정을 거치지 않는다.
Hermes 엔진을 사용하고, 릴리즈 빌드를 진행하고 있다면, 번들링 후 사전 컴파일 과정을 추가로 진행한다.
Meta(구 Facebook) 에서 Hermes 엔진에 대해 소개한 글을 보면 JIT 컴파일레이션 과정 없이 최적화를 수행하여 성능 향상을 이루어 냈다고 한다. 애초에 JIT 는 보안상의 이유로 iOS 환경에서 사용이 불가했는데, Hermes 의 최적화가 어떻게 진행되는지 간략히 살펴보도록 하자.
기존에는 빌드 타임 이후 생성된 결과물인 번들을 런타임에 파싱하고 컴파일하여 실행하는 과정을 거치게 된다. 대부분의 디바이스에서는 문제가 되지 않지만 저사양(Low-end) 기기에서는 이러한 과정이 초기 실행 속도를 늦추게 되는 원인이 된다.
Hermes 엔진은 이러한 문제를 극복하기 위해 생성된 번들을 빌드 타임에 미리 컴파일(Pre-compilation)하여 즉시 실행 가능한 바이트 코드(Bytecode) 형태로 변환한다. 이 덕분에 앱 실행 시 파싱과 컴파일 과정이 불필요하게 되어 초기 실행 속도에 좋은 성능을 보이게 된다.
빌드 타임에 컴파일 과정을 어떻게 수행하게 되는지 궁금하다면 아래 코드를 참고하길 바란다.
다시 한 번 이야기 하지만 사전 컴파일 과정은 릴리즈 빌드 시에만 진행되며, 개발 모드에서는 진행되지 않는다. 왜냐하면 개발 중에는 변경 사항에 따라 코드가 수시로 변경되고, 변경된 코드가 실행되기 때문이다.
개발 서버
번들링된 결과는 릴리즈 빌드 시 아티팩트(Artifact) 내에 포함되고, 개발 모드에서는 개발 서버를 통해 애플리케이션으로 전달된다.
Metro 번들러를 실행시키고 React Native 애플리케이션을 실행시켜보면, Metro 서버에서 빌드를 진행하고, 네트워크(localhost:8080)를 통해 번들을 전달하는 모습을 쉽게 볼 수 있을 것이다.
개발 서버에 대한 내용은 분량이 꽤나 많기 때문에 별도 글로 정리하여 소개하도록 하겠다.
마무리하며
React Native 의 기본 번들러인 Metro 의 경우 마냥 느린 것은 아니다. 지금까지 살펴본 것처럼 실행 환경의 제약 사항으로 인해 여러가지 처리 과정을 거치는데, 이 때문에 상대적으로 느린 성능을 보이고 있다.
JSC, Hermes 엔진 뿐만 아니라 공식적으로 V8 엔진(lite 등 경량 버전)에 대한 지원도 추가가 된다면 좋지 않을까 싶은데, 아직 먼 이야기인 것 같다. (react-native-v8이 존재하지만, 공식적인 지원이 아니기 때문에 프로덕션에서 사용하기에는 리스크가 있다고 본다)
React Native 번들링 과정은 조금 더 복잡한 제약 사항을 고려하여 진행되지만, 과정 자체는 일반적인 번들러와 비슷한 과정을 거쳐 번들을 만들어낸다는 것이다. 만들어진 번들은 개발 서버를 통해 애플리케이션으로 전달되고, 애플리케이션은 런타임에 이를 다운받아 실행하게 된다.
다음 글에서는 이렇게 만들어진 번들이 어떻게 실행되는지 살펴보도록 하겠다.