Skip to main content

React Native, Metro를 넘어서 (1,2)

About 4 minJavaScriptTypeScriptReact.jsReact NativeNext.jsArticle(s)blogyozm.wishket.comjsjavascripttstypescriptreactreactjsreact-jsreact-native

React Native, Metro를 넘어서 (1,2) 관련

React.js > Article(s)

Article(s)

React Native, Metro를 넘어서 (1) | 요즘IT
React Native, Metro를 넘어서 (1)

[FEConf2023에서 발표한 <FEConf 2023 [B4] React Native, Metro를 넘어서>open in new window][1]를 정리한 글입니다. 발표 내용을 2회로 나누어 발행합니다. 1회에서는 Metro에서 ESBuild로 번들러를 전환하게 된 배경을 소개하고 나아가 번들러가 무엇인지와 번들러의 역할을 수행하기 위한 기능인 Resolution과 Load, Optimization을 알아봅니다. 2회에서는 Optimization의 Minification과 Tree Shaking를 소개하고, 토스에서 Metro를 ESBuild로 바꿨던 여정을 이야기합니다. 본문에 삽입된 이미지의 출처는 모두 이 콘텐츠와 같은 제목의 발표 자료로, 따로 출처를 표기하지 않았습니다. 발표 자료는 FEConf2023 홈페이지open in new window에서 다운로드할 수 있습니다.

토스에서 프론트엔드 개발자로 일하고 있는 박서진이라고 합니다. 이 글에서는 저희가 매일 사용하고 있는 Metro, webpack 아니면 ESBuild와 같이 그런 번들러가 무엇인지에 대해서 자세하게 알아보겠습니다. 번들러가 무엇인지를 알면 Metro도 ESBuild도 생긴 것만 다른 똑같은 번들러라는 걸 알게 되고, 두 번들러를 (쉽지는 않습니다만) 갈아끼울 수 있다는 것도 알게 될 것입니다.


번들러 전환 배경: Metro에서 ESBuild로

토스에서는 다양한 서비스를 React Native로 개발하고 있습니다. 혜택, 피드 광고, 병원비 돌려받기와 같은 다양한 도메인의 제품이 React Native로 개발되고 있습니다.

토스와 React Native
토스와 React Native

React Native를 이용해서 개발하다 보면 자연스럽게 Metro라고 하는 것을 사용하게 됩니다. 웹에 웹팩이 있다면 React Native에는 Metro가 있습니다. 그야말로 개발 서버를 띄울 때부터 프로덕션에 배포할 때까지 정말 많은 곳에서 만나게 되는 도구입니다.

그런데 Metro를 쓰다 보면 되게 다양한 문제점을 만나게 됩니다.

Metro
Metro

첫째, '--reset-cache'라고 하는 굉장히 유명한 옵션이 있습니다. 일반적인 웹 개발을 하는 것과 다르게 수동으로 매번 캐시를 날려줘야 하다 보니 좀 불편한 점이 있습니다. 둘째, 별거 아닌 서비스임에도 불구하고 데브 서버부터 프로덕션 빌드까지 상당하게 오랜 시간이 걸렸습니다. Metro의 로딩 인디케이터가 있는데, 그 인디케이터를 보다 보면 많이 답답하고는 했습니다. 셋째, 현대적인 어떤 그런 번들러와 다르게 Tree Shaking과 같은 최적화 도구를 지원하지 않아서, 빌드된 결과물이 필요 이상으로 크기도 했습니다.

토스에서는 이런 문제들을 ESBuild라고 하는 번들러를 도입함으로써 해결했습니다. 단순히 번들러를 교체한 것만으로도 reset-cache라고 하는 것을 더 이상 기억하지 않아도 됐고, 빌드 속도도 눈부시게 빨라졌으며, Tree Shaking을 통해서 사용자들이 내려받아야 하는 JavaScript의 파일 크기도 많이 줄일 수가 있었습니다.

esbuild
esbuild

그야말로 마법과 같은 결과라고도 할 수 있습니다. 그래서 앞서 소개해 드렸던 바와 같이 Metro와 ESBuild와 같은 그런 번들러가 구체적으로 어떤 역할을 하는지에 대해서 알아보면서, 토스가 어떻게 Metro에서 ESBuild로 전환할 수 있었는지를 소개해 드리려고 합니다.


번들러: Metro와 ESBuild (+Webpack)는 무엇인가?

번들러가 무엇인지에 대해서 알기 위해서는, 먼저 번들러가 왜 필요한지를 먼저 알아야 합니다. React Native 아키텍처는 언뜻 보면 굉장히 복잡해 보일 수도 있습니다만, 사실은 굉장히 간단합니다.

먼저 토스는 JavaScript로 코드를 씁니다. 예를 들어서 어떤 뷰를 렌더링하는 코드를 쓰겠죠. 그러면 그 JavaScript 코드를 hermes나 아니면 v8과 같은 JavaScript 엔진이 실행되면서 네이티브 코드를 대신 호출해 줍니다.

예를 들어서, 뷰를 렌더링하는 거니까 iOS에서는 UIView를 생성하고 안드로이드에서는 android.view를 만드는 겁니다. 마치 도큐먼트의 createElement div를 크롬에서 JavaScript 엔진이 실행하면 div가 새로 생기는 것과 유사합니다.

React Native 아키택처
React Native 아키택처

그런데 React Native 아키텍처에서는 특별한 조치 없이는 JavaScript 파일이 단일 파일이어야 합니다. 여러 개의 파일을 입력으로 넣을 수가 없고 단 하나의 파일만 실행이 가능하다는 뜻입니다. JavaScript에서 개발할 때 파일을 나눠서 개발하는 것은 너무 당연한데요. 특히 리액트인 경우에는 컴포넌트 당 하나의 파일로 만들기도 합니다. 예를 들어, 일반적으로 앱이 있고, 앱에서는 컴포넌트 1과 2가 있으며, 그리고 그 두 개를 합친 것을 렌더링하는 방식으로 개발하고 싶어 합니다.

번들링 (전)
번들링 (전)

그런데 React Native는 앞서 말씀드린 것과 같이 한 개의 파일만 입력으로 받습니다. 그래서 쪼개진 파일을 입력으로 넣을 수가 없습니다.

그러다 보니 딜레마가 생기게 됩니다. 1개 파일에 코드를 다 개발해서 10만 줄 되는 그런 파일을 만들 수도 없고, 그렇다고 여러 개의 파일로 쪼개서 개발하자니 React Native가 이해하지도 못합니다.

이때 번들러라고 하는 것이 등장하게 됩니다. 번들러가 하는 가장 중요한 역할은 저희가 이렇게 쪼개서 개발한 파일을 하나로 합쳐주는 것입니다. 예를 들어서 앞서 언급한 앱 컴포넌트 1과 2로 나누어서 개발된 프로젝트를 이렇게 하나로 합쳐서 만들어주는 것입니다.

번들링 (후)
번들링 (후)

그래서 결과물이 하나의 번들로 만들어지기 때문에, 번들러라고 부르고 있습니다.

이렇게 JavaScript 코드를 하나의 파일로 만들면 이제 React Native에서는 이 파일을 처리할 수가 있습니다. 우리가 기대했던 대로 코드가 올바르게 동작하게 됩니다. 이것이 React Native에서 Metro의 주된 역할이라고 할 수 있겠습니다.

요약하고 넘어가 보겠습니다. 우선 브라우저나 React Native와 같은 개발 환경에서는 파일을 합쳐야 실행할 수 있는 경우가 많습니다. 그런데 일반적으로 개발자들은 쪼개서 개발하는 것을 선호합니다. 이 딜레마를 해결해 주는 것이 번들러이고, 이 번들러는 여러 쪼개진 JavaScript 파일을 합쳐주는 역할을 합니다.


번들러가 하는 일 - Resolution, Load, Optimization

이제 조금 더 깊은 내용으로 넘어가 보려고 합니다. 번들러는 여러 개의 쪼개진 파일을 하나로 합치는 역할을 하지만, 이런 역할을 수행하기 위해서 꼭 필요한 기능들이 있습니다. Resolution 그리고 Load가 그것입니다. 이제 각각에 대해서 알아보도록 하겠습니다.

번들러가 하는 일
번들러가 하는 일

Resolution

먼저 Resolution입니다. 번들러가 파일을 합치는 과정에서는 import 문이나 require 문의 경로를 정확히 아는 게 필수입니다. 예를 들어서 아래 예시에서 첫 번째 라인을 보면 ./App에서 앱을 가져온다고 되어 있는데, 이 ./App이라고 하는 게 정확하게 어떤 경로를 지정하는지를 알아야 합니다.

Resolution
Resolution

그런데 많은 경우에는 이게 모호하다고 할 수가 있습니다. 이 경우에도 ./App.js, ./App.ts, ./App.tsx 그리고 ./App/index.js 그렇게 해서 ./App/index.ios.js까지 굉장히 많은 선택지들이 있다는 것을 알 수가 있습니다. 그래서 Resolution이란 이렇게 모호한 ./App.js이라고 하는 요청을 정확한 파일 경로로 풀어내는 것이라고 할 수 있습니다.

이와 유사하게 굉장히 모호한 상황이 많습니다. 예를 들어, components에서 component1component2import하는 상황을 생각해 봅시다.

Resolution
Resolution

여기에서 components라고 하는 것이 npm에서 설치한 'components'라고 하는 패키지인지, 아니면 src 폴더 안의 components라고 하는 폴더인지, 아니면 lib 폴더 안에 있는 components 폴더인지 일반적으로는 정확하게 알 수 있는 방법이 없습니다. 그래서 요청에 맞춰 파일 경로를 정확하게 찾아주는 방법이 잘 정의되어 있어야 합니다.

이렇게 번들러에서 Resolution이라고 하는 것은 importrequire되는 파일 위치를 정확하게 찾아주는 일이라고 할 수 있습니다. 일반적으로 번들러에서는 이것에 대한 굉장히 좋은 기본 설정을 제공합니다. 그래서 크게 신경 쓰지 않고 개발할 수 있습니다.

Resolution - / 되는 파일 위치를 정확하게 찾아주는 일
Resolution - import/require 되는 파일 위치를 정확하게 찾아주는 일

혹시라도 이것을 커스터마이즈하고 싶은 경우가 있을 수도 있습니다. 예를 들어, 일반적인 경우와 다르게 React Native에서는 ios.js, native.js라고 하는 특수한 확장자를 사용하기도 합니다.

그런 특수한 경우를 잘 처리하기 위해서는 번들러에 여러 가지 설정을 추가할 수 있습니다. 예를 들어, Metro에서는 resolveRequest라고 하는 굉장히 직관적인 이름의 함수를 정의할 수 있습니다. 말 그대로 요청을 정확한 파일 경로로 풀어내는 것입니다. 그래서 이 resolveRequest라고 하는 함수는 moduleName이라고 하는 인자로 요청된 것에 대해서 정확한 파일의 경로 문자열을 반환하는 함수입니다. 이 부분에 대해서 자세한 설명이나 스펙은 메트로 공식 문서를 참고하시면 이해하실 수 있습니다.

Resolution - Metro에서 설정하기
Resolution - Metro에서 설정하기

이와 비슷하게 ESBuild에 대해서도 Resolution에 대한 규칙을 정의할 수 있습니다. 플러그인을 통해서 정의할 수 있는데, onResolve라고 하는 Metro랑 비슷한 이름의 API를 제공합니다. 그래서 resolution 규칙을 설정하고 싶은 필터를 잘 걸고, 필터에 해당하는 요청에 대해서 경로를 찾는 방법을 함수로써 정의할 수 있습니다.

Resolution - ESBuild에서 설정하기
Resolution - ESBuild에서 설정하기

그리고 ESBuild에서는 꼭 이렇게 복잡하게 정의를 하지 않더라도 간단하게 확장자 정도만 추가하고 싶다면, resolveExtensions라고 하는 옵션도 제공합니다. 그래서 굳이 복잡하게 플러그인 형태로 만들지 않고도 간단하게 resolving할 파일 확장자 목록을 정의합니다. 예를 들어, 아래 예시 같은 경우에는 ./test.ios.js, .native.js, .js 순으로 찾도록 하는 것입니다.

Resolution - ESBuild
Resolution - ESBuild resolveExtensions

이렇게 Resolution에 대해서 알아보았습니다. Resolution을 다 끝마친 뒤에는 어떤 일이 벌어진다고 생각하시나요? 굉장히 모호하게 작성되어 있었던 import 문들이 정확하게 어떤 파일들을 가리키는지 명확하게 나타나기 때문에, 이제 그 파일들을 합치기만 하면 최종적인 결과물을 만들어낼 수 있을 것입니다.

Load

그런데 번들러가 추가로 해주는 작업이 하나가 더 있습니다. 바로 Load입니다. 표준 JavaScript만 이용해서 개발하고 있다면 정말 좋겠지만, 사실 대부분의 경우에는 꼭 그렇지만은 않습니다. TypeScript는 JavaScript가 아니기 때문에 아쉽게도 브라우저나 React Native 환경에서 바로 사용할 수 없습니다. 번들러는 합치기만해서는 안 된다는 것이죠. TypeScript를 JavaScript로 바꿔주기도 해야 합니다.

비슷하게, React Native에서는 TypeScript는 아니지만 Flow를 이용하고 있는데요. 특이하게 라이브러리를 제공할 때 표준 JavaScript로 바꾸지 않고 Flow 코드를 그대로 npm에 올리고 있습니다. 이 경우에도 Flow는 표준 JavaScript가 아니기 때문에, 단순히 합치기만 하면 에러가 발생합니다. 이 경우에도 Flow 코드를 JavaScript로 변환시켜줘야 한다는 뜻입니다.

Load
Load

이처럼 번들러는 단순히 파일을 찾는 것에만 그치는 것이 아니라 JavaScript가 아닌 것을 JavaScript로 바꿔줘야 합니다. 이런 작업을 해주는 게 바로 Load입니다. 정확하게 말하자면 저희의 프로젝트 안에서 import되는 것들을 실제로 표준 JavaScript로 옮기는 작업을 Load라고 합니다. 번들러를 쓸 때 이 설정도 정확하게 잘 설정을 해줘야 합니다.

세상에는 굉장히 다양한 Loader들이 있습니다만, 일반적으로 TypeScript나 Flow, 아니면 최신 JavaScript, 옛날에는 optional chaining이나 async await 같은 문법도 완벽하게 표준화가 되어 있지 않은 때가 있었습니다. 그래서 이런 비표준 JavaScript를 Babel이나 SWC와 같은 컴파일러를 이용해서 표준 JavaScript로 바꾸는 Loader를 사용합니다. 특히 React Native에서는 Flow나 TypeScript와 같이 비표준 JavaScript를 쓰는 경우가 많아서 이것을 JavaScript로 바꾸는 방법을 잘 정의해 줘야 합니다.

일반적인 Loader
일반적인 Loader

이것을 Metro에서 어떻게 설정할 수 있는지를 살펴보겠습니다. Metro에서는 transformerPath라고 하는 명령어를 통해서 파일을 transform, 다시 말해, Load하는 방법을 지정할 수가 있습니다. 코드에서와 같이 transformFile이라고 하는 함수는 첫 번째 인자로 filePath를 받아서 해당 filePath에 있는 내용을 읽은 뒤에 결과물을 코드로 반환합니다. 예를 들어서 TypeScript 파일을 읽은 다음에 'Babel'이나 'SWC'로 컴파일 한 다음에 JavaScript 파일을 반환하는 것입니다. 자세한 Load에 관한 설정은 Metro 공식문서에서 볼 수 있습니다.

Load - Metro에서 설정하기
Load - Metro에서 설정하기

이것과 비슷하게 ESBuild에 대해서도 Load 설정을 변경할 수가 있습니다. 이전의 Metro 설정과 매우 유사합니다. 첫 번째 인자의 args.path를 통해서 Load 파일을 지정하면 이것을 읽어서 변환된 결과를 JavaScript로 반환하는 모습을 볼 수가 있습니다.

Load - ESBuild에서 설정하기
Load - ESBuild에서 설정하기

지금까지 Resolution과 Load 과정에 대해서 살펴보았습니다. 번들러는 이처럼 Resolution 과정을 거치면 import 되는 파일을 모두 찾아볼 수가 있고, 로드 과정을 거치면 import되는 파일이 TypeScript나 Flow라고 하더라도 표준적인 JavaScript로 모두 바꿀 수 있습니다. 그래서 import되는 JavaScript 파일들을 모두 다 준비한 상태이므로 번들러는 이 파일들을 모두 합치기만 하면 됩니다. 그러면 완전한 1개의 JavaScript 파일이 완성됩니다.

Resolution + Load 결과
Resolution + Load 결과

Optimization

그런데 번들러가 하나의 파일로 합쳐졌다고 하더라도 이 상태로는 프로덕션에 배포할 수가 없습니다. 왜냐하면 번들링을 하는 것만으로는 파일이 너무 커서 성능이 떨어지기 때문입니다. 예를 들어, 일반적인 React Native나 웹 프로젝트에서는 상당한 양의 의존성을 사용하게 됩니다. 토스만 하더라도 당연하게 사용하는 리액트 외에도 디자인 시스템, 스타일링, 애니메이션, 날짜 계산 이런 것들을 위해서 굉장히 다양한 라이브러리들을 사용하고 있는데, 특별한 최적화 없이 이 파일들을 단순히 합치는 것만으로는 파일이 너무 커져 성능이 떨어지게 됩니다.

Optimization - 번들러의 또 하나의 역할
Optimization - 번들러의 또 하나의 역할

그래서 번들러는 Resolution과 Load 외에 Optimization, 다시 말해서 최적화 작업을 수행합니다. 파일 크기를 작게 하기 위해서 다양한 작업을 수행한다고 할 수가 있습니다.

파일 크기를 줄이는 테크닉에는 크게 Minification과 Tree Shaking이 있습니다. Metro와 ESBuild의 차이를 조금 더 명확하게 알기 위해서, 이 각각의 테크닉이 구체적으로 어떤 역할을 하는지, 2회에서 알아보도록 하겠습니다.


React Native, Metro를 넘어서 (2) | 요즘IT

React Native, Metro를 넘어서 (2)

Optimization

Mnification

먼저 Minification의 Compression입니다. Compression은 말 그대로 소스 코드에서 압축할 수 있는 텍스트를 최대한 압축하는 걸 말합니다. 예를 들어서 undefined라고 하는 값은 void 0이라고 표현했을 때 세 글자가 줄게 됩니다. 비슷하게 '2 + 3'을 '5'로 줄인다면 파일 크기를 좀 더 줄일 수 있습니다. 그 외에 조건문이나 아니면 'Infinity' 같은 코드도 특수한 처리를 하면 짧게 줄일 수 있습니다. 이 외에도, 이것보다 사실 훨씬 복잡한, 그렇지만 동일한 코드 결과물을 내는 다양한 압축 방법론이 있습니다. 궁금하신 분들은 'Terser'라고 하는 유명한 오픈소스 프로젝트를 참고하시기를 바랍니다.

Mnification 1 - Compression (압축하기)
Mnification 1 - Compression (압축하기)

더 나아가서, Minification은 두 번째로 Mangling이라고 하는 테크닉이 있습니다. 이것은 변수나 클래스 아니면 함수의 이름을 바꿈으로써 파일 크기를 줄이는 테크닉입니다. 개발자들은 소스 코드를 작성할 때 읽는 사람이 이해하기 쉽도록 다소 길더라도 좀 더 설명적인 이름을 붙이고는 합니다.

하지만, 이렇게 붙여진 이름은 결과물의 파일 크기가 굉장히 커지도록 만듭니다. 그래서 번들러는 기본적으로 이렇게 이름을 짧게 바꿔줌으로써 파일 크기를 줄여줍니다. 아래 예시에서도 num 1num 2를 각각 'n'과 'r'로 바꿈으로써 상당히 줄어든 것을 볼 수가 있습니다.

Mnification 2 - Mangling (이름 바꿔주기)
Mnification 2 - Mangling (이름 바꿔주기)

번들러가 만든 배포 결과물을 보는 경우가 많을 것 같은데, 파일을 열어보면 이렇게 이름이 읽기 어려운 형태로 많이 바뀌어 있는 것을 보실 수가 있습니다. 파일 크기를 줄이기 위해서입니다.

Tree Shaking

Tree Shaking은 기존의 Minification과는 약간 접근이 다릅니다. 기존의 Minification은 코드 동작을 최대한 유지한 상태로 코드를 압축하는 데에만 머물렀다면 Tree Shaking은 import 된 코드 가운데 안 쓰는 코드를 선제적으로 삭제하는 테크닉입니다. 안 쓰는 코드를 삭제한다니까 정말 좋을 것 같은데, 실제로는 Tree Shaking은 굉장히 어려운 작업입니다.

왜 어려운지 두 가지 예시를 통해서 이거 한번 살펴보겠습니다. 아래 예시를 보시면, 먼저, 첫 번째 코드는 toss/utils 함수 라이브러리에서 add라고 하는 함수를 가져와서 쓰는 코드입니다. 이 코드를 살펴보면 add 함수 말고도 다른 함수들은 안전하게 삭제할 수 있을 것 같이 생겼습니다.

두 번째 코드는 약간 다릅니다. 이 코드는 어떤 라이브러리를 초기화하는 코드를 import를 하고 있지만, 그런데 그 import 문에서 아무것도 가져오고 있지 않습니다. 여기서 두 번째 코드에서는 아무것도 가져오고 있지는 않으니, 그냥 import 문을 삭제해 버려도 되는지 의문이 생깁니다. 왠지 안 될 것 같습니다.

Tree Shaking
Tree Shaking

두 번째 예시를 보면 약간씩 불안해지기 시작합니다. toss/utils에서 add를 가져오는 첫 번째 예시에서도 함부로 뭔가 안 쓸 것 같이 보이는 코드를 지웠다가 예기치 못한 문제가 발생하면 어떻게 할지 굉장히 불안해집니다. 그래서 Tree Shaking은 번들러가 구현하는 데 있어서 굉장히 어려운 점들이 많은 기능일 것이라고 추측할 수 있습니다.

그래서 일반적으로는 라이브러리를 만들 때 번들러가 안전하게 코드를 삭제시켜 줄 수 있도록 sideEffects라고 하는 필드를 사용하는데, sideEffects는 번들러가 함부로 지워서는 안 되는 그런 파일 목록을 지정합니다.

예를 들어서, 번들러는 sideEffectsfalse이면, 그 라이브러리 안에서 어떤 파일이 안 쓰였다고 하면 무조건 안전하게 지울 수가 있습니다. 그렇지 않고 sideEffects에 지우면 안 되는 그런 파일 목록이 지정되어 있다고 한다면, 그 파일들은 import 됐는데, 안 쓰고 있다고 하더라도 지워지지는 않습니다.

게다가 이 예시는 Tree Shaking을 가장 쉽게 쓸 수 있는 힌트입니다. 실제 번들러는 이것보다 훨씬 더 복잡한 과정을 거쳐서 Tree Shaking을 수행하기 때문에 번들러 입장에서는 구현하기가 상당히 까다로운 기능이라고 할 수 있습니다.

Tree Shaking
Tree Shaking

그래서 앞서 봤던 코드들 가운데, 코드를 그냥 단순히 더 짧게 만드는 Minification은 대부분의 번들러에서 Terser와 같은 오픈소스 라이브러리를 사용해서 기본적으로 지원합니다.

반면 Tree Shaking은 상당히 구현하기가 까다로운 어떤 그런 기능이기 때문에 일부 번들러에서는 지원하지 못하고 있습니다. 대표적으로 Metro는 아직 Tree Shaking을 지원하고 있지 않습니다. 경우에 따라서 JavaScript 파일 크기가 커질 수 있습니다. 그런데 이제 저희가 옮기려고 하는 ESBuild는 이 기능을 기본으로 지원합니다.


요약 - 번들러가 하는 세 가지 역할

지금까지 번들러가 하는 세 가지의 역할을 살펴보았습니다. 어려운 내용들도 나왔기 때문에 한 번 정리하고 넘어가겠습니다. 번들러는 하나의 JavaScript 번들을 만들기 위해서 Resloution, Load, Optimization의 세 가지 역할을 합니다.

번들러가 하는 일
번들러가 하는 일

먼저 Resloution은 import되는 그런 모호한 파일 경로들을 정확한 파일 경로로 바꿔줍니다. 그래서 번들링된 결과물에 어떤 파일들이 포함되는지를 정확하게 알 수 있도록 해줍니다.

다음으로 Load는 그 파일들이 TypeScript나 Flow처럼 JavaScript가 아니라고 하더라도 JavaScript로 변환해 주는 역할을 합니다. 그래서 Resloution이랑 Load를 하면 번들에 어떤 파일들이 포함되는지를 전부 알 수 있고 그것을 모두 JavaScript로 변환시키는 것이므로, 완벽한 번들을 만들 수가 있습니다.

그런데 그 번들 자체만으로는 번들의 결과가 상당히 크게 되니까 이를 최적화하는 작업이 필요한데, Optimization에서 Minification 그리고 Tree Shaking을 통해서 결과물의 크기를 줄이게 됩니다.


Metro를 ESBuild로 바꾸는 여정

지금까지 살펴봤던 것과 같이 Metro와 ESBuild는 모두 번들러로서 기본적으로 Resloution, Load, 그리고 Optimization 세 가지 역할을 한다고 할 수가 있습니다. 이 각각의 역할들에 대한 설정들을 동일하게 맞춘다고 한다면 Metro가 하는 역할을 거의 그대로 ESBuild로 옮길 수 있다는 말이 됩니다. 그래서 토스 프론트 플랫폼 팀에서는 이렇게 생각했습니다. “기본적으로 Metro랑 ESBuild는 모양만 다를 뿐 똑같은 역할을 하는데 설정을 거의 그대로 옮긴다면 실제로 React Native에서도 ESBuild를 사용할 수 있지 않을까?”

Metro와 ESBuild
Metro와 ESBuild

이런 생각에서 출발해서 실제로 레퍼런스를 찾아보았습니다. 'oblador/react-native-esbuildopen in new window'라고 하는 레포지토리가 있었습니다. 실제로 Metro에서 사용하는 굉장히 다양한 설정들을 ESBuild에서도 사용할 수 있도록 옮긴 것이었습니다.

이 레포지토리를 참조해서 설정을 적용한다면 동일하게 옮길 수 있을 거라는 확신이 있었습니다. 그래서 토스 팀에서는 이 레포스토리의 소스 코드를 살펴보면서 어떤 코드들이 있는지 분석했습니다.

GitHub - oblador/react-native-esbuild: Fast bundler and dev server for react-native using esbuild
Fast bundler and dev server for react-native using esbuild - oblador/react-native-esbuild

저희가 분석 과정에서 알게 된 점을 이야기하려는데요.. 코드의 'line by line'까지는 아니고 번들러의 세 가지 역할인 Resloution, Load, 그리고 Optimization 각각의 관점에서 Metro의 설정을 어떻게 ESBuild로 옮겼는지, 그 핵심적인 코드들을 위주로 소개해 드리려고 합니다. 혹시라도 설명을 들어보시고 더 궁금하신 점이 생기셨다면, 깃허브의 'react-native-esbuild' 레포지토리를 참고하시기 바랍니다.

Resolution

먼저 Resolution입니다. Resolution 관점에서 Metro랑 ESBuild가 가장 달랐던 점은 확장자입니다. 웹에서는 .ts, .tsx, .js처럼 굉장히 단순한 확장자만을 사용하는 반면, React Native에서는 조금 다르게 .ios.js, android.js, .native.js 같은 확장자를 사용하곤 했습니다. iOS에서만 실행되는 코드는 ios.js라고 명시하는 식입니다.

그래서 ESBuild에서도 이 특별한 확장자들에 대응해야 합니다. 이 부분은 다행히도 앞서 소개해 드렸던 resolveExtensionstensions 설정으로 대응할 수 있었습니다. 예를 들어, 첨부한 소스 코드에서와 같이 resolveExtensionstensions를 작성하면 .ios에서 .native 그리고 그렇게 해서 없는 것까지 순서대로 찾는 식이었습니다.

Resolution - resolveExtensionstensions
Resolution - resolveExtensionstensions

Load

다음으로 신경 써야 했던 것은 Load입니다. React Native에서는 특이한 문법들을 많이 사용합니다. 예를 들어서 React Native의 코어는 Flow를 사용합니다. 그리고 또 애니메이션을 사용하는 React Native의 reanimated라고 하는 라이브러리에서는 'worklet'이라고 하는 특수한 함수를 정의합니다.

이런 문법에 대응하기 위해서는 특별하게 Load를 하는 방법을 정의를 해야 했습니다. 아래 소스 코드를 한번 살펴보시면, 모든 프로젝트 파일을 대상으로 Flow 문법을 포함한 경우에는 Babel로 컴파일을 넣어서 Flow 문법을 실행할 수 있는 JavaScript 코드로 바꿔주었습니다. 또 reanimatedworklet 함수를 사용하는 경우에는 특수한 플러그인을 이용해서 함수를 컴파일했습니다. 이렇게 Flow와 TypeScript 그리고 worklet 함수에 대응하도록 하니까 특수한 문법에도 안전하게 대응할 수 있었습니다.

Load - Flow/Typescript Loader
Load - Flow/Typescript Loader

Optimization

특별하게 설정하지 않아도 자연스럽게 이득을 볼 수 있는 경우도 있었습니다. 바로 Optimization입니다. Metro는 이제 ESbuild는 Metro와 다르게 Tree Shaking을 지원하기 때문에 안 쓰는 import들을 지움으로써 파일 크기를 크게 감소시킬 수 있었습니다.

Minification, 다시 말해서 파일 크기 압축의 경우에는 양쪽 번들러에서 모두 다 지원했습니다. 그래서 토스에서는 ESBuild를 도입하는 것만으로도 JavaScript의 파일 크기를 크게 줄일 가 있었습니다.

그 외로 Metro가 특별하게 해주는 것들 몇 개를 대응을 해야 했습니다.

그 외, Metro가 특별하게 해주는 것들
그 외, Metro가 특별하게 해주는 것들

대표적으로는 InitializeCore.js라는, React Native의 코어 영역을 초기화시켜 주는 스크립트를 어떤 코드보다 먼저 실행시켰습니다. 그리고, 'hermes' 엔진 같은, 엔진에서 지원되지 않는 JavaScript의 내장 객체 혹은 API를 위해서 'Polyfill'를 추가했습니다.


결과

가장 기억에 남는 결과는 빌드가 굉장히 일관적으로 변했다는 것입니다. Metro는 번들러를 구현하는 과정에서 내부적으로 많은 캐싱 로직을 쓰고 있었습니다. 그런데 캐싱이 제때 풀리지 않는 경우가 많아서 수동으로 '--reset-cache'라고 하는 옵션을 이용해서 빌드를 해줘야 했습니다. 하지만 ESBuild는 원래부터 워낙 빠르기도 하고, 캐싱이 문제가 되지는 않아서 더 이상 빌드할 때 해당 옵션을 계속 염두에 두지 않아도 됐습니다. 개발 경험이 굉장히 좋아진 것입니다.

두 번째는 빌드 속도가 변했습니다. Metro는 JavaScript로 구현이 되어 있고 ESBuild만큼 동시성을 사용하고 있지도 않아서 빌드 속도가 많이 느렸습니다. 그런데 ESBuild는 Go 언어를 사용하고 있고 Goroutine을 이용해서 동시성을 강력하게 사용하고 있기 때문에 빌드 속도를 거의 10분의 1 가까이 줄일 수 있었습니다. 개발 사이클이 많이 짧아지게 된 것입니다.

결과 - 빌드 속도
결과 - 빌드 속도

마지막으로는, ESBuild에서는 Metro와 다르게 Tree Shaking을 기본적으로 지원했습니다. 그래서 안 쓰는 코드를 훨씬 더 적극적으로 삭제하는 게 가능해졌습니다. 덕분에 토스에서는 React Native의 JavaScript 파일 크기를 크게 줄일 수 있었습니다.

대표적으로 토스에서 사용하고 있는 '내 포인트 서비스'의 React Native 번들의 경우, hermes 바이트 코드 기준으로 원래 Metro에서는 1.8MB 정도 되는 꽤 큰 크기의 사이즈였는데 ESBuild에서는 0.6MB 정도로 크기를 크게 줄일 수 있었습니다.

결과 - Tree-Shaking
결과 - Tree-Shaking

지금까지 어떻게 토스가 Metro에서 ESBuild로 옮기자는 결정하게 됐으며, 대략적으로나마 어떻게 코드를 옮길 수 있었는지에 대해서 소개해 드렸습니다.


마치며

글을 마치기 전에, 지금까지 알아본 내용을 정리해보겠습니다.

발표 내용 되짚어보기
발표 내용 되짚어보기

먼저, 번들러에 대해서 많이 알아보았습니다. 번들러는 쪼개진 파일을 하나로 합치고 최적화하는 도구입니다. 그것을 위해서는 파일 경로를 정확하게 찾는 Resolution, 그리고 TypeScript나 Flow처럼 비표준 코드를 바로 실행할 수 있는 JavaScript로 바꿔주는 Load 방법을 설정해 줘야 했습니다. 그리고 Optimization 작업으로 결과물을 최적화시켜 줘야 했습니다.

발표 내용 되짚어보기
발표 내용 되짚어보기

그리고 Metro와 ESBuild는 모두 번들러라고 하는 점에서 앞서 언급한 세 가지 부분에 대한 설정을 그대로 옮기면 똑같이 동작할 거라고 생각할 수 있었습니다. 그래서 앞서 'react-native-esbuild'라고 하는 프로젝트를 소개해 드리면서 Resolution과 Load 관점에서 핵심적인 설정들을 설명해 드렸습니다.

그 결과로 토스팀에서는 일관적인 빌드 그리고 큰 속도 개선, 마지막으로 Tree Shaking을 통한 파일 크기 최적화를 얻을 수 있었습니다.


이찬희 (MarkiiimarK)
Never Stop Learning.

  1. FEConf2023에서 발표된 'React Native, Metro를 넘어서 / 박서진 토스 프론트엔드 개발자 ↩︎