Turbo Modules는 React Native New Architecture의 핵심 네이티브 모듈 시스템으로, 기존 Bridge 기반 NativeModules와 달리 JSI(JavaScript Interface)를 통해 자바스크립트와 네이티브 코드를 동기적이고 타입 안전하게 연결합니다. 0.76부터 New Architecture가 기본값이 되면서 새 프로젝트는 Turbo Modules로 시작하는 것이 사실상 표준이고, 기존 모듈은 Codegen 기반 TypeScript spec과 함께 점진적으로 마이그레이션할 수 있습니다. 이 글에선 Turbo Module을 처음부터 만들어 iOS·Android에 배포하는 전체 과정을 다뤄볼게요.
React Native 0.76+에서 New Architecture가 기본값이라 새 모듈은 Turbo Modules로 작성해야 합니다. 레거시 모듈도 Interop Layer로 동작하긴 하지만 성능 이점은 잃습니다.
핵심 흐름은 TypeScript spec → Codegen → iOS/Android 구현 → autolink이며, spec이 단일 진실 공급원(SSOT) 역할을 합니다.
Expo Modules는 Swift/Kotlin 친화 DX를 우선시하고, Nitro Modules는 마이크로벤치 성능에서 앞서며, Turbo Modules는 공식 표준이라는 점에서 각각 트레이드오프가 명확합니다.
레거시 NativeModule 마이그레이션은 spec을 먼저 작성하고 Codegen 산출물에 맞춰 시그니처를 정렬하는 것이 가장 안전한 경로입니다.
프로덕션 함정 대부분은 nullable 타입 불일치, 콜백 누수, iOS의 install() 호출 누락에서 발생합니다.
Turbo Modules란 무엇이며 기존 Native Modules와 무엇이 다른가?
Turbo Modules는 React Native의 New Architecture에서 도입된, JSI(JavaScript Interface) 위에서 동작하는 새로운 네이티브 모듈 시스템입니다. 기존 NativeModules가 JSON 직렬화와 비동기 Bridge 큐를 거쳐 메서드를 호출했다면, Turbo Modules는 자바스크립트에서 C++ HostObject로 매핑된 네이티브 함수를 직접 호출합니다. 호출 사이트에서 보면 일반 함수처럼 보이지만 실제로는 JSI가 V8/Hermes 객체와 네이티브 포인터를 다이렉트로 잇는 구조죠.
제가 6개의 앱을 출시하면서 가장 크게 느낀 차이는 단일 호출 지연이 아니라 고빈도 호출 시나리오였습니다. 카메라 프레임마다 네이티브 OCR을 호출한다거나, 제스처 라이브러리가 60fps로 위치 계산을 위임하는 경우, 기존 Bridge에서는 직렬화 비용과 큐 백프레셔로 프레임이 깨졌습니다 (지난 프로젝트에서 직접 겪었던 문제예요). JSI 기반에서는 동기 호출이 가능해 프레임 안에서 결과를 받을 수 있습니다.
핵심 차이점을 정리하면 다음과 같습니다.
호출 모델: Bridge(비동기, 직렬화) vs JSI(동기/비동기 모두 가능, 직접 호출).
타입 안전성: 기존엔 NSDictionary/ReadableMap를 손으로 파싱했고, Turbo Modules는 Codegen이 spec에서 strict한 C++/Kotlin/ObjC 인터페이스를 만들어줍니다.
지연 로딩: Turbo Modules는 처음 require된 시점에만 인스턴스화되어 앱 콜드 스타트가 빨라집니다 (Bridge는 모든 모듈을 시작 시 로드했죠).
오류 표면: 컴파일 타임에 시그니처 불일치를 잡으므로 런타임 undefined is not a function이 줄어듭니다.
React Native 0.76+에서 New Architecture가 기본값이 된 이유
React Native 0.76에서 New Architecture가 기본 활성화된 후 0.77, 0.78을 거치면서 Fabric 렌더러, TurboModule 시스템, Codegen 파이프라인이 한 묶음으로 안정화되었습니다. 2026년 기준 0.79가 안정 릴리스이며, Expo SDK 53도 New Architecture를 새 프로젝트의 기본값으로 채택하고 있어요. 이 흐름은 단순한 마케팅 푸시가 아니라 라이브러리 생태계가 따라잡았다는 신호이기도 합니다. Reanimated 4, Gesture Handler 2.20+, Screens 4.x, Skia 2.x가 모두 New Architecture 전용 코드 경로를 우선 유지보수하고 있죠.
제가 팀에 이 마이그레이션을 권할 때 강조하는 포인트는 두 가지입니다. 첫째, 네트워크 효과가 임계점을 넘었다는 것. 핵심 라이브러리가 New Architecture에서 더 빨리 진화하기 때문에 옛 아키텍처에 머무는 것이 곧 기능적 손해로 이어집니다. 둘째, Codegen이 만드는 빌드타임 안전망입니다. spec과 구현 시그니처가 어긋나면 컴파일이 실패하므로, 6개월 후 코드를 만지는 후임자에게도 무언의 가드레일이 됩니다.
다만 모든 팀에 "당장 모든 모듈을 Turbo로 마이그레이션하라"고 말하지는 않습니다. 마이그레이션 비용은 라이브러리 의존성, CI 빌드 시간, 자체 fork 패치의 양에 따라 다릅니다. 의존성 중 단 하나라도 New Architecture와 호환되지 않는 경량 모듈이 있다면 Interop Layer로 우선 동작시키고, 핵심 hot path에 있는 모듈만 먼저 Turbo로 전환하는 점진 전략이 현실적입니다. 자세한 성능 측면은 React Native 성능 최적화 가이드에서 다룹니다.
Turbo Module 만들기: TypeScript Spec부터 Codegen까지
Turbo Module의 SSOT(Single Source of Truth)는 TypeScript spec 파일입니다. spec에 선언된 시그니처를 기준으로 Codegen이 iOS의 ObjC++ 헤더와 Android의 Kotlin 인터페이스를 생성하고, 자바스크립트 측은 그 spec을 직접 import해 타입 추론을 받습니다. 즉 한 곳만 고치면 양쪽이 따라오죠.
create-react-native-library CLI로 골격을 만들면 boilerplate 대부분을 생략할 수 있습니다.
이 spec에서 주의할 점은 세 가지입니다. (1) any나 generic은 허용되지 않습니다. Codegen은 정확한 타입을 요구해요. (2) 동기 메서드는 반환값이 즉시 필요할 때만 사용하세요. UI 스레드에서 호출되면 그만큼 블로킹됩니다. (3) getEnforcing은 모듈이 로드되지 않으면 즉시 throw하기 때문에 디버깅 시간이 단축됩니다.
iOS의 경우 cd ios && bundle exec pod install를 실행하면 Codegen이 자동으로 돌아 RNSecureStorageSpec.h를 생성합니다. Android는 Gradle 빌드 중 :generateCodegenSchemaFromJavaScript 태스크가 같은 역할을 수행해요.
iOS 구현: Objective-C++와 Swift 인터롭
iOS에서는 Codegen이 만들어준 ObjC++ 프로토콜을 implement해야 합니다. 2026년에는 Swift만으로 Turbo Module을 작성하기에 아직 공식 지원이 실험적이라, 실무에서는 얇은 ObjC++ shim과 Swift 본체를 결합한 패턴을 가장 자주 씁니다. 제가 마지막 두 앱에서 그렇게 했고, 컴파일 오류가 가장 적었습니다.
import Foundation
import Security
@objc public class SecureStorageImpl: NSObject {
@objc public func read(key: String) -> String? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne
]
var item: CFTypeRef?
guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess,
let data = item as? Data else { return nil }
return String(data: data, encoding: .utf8)
}
@objc public func write(key: String, value: String) throws {
let data = value.data(using: .utf8)!
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key,
kSecValueData as String: data
]
SecItemDelete(query as CFDictionary)
let status = SecItemAdd(query as CFDictionary, nil)
guard status == errSecSuccess else {
throw NSError(domain: "SecureStorage", code: Int(status))
}
}
}
Android 구현: Kotlin로 작성하는 Turbo Module
Android는 Codegen이 만든 NativeSecureStorageSpec 추상 클래스를 상속받아 구현합니다. 2026년 RN 0.79 기준으로 Kotlin은 1차 시민이며, Java 보일러플레이트는 거의 사라졌어요.
package com.reactnativerelay.securestorage
import com.facebook.react.bridge.Promise
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.ReadableMap
import androidx.core.content.edit
class SecureStorageModule(reactContext: ReactApplicationContext) :
NativeSecureStorageSpec(reactContext) {
private val prefs by lazy {
reactContext.getSharedPreferences("rn_secure_storage", 0)
}
override fun getName() = NAME
override fun getItemSync(key: String): String? {
return prefs.getString(key, null)?.let { decrypt(it) }
}
override fun setItem(key: String, value: String, promise: Promise) {
try {
prefs.edit { putString(key, encrypt(value)) }
promise.resolve(null)
} catch (e: Exception) {
promise.reject("E_KEYSTORE", e.message, e)
}
}
override fun removeItem(key: String, promise: Promise) {
prefs.edit { remove(key) }
promise.resolve(null)
}
override fun configure(options: ReadableMap, promise: Promise) {
val requireBiometrics = options.getBoolean("requireBiometrics")
// ... KeyStore에 BiometricPrompt 바인딩 ...
promise.resolve(true)
}
private fun encrypt(plain: String): String { /* AES/GCM */ return plain }
private fun decrypt(cipher: String): String { return cipher }
companion object {
const val NAME = "SecureStorage"
}
}
패키지 등록은 BaseReactPackage를 상속한 클래스에서 두 메서드만 구현하면 됩니다.
class SecureStoragePackage : BaseReactPackage() {
override fun getModule(name: String, ctx: ReactApplicationContext) =
if (name == SecureStorageModule.NAME) SecureStorageModule(ctx) else null
override fun getReactModuleInfoProvider() = ReactModuleInfoProvider {
mapOf(
SecureStorageModule.NAME to ReactModuleInfo(
name = SecureStorageModule.NAME,
className = SecureStorageModule::class.java.name,
canOverrideExistingModule = false,
needsEagerInit = false,
isCxxModule = false,
isTurboModule = true
)
)
}
}
여기서 isTurboModule = true가 핵심입니다. 이 플래그가 false면 호출이 Interop Layer를 거쳐 레거시 경로로 떨어지고, 컴파일 타임 안전성과 동기 호출 능력을 잃거든요.
Turbo Modules vs Expo Modules vs Nitro Modules, 무엇을 선택해야 하나?
2026년 기준 React Native 생태계에는 네이티브 코드를 호출하는 세 가지 주요 방식이 있습니다. 비교 매트릭스를 먼저 보고 권장 선택을 정리할게요.
항목
Turbo Modules
Expo Modules
Nitro Modules
공식 표준
예 (React Native 코어)
Expo 권장
커뮤니티
주요 언어
Kotlin + ObjC++/Swift shim
Kotlin + Swift
Kotlin + Swift
Codegen
TypeScript spec → C++/Kotlin/ObjC
런타임 reflection 위주
TypeScript spec → C++ 직접
동기 호출
지원
제한적
강점 (핫패스 최적화)
마이크로벤치 성능
기준선
Turbo와 유사
일부 워크로드에서 2~4배
학습 곡선
중간 (ObjC++ 노출)
낮음 (Swift/Kotlin만)
중간~높음
적합한 사용처
장기 유지보수, 코어 라이브러리
Expo 앱, 빠른 개발
JSI 호출 빈도 매우 높은 모듈
저의 권장은 단순합니다. Expo 워크플로로 시작했다면 Expo Modules를 쓰세요. Swift/Kotlin만으로 충분하고 EAS와 통합되어 있습니다. Bare 워크플로이거나 라이브러리를 npm에 배포하려면 Turbo Modules가 정답입니다. 공식 표준이라 향후 코어 변경에 가장 빨리 적응합니다. Nitro Modules는 핫패스 한정으로, 카메라 프레임이나 오디오 처리 같이 마이크로벤치가 의미 있는 곳에서만 선택하세요. 자세한 Expo 워크플로는 Expo Router v5 가이드를 참고하세요.
기존 Native Modules를 Turbo Modules로 마이그레이션하기
레거시 모듈을 옮길 때 가장 흔한 실수는 "구현 코드를 먼저 고친다"입니다. 올바른 순서는 반대예요. spec을 먼저 쓰고, spec이 강제하는 시그니처에 맞춰 구현을 정렬합니다. 그래야 Codegen이 계약 위반을 컴파일 단계에서 잡아줍니다.
API 인벤토리 작성: 기존 RCT_EXPORT_METHOD 호출과 Java @ReactMethod 시그니처를 목록화합니다. 이때 nullable 여부, 기본값, 콜백/Promise 사용 패턴까지 적어두세요.
TypeScript spec 작성: 인벤토리에 맞춰 NativeFoo.ts를 생성합니다. any를 발견하면 그 자리에서 구체 타입으로 좁히세요. 이 단계가 가장 시간이 걸리지만 가장 큰 가치를 만듭니다.
Codegen 실행 후 산출물 확인: iOS에서는 build/generated/ios/RNFooSpec.h, Android에서는 build/generated/source/codegen/java 경로의 추상 클래스를 열어봅니다. 여기서 빠진 메서드가 있으면 spec이 불완전한 거예요.
JS 콜사이트 검증: NativeModules.Foo 대신 require('./NativeFoo').default를 사용하도록 호출부를 갱신합니다. 보통 어댑터 한 줄이면 충분해요.
점진 출시: feature flag로 신구 경로를 토글하면서 Sentry 같은 크래시 리포팅에서 회귀를 모니터링합니다. 안정성 검증의 일반 패턴은 React Native 테스트 가이드에 정리되어 있습니다.
제 다섯 번째 앱에서 이 순서를 거꾸로 진행했다가 30개 콜사이트를 두 번 고친 적이 있어요. spec-first 원칙을 지키면 그런 더블 워크가 사라집니다.
Turbo Modules 디버깅과 테스트
React Native 0.76에서 Flipper 통합이 제거된 이후, 표준 디버깅 도구는 Chrome DevTools 기반의 React Native DevTools입니다. Turbo Module 호출은 콘솔 로그와 함께 일반 함수 호출처럼 보이며, Hermes Inspector 탭에서 자바스크립트 측 스택을 추적할 수 있습니다. 네이티브 측 디버깅은 여전히 Xcode와 Android Studio의 네이티브 디버거를 사용합니다.
Jest 설정에서 moduleNameMapper로 ./NativeSecureStorage를 위 mock으로 매핑하면 비즈니스 로직 테스트는 네이티브 빌드 없이 통과합니다.
2) 네이티브 통합 테스트: XCTest / JUnit
JSI 시그니처가 깨지지 않았는지 확인하는 빠른 방법은 spec과 실제 시그니처의 매칭을 검증하는 컴파일 통과 자체입니다. 추가로 iOS에서는 XCTest로 Swift impl을 직접 호출하고, Android는 Robolectric으로 Kotlin 모듈의 동작을 검증하는 것이 빠릅니다. E2E 단에서 Maestro로 실제 화면 조작까지 묶으면 마이그레이션 회귀를 잡기에 충분해요.
프로덕션에서 자주 마주치는 함정과 해결책
여섯 앱에서 누적된 시행착오 목록입니다. 같은 함정에 두 번 빠지지 않도록 정리했어요.
nullable 타입 불일치
TypeScript spec에서 string | null로 선언했는데 ObjC++ 구현이 nonnull NSString *를 반환하면, Codegen 산출물과 충돌해 빌드 경고로 시작해 런타임 크래시로 끝납니다. 항상 spec과 ObjC nullability 어노테이션(nullable/nonnull)을 1:1로 맞추세요. 저는 이 버그로 한 번 출시 직후 핫픽스를 친 적이 있습니다.
Promise 콜백 누수
Android에서 비동기 처리 중 코루틴 취소나 액티비티 파괴가 발생하면 Promise가 영원히 미해결 상태로 남습니다. 매 분기마다 resolve 또는 reject를 정확히 한 번씩 호출하도록 강제하는 헬퍼를 만들고, lint 룰로 미사용 Promise를 잡으세요.
iOS install() 호출 누락
커스텀 JSI 바인딩(Turbo Module 내부에서 추가로 HostObject를 노출하는 경우)을 추가했다면, AppDelegate의 RCTRootView가 초기화된 직후 install()을 호출해야 합니다. Hermes 엔진이 ready 상태가 되기 전에 호출하면 "Runtime is not yet initialized"로 죽습니다.
Hermes 바이트코드 캐시 무효화
spec을 변경했는데 메트로 캐시가 살아있으면 옛 타입이 그대로 박혀 있을 수 있습니다. npx react-native start --reset-cache를 CI 전 단계로 한 번 더 돌리는 것이 가장 빠른 방어막이에요.
레거시 의존성과의 충돌
아직 New Architecture를 지원하지 않는 한두 개의 의존성이 있다면, Interop Layer가 자동으로 동작하지만 콘솔에 노이즈 로그가 남습니다. 패키지를 React Native 워킹 그룹의 호환성 매트릭스에서 확인한 뒤 fork/patch 또는 대체 라이브러리를 결정하세요. UI/애니메이션 쪽 호환성 작업이 필요하다면 Skia·Reanimated 가이드의 New Architecture 섹션도 참고가 됩니다.
자주 묻는 질문
Turbo Modules는 프로덕션에서 사용할 만큼 안정적인가요?
예. React Native 0.76부터 New Architecture가 기본값이고, 0.79 안정 릴리스 기준으로 Meta·Microsoft·Shopify 등 대규모 앱이 프로덕션에서 사용 중입니다. 라이브러리 생태계도 따라잡아 신규 모듈은 Turbo로 작성하는 것이 표준입니다.
기존 NativeModules를 꼭 마이그레이션해야 하나요?
아니요, 즉시는 아닙니다. Interop Layer가 레거시 모듈을 자동으로 호환시킵니다. 다만 핫패스에 있거나 활발히 수정되는 모듈은 Turbo로 옮길 때 동기 호출과 컴파일 타임 타입 안전성 같은 이점이 큽니다.
Turbo Modules를 만들 때 C++를 꼭 알아야 하나요?
대부분의 경우 아니요. iOS는 얇은 ObjC++ shim 한 파일에 표준 패턴만 복붙하면 되고, 실제 로직은 Swift/Kotlin으로 작성합니다. 커스텀 JSI HostObject를 직접 노출하는 고급 케이스에서만 C++ 지식이 필요합니다.
Codegen이 실패하면 어떻게 디버깅하나요?
가장 흔한 원인은 spec의 타입이 모호하거나(any, generic) package.json의 codegenConfig 경로가 잘못 지정된 경우입니다. npx react-native codegen --verbose로 실행하면 어떤 메서드에서 막혔는지 출력됩니다. spec을 한 메서드만 남기고 점진적으로 추가하며 분기를 좁히는 방법도 효과적이에요.
Expo 앱에서도 Turbo Modules를 만들 수 있나요?
가능합니다. npx create-expo-module로 만든 패키지는 기본적으로 Expo Modules API를 쓰지만, 같은 monorepo에서 create-react-native-library로 Turbo Module 패키지를 추가해 EAS Build에 함께 묶을 수 있습니다. 다만 Swift/Kotlin만으로 충분한 단순 모듈이라면 Expo Modules가 DX 측면에서 더 빠릅니다.