개요
이번 글에서는 새로운 아키텍처에서 함께 탄생한 개발 편의 기능인 코드젠(Codegen)에 대해 살펴보려고 한다.
Codegen 이란?
React Native 공식 문서에도 잘 설명이 되어있는데, 사용자가 정의한 인터페이스에 맞게 네이티브 모듈과 뷰(View) 코드를 작성해 주는 도구이다. 과거 브릿지 시절에는 네이티브 모듈 혹은 뷰를 구현해야 하는 경우 JavaScript 환경과 네이티브 간의 통신 과정을 직접 구현해야 했는데, 반복적인 작업이고 무엇보다 타입 불일치로 인해 오류가 발생하는 경우가 종종 생기기도 했다.
Codegen은 이러한 과정을 자동화하여 생산성 향상과 타입 안정성을 확보하는 도구로 자리매김하게 되었다.
다음과 같이 package.json 파일에 codegenConfig
필드를 추가하고, 구성 추가하면 Codegen 준비 과정은 끝이다.
TypeScript 혹은 Flow 로 구현하고자 하는 모듈의 사양(Spec)을 정의하면, iOS / Android 앱 빌드 시점에 Codegen 도구가 이를 파싱 하여 네이티브 코드를 자동으로 생성해 준다.
이번 글에서는 사용자가 작성한 사양이 어떤 과정을 거쳐 네이티브 코드로 변환되는지, 변환된 코드는 React Native 환경에 어떻게 바인딩되는지 전반적으로 살펴보려고 한다.
Codegen 명령어
Codegen은 앱이 빌드되는 시점에 자동으로 실행되도록 구성되어 있지만, React Native CLI를 통해 실행할 수도 있다.
npx react-native codegen
React Native 프로젝트 루트 경로에서 실행하면, 프로젝트 내에 존재하는 네이티브 모듈, 설치되어 있는 의존성 정보를 탐색하여 네이티브 코드를 생성해 준다.
Codegen 명령어는 기본 react-native.config.js 파일 내에서 등록되고 있고, 명령어의 실제 구현체는 generate-artifacts-executor 이다.
Codegen 과정 살펴보기
CLI를 통해 Codegen 작업을 수행하던, 앱을 빌드하던 모두 동일한 로직이 수행된다. 구현체를 하나씩 살펴보면서 구체적으로 어떻게 동작하는지 자세히 살펴보자.
Codegen 대상 라이브러리 탐색
프로젝트 루트 경로를 기준으로 package.json
파일과 react-native.config.js
파일을 로드하는 것을 확인할 수 있다. 이 파일들에 포함된 메타 정보를 기준으로 Codegen이 필요한 라이브러리들을 탐색(findCodegenEnabledLibraries)하게 된다.
findCodegenEnabledLibraries
Codegen 처리가 필요한 대상 라이브러리를 찾아야 하는데, 첫 번째로는 루트 package.json
에서 codegenConfig
이 있는지 탐색한다.
만약 codegenConfig.includesGeneratedCode
옵션이 활성화되어있는 경우 Codegen에 의해 생성된 코드들이 포함되어있음을 의미하므로 루트 package.json
에서 찾은 라이브러리만 Codegen 대상으로 처리한다.
반대로 활성화되어있지 않은 경우(일반적인 케이스)엔 외부 라이브러리(findExternalLibraries
) 그리고 react-native.config.js
값을 기준으로 추가로 탐색(findLibrariesFromReactNativeConfig
)하게 된다.
findExternalLibaries
외부 라이브러리를 찾는 과정은 다음과 같이 구현되어 있다.
package.json
파일의 dependencies
, devDependencies
, peerDependencies
에 명시되어 있는 의존성 정보들의 package.json
파일을 로드하고, 해당 파일의 codegenConfig
값을 읽는 구조이다.
extractLibrariesFromJSON 함수를 통해 codegenConfig
구성이 존재하는지 확인한 뒤 라이브러리 정보를 담고 있는 객체를 반환한다.
findLibrariesFromReactNativeConfig
이어서 react-native.config.js
파일에서 Codegen 대상 라이브러리를 찾는 부분이다. 구성에 포함된 dependencies
값을 기준으로 의존성을 찾는다. 그외 과정은 findExternalLibraries
함수와 동일하다.
최종적으로 findCodegenEnabledLibraries
함수의 반환 값은 다음과 같은 모습이다. Codegen 작업을 처리하기 위한 기본적인 정보라고 보면 된다.
이러한 과정을 거쳐 루트, 의존성으로 설치한 라이브러리들의 Codegen 정보를 모두 수집한다. 이제 수집된 정보를 기준으로 코드를 생성하는 작업을 진행한다.
네이티브 코드 생성
플랫폼별로 코드를 생성하기에 앞서, Auto Linking 이 비활성화되어있는 라이브러리를 찾는다. (findDisabledLibrariesByPlatform)
Auto Linking이 비활성화 되어있는 라이브러리인 경우 Codegen 대상에서 제외시킨다.
이어서 Codegen을 통해 생성되는 코드의 경로를 만들어오는 것을 볼 수 있다. (computeOutputPath)
구현체를 살펴보면, 기준 경로(baseOutputPath
)가 없는 경우에는 codegenConfig.outputDir
의 경로 혹은 플랫폼별 경로 codegenConfig.outputDir[platform]
를 사용한다.
그 외 플랫폼별 기본 경로를 따르는데, 경로는 각각 다음과 같다.
- Android:
<ROOT>/android/app/build/generated/source/codegen
- iOS:
<ROOT>/build/generated/ios
코드가 생성되어야 할 위치를 찾은 뒤에는 Codegen을 위한 스키마(Schema) 정보를 수집한다. (generateSchemaInfos)
스키마 정보에는 첫 번째로 애플(Apple) 기기의 지원 플랫폼이 포함된다. extractSupportedApplePlatforms
함수를 통해 라이브러리 내에 존재하는 *.podspec
파일들을 탐색한다. 파일 내에서 다음과 같은 형식을 찾은 뒤 ios, macos, tvos, visionos에 해당하는 플랫폼만 추출한다.
spec.platforms = { :ios => "11.0", :tvos => "11.0" }
# or
s.ios.deployment_target = "11.0"
애플 플랫폼 추출 이후에는 CodegenUtils.getCombineJSToSchema().combineSchemasInFileList()
를 호출하여 codegenConfig.jsSrcsDir
경로 내에 포함된 .js, .ts, .tsx 파일을 찾는다.
찾을 파일들은 combineSchemas 함수로 전달되는데, 파일 내에 다음과 같은 코드가 있는지 확인하고, 조건에 충족하는 파일만 남긴다.
// 1. `export default codegenNativeComponent`
// 2. `extends TurboModule`
// e.g.
export default codegenNativeComponent(/* ... */);
interface Spec extends TurboModule {
// ...
}
위 과정을 통해 걸러진 사양(Spec) 파일은 언어에 맞는 파서(Parser)에게 전달되고, 이를 통해 모듈의 스키마 정보를 추출한다.
스키마는 다음과 같은 형태로 구성되어 있다. (react-native-webview 라이브러리의 실제 스키마)
- modules: 정의된 모듈 정보
- type: 모듈의 유형 (NativeModule, Component)
- NativeModule
- moduleName: 모듈의 이름 (TurboModuleRegistry 에서 가져올 때 참조될 이름)
- aliasMap: Primitive 타입 외의 사용자 정의 타입
- enumMap: Enum 타입
- spec: Event Emitter, Methods 에 대한 사양
- Component
- components: 컴포넌트 정보 (Properties, Events 등 포함)
{
"modules": {
"NativeRNCWebViewModule": {
"type": "NativeModule",
"aliasMap": {},
"enumMap": {},
"spec": {
"eventEmitters": [],
"methods": [
{
"name": "isFileUploadSupported",
"optional": false,
"typeAnnotation": {
"type": "FunctionTypeAnnotation",
"returnTypeAnnotation": {
"type": "PromiseTypeAnnotation",
"elementType": {
"type": "BooleanTypeAnnotation"
}
},
"params": []
}
},
// ...
]
},
"moduleName": "RNCWebViewModule"
},
"RNCWebView": {
"type": "Component",
"components": {
"RNCWebView": {
"extendsProps": [
{
"type": "ReactNativeBuiltInType",
"knownTypeName": "ReactNativeCoreViewProps"
}
],
"events": [
{
"name": "onLoadingProgress",
"optional": false,
"bubblingType": "direct",
"typeAnnotation": {
"type": "EventTypeAnnotation",
"argument": {
"type": "ObjectTypeAnnotation",
"properties": [
{
"name": "url",
"optional": false,
"typeAnnotation": {
"type": "StringTypeAnnotation"
}
},
{
"name": "loading",
"optional": false,
"typeAnnotation": {
"type": "BooleanTypeAnnotation"
}
},
{
"name": "title",
"optional": false,
"typeAnnotation": {
"type": "StringTypeAnnotation"
}
},
{
"name": "canGoBack",
"optional": false,
"typeAnnotation": {
"type": "BooleanTypeAnnotation"
}
},
{
"name": "progress",
"optional": false,
"typeAnnotation": {
"type": "DoubleTypeAnnotation"
}
},
// ...
]
}
}
},
// ...
],
"props": [
{
"name": "pullToRefreshEnabled",
"optional": true,
"typeAnnotation": {
"type": "BooleanTypeAnnotation",
"default": false
}
},
{
"name": "scrollEnabled",
"optional": true,
"typeAnnotation": {
"type": "BooleanTypeAnnotation",
"default": true
}
},
// ...
],
"commands": [
{
"name": "goBack",
"optional": false,
"typeAnnotation": {
"type": "FunctionTypeAnnotation",
"params": [],
"returnTypeAnnotation": {
"type": "VoidTypeAnnotation"
}
}
},
{
"name": "reload",
"optional": false,
"typeAnnotation": {
"type": "FunctionTypeAnnotation",
"params": [],
"returnTypeAnnotation": {
"type": "VoidTypeAnnotation"
}
}
},
// ...
]
}
}
}
}
}
스키마를 살펴보면 네이티브 메서드의 인자 수, 인자의 타입, 반환 타입 / 컴포넌트의 프로퍼티 이름, 타입, 커맨드(Ref)에 대한 세부 정보가 포함되어있는 것을 알 수 있다.
모듈의 스키마를 모두 수집한 뒤에는 스키마 정보를 기반으로 네이티브 코드를 생성한다 (generateNativeCode)
전체적으로 살펴보면, 라이브러리별 임시 디렉터리를 생성하고 해당 경로에 코드를 생성, 마지막에 실제 output 경로로 복사하는 것을 확인할 수 있다.
여기서 중요한 부분은 스키마 정보를 기반으로 코드를 생성하는 부분(generateSpecFromInMemorySchema)이다.
getCodegen을 통해 @react-native/codegen
패키지 혹은 React Native 내에 포함된 Codegen 모듈을 로드하고, Codegen 모듈의 generate 메서드를 호출함으로써 네이티브 코드가 생성된다.
Codegen 내에는 여러 가지 제너레이터(Generator)들이 구현되어 있는데, 이를 통해 사전 정의된 템플릿 기준의 코드들이 생성된다.
예시로 GenerateModuleCpp 모듈에서는 C++ 코드를 생성하도록 구현된 것을 확인해 볼 수 있다.
추가로, 마지막 부분에서 플랫폼이 안드로이드인 경우, 생성된 .cpp
, .h
파일들을 {OUTPUT_DIR}/jni/react/renderer/components/{LIBRARY_NAME}
경로로 이동시키는 부분도 확인해 볼 수 있다.
이런 저런 과정이 많긴 하지만 간단히 요약해보면 다음과 같은 흐름으로 동작하는 것을 알 수 있을 것이다.
- 루트 package.json 로드 & codegenConfig 읽기
- 의존성 라이브러리의 package.json 로드 & codegenConfig 읽기
- 사양(Spec) 파싱하여 스키마(Schema) 정보 생성
- 네이티브 코드 생성
Auto Linking
앞서 사용자가 정의한 모듈의 사양이 네이티브 코드로 탄생하게 되는 과정에 대해 살펴보았다.
생성된 네이티브 코드들은 어떤 모습이며, 이렇게 생성된 코드는 어떻게 React Native 환경에 통합되는지 살펴보자.
Codegen을 통해 생성된 네이티브 코드들은 빌드시 거치는 Auto Linking 과정에서 React Native 환경에 통합된다.
Android
안드로이드 환경에서는 빌드시 Gradle 플러그인(@react-native/gradle-plugin)을 통해 Auto Linking 과정이 진행된다.
내부 구현체 중 GeneratePackageListTask, GenerateAutolinkingNewArchitecturesFileTask 에서 Auto Linking을 처리한다.
Auto Linking 과정을 통해 생성되는 코드들은 <ROOT>/android/build/generated/autolinking
내에 생성되며, Java 파일은 /java
, JNI 통합을 위한 C++ 구현체는 /jni
하위 디렉터리에 생성된다.
GeneratePackageListTask
이 태스크를 통해 Auto Linking 되어야 할 패키지 목록들이 자동으로 생성된다. 수동 링킹을 할 때와 유사한 것을 확인해 볼 수 있다.
GenerateAutolinkingNewArchitecturesFileTask
네이티브 빌드 통합을 위한 CMake 생성, TurboModule, View(Component)를 제공하기 위한 Provider 코드들을 생성하는 것을 확인할 수 있다.
생성된 네이티브 모듈을 제공하기 위해 autolinking 파일을 생성하는데, 헤더 파일을 보면 다음과 같이 정의되어있다.
autolinking_ModuleProvider
, autolinking_cxxModuleProvider
, autolinking_registerProviders
함수들이 정의되어 있고, 각 함수의 구현체는 다음과 같은 형식으로 자동 생성된다.
여기서 생성된 Provider를 통해 TurboModule, Fabric에서 모듈을 참조하기 때문에 이 부분을 잘 기억하고 있자.
iOS
iOS의 경우에도 안드로이드와 유사하게 처리되는데, React Native 프로젝트의 Podfile
을 열어보면 다음과 같은 코드를 볼 수 있다.
여기에서 주의 깊게 볼 부분은 사전 정의된 use_native_modules
, use_react_native
이다.
use_native_modules
여기에서 Auto Linking을 처리한다. 구현체를 조금 살펴보면 네이티브 모듈들을 찾은 뒤 해당 네이티브 모듈의 podspec을 찾아 빌드 타겟에 추가하는 것을 확인할 수 있으며, 이 덕분에 직접 빌드 구성을 손대지 않고도 외부에 설치한 네이티브 모듈을 손쉽게 빌드할 수 있게 된다.
use_react_native
내부 구현을 따라가 보면 Codegen을 처리하는 run_codegen 을 확인할 수 있다.
run_codegen에서는 use_react_native_codegen_discovery 를 호출하는데, 이는 앞서 살펴본 generate-codegen-artifacts
모듈을 실행시켜 코드를 생성하는 역할을 수행한다.
Module Binding
Codegen, Auto Linking을 통해 네이티브 모듈들이 빌드될 수 있게 되었다. 이렇게 생성된 네이티브 모듈들이 React Native 환경에 어떻게 통합되는지 살펴보자.
Android
이전 내용에서 autolinking.h
, autolinking.cpp
파일이 생성되는 것을 확인했다.
안드로이드에서 React Native 애플리케이션이 실행될 때 JNI가 초기화되는데, 이 코드를 잠시 살펴보자
익숙한 이름들이 보이는가? 잘 기억하고 있으라고 했던 그 부분이다 (autolinking.h 내에 정의된 함수들). Codegen을 통해 생성된 C++ 기반 Provider들은 여기를 통해 참조된다.
TurboModuleManagerDelegate
그리고 ComponentsRegistry
에 Codegen을 통해 생성된 Provider 들이 바인딩된다.
바인딩된 제공자를 통해 React Native가 네이티브 모듈 그리고 뷰(View)에 접근할 수 있게 된다.
iOS
Codegen을 통해 build/generated/ios
경로에 RCTAppDependencyProvider
파일이 생성된다.
구현체를 보면 내부에서 Provider 들을 제공하고 있는 것을 확인할 수 있다. 각 Provider는 동일한 Codegen 경로 내에 생성(RCTModuleProviders
, RCTThirdPartyComponentsProvider
)되어있으며 각 파일은 다음과 같이 네이티브 모듈 혹은 뷰(View) 구현체를 바인딩하고 있다.
안드로이드와 동일하게 Codegen된 네이티브 모듈들을 각 Provider가 제공하고 있음을 확인할 수 있고, Provider 들은 RCTAppDependencyProvider
에 포함되는 것을 확인했다.
마지막으로, AppDelegate
파일을 보면 Codegen으로 생성된 RCTAppDependencyProvider
를 참조하고 있는 것을 볼 수 있다.
마무리하며
지금까지 Codegen이 어떤 과정을 거쳐 모듈 사양 정보를 수집하고, 스키마를 파싱하며, 어떤 네이티브 코드들이 생성되는지 살펴보았다.
Codegen이 도입되면서 개발 경험과 안정성을 챙기며, 모듈의 핵심 구현에만 집중할 수 있게 되었기 때문에 전반적인 구조에 대해 잘 알고 있으면 좋을 것 같다.
다음 글에서는 Codegen과 연관되어 있는 터보 모듈(Turbo Module)에 대해 자세히 살펴보도록 하자.