React NativeExpoMetroJSITurboModulesFabricCodegenHermesMobile Development

React Native + Expo + Metro + 네이티브 구현 완전 해설

Sloth255
Sloth255
·2 min read·436 words

React Native를 사용하다 보면 "어떻게 되는지는 모르겠지만 일단 동작한다"는 느낌이 들기 쉽습니다. 이 글에서는 Metro, JSI, Codegen, Expo의 작동 원리부터 Expo SDK로 커버할 수 없는 네이티브 구현 영역까지, 내부에서 무슨 일이 일어나고 있는지를 일관되게 해설합니다.


1. React Native의 핵심 철학

React Native의 핵심은 JS로 작성한 UI 로직을 네이티브 UI 컴포넌트로 변환한다는 점입니다. ReactDOM이 <div>를 DOM으로 변환하는 것처럼, React Native는 <View><Text>를 iOS의 UIView, Android의 android.view.View로 매핑합니다.

즉, WebView에서 HTML을 렌더링하는 것이 아니라 진짜 네이티브 UI를 생성합니다. 이것이 Cordova나 Ionic과의 결정적인 차이입니다.


2. 아키텍처의 진화: Bridge에서 JSI로

React Native에는 현재 두 가지 아키텍처가 공존하고 있습니다.

flowchart LR
  subgraph OLD["구 아키텍처 (Bridge)"]
    direction TB
    J1["JS 스레드<br/>React 로직"] -->|"JSON 직렬화"| BR["Bridge<br/>비동기 큐 ⚠"]
    BR -->|"JSON 역직렬화"| N1["네이티브 스레드<br/>UIKit / Android View"]
    SH["Shadow 스레드<br/>Yoga 레이아웃"] --> N1
  end
  subgraph NEW["신 아키텍처 (JSI)"]
    direction TB
    HM["Hermes 엔진<br/>바이트코드 실행"] -->|"C++ 직접 참조"| JSI["JSI<br/>Host Objects"]
    JSI --> FAB["Fabric<br/>동기 렌더러"]
    JSI --> TM["TurboModules<br/>지연 초기화"]
    FAB --> N2["네이티브 레이어"]
    TM --> N2
  end

구 아키텍처(Bridge)의 문제점

기존 React Native는 JS 스레드와 네이티브 스레드가 Bridge라는 비동기 단일 병목을 통해 통신했습니다. 모든 교환이 JSON 문자열로 직렬화→전송→역직렬화라는 왕복을 거치므로, 애니메이션이나 제스처 등 빈도 높은 작업에서 성능이 저하되기 쉬운 구조였습니다.

신 아키텍처(JSI + Hermes)의 혁신

JSI(JavaScript Interface)는 C++로 구현된 얇은 바인딩 레이어로, JS 엔진이 네이티브 객체를 직접 동기적으로 참조할 수 있게 해줍니다. 이를 통해 다음이 실현되었습니다.

  • Fabric — UI 트리를 동기적으로 구성·업데이트할 수 있는 새로운 렌더러
  • TurboModules — 필요할 때까지 로드하지 않는 지연 초기화
  • Hermes — JS를 바이트코드로 사전 컴파일해 시작 시간을 대폭 단축하는 Facebook 제작 엔진

3. Codegen: 타입 안전 네이티브 통신의 핵심

Codegen은 신 아키텍처에서 가장 간과되기 쉬운 메커니즘입니다. JSI와 TurboModules가 통신을 가능하게 한다면, Codegen은 빌드 시에 통신이 올바르게 이루어짐을 보장합니다.

flowchart TD
  SPEC["JS Spec 파일<br/>TypeScript / Flow로 타입 정의"]

  subgraph BUILD["빌드 시"]
    CG["Codegen<br/>pod install / Gradle 시 실행"]
  end

  subgraph OUT["생성되는 결과물"]
    CPP["C++ 추상 클래스<br/>TurboModule 기반 인터페이스"]
    DESC["Component Descriptor<br/>Fabric 네이티브 컴포넌트용"]
    JSTYPE["JS 타입 정의<br/>.d.ts 상당"]
  end

  subgraph NATIVE["네이티브 구현"]
    IOS["iOS<br/>Swift / Obj-C로 구현"]
    AND["Android<br/>Kotlin / Java로 구현"]
  end

  RUNTIME["JSI 경유<br/>타입 안전 · 동기 호출"]

  SPEC --> CG
  CG --> CPP & DESC & JSTYPE
  CPP --> IOS & AND
  DESC --> IOS & AND
  IOS --> RUNTIME
  AND --> RUNTIME

구 아키텍처에서는 JS와 네이티브의 인터페이스가 거의 "신사 협정"이었습니다. JS에서 NativeModules.MyModule.doSomething(42)를 호출해도 네이티브가 String을 기대할 경우, 런타임 크래시가 발생할 때까지 알 수 없었습니다.

Codegen은 이 문제를 빌드 시에 해결합니다. 개발자는 TypeScript 또는 Flow로 "이 네이티브 모듈은 이런 타입을 받는다"는 Spec(사양서)만 작성하면 됩니다. pod install(iOS)이나 Gradle 빌드(Android) 시점에 Codegen이 실행되어 C++ 추상 클래스, iOS와 Android용 네이티브 템플릿 코드, JS 측 타입 정의를 자동으로 생성합니다.


4. Metro Bundler의 파이프라인

Metro는 React Native 전용 JavaScript 번들러입니다. webpack과 같은 역할을 하지만 모바일 개발에 최적화되어 있습니다.

flowchart LR
  SRC[".js / .ts / .tsx<br/>소스 파일"]
  RES["해석<br/>import 경로 해결"]
  TRA["변환<br/>Babel ES 변환"]
  SER["직렬화<br/>번들 생성"]
  DEV["단말기에 전달<br/>HTTP :8081"]
  HMR["HMR<br/>변경 모듈만 재전송"]
  CACHE["파일 시스템 캐시<br/>2회차부터 고속"]

  SRC --> RES --> TRA --> SER --> DEV
  TRA -.->|"파일 변경 감지"| HMR
  HMR -.->|"상태 유지하며 반영"| DEV
  TRA <-.->|"변환 결과 캐시"| CACHE

Metro의 처리는 3단계로 이루어집니다.

  • 해석 — Node.js 방식의 모듈 해석으로, foo.ios.ts / foo.android.ts 등 플랫폼별 파일도 자동으로 선택합니다
  • 변환 — Babel이 JSX, TypeScript, 최신 ES를 변환하고 결과를 파일 시스템 캐시에 저장하므로 2회차부터 빠릅니다
  • 직렬화 — 최종 번들을 생성하고 HTTP 서버를 통해 단말기에 전달합니다

특별히 주목할 것은 **HMR(Hot Module Replacement)**입니다. 변경된 모듈의 그래프만 차분 업데이트하므로, React 컴포넌트의 상태를 유지하면서 코드 변경을 즉시 반영할 수 있습니다. 프로덕션 빌드 시에는 --minify와 Hermes 바이트코드 변환이 결합되어 파일 크기와 시작 시간 모두를 줄입니다.


5. Expo의 레이어 구조와 개발 도구

flowchart TD
  APP["내 앱<br/>App.tsx / screens / hooks"]
  SDK["Expo SDK<br/>expo-camera / expo-location / expo-router …"]
  EMC["expo-modules-core<br/>Swift / Kotlin DSL로 타입 안전 기술"]
  RN["React Native Core<br/>View / Text / Animated / StyleSheet"]
  IOS["iOS<br/>UIKit / AVFoundation / CoreLocation"]
  AND["Android<br/>View / Camera2 / LocationManager"]
  GO["Expo Go<br/>QR 스캔으로 즉시 실행"]
  EASB["EAS Build<br/>클라우드에서 .ipa / .apk 생성"]
  EASU["EAS Update<br/>OTA로 JS 번들만 배포"]
  STORE["App Store / Google Play"]

  APP --> SDK
  EMC --> SDK
  SDK --> RN
  RN --> IOS & AND
  GO -->|"개발 시 호스팅"| APP
  EASB --> STORE
  EASU -->|"App Store 심사 없음"| APP

Expo SDK는 카메라, 위치, 알림, 파일 시스템 등의 네이티브 기능을 버전 관리된 패키지로 제공합니다. 직접 네이티브 코드를 작성하지 않아도 된다는 것이 최대 장점입니다.

expo-modules-core는 Swift와 Kotlin DSL을 사용해 네이티브 모듈을 선언적으로 작성할 수 있는 새로운 메커니즘으로, Codegen과 통합되어 있습니다. 기존 NativeModules 브리지보다 타입 안전하고 코드량도 적으며, Expo의 서드파티 SDK는 거의 모두 이것으로 작성되어 있습니다.

**EAS(Expo Application Services)**는 Build, Update, Submit, Workflows 등 여러 서비스의 총칭입니다. 이 글에서는 EAS BuildEAS Update에 집중합니다. EAS Build는 클라우드에서 네이티브 바이너리를 빌드하므로 Mac 없이도 iOS 빌드가 가능합니다. EAS Update는 JS 번들과 에셋을 OTA로 배포해, 네이티브 코드를 변경하지 않는 범위의 수정을 신속하게 전달합니다.


6. Expo가 대응하지 못하는 영역과 워크플로 선택

Expo SDK가 주로 커버하지 못하는 영역은 다음과 같습니다.

  • Bluetooth LE / NFC — 플랫폼 특유의 주변장치 통신
  • 독자적 결제/생체인증 SDK — 벤더 제공 .framework / .aar 직접 통합
  • 실시간 음성·영상 처리 — WebRTC나 독자 코덱의 저수준 작업
  • 사내 네이티브 라이브러리 — 기존 앱에서 분리한 Swift / Kotlin 자산
  • 커스텀 카메라 뷰 — OpenGL / Metal을 사용한 독자 렌더링
flowchart TD
  START["필요한 네이티브 기능은?"]

  START --> Q1{"Expo SDK / 공개 라이브러리로<br/>커버 가능?"}
  Q1 -->|"예"| MANAGED["Managed Workflow<br/>네이티브 코드 불필요<br/>npx expo start만으로 완결"]
  Q1 -->|"아니오"| Q2{"Config Plugin으로<br/>설정 변경만으로 해결 가능?<br/>(Info.plist / AndroidManifest 추가 등)"}
  Q2 -->|"예"| PLUGIN["Config Plugin 작성<br/>app.json에 추가<br/>prebuild로 자동 적용"]
  Q2 -->|"아니오"| Q3{"독자 네이티브 코드의 규모는?"}
  Q3 -->|"경량<br/>(기존 SDK 래핑)"| BARE["Bare Workflow<br/>npx expo prebuild<br/>→ ios/ android/ 직접 관리"]
  Q3 -->|"본격적<br/>(독자 모듈 / UI)"| MODULE{"무엇을 만드나?"}
  MODULE -->|"로직 계열<br/>Bluetooth / 결제 / 암호화"| TM["TurboModule<br/>+ Codegen"]
  MODULE -->|"UI 컴포넌트 계열<br/>커스텀 카메라 뷰 등"| FAB["Fabric Component<br/>+ Codegen"]
  BARE --> TM & FAB
  PLUGIN --> BARE

경미한 설정 변경은 Config Plugin으로 흡수할 수 있습니다. 하지만 코드를 작성해야 하는 경우에는 npx expo prebuildios/android/ 디렉터리를 생성하고 Bare Workflow로 이행하는 것이 현실적인 출발점입니다.


7. 전체 아키텍처: JSI + Codegen + 네이티브 구현

flowchart TB
  subgraph JS["JS 레이어 (Hermes)"]
    APP["앱 코드 / React 컴포넌트"]
    SPEC["JS Spec<br/>(TypeScript + TurboModuleRegistry)"]
  end

  subgraph CODEGEN["Codegen (빌드 시 자동 생성)"]
    GEN_CPP["C++ 추상 클래스<br/>TurboModule / ComponentDescriptor"]
    GEN_SHADOW["ShadowNode / Props 정의"]
  end

  subgraph JSI_LAYER["JSI 레이어 (C++)"]
    JSI["JSI Host Object"]
    FABRIC["Fabric 렌더러"]
  end

  subgraph NATIVE_IOS["iOS 네이티브 구현"]
    SWIFT["Swift / Obj-C 구현 클래스<br/>(C++ 추상 클래스 상속)"]
    SDK_IOS["사내 SDK / 외부 .framework<br/>예: CoreBluetooth / Stripe"]
    UIVIEW["UIView 서브클래스<br/>(커스텀 UI 컴포넌트)"]
  end

  subgraph NATIVE_AND["Android 네이티브 구현"]
    KOTLIN["Kotlin / Java 구현 클래스<br/>(C++ 추상 클래스 상속)"]
    SDK_AND["사내 SDK / 외부 .aar<br/>예: BluetoothGatt / Stripe"]
    ANDROIDVIEW["View 서브클래스<br/>(커스텀 UI 컴포넌트)"]
  end

  SPEC -->|"타입 정의 읽기"| CODEGEN
  GEN_CPP --> JSI
  GEN_SHADOW --> FABRIC
  APP -->|"동기 / 비동기 호출"| JSI
  JSI --> SWIFT & KOTLIN
  FABRIC --> UIVIEW & ANDROIDVIEW
  SWIFT --> SDK_IOS
  KOTLIN --> SDK_AND

핵심은 Spec → Codegen → 네이티브 구현이라는 단방향 의존 관계입니다. JS 측에서 타입 정의를 작성하면, Codegen이 빌드 시에 C++ 추상 클래스를 생성하고, Swift / Kotlin 구현 클래스는 이를 상속하기만 하면 됩니다. 타입 정합성은 컴파일러가 보장하며, "신사 협정"으로 인한 런타임 크래시 원인이 사라집니다.


8. TurboModule 구현 흐름 (Bluetooth 예시)

참고: 아래 코드는 React Native 공식 TurboModule / Codegen / Custom Events 가이드를 토대로 한 간략화 예시입니다. 공식 샘플의 직접 인용이 아닌, Bluetooth를 소재로 재구성한 것입니다.

① JS Spec 작성

// src/specs/NativeBluetoothModule.ts
import type { TurboModule, CodegenTypes } from 'react-native';
import { TurboModuleRegistry } from 'react-native';

export type ScanResult = {
  id: string;
  name: string;
  rssi: number;
};

export interface Spec extends TurboModule {
  startScan(serviceUUIDs: string[]): void;
  stopScan(): void;
  connect(deviceId: string): Promise<boolean>;
  readonly onDeviceFound: CodegenTypes.EventEmitter<ScanResult>;
}

export default TurboModuleRegistry.getEnforcing<Spec>('NativeBluetoothModule');

② Codegen이 생성하는 것 (자동)

flowchart LR
  SPEC["NativeBluetoothModule.ts<br/>(개발자가 작성)"]

  subgraph AUTO["자동 생성 코드"]
    CPP["C++ glue code<br/>JSI / Codegen 생성물"]
    IOS_H["iOS glue code<br/>Obj-C++ / header 생성물"]
    AND_J["Android glue code<br/>JNI / Spec 클래스 생성물"]
  end

  subgraph IMPL["개발자가 구현"]
    SWIFT_IMPL["NativeBluetoothModule.swift<br/>생성된 Spec을 구현하고 CoreBluetooth 호출"]
    KOTLIN_IMPL["NativeBluetoothModule.kt<br/>생성된 Spec을 구현하고 BluetoothGatt 호출"]
  end

  SPEC --> CPP
  SPEC --> IOS_H
  SPEC --> AND_J
  CPP --> SWIFT_IMPL & KOTLIN_IMPL
  IOS_H --> SWIFT_IMPL
  AND_J --> KOTLIN_IMPL

③ iOS 구현 (Swift)

// ios/NativeBluetoothModule.swift
import CoreBluetooth

@objc(NativeBluetoothModule)
class NativeBluetoothModule: NativeBluetoothModuleSpec, CBCentralManagerDelegate {
  private var centralManager: CBCentralManager!

  override func startScan(_ serviceUUIDs: [String]) {
    let uuids = serviceUUIDs.map { CBUUID(string: $0) }
    centralManager.scanForPeripherals(withServices: uuids)
  }

  func centralManager(_ central: CBCentralManager,
                      didDiscover peripheral: CBPeripheral,
                      advertisementData: [String: Any], rssi: NSNumber) {
    emitOnDeviceFound([
      "id": peripheral.identifier.uuidString,
      "name": peripheral.name ?? "",
      "rssi": rssi
    ])
  }
}

④ Android 구현 (Kotlin)

// android/src/main/java/NativeBluetoothModule.kt
class NativeBluetoothModule(reactContext: ReactApplicationContext)
    : NativeBluetoothModuleSpec(reactContext), BluetoothGatt.Callback {

  private val adapter = BluetoothAdapter.getDefaultAdapter()

  override fun startScan(serviceUUIDs: ReadableArray) {
    val filters = serviceUUIDs.toArrayList().map { uuid ->
      ScanFilter.Builder().setServiceUuid(ParcelUuid.fromString(uuid as String)).build()
    }
    adapter.bluetoothLeScanner.startScan(filters, ScanSettings.Builder().build(), scanCallback)
  }

  private val scanCallback = object : ScanCallback() {
    override fun onScanResult(callbackType: Int, result: ScanResult) {
      val params = Arguments.createMap().apply {
        putString("id", result.device.address)
        putString("name", result.device.name ?: "")
        putInt("rssi", result.rssi)
      }
      emitOnDeviceFound(params)
    }
  }
}

9. Fabric Component 구현 흐름 (커스텀 카메라 뷰 예시)

TurboModule이 로직을 담당한다면, Fabric Component는 네이티브 UI를 JSX에 내장하기 위한 메커니즘입니다. OpenGL / Metal을 사용한 독자 렌더링이나 기존 네이티브 앱에서 가져온 View를 그대로 사용할 때 필요합니다.

참고: 아래도 이해를 위한 최소 예시입니다. Codegen의 import 경로와 타입명은 React Native 버전에 따라 다를 수 있으므로, 구현 시에는 사용 중인 버전의 공식 문서를 확인하세요.

// src/specs/NativeCameraViewSpec.ts
import type { ViewProps } from 'react-native';
import codegenNativeComponent from 'react-native/Libraries/Utilities/codegenNativeComponent';
import type { DirectEventHandler, Float } from 'react-native/Libraries/Types/CodegenTypes';

type FrameCapturedEvent = Readonly<{
  uri: string;
}>;

interface NativeProps extends ViewProps {
  zoom?: Float;          // Codegen이 Float → CGFloat / float로 변환
  torchEnabled?: boolean;
  onFrameCaptured?: DirectEventHandler<FrameCapturedEvent>;
}

export default codegenNativeComponent<NativeProps>('CameraView');

Codegen은 이 Spec에서 ShadowNode, ComponentDescriptor, Props의 C++ 정의를 생성합니다. iOS에서는 RCTViewComponentView를 상속한 CameraViewComponentView를, Android에서는 ReactViewGroup을 상속하여 구현합니다. JS 측에서는 단순히 <CameraView zoom={2.0} torchEnabled />로 사용할 수 있습니다.


10. 사내 SDK / 외부 .framework·.aar 통합

iOS: CocoaPods로 로컬 참조

# ios/Podfile
pod 'CompanyPaymentSDK', :path => '../vendor/ios/CompanyPaymentSDK'
# 또는 xcframework의 경우
pod 'CompanyPaymentSDK', :podspec => '../vendor/ios/CompanyPaymentSDK.podspec'

Podfile에 추가하고 pod install을 실행하면, 이후 일반 Swift 클래스처럼 import CompanyPaymentSDK로 사용 가능합니다. TurboModule 구현 클래스에서 직접 호출하면 됩니다.

Android: 로컬 Maven / .aar 참조

// android/app/build.gradle
dependencies {
  implementation files('../vendor/android/company-payment-sdk.aar')
  // 또는 사내 Maven 저장소
  implementation 'com.company:payment-sdk:1.4.0'
}

11. 실제 개발 루프: 시작부터 동작 확인까지

flowchart TD
  INIT["npx create-expo-app MyApp<br/>--template blank-typescript"]

  subgraph START["npx expo start"]
    METRO["Metro Bundler 시작<br/>port 8081"]
    QR["QR 코드 + 단축키 표시<br/>i = iOS  a = Android  w = Web"]
  end

  subgraph DEVICE["연결 대상 선택"]
    SIM["iOS Simulator<br/>Xcode 포함 (Mac 전용)"]
    EMU["Android Emulator<br/>Android Studio AVD"]
    GO["실기기 + Expo Go<br/>LAN 또는 Tunnel"]
  end

  subgraph NATIVE_BUILD["최초에만: 네이티브 빌드"]
    POD["pod install<br/>iOS CocoaPods"]
    GRADLE["Gradle build<br/>Android"]
    CODEGEN_RUN["Codegen 실행<br/>Spec → 네이티브 템플릿"]
  end

  BUNDLE["JS 번들 기기에 전송<br/>Metro HTTP 서버 경유"]
  RENDER["화면 표시"]

  INIT --> START
  METRO --> QR
  QR --> SIM & EMU & GO
  SIM --> NATIVE_BUILD
  EMU --> NATIVE_BUILD
  NATIVE_BUILD --> BUNDLE
  GO --> BUNDLE
  BUNDLE --> RENDER

npx expo start를 실행하면 Metro가 시작되고 터미널에 QR 코드와 키보드 단축키가 표시됩니다.

  • i 키 → iOS Simulator (Xcode가 설치된 Mac에서 동작)
  • a 키 → Android Emulator (Android Studio AVD Manager에서 만든 가상 기기)
  • 실기기의 경우 Expo Go에서 QR을 스캔. LAN 내라면 거의 바로 연결됩니다

npx expo run:iosnpx expo run:android로 명시적으로 네이티브 빌드를 실행하면, 최초에는 pod install(iOS)이나 Gradle(Android)이 실행되며 그 안에서 Codegen도 동작해 네이티브 템플릿 코드가 생성됩니다. 2회차부터는 JS 번들 차분만 전송되므로 빠릅니다.


12. Bare Workflow에서의 변경 루프

flowchart TD
  subgraph CHANGE["변경 종류로 분기"]
    JS_ONLY["JS만 변경<br/>컴포넌트 / 로직 / 스타일"]
    NATIVE_CHANGE["네이티브 코드 변경<br/>Swift / Kotlin / Spec / Podfile"]
  end

  subgraph JS_LOOP["JS 변경 루프 (고속)"]
    METRO_HMR["Metro HMR<br/>차분 번들 전송<br/>수백 ms"]
    SIM_JS["에뮬레이터에 즉시 반영<br/>앱 상태 유지"]
  end

  subgraph NATIVE_LOOP["네이티브 변경 루프 (저속)"]
    CODEGEN_RUN["Codegen 재실행<br/>pod install (iOS)<br/>Gradle sync (Android)"]
    REBUILD["네이티브 재빌드<br/>npx expo run:ios<br/>npx expo run:android"]
    SIM_NATIVE["에뮬레이터 / 실기기에서 확인"]
  end

  JS_ONLY --> METRO_HMR --> SIM_JS
  NATIVE_CHANGE --> CODEGEN_RUN --> REBUILD --> SIM_NATIVE

  SIM_JS -->|"이상 없음"| DONE["동작 확인 완료"]
  SIM_NATIVE -->|"이상 없음"| DONE
  SIM_JS -->|"버그 발견"| DEBUG_JS["Hermes Debugger<br/>React DevTools"]
  SIM_NATIVE -->|"네이티브 크래시"| DEBUG_NATIVE["Xcode / Android Studio<br/>네이티브 디버거"]
  DEBUG_JS --> JS_ONLY
  DEBUG_NATIVE --> NATIVE_CHANGE

JS만 변경하면 Metro HMR이 수백 밀리초 안에 차분을 전송합니다. 상태도 유지됩니다.

네이티브 코드를 변경한 경우에는 재빌드가 필요합니다. iOS 최초 빌드는 2~5분, 차분은 30초~1분 정도가 기준입니다. Android는 Gradle 캐시가 효과적이면 비슷하지만 최초 빌드는 더 걸립니다.

이 비대칭성이 Bare Workflow 개발 효율의 핵심입니다. 네이티브 경계면(Spec)을 먼저 고정하고, 구현의 대부분을 JS 측에 집중시키는 설계로 무거운 네이티브 빌드 빈도를 줄일 수 있습니다.


13. 디버깅: JS와 네이티브 경계를 넘는 조사

flowchart TD
  SYMPTOM["증상 관찰"]

  SYMPTOM --> Q1{"크래시 종류"}

  Q1 -->|"JS 오류<br/>LogBox 빨간 화면"| JS_ERR["Hermes Debugger로 조사<br/>브레이크포인트, 스택 트레이스<br/>Source Map으로 TS 줄번호"]
  Q1 -->|"네이티브 크래시<br/>앱이 종료 / 프리즈"| NAT_ERR["크래시 로그 확인"]
  Q1 -->|"동작은 하지만<br/>결과가 이상함"| BORDER["JS↔Native 경계 의심"]

  NAT_ERR --> IOS_CRASH["iOS: Xcode<br/>Debug Navigator<br/>+ lldb 연결"]
  NAT_ERR --> AND_CRASH["Android: Android Studio<br/>Logcat 스택 트레이스<br/>+ Native Debugger"]

  BORDER --> LOG_IOS["iOS: NSLog / os_log<br/>npx react-native log-ios"]
  BORDER --> LOG_AND["Android: Log.d<br/>npx react-native log-android"]
  BORDER --> JS_LOG["JS 측: console.log로<br/>TurboModule 반환값 확인"]

  IOS_CRASH --> INSTRUMENTS["Instruments<br/>Time Profiler / Allocations<br/>메모리 누수 / CPU 특정"]
  AND_CRASH --> PROFILER["Android Studio<br/>Memory / CPU Profiler"]

  JS_ERR --> FIXED["수정 → Metro HMR로 즉시 반영"]
  INSTRUMENTS --> FIXED2["수정 → npx expo run:ios로 확인"]
  PROFILER --> FIXED3["수정 → npx expo run:android로 확인"]
  LOG_IOS & LOG_AND & JS_LOG --> BORDER2["원인 파악<br/>→ Spec / 구현 수정"]

JS 오류: Hermes Debugger

Cmd+D → "Open Debugger"로 Chrome DevTools가 열립니다. Metro가 Source Map을 제공하므로 번들된 코드가 아닌 원본 TypeScript 줄번호에 브레이크포인트를 설정할 수 있습니다. TurboModule 호출에서 예외가 발생한 경우에도 콜 스택을 JS 측까지 추적할 수 있습니다.

네이티브 크래시: Xcode / Android Studio 디버거 연결

# iOS: 실행 중인 시뮬레이터에 lldb 연결
npx expo run:ios --configuration Debug
# → Xcode가 자동으로 열리고 디버거가 연결됨

# Android
npx expo run:android
# → "Run > Attach Debugger to Android Process"로 연결

실기기에서만 재현되는 크래시(Bluetooth, 카메라, 센서 계열 특히)는 USB로 연결된 실기기에 같은 방법으로 디버거를 연결할 수 있습니다.

경계 조사: 로그로 샌드위치

JS↔Native 경계에서 데이터가 변질되는 경우, 양쪽에서 로그로 샌드위치하는 것이 가장 빠릅니다.

// Swift 측
os_log("BluetoothModule.connect called: %{public}@", deviceId)
// → npx react-native log-ios로 필터링해 확인
// Kotlin 측
Log.d("BluetoothModule", "connect called: $deviceId")
// → npx react-native log-android로 필터링해 확인
// JS 측
const result = await BluetoothModule.connect(deviceId);
console.log('connect result:', result); // Metro에 출력

React DevTools

npx react-devtools
# → 포트 8097에서 기기 연결 대기

컴포넌트 트리를 시각적으로 확인하고, 인스펙터에서 props와 state를 직접 수정해 실시간으로 동작을 확인할 수 있습니다.


14. 에뮬레이터와 실기기의 구분 사용

확인하고 싶은 내용 iOS Simulator Android Emulator 실기기
UI · 레이아웃 충분 충분 최종 확인
JS 로직 · API 충분 충분 불필요
Bluetooth / NFC 불가 불가 필수
카메라 / 마이크 부분적 불가 필수
푸시 알림 부분적 부분적 권장
성능 참고 정도 참고 정도 필수
생체인증 Face ID 시뮬 가능 지문 시뮬 가능 권장

네이티브 모듈을 포함한 개발에서는 "에뮬레이터에서 JS 동작을 고정하고, 실기기에서 네이티브 기능을 검증"하는 2단계 운용이 현실적입니다. 특히 Bluetooth나 NFC는 에뮬레이터에서 전혀 테스트할 수 없으므로, 실기기 대수와 OS 버전 커버리지가 품질에 직결됩니다.


정리

기술 스택 전체상

레이어 기술 역할
번들러 Metro 소스 트랜스파일, HMR, 플랫폼 분기
JS 엔진 Hermes 시작 고속화를 위한 바이트코드 실행
JS↔Native 브리지 JSI C++ 경유 동기 바인딩
코드 생성 Codegen Spec에서 타입 안전한 네이티브 템플릿 자동 생성
UI 렌더러 Fabric JSI 위에서 동작하는 동기 UI 렌더링
네이티브 API TurboModules Codegen 생성 클래스를 상속한 지연 초기화 모듈
개발 기반 Expo / EAS SDK, 클라우드 빌드, OTA 업데이트

네이티브 구현을 포함한 개발 전체 흐름

① JS Spec 작성 (TypeScript로 타입 정의)
       ↓
② pod install / gradle build → Codegen이 C++ 템플릿 자동 생성
       ↓
③ Swift / Kotlin으로 구현 (사내 SDK / 외부 라이브러리 호출)
       ↓
④ npx expo run:ios / run:android로 네이티브 빌드 & 에뮬레이터 확인
       ↓
⑤ JS 측 호출 코드 작성 → Metro HMR로 고속 이터레이션
       ↓
⑥ JS 오류 → Hermes Debugger
   네이티브 크래시 → Xcode / Android Studio 디버거
   경계 불일치 → log-ios / log-android + console.log로 샌드위치
       ↓
⑦ 실기기에서 Bluetooth / 카메라 / 센서 계열 검증
       ↓
⑧ EAS Build로 스토어용 바이너리 생성

Bridge에서 JSI + Codegen으로의 이행으로 React Native는 "동작하지만 취약함"에서 "타입 안전하고 성능이 높음"으로 크게 변화했습니다. Expo 밖으로 나오면 "빌드가 무겁다", "2가지 언어로 같은 로직을 작성한다"는 비용이 생기지만, Codegen이 Spec을 공통된 유일한 진실의 근원으로 삼음으로써, 타입 불일치로 인한 런타임 크래시라는 최대 위험을 설계 단계에서 제거합니다.


참고 URL 목록

참고: 본문은 아래 자료를 토대로 필자가 요약·재구성한 것입니다. 문중 설명이나 코드 예시는 이해하기 쉽도록 간략화되어 있으며, 참고 URL의 직접 인용이 아닙니다. 구현 시에는 사용 중인 버전에 해당하는 공식 문서를 우선하세요.

참조 URL

React Native 공식

Expo 공식