개요
esbuild 기반의 React Native 번들러를 개발하게 된 계기와 개발을 진행하면서 겪었던 수많은 문제들 그리고 이들을 해결하기까지의 과정을 간략히 소개하려고 한다. 자체 개발한 번들러는 아래 링크에서 확인할 수 있고, 오픈소스로 모두 공개되어 있다.
https://react-native-esbuild.vercel.app
번들러를 개발하면서 느낀 점은 생각했던 것보다 넓고 깊은 지식을 요구한다는 점이다.
번들러가 동작하는 방식, 트랜스파일링과 최적화 과정, 번들 코드가 실행되는 런타임 그리고 플랫폼에 대한 이해, 프로그래밍 언어의 특성 등 일반적인 상황에서 접하기 어려운 내용들이 요구된다는 것을 이번 계기를 통해 제대로 느낄 수 있었다.
아래 글은 React Native의 숨겨진 부분들을 정리하여 소개한 내용인데, 이를 참고하면 본 글을 이해하는데 도움이 될 것이다.
프론트엔드 생태계와 번들러
과거 자바스크립트로 작성한 코드를 단순히 브라우저에서 로드하여 페이지에 동적인 기능을 제공했던 시절이 있었다. 기본적인 버튼 클릭, 입력 필드 처리 정도에 불과했지만, 자바스크립트 언어의 변화 그리고 Node.js 의 등장 등 여러 가지 일들이 생기면서 오늘날 자바스크립트 생태계는 엄청나게 커졌다.
프론트엔드 개발자라면 빠르게 변화하는 생태계를 누구보다 더 잘 느끼고 있을 것이다. 여러가지 라이브러리와 프레임워크들이 쏟아져 나오고, 심지어 개발을 위한 도구들도 넘쳐나고 있다. 그 중 현대 프론트엔드 개발에 있어 반드시 필요한 번들러도 포함되는데,현재 널리 사용되고 있는 번들러는 Webpack, Rollup, Turbopack, esbuild, Vite 등이 있을 것이다.
React Native 애플리케이션의 경우 일반적인 웹 프론트엔드 애플리케이션과 같다고 볼 수는 없지만, 자바스크립트 기반 그리고 React 를 축으로 하여 개발한다는 부분은 어느 정도 일치한다. 지금처럼 거대하고 잘 활성화되어있는 자바스크립트 생태계(Eco system)에서 개발할 수 있다는 것은 분명 큰 장점이라고 생각한다.
React Native 도 코드를 작성한 후 실제 런타임(엔진)에서 실행하기 전에 번들링 과정을 거치게 되는데 이때 번들러가 필요하다. React Native의 경우 기본적으로 Metro 라고 하는 번들러를 기반으로 번들링 되도록 구성되어 있으며, Meta(구 Facebook)에서 React Native 프로젝트를 타겟 한 자바스크립트 번들러이다.
Metro 번들러의 한계
아직 서론이지만 벌써 여러 가지 번들러 이름이 언급되었다. 그중 Metro 도 포함되는데 React Native 애플리케이션을 개발해 본, 혹은 개발하고 있는 개발자라면 여러 문제점을 겪어본 경험이 있을 것이다.
필자 본인의 경우에는 팀 내에서 빌드시 여러 가지 이슈가 있어 번들링 전에 항상 캐시를 제거(--reset-cache
)하고 있다.
라이브러리 업데이트 같은 작업을 하지 않았음에도 종종 번들이 꼬이는 문제가 발생하여 조치한 사항인데, 이로 인해 Metro를 실행시키고 최초 번들링을 하게 되면 꽤 오랜 시간이 소요된다. 캐싱을 통해 누릴 수 있는 시간 단축이라는 생산성 향상 효과를 전혀 누리지 못하고 있는 상태이다.
또한 Metro는 모듈 참조(Resolve) 시 상당히 느슨(Loose)하게 처리하는데, 존재하지 않는 대상을 import
할 경우 빌드 타임에 이를 알려주지 않는다. 이는 개발자가 인지하지 못한 상태로 빌드하고, 프로덕션 환경에 배포까지 하게 될 경우 예기치 못한 충돌을 유발하는 원인이 되기도 한다.
마지막으로 빌드 속도이다. React Native가 갖고 있는 플랫폼 특성상 코드를 변환하는 과정은 반드시 필요한데, Metro는 이를 달성하기 위해 Babel 을 주로 사용하고 있다. Babel 덕분에 프론트엔드 생태계가 여기까지 성장했다고 해도 과언이 아닐 정도로 훌륭한 도구임은 분명하다.
다만 오늘날의 개발 환경에서 상대적으로 느린 성능을 보여주고 있는데, Metro에서는 React Native 런타임에서 코드를 실행시킬 수 있는 형태로 변환하기 위해 수많은 Babel 플러그인들을 사용하고 있다. 프로덕션 애플리케이션은 수천, 수만 개의 모듈(코드)들이 존재하는데 이를 일일이 변환하고 있으니 느린 것은 당연하다.
캐싱 이슈와 모듈 참조, 그리고 속도 문제가 있다. 그 외 Tree Shacking을 지원하지 않는 등 여러 기능적인 부족함 또한 존재한다.
토스(Toss)에서도 React Native 애플리케이션을 개발하며 겪었던 불편한 사항을 소개한 영상이 있는데, 여기에서도 번들링 과정과 Metro가 언급되고 있다.
(토스ㅣSLASH 23 - 달리는 토스 앱에 React Native 엔진 더하기)
개발하기에 앞서
필자 본인은 앞서 소개한 여러 가지 문제들이 개선된다면 분명 개발 경험이 크게 향상될 것이라고 생각했다. Metro의 경우 이미 거대한 프로젝트 중 하나이고 Metro 내부 팀도 성능을 개선하기 위해 swc를 도입하는 등 개선 작업을 진행하고 있지만, 구조적으로 단기간에 개선되기에는 시간이 걸릴 것이라고 생각했기에 직접 React Native 번들러를 개발해보자 라는 무모한 계획을 세우게 되었다.
주요 목표는 아래와 같이 설정했다.
- 캐싱 문제 해결 & 빌드 속도 개선으로 개발 경험 향상
- 기존 React Native CLI 커맨드와 호환되는 도구 제공 (대체 가능하도록)
필자는 여러가지 사이드 프로젝트를 진행하면서 구성해 본 Webpack, Rollup 그리고 Rust 기반의 Rspack를 사용해 본 경험이 있었고, 머릿속에는 주요 번들링 과정은 어차피 잘 만들어진 번들러가 수행하니 "그냥 가져다가 사용하면 되겠다"라는
어리석은
생각을 했었다.
이어서 수많은 번들러 중 어떤 번들러를 사용할 것인가에 대해 고민이 필요했다. 주목적은 빌드 속도 개선인데, 실험적이지만 esbuild 가 부가적인 기능을 배제하는 대신 최고의 성능을 추구한다는 목표를 갖고 있다는 것을 알게 되었다. 그리고 이미 토스에서 esbuild 기반의 빌드 시스템 구현을 성공했다는 선례도 존재했기에 기반 번들러를 esbuild로 선정하게 되었다.
고려 사항
번들링을 수행하고 실제 애플리케이션에 실행시키는 데 있어 미리 인지하고 있어야 하는 사항들이 있다.
React Native는 기본적으로 Hermes 그리고 JSC 런타임(엔진)을 지원하는데, 일단 Hermes는 ES6를 완전하게 지원하지 않고, 글 작성하는 시점을 기준으로 치명적인 블록 스코프 버그가 있다. 최근 React Native의 기본 런타임이 Hermes로 변경되었기에 기본적으로 Hermes를 지원하려고 한다.
Hermes의 ECMA 스펙 이슈 말고도 고려해야 할 부분이 있는데, 바로 일부 Meta(구 Facebook)의 라이브러리 혹은 React Native 생태계의 경우 정적 타입 분석을 위해 Flow를 사용한다는 것이다. 파일 확장자는 자바스크립트이지만, 실제 구문은 전혀 다르다. 이 역시 트랜스파일 과정에서 모두 제거해주어야 한다.
구조 설계
여러 가지 레퍼런스를 조사하고, React Native 그리고 Metro 내부 코드를 들여다보며 어떤 형태로 구조를 가져갈지 설계를 진행했다. 구상한 구조를 큰 그림으로 보면 아래와 같다.
소스코드와 에셋(이미지)등의 파일은 esbuild를 통해 로드하고, 코드 변환이나 에셋 전처리 등의 여러가지 커스텀 작업들은 플러그인으로 구현하고자 한다.
esbuild에는 기본적으로 Loader가 구현되어 있는데, 자바스크립트 Loader의 경우 아쉽게도 ES6 미만을 지원하지 않는다. 즉, esbuild의 자바스크립트 Loader는 사용할 수 없다. 그래서 대안으로 선택한 것이 swc이다. Rust 언어 기반의 트랜스파일러인데 Babel을 대체할 수 있는 대안으로 떠오르고 있다.
swc는 기본적으로 ES6 미만 타겟으로의 트랜스파일링을 지원하기에 좋은 방안이 될 것이라고 생각했다. 그리고 추가로 고려해야 할 Flow 구문의 경우 Babel 플러그인을 통해 처리할 수 있지만, Babel을 사용하는 순간 성능이 크게 떨어지기에, 보다 훨씬 빠른 sucrase 라는 대안을 찾았다.
이렇게 ES6 미만으로 변환하는 작업은 swc, Flow 구문 제거는 sucrase를 사용하려고 한다. 그 외 변환할 수 없는 경우에는 Metro와 동일하게 Babel을 통해 처리하려고 한다.
마지막으로 esbuild는 모듈 의존 관계를 분석하여 Tree Shaking을 진행하고, Minify, Sourcemap 생성 등의 작업을 끝으로 번들링 과정을 마무리하도록 구상했다.
번들러 개발 시작
개발을 시작하기에 앞서 GitHub에 저장소를 생성하고 머릿속으로 구상한 내용을 기준으로 Yarn workspace 기반의 모노레포를 구성했다. 모노레포는 주요 기능들을 모듈화 하고 패키지로 분리하여 관리하기 위함이었다.
패키지 이름은 @react-native-esbuild
으로 결정했고, 패키지별로 이름을 갖도록 구성했다.
지금은 패키지가 많이 늘어났지만 초기에는 아래와 같이 패키지가 나누어져 있었다.
- 주요 번들링 작업을 수행하는
core
패키지 - CLI를 구현할
cli
패키지 - 개발 서버 기능을 구현할
dev-server
패키지 - 프로젝트에서 사용할
utils
패키지 - 전반적인 구성을 담당하는
config
패키지
이외에도 타입스크립트, ESLint 등 기본적인 구성을 마친 후 본격적으로 개발을 진행하게 되었다.
Module Resolution
번들링은 시작점(Entry point)으로부터 시작된다. React Native 프로젝트의 경우 프로젝트 루트 경로에 있는 index.js
파일이 된다.
index.js
에서 참조하고 있는 파일이 어디에 있는지 탐색하고, 탐색한 모듈은 또 어떤 모듈을 참조하고 있는지 반복적으로 찾아나간다. 이 과정을 Module Resolution이라고 하며, 과정이 마무리되면 모듈 간 의존 관계를 파악할 수 있게 된다.
React Native 프로젝의 경우 조금 특이한 부분이 있는데 일반적인 모듈(.js
, .ts
) 뿐만 아니라 플랫폼 지정 확장자를 지원한다. 예를 들면 아래와 같은 경우이다.
실제 파일의 확장자(tsx
) 앞에 특정 플랫폼이 포함되는 형태이다. Metro 번들러는 어느 플랫폼을 대상으로 번들링을 진행하는지에 따라 Resolution 과정에서 참조하는 파일이 달라진다.
esbuild에서는 옵션을 통해 쉽게 구현할 수 있는데, 아래와 같이 구성하면 된다.
구성을 살펴보면 코드의 시작점(entryPoints)과 번들 결과로 저장(outfile)할 파일의 이름이 포함되어 있다. mainFields 의 경우 모듈 참조 과정에서 사용되는데, 자세한 내용은 처음에서 소개한 글의 번들러 파트를 참고하도록 하자.
플랫폼 지정 확장자는 resolveExtenstions옵션 값으로 추가하면 된다. esbuild에는 기본적으로 구현된 Loader가 있는데, 이는 Resolve 과정을 거친 모듈 혹은 파일을 처리하는 역할을 수행한다. Loader를 명시적으로 지정하지 않으면 확장자에 따라 기본 Loader로 전달된다.
번들 내에 포함되지 않는 대상(이미지 파일 등)은 file
Loader가 처리하도록 구성한 모습을 볼 수 있다.
resolveExtension
에 추가한 확장자들은 기본 Loader에게 전달되는데, 우리는 소스코드를 React Native 런타임에서 구동 가능한 형태로 변환해야 한다.
Plugins
현재 프로젝트에 구현된 여러 플러그인들이 있는데, esbuild의 플러그인은 아주 쉽게 구현할 수 있으며 강력하다.
onResolve
는 모듈 Resolution 과정에서 참조한 경로를 발견했을 때 트리거된다. 이 과정에서 모듈의 경로를 임의의 경로로 변경하는 등의 작업을 처리할 수 있다.
onLoad
는 모듈 Resolution 후 모듈의 Loader로 전달되기 전에 트리거 된다. 여기서 Loader로 전달하기 전에 모듈을 커스터마이징 할 수 있다. 현재 구현한 번들러의 경우 여기에서 코드를 변환하고 있다.
플러그인에 대한 자세한 내용은 esbuild 공식 문서를 참고하도록 하자.
Transformer
Resolution을 거쳐 코드가 Load 된 경우 본격적으로 트랜스파일링을 진행한다.
모듈 파일을 읽고, 코드를 변환하는 모듈로 전달하도록 구현했다. 코드 변환의 경우 앞서 이야기했던 것처럼 swc, sucrase를 통해 처리되고, 소개하지 않은 추가적인 처리 과정 또한 존재하는데 이를 파이프라인이라는 객체로 추상화하여 처리하도록 구현했다.
이 파이프라인은 esbuild 번들링 과정뿐만 아니라 추후 소개할 Jest 트랜스포머에서도 다시 재사용된다.
파이프라인을 거친 코드는 최종적으로 자바스크립트 형태의 코드를 반환한다. 자바스크립트 구문이기 때문에 esbuild로 결과를 반환할 때 자바스크립트 Loader를 사용하도록 명시적으로 추가해주고 있다.
코드 변환뿐만 아니라 폴리필(Polyfill) 적용, React Native 초기화 스크립트 주입 등 복잡하고 많은 과정이 필요한데 관련된 내용은 처음에 소개한 글에서 확인 가능하다. 이 과정을 모두 거치면 자바스크립트로 변환된 번들 파일이 생성된다.
문제 해결
성공적인 번들링까지의 과정도 사실 쉽지만은 않았다. 수많은 시행착오를 거쳐 정상적으로 번들링 되도록 구현했으나, 실제로 번들을 실행시켰을 때 발생한 런타임 에러 등, 더 많은 어려운 문제들이 기다리고 있었다. 필자 본인이 겪었던 몇 가지 문제들과 해결 방안을 간략히 소개하도록 하겠다.
(참고로 소개할 이슈들은 Hermes 런타임에서 발생한 이슈이다)
잘못된 블록 스코프 동작
생성된 번들은 자체 구현한 개발 서버(dev-server 패키지)를 통해 애플리케이션으로 전달되도록 구현했는데, 앱을 실행시켜 번들을 로드했을 때 아래 에러를 마주했다.
에러를 확인해보면 조금 이상한 부분이 보일 것이다. React.createContext
를 참조하여 호출하고 있는 코드에서 발생한 에러인데 값이 조금 이상하다. 함수가 아니라 '18.2.0'
즉, 문자열이다.
코드 트랜스파일링 과정에 이슈가 있나 싶어 여러가지를 리서치 했었는데 관련된 내용을 찾지 못했다. 그래서 에러 내용에 포함되어있는 Stack trace를 통해 어느 지점에서 문제가 발생했는지 번들 코드를 직접 열어 살펴보았다. 호출 스택을 거슬러 올라가보아도 문제가 될만한 부분은 찾지 못했다. 아니, 정상적인 코드였다.
계속 거슬러 올라가다 결국 첫 호출 스택에 도달했을 때, 조금 이상하게 동작하는 코드를 찾았다.
esbuild에서 빌드 런타임에 주입하는 모듈 함수 코드인데 강조해둔 부분을 잘 보자. 코드 자체는 객체의 속성을 다른 객체로 복사하는 역할을 수행하는 함수이다. 단순히 프로퍼티를 복사하는 것이 아니라 대상 객체의 getter
로 원본 객체의 속성 값에 접근할 수 있도록 구현되어있는 모습을 볼 수 있다.
만약 for
반복문을 돌고 있는 key
변수의 스코프가 함수 스코프라면 어떻게 될까? 반복이 모두 끝난 시점의 값을 들고 있을 것이다. 이 말인 즉슨, 객체의 마지막 속성 값에만 접근 가능한 상태가 된다는 것이다.
let
키워드로 선언한 변수는 블록 스코프로 동작하는 것이 ES6의 사양이다. 그러나 Hermes에서는 위 코드를 실행했을 때 let
키워드로 선언된 key
변수가 블록 스코프를 갖는 것이 아니라 함수 스코프를 갖는 것처럼 동작하는 치명적인 버그가 있다.
React.createContext
가 잘못된 문자열 값을 참조하는 것이 문제였는데, React.useState
등 모든 속성의 참조값은 마지막 속성의 값인 '18.2.0'
이 되어버리는 아이러니한 상황이었다.
관련된 이슈가 등록되었나 Hermes 저장소를 살펴보았는데, 동일한 원인으로 제보된 이슈가 존재했다. 그러나 아직 Hermes 정식 릴리즈로 출시된 상황은 아니라 현재 글을 작성하고 있는 시점에도 여전히 블록 스코프 이슈는 해결되지 않았다.
https://github.com/facebook/hermes/issues/575
이 문제를 해결하기 위해서는 반복문 내에서 새로운 함수 스코프를 갖도록 수정하면 되는데, esbuild 에서 주입해주는 코드였기에 외부에서 커스터마이징이 불가했다. esbuild 구현 코드를 살펴보니 다행히도 특정 옵션을 활성화 하면 let
을 사용하지 않고 var
기반의 변수로 동작하도록 구현된 것을 확인했다.
esbuild 옵션 중 supported 의 for-of
를 지원하지 않도록 변경하면 문제가 되던 __copyProp
의 블록 스코프 문제가 완벽하게 해결된다. 실제 프로젝트에 적용된 코드는 아래에서 확인할 수 있다.
지역 스코프 최적화
이런 저런 이슈들이 많이 있었지만 모두 해결하여 번들 코드가 Hermes에서 구동될 수 있게 되었다. 기쁨도 잠시, esbuild를 통해 생성한 번들을 실행시켰을 때 심각한 성능 문제가 발생한다는 것을 발견했다.
앱을 처음 실행 시켰을 때 번들을 로드하고 화면을 렌더링하게 되는데, 일반적인 상황이라면 수 초(2~3초) 내로 끝나야 한다. 그러나 20초가량 흰 화면 상태에 머물러 있다가 화면이 보여지는 심각한 성능 문제였다.
단, 몇 가지 특이 사항이 있었는데, 20초 가량의 시간이 지나면 앱이 정상적으로 실행되고 더이상의 성능 이슈는 발생하지 않았다는 것, JSC 그리고 V8(Debugging Proxy) 런타임에서는 아무런 성능 문제 없이 잘 실행된다는 점이었다. 추가적으로 릴리즈 빌드에서 적용되는 Hermes 사전 바이트 코드 컴파일을 거쳐도 문제는 해결되지 않았다.
이러한 상황속에서 에러나 Stack trace 없이 수십만 줄의 번들 코드를 일일히 살펴본다는 것은 불가능에 가까웠다. 그래서 Chrome Debugger 를 켜서 초기 실행 시점에서 프로파일링(Profiling)을 진행하게 되었다.
프로파일링을 통해 발견한 부분을 번들에서 찾으니 아래 코드에서 성능 문제가 발생하는 것임을 확인했다.
__esm
, __commonJS
둘 다 esbuild로부터 주입되는 함수인데, Object.getOwnPropertyNames가 Hermes에서 심각한 성능 문제를 일으키는 것이 아닌 이상 문제가 발생할 수 없는 코드이다.
블록 스코프 버그처럼 혹시 화살표 함수 표현식에 성능 문제가 있는 것인가, 중첩된 함수 스코프 내의 매개변수에 접근할 때 느려지는 것인가, 매개변수 재할당 그리고 참조에 성능 문제가 있는 것인가 등 여러 가능성을 열어두고 해결하고자 노력했다.
계속되는 가설과 검증, 그리고 완벽한 실패 과정을 반복하여 좌절하고 있던 중 esbuild 번들 포맷을 살펴보게 되었다.
이 당시 esbuild 번들 포맷을 IIFE(즉시 실행 함수 표현식)로 사용하도록 구성했기에 번들 코드 내에서 모든 모듈들은 아래와 같이 함수 내에 감싸여진 상태였다.
여기서 이 IIFE를 제거한 후 번들을 실행해보니, 무슨 일이 있었나 싶을 정도로 빠른 속도로 번들이 실행되었다.
단지 감싸고 있는 함수를 제거했을 뿐인데, 심각했던 성능 문제가 한 번에 해결되었다. 한편으론 안도의 한숨을 내쉬었지만, 반대로 오랜시간 삽질하던 문제가 간단한 수정으로 해결되었다는 것이 다소 어이없기도 했다.
실제로 프로파일링을 다시 진행해보니 정말 성능 이슈가 깔끔하게 해결되었다.
우선 해결 방법은 찾았지만, 왜 이런 동작을 하는지 근본적인 원인 파악을 해보고자 다시 문제를 찾아나섰다.
문제가 발생했던 상황처럼 모듈을 함수로 한 번 감싼 형태, 그리고 함수 없이 모듈이 전역에 노출되어있는 형태의 데모 코드를 준비했다. 색상으로 강조해둔 부분은 각각 아래와 같다.
- 파란색: esbuild 모듈 함수와 동일한 구현체
- 노란색: 모듈 정의 및 구현
- 빨간색: 모듈 참조
Hermes 소스코드를 내려받아 빌드하고, 해당 환경에서 데모 코드를 직접 실행시켜보았다.
그러나 데모 코드의 코드 양이 많지 않아서인지 눈에 띄는 부분 성능 이슈는 발견하지 못했다. 코드는 우리가 눈으로 보기 쉬운 형태로 작성된 데이터일 뿐이고 실제로 코드가 어떻게 동작하는지 살펴보려면 조금 더 낮은 수준에서 분석을 진행해봐야겠다는 생각을 했다.
코드는 런타임(엔진)에서 실행되고, 엔진은 코드를 바이트 코드(Bytecode)로 변환하여 실행하기 때문에 이 부분을 살펴보기로 했다.
Hermes에서 기본적으로 지원하는 플래그를 추가하여 쉽게 바이트 코드를 확인할 수 있다. 먼저 성능 문제가 발생하던 IIFE로 감싼 코드의 바이트 코드를 살펴보자.
빨간색 부분은 전역(Global) 스코프, 파란색 부분은 IIFE 함수의 내부(Local) 스코프를 바이트 코드 내에서 확인할 수 있었다.
바이트 코드를 자세히 살펴보기에 앞서, Hermes 엔진에서 지원하는 명령어들은 아래 코드에서 확인해볼 수 있다.
https://github.com/facebook/hermes/blob/v0.12.0/include/hermes/BCGen/HBC/BytecodeList.def
노란색 부분을 보면 IIFE 내부 스코프에서 CreateEnvironment
명령어를 통해 환경(Environment)을 생성하고 있다. 여기서 이야기하는 환경은 자바스크립트의 실행 컨텍스트 즉, 클로저와 식별자 등이 저장되는 추상적인 개념 정도로 생각하면 될 것 같다.
파란색 부분을 보면 실제로 클로저를 생성하여 환경에 할당하는 명령어를 확인할 수 있다.
이어서 살펴보면 빨간색 부분이 모듈 정의 그리고 할당을 처리하는 명령어들이다. 객체와 클로저를 생성하고 __commonJS
를 호출하고, 앞서 생성한 환경(Environment)에 할당하고 있다.
이어서 정의한 모듈이 호출되는 부분을 살펴보도록 하자.
노란색 부분을 살펴보면 GetEnvionment
명령어를 통해 앞서 생성한 환경(Environment)을 가져온 후 빨간색 부분에서 LoadFromEnvironment
명령어로 모듈을 참조하고 있는 모습을 확인할 수 있다.
정리하면 환경(Environment)을 가져온 후 해당 환경에서 모듈 참조를 로드하고 있다. 우선 이정도까지 확인하고 IIFE 가 없는 코드는 어떻게 동작하는지 살펴보자.
모듈을 감싸는 IIFE가 없다보니 모든 값들이 전역 스코프 내에서 정의되고 할당되어있다.
초록색 부분을 보면 GetGlobalObject
명령어를 통해 전역 객체 참조를 가져온 후 각 모듈을 전역 객체에 PutById
명령어로 ID 값과 함께 할당하고 있다.
이어서 모듈을 참조하는 부분을 살펴보자.
참조하는 과정 역시 전역 객체를 가져온 후 ID를 통해 모듈 참조를 가져오고 있다.
React Native 프로젝트의 모듈은 서로 참조 가능하고, 중첩될 수 있다. 예를 들어 A 모듈을 B, C모듈에서 참조하고 C 모듈에서는 D, E 모듈을 참조하는 등 의존 관계 그래프는 복잡하고, 규모가 커질수록 모듈을 찾는데 필요한 깊이(Depth)는 더욱 더 깊어진다.
IIFE로 감싼 코드의 경우 환경(Environment)을 생성하고 해당 컨텍스트에 모듈 참조를 할당한다. 그리고 참조할 때에는 환경(Environment)을 가져온 후(GetEnvironment
) 참조하고자 하는 모듈을 로드(LoadFromEnvironment
)한다. 복잡한 모듈 의존 관계에서는 모듈을 참조하기 위해 환경(Environment)을 가져오고 로드하는 과정을 계속해서 반복하게 된다.
반면 IIFE가 없는 경우에는 무조건 전역 객체에 ID로 모듈 참조를 할당(PutById
)하고, 가져올 때에도 ID를 통해 바로 가져온다(GetByIdShort
). 즉, 모듈 의존 관계가 아무리 복잡하더라도 전역 객체에서 ID로 즉시 할당하고 접근하기 때문에 성능상 이득이 훨씬 크다.
결국 문제로 돌아와보면 모듈들을 함수로 감싼것이 원인인가 싶을 수 있다. 다시 이야기 하자면 JSC, V8 런타임에서는 IIFE로 모듈들을 감쌌더라도 아무런 성능 이슈가 없었고 Hermes 환경에서만 이슈가 발생했다. 필자 개인적으로는 Hermes에서 바이트 코드 최적화를 제대로 수행하지 못한 것이 원인이라고 본다.
드디어 성능 이슈의 원인과 해결 방법 모두 발견했다. esbuild 번들에서 IIFE를 어떻게 없애는지가 관건인데, esbuild 번들 형식을 IIFE에서 CJS 혹은 ESM으로 구성하면 IIFE 없이 전역에 모든 모듈을 노출시키는 형태로 번들을 생성할 수 있다.
실제 프로젝트에서 단 한 줄 만으로 성능 이슈를 수정했고, 기록은 아래에서 확인 가능하다.
https://github.com/leegeunhyeok/react-native-esbuild/pull/7/files
성능 그리고 변화
기존 Metro와 비교했을 때 성능이 꽤나 많이 향상되었다. 프로젝트 규모가 작은 경우 약 2배 가까운 성능을 보여주지만, 규모가 커지면 커질수록 성능 향상폭은 더 커졌다. 아래 참고 자료는 프로덕션에 배포되어있는 실제 프로젝트를 대상으로 진행한 초기 버전 테스트 결과이다.
모두 캐시를 제거한 뒤 번들링을 진행했는데 Metro는 약 45초, 자체 개발한 번들러는 약 7초 가량 소요되었다.
대략 600%, 즉 6배의 성능 향상폭을 경험할 수 있었다.
AS IS
- 느린 속도
- 캐시 문제로 인한 비일관적인 빌드
- 느슨(Loose)한 모듈 Resolution
- 트리 셰이킹 미지원
- 개발 편의 기능(HMR, Hot Module Replacement) 제공
TO BE
- 빠른 속도
- 캐시 문제 해결로 일관적인 빌드
- 엄격(Strict)한 모듈 Resolution
- 트리 셰이킹 지원
- 다소 부족한 개발 편의 기능(Live Reload) 제공
많은 부분에서 자체 개발한 번들러가 우세하지만, 치명적인 단점 또한 존재한다.
그것은 바로 HMR을 지원하지 않는 것인데, esbuild은 HMR을 지원하지 않는다. 이로 인해 개발 중 코드를 수정했을 때, 해당 부분을 즉시 반영하지 못한다. 대신 실시간 새로고침 기능을 구현하여 추가해둔 상태이다. HMR에 대한 내용은 아래에서 다시 이야기 해보도록 하자.
번들러 살펴보기
Custom CLI
개발 목표 중 하나가 기존 React Native CLI 와 호환성을 갖추는 것이었는데, 어느정도 달성했다.
개발 서버를 실행시키는 start
커맨드, 번들을 생성하는 bundle
커맨드 등을 구현했고, 주요 옵션들도 기존 CLI와 동일하게 구현했다.
덕분에 Android Stuido, XCode 등에서 릴리즈 빌드를 수행할 때 자체 개발한 번들러를 사용할 수 있도록 간단하게 마이그레이션 할 수 있다. 자세한 내용은 아래 문서를 참고하도록 하자.
https://react-native-esbuild.vercel.app/native-setup/android
https://react-native-esbuild.vercel.app/native-setup/ios
Dev server
개발 모드에서 번들을 실행시킬 수 있도록 CLI를 통해 환경을 제공한다.
Hot Module Repacement(Hot Reload)
앞서 esbuild가 HMR을 지원하지 않아 대신 실시간 새로고침 기능을 제공하고 있다고 이야기 했다. 실제 개발 환경에서 사용해보면, 코드 한 줄만 변경하더라도 변경한 부분이 아닌 앱 전체가 새로고침되어 꽤나 불편함을 느낄 수 있다.
빠른 빌드 속도 뿐만 아니라 개발 경험도 중요한 목표 중 하나였기에, 자체적으로 HMR 기능을 구현하고 있다.
HMR은 모듈 단위로 변경된 부분을 대체하여 런타임에 반영하는 반면 Hot Reload는 변경한 코드 부분만 반영하는 방식이다. 조금은 다르지만 둘 다 동일한 기능을 수행한다고 봐도 무방하다.
다시 본론으로 돌아와서, 현재 작업 중인 내용은 완전한 Hot Module Replacement는 아니고 React에서 공식적으로 제공하는 react-refresh 를 기반으로 React 컴포넌트 대상의 Hot Reload를 구현하는 중이다.
PoC를 진행하여 동작하는 것을 확인했고 현재 계속해서 개발 중이다. react-refresh
의 동작 방식은 컴포넌트를 런타임에 등록하고, 훅을 사용하는 경우 이를 구분하기 위한 시그니쳐를 생성하여 react-fresh/runtime 컨텍스트에 저장해둔다.
이후 변경된 컴포넌트를 발견하게 되면 React의 Reconciler로 해당 사항을 전달하여 상태(State)를 유지한체 컴포넌트를 Re-render 하거나, 아예 Re-mount 처리하여 UI에 변경 사항을 반영한다.
여기서 중요한 부분은 "변경된 컴포넌트(모듈)를 발견" 그리고 "변경 사항 전달" 두 가지이다. esbuild는 HMR을 지원하지 않고, 코드가 수정되더라도 어느 코드(모듈)가 수정되었는지 알려주지 않고 전체 재빌드를 수행하기 때문에 esbuild에서 제공하는 watch 기능을 포기하고 커스텀 파일 시스템 감시자를 구현하는 방향으로 작업을 진행하고 있다.
특정 파일이 변경되면 해당 파일만 변환(Transform) 처리하고, 변환된 코드는 웹 소켓을 통해 React Native 애플리케이션 런타임으로 전달한 후 평가eval()
한다 (정확하게는 globalEvalWithSourceUrl() 이다). 여기서도 단순히 변환된 코드를 실행하면 문제가 발생하는데, 바로 모듈의 의존 관계이다.
컴포넌트를 구현한 모듈 내에서 참조(import
)하고 있는 외부 모듈이 존재하는 경우 외부 모듈을 정상적으로 참조 수 있어야 한다. 이를 해결하기 위해 사전에 모듈의 참조(Reference)를 전역 객체에 저장해두고, 해당 모듈을 참조하는 방향으로 구현하고 있다. 단, 참조하고 있는 모듈이 변경될 경우에는 어쩔 수 없이 다시 전체 빌드를 진행해야 한다.
모듈 참조 구문을 변경하는 작업은 swc 기반의 커스텀 플러그인을 구현하여 처리하려고 한다.
esbuild에서 HMR을 지원하게 된다면 이러한 작업은 대체될 수 있으나, esbuild가 추구하는 방향(부가적인 기능을 최소화하여 빠른 퍼포먼스 유지)과 맞지 않기에 공식적으로 HMR 기능은 제공되지 않을 가능성이 높다.
현재까지 시도한 PoC를 기반으로 작업한 결과, 어느정도 HMR 기능이 동작하고 있다.
본 작업은 저장소의 이슈로 생성되어있으며, 기능 구현 및 테스트가 완료될 경우 릴리즈 할 예정이다.
https://github.com/leegeunhyeok/react-native-esbuild/issues/38
HMR에 대해 조금 더 자세히 알고 싶다면 아래 글을 참고하도록 하자.
Web
React Native를 기반의 애플리케이션을 개발하는 경우 대부분 네이티브(Android, iOS)를 타겟으로 한다. 알다시피 React Native는 여러 플랫폼을 지원하는데 그중 웹이 포함된다.
웹을 위한 개발 서버도 제공하고 있으며 추가적인 구성 없이, serve
라는 커맨드를 통해 웹 개발 서버를 실행시킬 수 있다. 빌드를 할 경우에는 네이티브와 동일하게 bundle
커맨드를 사용하면 된다.
아래는 하나의 코드 베이스를 네이티브(iOS)와 웹에서 각각 실행시킨 결과물이다.
Jest transformer
번들링을 수행하면서 코드 변환을 진행한다. 이를 파이프라인이라는 객체로 추상화 했다고 이야기 했는데 이 파이프라인을 그대로 가져와서 Jest 테스트에 사용할 수 있도록 구현했다.
React Native는 기본적으로 babel-jest를 사용하도록 구성되어있는데, 이를 자체 구현한 변환 기능으로 대체할 수 있다. 이를 실제 프로덕션 프로젝트에 적용해 보았는데 빌드 시간과 동일하게 월등히 빠른 속도를 보여주고 있다.
마무리하며
길다면 길고 짧다면 짧은 여정을 통해 여기까지 오게 되었다. 전해주고 싶은 이야기들은 훨씬 더 많은데, 정리하는 데 한계가 있어 이정도 공유하려고 한다.
본 프로젝트는 본업이 있다보니 개인 시간이 있을때마다 틈틈히 개발을 진행했고, 번들링 그리고 완벽하게 번들을 실행하는데까지 약 1.5개월 가량 소요되었다. 중간에 사내 팀에게 공유를 했었는데, 도전하는 것에 있어 개방된 분위기였기에 업무에서 esbuild 번들러 개발하고 프로젝트에 적용할 수 있도록 어느정도의 시간을 할당 받기도 했었다.
돌이켜보면 필자 본인이 한 일은 잘 만들어진 오픈소스를 조합해서 React Native가 갖는 특성에 맞게 커스터마이징 한 것이 전부이다. 과거였다면 자체 번들러를 처음부터 개발하는 것이 불가능(상당히 어려운)에 가까운 일이었겠지만, 훌륭한 오픈소스들이 있었기에 여기까지 올 수 있었다. 개발자는 단순히 개발하는 것 뿐만 아니라 이처럼 오픈소스를 본인 코드에 잘 녹여내는 것도 중요한 능력이라는 생각을 느꼈다.
또한, 개발하면서 마주할 수 있는 여러가지 문제들이 있는데 이는 예측이 불가할 뿐더러 플랫폼 종속적인 경우도 있어 상당히 깊은 수준을 요구하는 경우도 있었다. 필자 본인은 숨겨져있는 곳을 살펴보는걸 좋아한다. 예를 들면 "자바스크립트가 어떻게, 왜 이렇게 동작할까?" 등과 같은 것이다.
프로그래밍에서 범용적으로 사용하는 디자인 패턴 같은 것 말고 왜 자바스크립트 언어를 파고 있나요?
이는 실제로 본인이 과거에 받았던 질문이다. 일반적인 상황에서는 개발을 할 때 언어 혹은 플랫폼에 대한 깊은 이해가 불필요할 수 있고 오히려 시간 낭비가 될 수 있다는 것에 대한 의견 자체는 어느정도 공감한다. 하지만, 애플리케이션을 넘어 그 아래로 파고 들게 되면 이러한 지식들은 반드시 요구된다는 것을 이번 계기에 확실히 알게 되었고, 결코 헛된 노력이 아니라는 것을 알게 되었다.
이번에 번들러를 개발하면서 React Native 그리고 Metro 번들러의 구조를 분석해볼 수 있었고 기존에 자바스크립트의 동작을 조금이나마 파헤쳐 보고자 살펴본 경험이 있었기 때문에, 작업하면서 발생했던 문제들을 해결할 수 있었다고 생각한다.
이번 글은 여기까지 하고 마무리 하려고 한다. 참고로 지금까지 소개한 esbuild 기반의 React Native 번들러는 모두 오픈소스로 공개되어있으며 아래 저장소에서 확인 가능하다.
https://github.com/leegeunhyeok/react-native-esbuild
관련하여 궁금한 점이 있거나, 이슈를 발견한 경우 아래 저장소에 이슈로 남겨주거나 이메일로 보내주길 바란다.