들어가며
그린필드(Greenfield)는 앱 전체가 React Native로 시작하는 구조다. 브라운필드(Brownfield)는 기존 Native 앱에 React Native를 모듈 단위로 통합하는 방식이다.
두 방식은 iOS 부트스트랩의 출발점이 다르다. 그린필드는 @main에서 즉시 RCTHost가 올라오지만, 브라운필드는 사용자가 RN 화면에 진입하는 시점까지 초기화를 미룬다.
그린필드 vs 브라운필드
| 그린필드 | 브라운필드 | |
|---|---|---|
| Entry point | @main + RCTReactNativeFactory.startReactNative() | 기존 AppDelegate, RN은 별도 초기화 |
| RN 초기화 시점 | 앱 시작 즉시 | RN 화면 첫 진입 시 (lazy) 또는 앱 시작 시 preload |
| UIWindow | RN이 Window 전체 소유 | Native ViewController 안에 RN View 임베드 |
| 내비게이션 | React Navigation 등 JS 기반 | Native 내비게이션이 RN ViewController를 포함 |
| Feature flag | 불필요 | #if RN_ENABLED 조건부 컴파일 |
브라운필드 진입점: RNBaseViewController
기존 Native 앱에서 RN 화면을 여는 방법은 RCTRootView를 Native UIViewController에 임베드하는 것이다.
// Generic Props 타입으로 JS에 전달할 데이터를 타입 안전하게 관리한다
class RNBaseViewController<Props: Encodable>: UIViewController {
private let moduleName: String
private let initialProps: Props
override func viewDidLoad() {
super.viewDidLoad()
#if RN_ENABLED
let delegate = ReactNativeDelegate()
let factory = RCTReactNativeFactory(delegate: delegate)
delegate.dependencyProvider = RCTAppDependencyProvider()
// Native ViewController가 RN Root View를 소유한다
let rootView = factory.rootViewFactory.view(
withModuleName: moduleName,
initialProperties: initialProps.asParams()
)
view = rootView
#else
let label = UILabel()
label.text = "React Native is disabled"
view.addSubview(label)
#endif
}
}구체적인 RN 화면은 이 base를 상속한다:
class TransportReviewViewController: RNBaseViewController<TransportReviewProps> {
init(orderId: String, token: String) {
super.init(
moduleName: "TransportReviewScreen",
initialProps: TransportReviewProps(orderId: orderId, token: token)
)
}
}
// 기존 Native 내비게이션에서 일반 ViewController처럼 push한다
let vc = TransportReviewViewController(orderId: order.id, token: authToken)
navigationController?.pushViewController(vc, animated: true)부트스트랩 차이
그린필드에서는 앱 시작 시 RCTHost가 즉시 초기화되고 JS Thread·Hermes·번들 로드가 모두 완료된다. 브라운필드에서는 이 시퀀스가 RN ViewController가 viewDidLoad()를 호출하는 시점까지 미뤄진다.
TurboModule: Native ↔ JS 양방향 통신
브라운필드에서는 기존 Native 기능(딥링크 처리, 토큰 갱신, 화면 닫기 등)을 JS에서 호출할 필요가 있다. Codegen을 통해 TurboModule을 생성한다.
TypeScript 스펙이 단일 소스 오브 트루스다:
// NativeSendy.ts — Codegen 입력
import { TurboModule, TurboModuleRegistry } from 'react-native';
export interface Spec extends TurboModule {
closeCurrentComponent(params: CloseParams): Promise<void>;
executeDeepLink(url: string): Promise<void>;
refreshToken(): Promise<void>;
}
export default TurboModuleRegistry.get<Spec>('NativeSendy');Codegen이 이 스펙으로부터 C++ 헤더(NativeSendySpec.h)를 자동 생성한다. iOS 구현체는 이 헤더를 채운다:
// RCTNativeSendy.mm — JSI Bridge 구현
- (std::shared_ptr<facebook::react::TurboModule>)getTurboModule:
(const facebook::react::ObjCTurboModule::InitParams &)params {
return std::make_shared<facebook::react::NativeSendySpecJSI>(params);
}JS에서의 호출은 JSI를 통해 C++ → Objective-C++ → Swift 구현체까지 동기적으로 전달된다.
#if RN_ENABLED: 조건부 컴파일
브라운필드의 장점 중 하나는 RN을 점진적으로 도입하거나 비활성화할 수 있다는 점이다. RN_ENABLED 플래그를 끄면 RN 관련 코드가 컴파일에서 제외되어 완전한 Native 앱으로 빌드된다. RN 번들 없이도 앱이 동작해야 하는 상황(초기 도입 단계, A/B 테스트 등)에 유용하다.
📖 용어 설명
- 그린필드(Greenfield): 처음부터 React Native로 만드는 앱. UIWindow 전체가 RN 소유
- 브라운필드(Brownfield): 기존 Native 앱에 RN을 통합. 특정 화면만 RN으로 구성
- RNBaseViewController: Native ViewController가 RN Root View를 소유하는 브라운필드 패턴
- Codegen: TypeScript 스펙 → C++ 헤더를 자동 생성하는 도구. TurboModule의 타입 안전성을 보장
#if RN_ENABLED: RN 코드를 조건부로 포함/제외하는 컴파일 플래그