개요
이번 글에서는 React Native 애플리케이션을 개발할 때 접하고 있는 기능들이 어떻게 동작하는지 살펴보려고 한다.
Logging
지난 번들러 글에서 살펴본 내용인데, 빌드 타임에 console 폴리필이 코드 최상단에 주입된다는 것을 기억하는가?
해당 폴리필은 React Native 애플리케이션의 로그 포맷 개선 그리고 네이티브로 메시지를 전달하기 위한 기반을 마련하는 폴리필이다.
Native
먼저 React Native 애플리케이션 내에서 기록한 콘솔 로그가 어떻게 네이티브 로그에도 기록되는지 한 번 살펴보도록 하자.
폴리필을 확인해 보면 아래와 같이 nativeLoggingHook이 존재할 경우 콘솔 객체의 메소드를 재정의(Override)한다. 코드를 살펴보면 알 수 있지만, 메시지를 로그 레벨과 함께 nativeLoggingHook 으로 전달하도록 처리되어있다.
nativeLoggingHook
은 이전 글에서도 언급했지만, 네이티브에서 로그를 수신하여 네이티브 로그에 기록하기 위한 함수이다. 이는 JSIExecutor
를 통해 자바스크립트 런타임에 주입되며 bindNativeLogger
메소드에서 주입되는 모습을 확인할 수 있다.
구현부를 살펴보면 인자로 전달받은 logger
로 자바스크립트 런타임으로부터 전달받은 메시지 데이터와 로그 레벨을 전달하고 있다. 여기서 참조하는 로거는 플랫폼별로 다르게 구현되어 있는데, 조금 더 자세히 살펴보도록 하자.
Android
안드로이드의 경우 JSExecutor
가 JNI를 통해 로드될 때 처리되는 OnLoad
(JSC, Hermes)에서 bindNativeLogger
를 호출하게 된다.
Hermes 런타임을 기준으로 살펴보면 아래와 같다.
결국 네이티브의 reactAndroidLoggingHook를 호출하도록 구현되어 있는데, 이를 통해 네이티브 로그를 기록하게 된다.
iOS
iOS도 Android와 유사하게 런타임이 초기화될 때 RCTJSIExecutorRuntimeInstaller에서 bindNativeLogger
를 호출한다.
네이티브의 _RCTLogJavaScriptInternal 를 호출하도록 구현되어 있는데, 이를 통해 네이티브 로그를 기록하게 된다.
자바스크립트 런타임의 로그가 네이티브 로그로 전달되고 기록되는 과정을 정리하자면 아래와 같다.
JSI(nativeLoggingHook
)를 통해 메시지 데이터를 네이티브에서 수신하고, 이를 각 플랫폼에 맞게 구현된 Logger에게 전달하여 로그를 기록하게 된다.
Terminal
네이티브에서 로그를 확인할 수 있지만, 우리가 번들러(Metro)를 실행한 터미널 환경에도 로그를 확인할 수 있다.
이 동작의 시작은 console 폴리필로 다시 거슬러 올라가는데, 폴리필이 적용된 경우 console
객체에 _isPolyfilled 속성을 true
로 정의한다. 해당 값에 따라 번들러로 로그 데이터를 전송할지 결정된다.
지난 번들러 글에서 폴리필 주입 이후 초기화(initializeCore) 코드가 실행된다고 이야기했다.
여기에서 setUpDeveloperTools 모듈이 호출되어 개발 환경이 구성되고, 콘솔 폴리필이 적용된 경우 웹소켓을 통해 번들러 서버로 메시지 데이터를 전송하도록 구성된다.
코드를 살펴보면 기본 콘솔 메소드를 호출과 동시에 HMRClient의 log 메소드를 호출하도록 다시 재정의(Override)되는 모습을 확인할 수 있다.
HMRClient
는 개발 환경에서 HMR(Hot Module Replacement) 기능과 관련된 동작을 처리하기 위한 Client-Side 모듈이다. 이는 Metro 번들러에 구현된 HmrServer 와 웹소켓을 통해 상호작용 하게 되는데, ws://host/hot
주소로 웹소켓 커넥션을 맺게 된다.
자세히 살펴보면 HMRClient
내에서 metro-runtime
패키지의 HMRClient 를 참조하고 있다. 이름이 동일하여 혼동될 수 있으니 이번 글에서는 MetroHMRClient
라고 표기하도록 하겠다.
HMRClient.log
의 실체는 결국 MetroHMRClient.send 인 것을 확인할 수 있다.
여기까지의 과정을 정리하자면, 런타임에서 콘솔 로그를 출력하면 Metro 번들러의 /hot
엔드포인트로 { type: 'log' }
형태의 JSON 데이터를 전송한다.
이렇게 전달된 메지시 데이터는 메트로의 HmrServer 에서 수신하는데, reporter
로 정보를 다시 전달하고 있다.
reporter
는 무엇일까? Metro 번들러 구성에서 확인 가능하며, 기본 값으로 TerminalReporter가 사용되도록 구현되어 있다.
TerminalReporter
구현을 살펴보면, 아래와 같이 수신한 메시지 데이터를 콘솔에 출력하도록 구현된 것을 확인할 수 있다.
logToConsole 로 데이터를 전달하여 색상을 입히고 포맷팅을 거친 후 최종적으로 터미널 상에 로그가 기록된다.
지금까지 살펴본 내용을 정리해 보면 아래와 같이 나타낼 수 있다.
Flipper
Flipper 는 Meta(구 Facebook)에서 개발한 디버깅 도구이다.
React Native 애플리케이션을 개발할 때 유용하게 사용되는데, Flipper에서도 React Native의 로그를 확인할 수 있다. 동작 메커니즘은 터미널에 로그를 전달하는 것과 유사한데, 한 번 살펴보도록 하자.
Flipper 내부에서는 로그 이벤트를 수신하기 위해 ws://host/events
주소로 웹소켓 커넥션을 맺는다.
Metro 번들러 코드를 살펴보면 /events
에 해당하는 엔드포인트를 찾아볼 수 없다. 어디에 구현되어 있는 것일까?
결론을 말하자면 @react-native-community/cli-plugin-metro에서 start 커맨드(개발 서버 실행)를 실행할 때 추가적인 웹 소켓을 구성하도록 Metro 구성에 포함하며, 구현체는 @react-native-community/cli-server-api 패키지 내에서 확인할 수 있다.
그리고 아래와 같은 reporter 를 사용하도록 구현되어 있는데, 앞서 살펴본 TerminalReporter
를 통해 터미널에 로그를 출력하는 것뿐만 아니라, 이벤트 소켓으로 데이터를 전송하도록 구현되어 있다.
이렇게 전송된 메시지 데이터는 Flipper에서 수신하여 로그 메시지를 UI 상에 렌더링 된다.
Flipper로 로그 메시지가 전달되는 과정을 정리하면 아래와 같다.
HmrServer
로 메시지가 전달되는 부분까진 터미널 로그와 동일하다. 다만, 기본값이 아닌 커스터마이징 된 Reporter를 통해 이벤트 소켓으로 메시지를 다시 전달하는 과정이 추가되었고, Flipper에서는 해당 웹 소켓과 커넥션을 맺어 메시지를 수신하게 된다.
HMR(Hot Module Replacement)
HMR(Hot Module Replacement)는 Metro 번들러에서 제공하는 핵심 기능 중 하나로, 개발 환경에서 코드의 변경 사항을 실시간으로 반영해 주는 기능이다. react-refresh 기반으로 구현되어 있으며, 서버와 클라이언트 구현부 모두 상당히 복잡하기에 간략히 살펴보고 넘어가도록 하자.
코드의 변경 사항이 생길 경우 변경이 발생한 모듈과 의존성으로 참조하고 있는 모듈을 다시 빌드한 후 번들의 변경 사항 정보를 HmrServer
를 통해 HMRClient
로 전달한다.
이때 전달되는 메시지의 형식은 { type: 'update' }
형태의 JSON 메시지를 전달한다.
클라이언트에서는 이를 수신하여 런타임에 반영하며, 네이티브의 globalEvalWithSourceUrl 를 호출하게 된다. 이 함수 역시 JSIExecutor
로부터 런타임에 주입된다.
변경에 대한 정보를 수신한 후 동적으로 평가된다는 것까지 살펴보았다. 자세한 HMR 구현은 react-refresh
를 참조하길 바라며, HMR 메시지 타입과 동작 방식을 조금 더 살펴보고 싶다면 아래 코드를 참조하길 바란다.
Build Status
우리가 Metro 번들러를 실행시키고, 개발 모드의 애플리케이션을 실행시키면 Metro 번들러에서는 작업을 큐에 넣고 빌드를 시작하게 된다.
이때, 애플리케이션 상단에는 빌드 진행 상황(%)이 노출되는데 내부적으로 어떻게 동작하는지 한 번 살펴보자.
개발 모드의 애플리케이션을 실행하면 내부적으로 Metro 서버에 접근하여 번들을 로드하려고 시도한다. 이때 접근하는 프로토콜은 HTTP이며 접근하는 주소는 GET /index.bundle
이다.
주소에는 쿼리 스트링이 포함되어 있는데 이를 살펴보면 플랫폼 정보, 개발 모드 여부, minify 처리 여부 등이 포함된 것을 확인할 수 있다.
요청 헤더를 살펴보면 어떤 타입의 데이터를 요청하는지 확인 가능한데, 특이하게도 번들 데이터를 요청하는 것임에도 불구하고 타입은 application/javascript
가 아닌 multipart/mixed
이다.
multipart/mixed
타입은 한 번의 요청에서 여러 가지 타입이 결합된 응답을 수신할 수 있는데, Metro에서 번들 요청을 수신하면 어떤 형태로 데이터를 응답하는지 살펴보자.
먼저 요청을 받게 되면 MultipartResponse 로 wrapping한다. MultipartResponse
는 multipart/mixed
형태에 맞게 응답하도록 구현된 모듈이다.
Content-Type: multipart/mixed; boundary=3beqjf3apnqeu3h5jqorms4i
--3beqjf3apnqeu3h5jqorms4i
header_key: header_value
data
--3beqjf3apnqeu3h5jqorms4i
header_key: header_value
data
--3beqjf3apnqeu3h5jqorms4i
header_key: header_value
data
--3beqjf3apnqeu3h5jqorms4i--
먼저 요청을 수신한 후 클라이언트로 데이터의 경계를 구분하기 위한 boundary
값을 응답한다. 이후 응답이 완료될 때까지 클라이언트로 조각(Chunk) 난 응답을 전달할 수 있다.
각 응답 조각은 --{boundary}
로 구분되고, 응답의 종료는 --{boundary}--
로 구분한다.
Metro 내부에서는 빌드 상태를 주기적으로 이벤트로 수신하고 이를 클라이언트로 응답한다. 이벤트에는 전체 모듈 수, 빌드된 모듈 수가 포함되어 있는데 이를 JSON 형태의 값으로 변환하여 응답한다. 이후 빌드가 완료되었을 때 최종적으로 번들 데이터를 전달한다.
실제로 응답되는 데이터를 살펴본다면 아래와 같은 값이 응답될 것이다.
Content-Type: multipart/mixed; boundary=3beqjf3apnqeu3h5jqorms4i
--3beqjf3apnqeu3h5jqorms4i
Contnet-Type: application/json
{"done": 0, "total": 10}
--3beqjf3apnqeu3h5jqorms4i
Contnet-Type: application/json
{"done": 5, "total": 10}
--3beqjf3apnqeu3h5jqorms4i
Contnet-Type: application/javascript
{BUNDLE_DATA}
--3beqjf3apnqeu3h5jqorms4i--
번들링이 완료되면 번들 데이터를 전달하고 multipart/mixed
응답 종료를 알리도록 구현되어 있다.
이렇게 응답된 데이터는 각 플랫폼에서 수신하여 JSON 파싱을 거쳐 UI 상에 렌더링 하게 된다.
Android는 BundleDownloader, iOS는 RCTJavaScriptLoader에서 처리되는 것을 확인할 수 있다.
마무리하며
오늘 살펴본 내용은 개발과 관련된 내용들로 정리했다. 이미 개발을 하면서 접하고 있는 것들이지만 내부적으로 어떻게 동작하는지 궁금했던 사람들도 있을 것이다.
참고로 이번 글이 React Native Under The Hood 시리즈의 마지막 게시글이며 추후 기회가 된다면 더 다양한 내용에 대해 정리하여 공유할까 한다.
여담이지만, 이 시리즈를 기획하게 된 계기는 사실 Metro 번들러가 아닌 자체적으로 번들러를 개발하는 과정으로부터 시작되었다. 작업을 하면서 엄청난 삽질을 하게 되었는데, 이 과정 속에서 알게 된 내용들을 정리한 결과가 바로 이번 시리즈의 게시글들이다.
자체적으로 개발하던 번들러는 어느 정도 구현이 완료되어 현재 배포되어 있다. 관심이 있다면, 아래 저장소에서 확인 가능하다.
https://github.com/leegeunhyeok/react-native-esbuild
이후 글에서는 번들러를 개발하면서 겪었던 수많은 일들에 대해 간단하게 공유하려고 한다.
마지막으로 이번 시리즈 글을 읽어준 독자들에게 감사하다는 인사말을 남기고 마무리하도록 하겠다.