본문 바로가기
Mobile2026년 9월 12일17분 읽기

React Native 새 아키텍처 — Fabric·TurboModules·JSI 마이그레이션 완전정리

YS
김영삼
조회 9
React Native 새 아키텍처 — Fabric·TurboModules·JSI 마이그레이션 완전정리

React Native 새 아키텍처는 JSON 직렬화 브리지를 JSI라는 C++ 직접 호출 계층으로 갈아치운 것이다. Fabric(렌더러)·TurboModules(네이티브 모듈)·Hermes(엔진)가 그 위에서 동기·타입 안전 호출로 돌아간다.

0.68에서 옵트인으로 등장해 0.76부터 기본값이 됐다. 브리지가 사라지면서 초기 렌더 지연·리스트 스크롤 끊김·모듈 호출 왕복 비용이 줄지만, 서드파티 네이티브 모듈 호환성이 마이그레이션의 진짜 관문이다. 이 글은 각 조각이 뭘 바꾸는지와, 실제로 넘길 때 밟는 순서·함정을 정리한다.

몇 년 전 RN 프로젝트에서 카메라 프레임을 JS로 넘겨 처리하는 기능을 만들다가 브리지에서 완전히 막힌 적이 있다. 프레임 하나 넘길 때마다 base64로 직렬화하고, 그걸 JSON 큐에 태워 JS 스레드로 보내고, 다시 역직렬화하는데 — 30fps는커녕 5fps도 버거웠다. 그때는 "RN은 원래 이런 거지" 하고 넘겼는데, JSI를 처음 만졌을 때 그게 아니었다는 걸 알았다. 문제는 RN이 아니라 브리지였다.

새 아키텍처가 대체 뭘 바꾸나?

한 문장으로: 비동기 JSON 메시지 큐(브리지)를 없애고, JS와 네이티브가 같은 메모리를 공유하는 C++ 인터페이스(JSI)로 직접 함수를 호출하게 만든 것이다. 기존(구) 아키텍처에서 JS가 네이티브 모듈을 부르면 인자를 JSON 문자열로 바꿔 큐에 넣고, 네이티브 스레드가 그걸 꺼내 파싱해서 실행한 뒤, 결과를 다시 직렬화해 돌려줬다. 모든 경계 통과가 직렬화 + 비동기였다.

새 아키텍처는 이 세 가지를 새로 얹는다. JSI(JavaScript Interface)는 JS 엔진에 독립적인 C++ API로, JS 객체가 네이티브 함수를 동기적으로 직접 참조할 수 있게 한다. Fabric은 이 JSI 위에서 도는 새 UI 렌더러고, TurboModules는 새 네이티브 모듈 시스템이다. 셋 다 브리지가 아니라 JSI를 공유 기반으로 쓴다.

용어가 자주 섞여 쓰이는데, 계층을 이렇게 정리하면 헷갈리지 않는다.

구성요소 계층 역할 구 아키텍처의 대응물
JSI기반 C++ 인터페이스JS↔네이티브 동기 직접 호출브리지(JSON 메시지 큐)
TurboModules네이티브 모듈모듈 지연 로딩 + 타입 안전 호출Native Modules
FabricUI 렌더러뷰 트리 생성·커밋·마운트Paper(구 UIManager)
HermesJS 엔진바이트코드 사전컴파일 실행JSC(JavaScriptCore)
Codegen빌드타임 도구TS 스펙→C++/Java/ObjC 바인딩 생성(없음, 수동 브리지)
참고 "새 아키텍처 = Fabric"이라고 생각하는 사람이 많은데, 정확히는 JSI가 토대이고 Fabric·TurboModules는 그 위 응용이다. Hermes는 새 아키텍처와 별개로도 켤 수 있지만, 실무에선 사실상 세트로 묶여 다닌다. 새 아키텍처를 켜면 Hermes가 기본 엔진이다.

브리지는 왜 병목이었나?

브리지의 병목은 세 가지가 겹친 결과였다. (1) 직렬화 비용 — 모든 데이터가 JSON 문자열로 인코딩·디코딩됐다. (2) 비동기 강제 — 큐 기반이라 즉시 값을 못 받고 항상 콜백/Promise를 거쳐야 했다. (3) 배칭 지연 — 메시지를 모아서 보내니 프레임 경계에서 밀리면 UI가 한 박자 늦게 반응했다.

가장 티 나는 건 초기 렌더와 리스트 스크롤이다. 구 아키텍처에서 화면을 그리려면 JS가 뷰 명세를 만들어 브리지로 던지고, 네이티브가 그걸 받아 UIManager로 뷰를 생성했다. 이 왕복이 앱 시작 시 대량으로 몰리면 첫 화면이 늦게 뜬다. FlatList를 빠르게 스크롤할 때 빈 셀(블랭크)이 잠깐 보이는 것도 같은 이유다 — 셀 생성 명령이 브리지 큐에서 밀린다.

JSI는 이 큐 자체를 없앤다. JS 값이 jsi::Value로서 C++ 함수를 직접 붙잡을 수 있으니, 직렬화도 큐도 필요 없다. 필요하면 동기 호출이 가능하다. 예를 들어 구 아키텍처에서 불가능했던 "JS에서 네이티브 함수를 호출해 즉시 반환값 받기"가 TurboModule에선 자연스럽다.

// 구 아키텍처: 항상 비동기. 즉시 값을 못 받는다.
import { NativeModules } from 'react-native';
const { Calculator } = NativeModules;
Calculator.add(2, 3, (result) => {
  // 콜백으로만 결과 수신 (브리지 왕복)
  console.log(result);
});

// 새 아키텍처(TurboModule): 동기 반환이 가능하다.
import Calculator from './specs/NativeCalculator';
const result = Calculator.add(2, 3); // 즉시 5
console.log(result);

Fabric 렌더러는 무엇이 다른가?

Fabric은 렌더링을 렌더(Render) → 커밋(Commit) → 마운트(Mount) 세 단계로 나누고, 대부분을 C++ 코어에서 처리하는 새 렌더러다. 핵심은 뷰 트리를 불변(immutable) 섀도우 트리로 관리한다는 점 — React의 개념 모델과 자연스럽게 맞아떨어진다.

실무에서 체감되는 차이는 이렇다. 첫째, 동기 레이아웃 측정이 가능하다. 구 아키텍처에서 measure()는 비동기 콜백이라 "측정하고 바로 그 값으로 스크롤"하는 게 까다로웠는데, Fabric에선 커밋 단계에서 레이아웃이 확정되므로 이런 패턴이 안정적이다. 둘째, 동시성(Concurrent) 기능React 18의 startTransition, Suspense 같은 기능이 렌더러 차원에서 제대로 지원된다. 셋째, 뷰 플래트닝(view flattening) 같은 최적화가 C++에서 일어나 네이티브 뷰 계층이 얕아진다.

개인적으로 가장 반가웠던 건 onLayout 타이밍이 훨씬 예측 가능해진 것이다. 구 아키텍처에선 레이아웃 이벤트가 프레임 하나 뒤에 오는 경우가 흔해서 "깜빡임"을 잡느라 setTimeout(…, 0) 같은 지저분한 우회를 쓰곤 했는데, Fabric에선 그럴 일이 확 줄었다.

주의 Fabric으로 넘어가면 직접 만든 네이티브 UI 컴포넌트(예: 커스텀 지도 뷰, 비디오 플레이어)는 그냥 동작하지 않는다. 뷰 매니저를 Fabric 방식(ComponentDescriptor + Codegen 스펙)으로 다시 써야 한다. 서드파티 UI 라이브러리를 쓴다면 그 라이브러리가 Fabric 대응 버전을 냈는지부터 확인해라 — 이게 마이그레이션에서 제일 자주 발목 잡는 부분이다.

TurboModules와 Codegen — 타입 안전은 어디서 오나?

TurboModules의 타입 안전성은 Codegen에서 온다. 네이티브 모듈의 인터페이스를 TypeScript(또는 Flow) 스펙 파일로 선언하면, 빌드 시 Codegen이 그 스펙을 읽어 C++/Java/Objective-C 바인딩 코드를 자동 생성한다. JS 쪽 타입과 네이티브 쪽 시그니처가 같은 소스에서 나오므로 어긋날 수가 없다.

또 하나 중요한 특징은 지연 로딩(lazy loading)이다. 구 아키텍처는 앱 시작 시 등록된 모든 네이티브 모듈을 초기화했다. 모듈이 50개면 하나도 안 쓰는 화면에서도 50개가 다 뜬다. TurboModules는 JS가 실제로 그 모듈에 처음 접근할 때 초기화한다. 모듈이 많은 큰 앱일수록 시작 시간(TTI)에 유리하다.

스펙 파일은 파일명이 Native로 시작해야 Codegen이 인식한다. 이건 문서에 있긴 한데 처음엔 다들 한 번씩 놓친다.

// specs/NativeCalculator.ts  (파일명이 Native* 여야 인식됨)
import type { TurboModule } from 'react-native';
import { TurboModuleRegistry } from 'react-native';

export interface Spec extends TurboModule {
  // 동기 반환값도 선언 가능
  add(a: number, b: number): number;
  // Promise 반환도 물론 됨
  fetchToken(userId: string): Promise<string>;
}

export default TurboModuleRegistry.getEnforcing<Spec>('Calculator');
# Codegen 산출물 확인 (스펙→바인딩이 잘 생성됐는지)
cd ios && bundle exec pod install
# 생성된 코드는 보통 여기 떨어진다:
#   ios/build/generated/ios/  (RCTThirdPartyComponents, Native*SpecJSI.h 등)
# 안드로이드는:
#   android/app/build/generated/source/codegen/

Hermes는 새 아키텍처의 필수인가?

엄밀히는 필수가 아니지만, 사실상 기본이고 권장이다. Hermes는 페이스북이 RN용으로 만든 JS 엔진으로, JS를 빌드 시점에 바이트코드로 미리 컴파일한다. 그래서 앱 실행 시 JS를 파싱·컴파일하는 비용이 사라져 시작이 빠르고, 메모리 사용도 대체로 낮다.

Hermes와 JSI의 관계가 헷갈릴 수 있는데 — JSI는 엔진에 독립적인 추상 인터페이스고, Hermes는 그 인터페이스를 구현한 엔진 중 하나다. 이론상 JSC 위에서도 JSI가 돌지만, 새 아키텍처의 성능 이점을 온전히 보려면 Hermes 조합이 표준이다. 0.70 이후로 Hermes는 기본 엔진이 됐다.

항목 구 아키텍처(Bridge + Paper + JSC) 새 아키텍처(JSI + Fabric + Hermes)
JS↔네이티브 호출JSON 직렬화 + 비동기 큐C++ 직접 참조, 동기 가능
네이티브 모듈 초기화시작 시 전부(eager)최초 접근 시(lazy)
타입 안전런타임까지 미검증Codegen으로 빌드타임 검증
동기 레이아웃 측정비동기 콜백커밋 단계에서 확정
React 18 동시성제한적렌더러 차원 지원
기본 엔진JSCHermes(바이트코드 사전컴파일)

마이그레이션 체크리스트 — 순서가 중요하다

결론부터: 코드를 고치기 전에 의존성 감사부터 하라. 새 아키텍처 전환 실패의 90%는 서드파티 네이티브 라이브러리가 아직 대응 안 된 경우다. 순서는 대략 이렇다.

  1. RN 버전 올리기. 최소 0.68 이상, 실무적으론 0.76+ 권장(0.76부터 새 아키텍처가 기본). 여러 버전을 건너뛴다면 rn-diff-purge/Upgrade Helper로 네이티브 파일 diff를 하나씩 반영.
  2. 의존성 감사. reactnativedirectory.com에서 각 라이브러리의 "New Architecture" 지원 여부 확인. 안 되는 게 있으면 대체재 찾기 or 직접 패치 계획.
  3. Hermes 켜기. 아직 JSC면 먼저 Hermes로 전환해 안정화(별도 단계로 떼어 두는 게 디버깅에 유리).
  4. 새 아키텍처 플래그 ON. iOS는 RCT_NEW_ARCH_ENABLED=1 pod install, 안드로이드는 gradle.propertiesnewArchEnabled=true.
  5. 자체 네이티브 모듈/뷰 이관. Native Modules→TurboModules, 커스텀 뷰→Fabric 컴포넌트로 Codegen 스펙 작성.
  6. 인터롭 레이어로 버티기. 아직 못 고친 구형 모듈은 상호운용(interop) 레이어가 브리지 호환으로 돌려준다. 다만 성능 이점은 그 모듈에 한해 안 나온다.
  7. 전 화면 QA. 특히 리스트·애니메이션·커스텀 뷰·모달 중첩. Fabric에서 레이아웃 미묘하게 달라지는 곳이 나온다.
# iOS: 새 아키텍처로 pod 재설치
cd ios
RCT_NEW_ARCH_ENABLED=1 bundle exec pod install

# Android: android/gradle.properties
# newArchEnabled=true
# hermesEnabled=true

# 클린 빌드 (캐시가 자주 말썽이다)
cd android && ./gradlew clean
watchman watch-del-all && rm -rf node_modules && yarn install
참고 Expo를 쓴다면 이야기가 훨씬 수월하다. SDK 51+에서 새 아키텍처를 옵트인할 수 있고, SDK 52부터는 새 프로젝트 기본값이다. app.jsonnewArchEnabled 한 줄과 expo prebuild --clean이면 대부분 끝난다. 다만 config plugin이 없는 순수 네이티브 모듈은 여기서도 똑같이 확인이 필요하다.

실제로 데인 함정들

문서엔 잘 안 나오지만 현장에서 반복되는 것들만 추린다.

  • 동기 호출을 남발하지 마라. TurboModule이 동기 가능이라고 무거운 작업을 동기로 부르면 JS 스레드가 그대로 멈춘다. 무거운 건 여전히 Promise로. 동기는 "짧고 확정적인" 값에만.
  • 인터롭 레이어는 만능이 아니다. 구형 뷰 매니저 중 일부는 상호운용에서 제대로 안 뜬다. 특히 requireNativeComponent로 직접 만든 오래된 컴포넌트가 그렇다.
  • 스타일 미세 차이. Fabric에서 flex 기본 처리, zIndex, 오버플로 클리핑이 구 렌더러와 픽셀 단위로 다를 수 있다. 디자인 QA를 반드시 눈으로.
  • Codegen 캐시 지옥. 스펙을 바꿨는데 반영이 안 되면 ios/build안드로이드 build/generated를 지우고 다시. pod install만으론 안 될 때가 많다.
  • 로그 필터. 새 아키텍처 관련 오류는 네이티브 로그(Xcode/logcat)에 C++ 스택으로 뜬다. JS 콘솔만 보면 원인을 못 찾는다. jsi::JSError가 보이면 스펙-네이티브 시그니처 불일치를 의심.

개인적으로는, 규모가 어중간한 앱이라면 "지금 당장 전부 이관"보다 Hermes → 새 아키텍처 플래그 → 인터롭으로 굴리기 → 병목 모듈만 순차 이관 순으로 점진 전환하는 게 사고가 적었다. 한 번에 다 갈아엎으면 어디서 깨졌는지 찾는 데 며칠을 날린다.

언제 서두르고, 언제 미룰까?

신규 프로젝트라면 고민할 것 없이 처음부터 새 아키텍처로 시작하는 게 맞다. 0.76+ 기본값이고, 앞으로 나오는 라이브러리·기능이 여기 맞춰진다. 반대로 출시 임박했거나, 네이티브 의존성이 무겁고 그중 미대응 라이브러리가 핵심 기능이라면 급하게 넘기지 마라. 대응 버전이 나올 때까지 기다리거나 대체재 검증을 먼저 하는 게 현명하다.

성능이 실제로 절박한 경우 — 고빈도 네이티브 통신(카메라 프레임, 오디오, 센서 스트림), 무거운 리스트, 복잡한 제스처 — 라면 이관 이득이 확실하다. 앞서 말한 그 카메라 프로젝트를 나중에 JSI 기반으로 다시 짰을 때, 프레임을 JS로 복사하지 않고 포인터로 넘기니 체감이 완전히 달라졌다.

자주 묻는 질문

새 아키텍처를 켜면 앱이 무조건 빨라지나요?

항상은 아니다. 브리지가 병목이 아니던 앱(정적 화면 위주, 네이티브 통신이 적은 앱)은 체감 차이가 작을 수 있다. 이득이 큰 곳은 초기 렌더, 빠른 리스트 스크롤, 고빈도 네이티브 호출이다. 무거운 작업을 동기로 잘못 부르면 오히려 느려질 수도 있으니 프로파일링으로 확인하는 게 맞다.

JSI와 Fabric, TurboModules는 뭐가 다른가요?

JSI는 토대(JS↔네이티브 C++ 인터페이스), Fabric은 그 위의 UI 렌더러, TurboModules는 그 위의 네이티브 모듈 시스템이다. 즉 JSI가 도로이고 Fabric·TurboModules는 그 도로를 달리는 두 종류의 차라고 보면 된다.

기존 네이티브 모듈을 안 고쳐도 돌아가나요?

상당수는 상호운용(interop) 레이어 덕에 그대로 돈다. 다만 그 모듈은 브리지 호환 경로로 실행되므로 새 아키텍처의 성능 이점은 못 본다. 커스텀 UI 뷰(뷰 매니저)는 인터롭에서 안 되는 경우가 있어 Fabric 방식으로 다시 써야 할 수 있다.

Hermes를 꼭 써야 하나요?

기술적으로 필수는 아니지만 사실상 표준이자 기본값이다. 새 아키텍처의 시작 시간·메모리 이점을 온전히 보려면 Hermes 조합을 쓰는 게 맞다. JSC로도 JSI가 동작은 하지만 권장 구성은 아니다.

Codegen이 정확히 뭘 하나요?

TypeScript/Flow로 쓴 모듈·컴포넌트 스펙을 빌드 시점에 읽어 C++/Java/Objective-C 바인딩 코드를 자동 생성한다. JS 타입과 네이티브 시그니처가 같은 스펙에서 나오므로 타입 불일치를 빌드 단계에서 잡아준다. 스펙 파일명은 Native로 시작해야 인식된다.

Expo에서도 새 아키텍처를 쓸 수 있나요?

쓸 수 있다. SDK 51+에서 옵트인, SDK 52+부터 새 프로젝트 기본값이다. app.jsonnewArchEnabled를 켜고 expo prebuild --clean을 돌리면 된다. config plugin이 없는 순수 네이티브 모듈만 별도로 호환을 확인하면 된다.

지금 있는 프로덕션 앱, 당장 이관해야 하나요?

급할 이유는 없다. 핵심 의존성이 아직 미대응이거나 출시가 임박했다면 미루는 게 낫다. 다만 신규 라이브러리·기능이 새 아키텍처 중심으로 나오고 있으니 로드맵에는 반드시 넣어라. 점진 전환(Hermes→플래그→인터롭→병목 모듈 순)이 가장 안전하다.

댓글 0

아직 댓글이 없습니다.
Ctrl+Enter로 등록