마운틴고(등산 앱)를 구상하면서 Expo로 RN을 공부하는 중에 알게 된 것들을 적어둔다. 나중에 다시 볼 기록이라, 당장 안 쓰더라도 "왜 그런지" 끝까지 따라가본 것까지 남긴다.
1. Expo의 위치
RN을 bare로 시작하면 Xcode, Android Studio, 네이티브 빌드, 서명, 인증서를 처음부터 만져야 한다. Expo는 그 네이티브 영역을 추상화한 툴체인이다.
EAS Build / Submit — 로컬 네이티브 IDE 없이 클라우드 빌드·제출
Expo Router — 파일 기반 라우팅 (Next.js App Router와 구조가 닮음)
Expo Modules API — 네이티브 모듈을 직접 짤 때의 진입점
OTA 업데이트 — 스토어 심사 없이 JS 번들만 갱신
bare RN이 "webpack 직접 설정"이라면 Expo는 "Next.js처럼 갖춰진 프레임워크" 쪽에 가깝다는 감각으로 이해했다.
학습 메모. Expo를 쓴다고 네이티브를 영영 안 봐도 되는 건 아니다. "관리되는(managed)" 흐름으로 시작하되, 카메라·위치·센서처럼 네이티브가 깊게 필요해지면 config plugin이나 development build로 내려가게 된다. 마운틴고는 GPS·고도계·백그라운드 위치가 핵심이라, 순수 managed로는 한계가 올 것 같아 development build 흐름도 같이 보는 중이다.
버전 감각 (2026 상반기)
이 영역은 빠르게 바뀌어서 어느 시점 기준인지가 중요하다.
SDK 55가 2026년 2월 RN 0.83과 함께 나오면서 레거시 아키텍처가 제거됐다. 플래그가 아니라 아예 사라진 것이라, app.json에 newArchEnabled: false를 남겨도 조용히 무시된다. SDK 55 이후는 전부 New Architecture에서만 돌고 끌 수 없다. 레거시가 필요하면 SDK 54 이하를 써야 한다.
SDK 56 베타는 RN 0.85.2 / React 19.2.3로 올라갔고, Hermes v1이 기본 엔진이 되면서 시작 속도·메모리·런타임 성능이 개선됐다. 옛 자료를 따라 하다 막히기 쉬운 변경 하나 — Expo Router가 React Navigation을 자체 포크로 가져가면서 react-navigation을 직접 의존성으로 두지 않게 됐고, @react-navigation/*를 직접 import하던 코드는 깨진다.
RN은 React를 그대로 쓴다. 컴포넌트, props, state, hooks, Context, 재조정(reconciliation)이 전부 동일하다. SDK 56이 React 19.2를 쓰듯 RN은 웹과 같은 React 버전을 따라간다.
핵심은 — React는 "무엇을 그릴지"를 다루는 라이브러리이고, "어디에 그릴지(렌더 타깃)"는 별도라는 점이다. 웹은 ReactDOM이 브라우저 DOM에, RN은 Fabric이 네이티브 뷰에 그린다. 두뇌(React)는 공유, 손발(렌더러)만 다르다.
구분
React (웹)
React Native
렌더러
ReactDOM
Fabric
기본 요소
div, span, p
View, Text, Image
스타일
CSS / className
StyleSheet 객체
라우팅
Next.js App Router 등
Expo Router
이벤트
onClick
onPress
div→View, p→Text로 바뀌고 스타일이 JS 객체가 되는 정도. 그 위의 로직(상태, 데이터 흐름, 합성)은 웹에서 하던 게 거의 그대로 통한다. React 지식이 그대로 자산이 되는 셈이라, 새로 익히는 건 렌더 타깃이 바뀌며 달라지는 얇은 층(기본 컴포넌트·스타일·네이티브 API)뿐이다. 마운틴고는 위치·지도·센서를 쓰니 그 네이티브 API 층을 보는 중이다.
헷갈렸던 것. "React Native = React + 네이티브"가 아니라, 정확히는 "React + Fabric 렌더러"다. React는 렌더러를 갈아끼울 수 있는 구조(react-reconciler)이고, ReactDOM과 Fabric은 그 렌더러의 두 구현체다. 그래서 react-three-fiber(3D)나 ink(터미널 UI)처럼 DOM도 네이티브도 아닌 렌더 타깃도 존재한다. "React는 렌더 타깃과 분리돼 있다"가 핵심.
문제는 세 가지였다. (1) 모든 호출이 비동기라, "지금 당장 네이티브 값을 읽어 와야 하는" 동기 작업이 불가능했다. (2) 주고받는 데이터를 매번 JSON 직렬화/역직렬화해서, 대량 데이터(리스트, 애니메이션 프레임)에서 비용이 컸다. (3) 직렬화된 메시지가 큐에 쌓여 한 번에 처리되는 구조라 병목이 생겼다.
한 장면으로 기억. 스크롤 위치를 매 프레임 네이티브에 물어볼 때, 그 호출이 전부 JSON으로 직렬화돼 비동기 큐에 쌓인다. JS와 네이티브가 같은 60fps로 못 맞춰지면 끊김(jank)이 된다. New Architecture는 이 큐 자체를 없앤다.
New Architecture의 토대는 JSI(JavaScript Interface) 다. 이걸 이해하려고 가장 시간을 많이 썼다.
핵심은 — JS 엔진(Hermes)도, 네이티브를 잇는 계층도 둘 다 C++로 쓰여 있다는 점이다. JS 런타임(Hermes/JSC)이 C++ 환경에 살고, JSI는 HostObject라는 C++ 객체를 만들어 JS 런타임에 등록한다. 둘 다 C++ 메모리 공간에 있으므로, JS 런타임이 이 HostObject에 직접 참조 메모리 접근 권한을 갖는다. 그래서 JavaScript가 브리지나 직렬화 없이 HostObject의 속성·메서드를 직접 호출할 수 있다.
풀어 쓰면 — JSI는 "JS에서 부를 수 있는 C++ 객체(HostObject)"를 JS 세계에 심어둔다. JS가 그 객체의 메서드를 호출하면, JSON으로 싸서 큐에 넣는 게 아니라 C++ 함수를 곧바로 호출한다. 같은 메모리 공간에 있으니 직렬화가 필요 없고, 동기 호출이 가능하다.
그 C++ 메서드가 실제 OS 기능(카메라·위치 등)에 닿는 과정은 한 단계 더 있다. JS가 HostObject의 메서드에 접근하면 대응하는 C++ 메서드가 먼저 불리고, 그 C++ 메서드가 interop을 통해 실제 네이티브 메서드를 호출한다. iOS는 Objective-C(또는 Swift)를 쓰는데, C++에서 Objective-C를 부르려면 .mm(Objective-C++) 파일을 다리로 쓴다.
여기서 SDK 56의 변화가 연결된다. 지금까지 모든 JS→네이티브 호출은 Swift → Objective-C++ → C++ 세 언어 층을 거쳤는데, SDK 56은 Swift를 JSI에 직접 연결(Swift/C++ interop)해 중간 층을 들어냈다. 위에서 본 ".mm 다리"를 한 단계 줄인 셈이다. 내부 흐름을 따라가보고 나니 이 릴리스 노트 한 줄이 비로소 무슨 말인지 읽혔다.
JSI를 이해하는 열쇠. Hermes(JS 엔진)와 네이티브를 잇는 계층이 둘 다 C++다. 그래서 "다리를 건너는" 게 아니라 "같은 방 안에서 함수를 직접 부르는" 모델이 된다. HostObject = JS 세계에 심어둔 C++ 객체. JS가 그 메서드를 부르면 C++ 함수가 즉시 실행. 직렬화 없음, 동기 호출 가능. 이게 Reanimated 같은 라이브러리가 UI 스레드에서 애니메이션을 직접 구동할 수 있는 이유이기도 하다(worklet이 JSI 위에서 돈다).
Fabric (새 UI 렌더러): JSI를 사용하며, React Native 렌더링의 선점(preemption)을 가능하게 한다. 선점이 가능하다는 건, 더 급한 업데이트가 오면 진행 중인 렌더를 멈추고 양보할 수 있다는 뜻이다. 이게 React 18+의 동시성 기능(Suspense, transition)과 맞물린다. 레거시 렌더러는 React 18의 concurrent 기능을 지원하지 못했는데, Fabric이 이걸 가능하게 한다. 웹에서 쓰던 동시성 사고방식이 네이티브에서도 통하게 된 지점이다.
TurboModules (새 네이티브 모듈): JSI를 사용해, 필요할 때 지연 로딩되는 더 가벼운 네이티브 모듈을 만든다. 옛 구조는 앱 시작 시 모든 네이티브 모듈을 다 로드했는데, TurboModules는 실제로 쓸 때 로드한다. 시작 속도에 직접 영향을 준다.
성능 수치도 참고로 적어둔다. 일반 앱은 UI 스레드 10~30% 개선, 네이티브 모듈을 많이 쓰는 앱은 스레드 간 호출이 최대 3배까지 개선된다고 한다.
Fabric의 '선점'. 사용자 입력처럼 급한 업데이트가 오면 진행 중이던 저우선 렌더(목록 등)를 멈추고 양보할 수 있다. 웹에서 startTransition으로 하던 우선순위 구분이 네이티브 렌더에도 적용되는 셈. TurboModules는 반대로 "시작 비용"을 줄인다 — 안 쓰는 모듈은 로드 안 함.
마지막 꼬리. New Architecture 글마다 Hermes가 같이 나오길래, 이게 정확히 뭘 하는지도 팠다. Hermes는 Meta가 모바일에 맞춰 만든 JS 엔진이다.
일반적인 JS 엔진은 앱이 켜진 뒤 런타임에 소스를 파싱·컴파일한다. 이게 시작을 지연시킨다. Hermes는 AOT(ahead-of-time) 컴파일로, 빌드 시점에 JavaScript 소스를 최적화된 바이트코드로 변환한다. 런타임에는 그 바이트코드를 바로 실행한다.
빌드 단계의 변환을 더 따라가면 이렇게 흐른다. 소스를 Hermes IR(중간 표현)로 나타내고, Hermes 옵티마이저가 원래 의미를 보존한 채 그 IR을 더 효율적인 형태로 바꾼다. 정리하면 소스 → 파싱 → IR → 최적화 → 바이트코드가 빌드 때 끝나고, 그 바이트코드는 .hbc 파일로 앱에 번들된다. 그 결과 앱 크기가 줄고 시작 속도와 메모리 사용이 개선된다.
그래서 런타임에 Hermes가 하는 일은 "이미 만들어진 바이트코드를 읽어 실행"하는 것뿐이다. 실행 도중 화면을 그릴 때의 전체 경로를 이으면 — Hermes가 바이트코드를 실행하다 <Text>Hello</Text>를 만나면 JSI를 호출하고, JSI가 Fabric 렌더러를 부르며, Android에선 실제 TextView, iOS에선 UILabel이 된다. 3-2부터 여기까지가 한 줄로 이어진다: JS 소스(빌드 때 바이트코드) → Hermes 실행 → JSI → Fabric → 네이티브 뷰.
가비지 컬렉션도 모바일에 맞춰져 있다. Hermes는 일시정지가 매우 짧은 GC를 동시 실행해 60fps 애니메이션에 유리하게 만든다. 애니메이션 중 GC가 길게 멈추면 프레임이 튀는데, 그걸 줄이려는 설계다.
AOT가 주는 실질 차이. 보통 JS 엔진은 앱이 켜진 뒤 소스를 파싱·컴파일하느라 첫 화면이 늦는데, Hermes는 그 작업을 빌드 때 끝내(.hbc 바이트코드) 런타임엔 실행만 한다. 그래서 시작이 빠르고 번들이 작다.
곁가지로 알게 된 점..hbc는 사람이 못 읽는 바이트코드지만 디스어셈블 도구(hbctool)가 있어서, 거꾸로 보면 RN 앱의 로직이 바이트코드로 어떻게 표현되는지 볼 수 있다. 보안 관점에선 "Hermes가 난독화는 아니다"라는 뜻이기도 하다. (마운틴고에 결제·인증이 들어가면 민감 로직은 클라이언트 번들에 두지 말아야겠다는 메모.)
(여기까지 따라가고 나니, "New Architecture" "Hermes" "JSI"가 따로 도는 단어가 아니라 하나의 경로 위 부품들이라는 게 잡혔다.)
마운틴고를 만들다 보면 웹 관리 화면(Next.js)과 모바일 앱(Expo)이 같은 로직을 공유하게 될 텐데, 두 저장소로 나누면 타입·로직·API 클라이언트를 두 번 쓰게 된다. 웹과 모바일을 함께 가져가는 제품은 두 코드베이스, 두 세트의 API, 두 인증, 두 데이터 레이어를 유지하게 되고, 웹용 로직을 모바일용으로 다시 쓴 뒤 영원히 동기화해야 한다. 모노레포는 이 중복을 줄이려는 접근이다. 아직 적용 전이라 학습한 범위에서 정리해둔다.
4-1. 구조
my-monorepo/
apps/
web/ ← Next.js (App Router)
mobile/ ← Expo (Expo Router)
packages/
core/ ← 타입, 비즈니스 로직, API 클라이언트
ui/ ← 공유 컴포넌트
config/ ← 공유 tsconfig, eslint
turbo.json
package.json ← workspaces
apps/는 배포되는 앱, packages/는 둘이 공유하는 코드. 비즈니스 로직·타입을 packages/에 한 번만 쓰고 양쪽 앱이 가져다 쓰는 게 핵심이다.
4-2. 공유 경계 — 무엇을 묶고 무엇을 나누나
전부 공유할 수는 없다. 렌더 타깃이 다르기 때문이다.
공유하기 좋음: 타입 정의, 검증 스키마(Zod 등), API 호출 로직, 순수 비즈니스 로직(거리·고도 계산 같은 순수 함수), 상수
공유가 까다로움: UI. div와 View가 다르다. NativeWind(웹·네이티브 공통 Tailwind) 같은 크로스플랫폼 스타일링을 쓰면 하나의 React 컴포넌트 세트를 web·iOS·Android에 렌더링하는 구조를 시도할 수 있고, Turborepo로 빌드·캐싱을 관리한다. 또는 아예 플랫폼별로 UI를 나눈다.
마운틴고에 대입하면 — "두 좌표 간 거리·경사도 계산", "등산로 데이터 타입", "API 클라이언트"는 packages/core에 한 번 두고 웹·모바일이 공유, 지도 UI는 플랫폼별로 분리하는 그림이 된다.
4-3. 한 단계 더 — 타입까지 끝에서 끝으로
로직뿐 아니라 API 타입까지 공유하려면 tRPC 같은 접근이 있다. create-t3-turbo는 tRPC로 API 레이어를 공유하는 스타터다. tRPC 라우터를 한 번 짜면 Next.js 서버 컴포넌트와 Expo 화면에서 같은 타입으로, 코드 중복 없이 호출할 수 있다.
SSOT와 같은 결. tRPC가 모노레포에서 빛나는 이유는 서버에서 정의한 입출력 타입이 별도 코드 생성 없이 클라이언트 호출까지 그대로 흐르기 때문이다. seller-insight에서 Zod 스키마 하나로 타입·검증을 한 곳에 둔 SSOT와 같은 결. "진실은 한 곳, 나머지는 파생"이 RN/웹 양쪽에 적용되는 그림.
4-4. 따라가다 만난 함정 — Metro와 symlink
모노레포 자료를 보다 보면 "Metro가 packages를 못 읽는다", "symlink 문제" 같은 말이 반복해서 나온다. 이유를 정리하면 — Next.js의 번들러(Webpack/Turbopack)는 모노레포 workspace의 symlink를 잘 따라가는데, RN의 번들러인 Metro는 전통적으로 프로젝트 루트 밖(상위 packages 폴더)이나 symlink를 따라가는 데 제약이 있었다. 그래서 apps/mobile에서 packages/core를 import하면 Metro가 못 찾는 상황이 생긴다.
실제 모노레포를 직접 구축한 사람들의 기록을 보면, 문서를 짜맞추고 pnpm symlink를 디버깅하고 어떤 설정이 어디 가는지 알아내며 진행했다는 얘기가 공통으로 나온다. 결국 metro.config.js에서 watchFolders(상위 폴더를 감시 대상에 추가)와 모듈 해석 경로(nodeModulesPaths)를 손봐야 하는 지점인데, 이건 실제로 모노레포를 세팅할 때 부딪혀보고 따로 정리할 생각이다.
모노레포가 공짜는 아니라는 점도 적어둔다. 웹만 필요하면 이 복잡도는 값을 못 하고, 웹과 모바일이 처음부터 다 필요한 제품일 때 가치가 있다. 마운틴고는 모바일이 본체이고 웹 관리 화면이 따라붙는 형태라, 처음부터 모노레포로 갈지 모바일 먼저 만들고 합칠지는 더 보는 중이다.