개요
React Native로 뇌졸중을 자가진단 할 수 있는 `노졸중` 애플리케이션을 만들고 사이드 임팩트 공모전에 참여했었다. 처음엔 RN만으로 모든 기능을 구현할 수 있을 줄 알았는데 제약사항도 있었고 따로 고려해야 하는 부분들도 꽤나 있었다. 얼굴 비대칭도 검사, 동작 검사를 위해 Google MLKit의 Face detection, Face mesh, Pose detection 등의 기능들을 사용해야 했는데, 이들은 `Java/Kotlin`, `Swift`로 작성된 네이티브 모듈이다. 따라서 RN에서 사용하려면 추가적인 작업이 필요했다.
React와 RN은 문법이 비슷하지만 기반이 되는 아키텍처는 상당히 달랐다. 나도 프론트엔드 개발자이기도 하고, 대부분의 사람들은 RN으로 처음 개발하기보다는 React 경험이 있는 사람이 사용하지 않을까 싶다. 그래서 React, 브라우저 환경과 비교하며 RN, 모바일 환경이 어떻게 다른지 한 번 알아보고 이를 프로젝트에 어떻게 적용했는지 알아보자.
브라우저 내부 동작을 알아보자(+Web Worker)를 먼저 읽고 오면 이해하기 수월하다.
과거의 아키텍처 (Bridge)
React Native의 아키텍처는 위와 같다. 각 요소들을 먼저 간단하게 설명하면,
Metro
- ES6, JSX를 각 환경에서 실행 가능한 javascript 코드로 트랜스파일링
- 각 환경에 맞게 번들링
- Babel과 Webpack의 역할을 동시에 수행하는 빌드 타임 도구
Javascript Thread
- React Native 애플리케이션의 비즈니스 로직이 실행됨
- Virtual DOM의 변경사항을 계산하고 JSON 형태로 직렬화하여 네이티브 측으로 전달
UI Thread
- UI 렌더링 및 사용자 인터랙션 처리
- Javascript Thread와 Bridge를 통해 데이터를 주고받음
Shadow Thread
- `Yoga`라는 레이아웃 엔진을 사용하여 UI 컴포넌트의 레이아웃 계산
Bridge
- Javascript 스레드와 UI 스레드 간 비동기 통신 담당
자세히 알아보기 전에 브라우저에서 React가 동작하는 방식을 먼저 살펴보자.
React로 작성된 코드는 Babel의 트랜스파일링에 의해 Javascript 코드로 변환되고 브라우저의 렌더러 프로세스의 메인 스레드에서 UI는 Blink 엔진에서 처리되고, Javascript는 V8 등의 자바스크립트 엔진에 의해 실행된다. 이 두 엔진이 서로 상호작용 하면서 화면을 보여주게 된다. 또한 fiber 노드로 Virtual DOM을 생성하고 재조정 과정을 통해 실제 DOM 업데이트를 최적화한다.
React Native 앱은 브라우저와 달리 단일 페이지만 표시하므로 단일 프로세스로 작동하며, Javascript 스레드, UI 스레드, Shadow 스레드로 분리되어 서로 상호작용하며 작동한다. 따라서 복잡한 Javascript 연산이 UI의 반응성을 방해하지 않는다. 또한 RN도 fiber 노드를 사용하지만 리액트처럼 Virtual DOM이 전체 트리를 비교하지 않고 Shadow 스레드에 shadow tree 구조로 저장해 둔다. 자바스크립트 엔진에서는 ‘어떤 컴포넌트의 어떤 걸 변경하라’는 정보만을 UI 스레드로 전달한다. Shadow 스레드에서는 이를 받아 레이아웃을 다시 계산한 뒤 UI 스레드로 넘겨주고, 실제 변경사항만을 받아서 네이티브 뷰를 업데이트한다. 네이티브 UI 컴포넌트는 웹의 DOM보다 더 무겁고 생성/삭제 비용이 크기 때문에 직접적인 업데이트 방식이 전체 트리를 비교하는 것보다 효율적이다.
Javascript Thread
먼저 자바스크립트 코드는 자바스크립트 엔진에 의해 실행된다. 크롬 브라우저가 사용하는 비교적 무거운 V8과 달리, RN에서는 Safari에서 사용하는 더 가벼운 `JavaScriptCore` 엔진을 사용했다. V8 엔진에서는 고도로 최적화된 컴파일러인 Turbofan 컴파일러로 최적화하는 반면, JavaScriptCore 엔진은 3단계의 컴파일러로 최적화한다. 이러한 섬세한 최적화는 배터리 수명과 메모리 관리가 더 중요한 모바일 환경의 요구사항에 더 적합하다. iOS 기기에서는 자체적으로 JavaScriptCore 엔진이 설치되어 있으므로 RN 앱이 iOS에서 실행될때는 이 엔진을 사용하고, Android 기기에서는 앱 번들에 JavaScript 엔진이 포함되어 설치된다. 개발 시에는 V8 엔진이 사용된다.
React Native 0.70 버전부터는 Meta에서 RN을 위해 개발한 `Hermes` 엔진을 사용한다. 일반적인 자바스크립트 엔진은 실행 시점에 코드를 해석하고 컴파일하는 JIT(Just In Time)방식을 사용하는데, Hermes는 앱을 빌드할 때 자바스크립트 코드를 바이트코드로 미리 컴파일하는 AOT(Ahead-of-Time) 방식을 사용한다.
이 방식은 동적으로 코드를 실행하기엔 적합하지 않지만 미리 컴파일하기 때문에 앱의 시작 시간이 크게 단축되고, 메모리 사용량이 줄어들었다. 즉, JIT와 비교하여 메모리와 CPU 간의 어느 정도 trade off 관계가 성립하는데, Meta는 CPU 성능보다는 메모리 최적화와 초기 로딩 시간 개선이 사용성에 훨씬 더 중요하다는 판단을 내렸고 실제로 유효했다. 더불어서 자바스크립트 코드가 더 컴팩트한 바이트코드 형태로 저장되기 때문에 앱의 바이너리 크기가 작아졌고 모바일 환경에 최적화된 가비지 콜렉션 알고리즘을 도입하였다.
이러한 자바스크립트 엔진에서 React 컴포넌트를 네이티브가 이해할 수 있는 자료구조로 변환하여 `Bridge`를 통해 전송한다.
function WelcomeScreen() {
const [username, setUsername] = useState('Guest');
return (
<View style={styles.container}>
<Text style={styles.greeting}>
Welcome, {username}!
</Text>
<Button
title="Login"
onPress={() => setUsername('User')}
/>
</View>
);
}
이러한 리액트 코드가 있으면, 먼저 이 JSX 코드를 React 엘리먼트 객체로 변환한다.
{
type: 'View',
props: {
style: styles.container,
children: [
{
type: 'Text',
props: {
style: styles.greeting,
children: `Welcome, ${username}!`
}
},
{
type: 'Button',
props: {
title: 'Login',
onPress: () => setUsername('User')
}
}
]
}
}
그리고 이를 기반으로 `Fiber` 노드를 생성한다.
{
type: 'View',
stateNode: null, // 네이티브 뷰 참조를 저장할 공간
child: TextFiber, // 자식 Fiber 노드
sibling: ButtonFiber, // 형제 Fiber 노드
return: ParentFiber, // 부모 Fiber 노드
pendingProps: {/* ... */},
memoizedProps: {/* ... */},
updateQueue: [], // 상태 업데이트 큐
// ... 기타 Fiber 관련 정보들
}
이 Fiber 노드를 네이티브 측으로 전달하기 위해 직렬화한 뒤, 브릿지를 통해 전달한다.
{
type: 'CREATE_VIEW',
tag: 1, // 뷰 식별자
props: {
style: {
// 레이아웃 속성들이 Yoga 엔진이 이해할 수 있는 형태로 변환됨
width: 100,
height: 100,
// ... 기타 스타일 속성들
}
}
}
웹 브라우저에서는 메인 스레드에서 V8(자바스크립트 엔진)이 코드를 실행하고, 같은 메인 스레드에서 Blink(렌더링 엔진)가 DOM 조작과 렌더링을 처리한다. RN은 이 작업들을 별도 스레드로 분리했다. 두 스레드가 동시에 같은 데이터에 접근하면 동시성 문제가 발생할 수 있으므로 브릿지를 사용하여 JSON으로 직렬화하여 비동기적으로 메시지를 주고받는데, 이 과정으로 인해 성능의 오버헤드가 발생한다.
UI Thread & Shadow Thread
UI 스레드가 브릿지를 통해 일련의 명령을 받으면 이를 역직렬화 한 뒤 Shadow 스레드로 전달한다. 사실 전달한다기보다 메모리를 공유한다. 브라우저에서는 메인 스레드와 워커 스레드가 메시지 패싱을 통해 통신하는데 이는 여러 스레드가 동시에 렌더링 엔진에 접근하면 발생할 수 있는 화면 불일치 문제 때문이다. 하지만 RN에서는 공유 메모리 공간을 통해 데이터를 교환한다. 이는 동시성 문제를 야기할 수 있지만 RN의 구현에서 적절하게 처리하였고 직접 메모리에 접근하므로 더 빠른 데이터 공유가 가능하다.
Shadow 스레드에서는 Yoga 레이아웃 엔진을 사용하여 각 요소의 정확한 크기와 위치를 계산하고 스타일 속성들을 플랫폼별 값으로 변환한다. React 에서는 가상 DOM을 이용하여 기존 DOM과 전체 트리를 비교하여 수정된 부분을 확인하고 그 부분만 업데이트하는 반면 RN의 Shadow 스레드는 UI의 Shadow Tree를 메모리에 유지하고 있고, 자바스크립트 엔진이 보낸 명령을 Yoga 엔진이 받아서 새로운 레이아웃을 계산하고 변경된 부분을 식별하여 UI 스레드에 전달한다. 네이티브 UI 컴포넌트는 웹의 DOM보다 더 무겁고 생성/삭제 비용이 크기 때문에 전체 트리를 비교하지 않고 직접적으로 업데이트한다.
UI스레드는 전달된 정보를 바탕으로 실제 네이티브 뷰를 생성하고 업데이트한다. iOS에서는 UIKit을, Android에서는 Android View 시스템을 사용하여 화면을 업데이트한다.
이벤트
이제 이벤트가 발생했을 때 일어나는 과정을 살펴보자. UI 스레드에서 터치 이벤트를 감지하면 이벤트 데이터는 브릿지를 통해 Javascript 스레드로 전달된다.
{
type: 'touchEnd',
target: 'button_123', // 터치된 뷰의 식별자
timestamp: 1635789042, // 이벤트 발생 시간
touches: [{
pageX: 150,
pageY: 200,
identifier: 0
}]
}
그리고 Javascript 스레드는 전달받은 이벤트를 RN의 이벤트 시스템에서 처리한다.
function handleTouchEvent(nativeEvent) {
// 1. 이벤트 객체를 React 스타일로 변환
const reactEvent = createReactEvent(nativeEvent);
// 2. 이벤트가 발생한 컴포넌트 찾기
const targetComponent = findComponentByTag(nativeEvent.target);
// 3. 등록된 이벤트 핸들러 호출
targetComponent.props.onPress(reactEvent);
}
이로 인해 상태가 업데이트되고 UI가 갱신되면 다시 위의 과정대로 작동하여 네이티브 측으로 전달된다. 이러한 과정에서 브릿지를 통한 통신에서 오버헤드가 발생한다. 즉 `RN은 네이티브 언어로 변환해야 해서 느리다`라는 말은 잘못된 이해이다. 여기서 모든 과정들은 비동기적으로 처리된다. 또한 반응성을 개선하기 위해 여러 이벤트나 상태 업데이트가 짧은 시간 내에 발생하면 배치로 한 번에 묶어서 처리하고 메모리를 관리하기 위해 이벤트 객체는 필요한 시점에 생성되고 처리가 완료되면 즉시 정리된다.
이후에는 여러 한계로 인해 브릿지를 사용하지 않는 아키텍처로 변경되었다. 밑에서 자세히 다룰 예정이다.
적용
이제 RN에서 Google MLKit의 모듈들을 사용하기 위해 이를 연결하는 브릿지를 만든 과정들을 설명하고자 한다. iOS의 PoseEstimation 모듈이 좀 간단하니 이거로 알아보자.
네이티브 모듈 설치
먼저 iOS의 패키지 관리자인 CocoaPods를 통해 프로젝트의 네이티브 부분에 MLKit을 설치해야 한다.
// ios/Podfile
pod 'GoogleMLKit/FaceDetection', '6.0.0'
pod 'GoogleMLKit/PoseDetection', '6.0.0'
네이티브 코드로 브릿지 작성
Swift로 실제 포즈 인식을 수행하는 네이티브 모듈을 구현한다. 이 모듈은 Base64로 인코딩 된 이미지를 입력받아 관절 좌표를 반환한다.
// ios/NoJolJung/PoseEstimationModule.swift
import Foundation
import MLKit
import MLKitVision
@objc(PoseEstimationModule)
class PoseEstimationModule: NSObject {
@objc
func detectPose(_ imageBase64: String, resolver resolve: @escaping RCTPromiseResolveBlock, rejecter reject: @escaping RCTPromiseRejectBlock) {
guard let imageData = Data(base64Encoded: imageBase64),
let image = UIImage(data: imageData) else {
reject("INVALID_IMAGE", "Could not decode image data", nil)
return
}
let visionImage = VisionImage(image: image)
visionImage.orientation = image.imageOrientation
let options = PoseDetectorOptions()
options.detectorMode = .singleImage
let poseDetector = PoseDetector.poseDetector(options: options)
do {
let poses = try poseDetector.results(in: visionImage)
if let pose = poses.first {
var landmarks: [[String: Any]] = []
for landmark in pose.landmarks {
landmarks.append([
"x": landmark.position.x,
"y": landmark.position.y,
"z": landmark.position.z,
"likelihood": landmark.inFrameLikelihood
])
}
resolve(landmarks)
} else {
resolve([])
}
} catch {
reject("POSE_DETECTION_FAILED", "Failed to detect pose: \(error.localizedDescription)", error)
}
}
@objc
static func requiresMainQueueSetup() -> Bool {
return false
}
}
이 Swift로 작성한 모듈을 React Native에 노출시키는 Objective-C 브릿지 설정 파일도 필요하다.
// ios/NoJolJung/PoseEstimationModule.m
#import <React/RCTBridgeModule.h>
@interface RCT_EXTERN_MODULE(PoseEstimationModule, NSObject)
RCT_EXTERN_METHOD(detectPose:(NSString *)imageBase64
resolver:(RCTPromiseResolveBlock)resolve
rejecter:(RCTPromiseRejectBlock)reject)
@end
인터페이스 작성
네이티브 모듈을 편리하게 사용하기 위한 인터페이스를 작성한다.
// modules/PoseEstimationModule.ts
import {NativeModules} from 'react-native';
interface PoseLandmark {
x: number;
y: number;
z: number;
likelihood: number;
}
interface PoseEstimationNativeModule {
detectPose(imageBase64: string): Promise<PoseLandmark[]>;
}
const {PoseEstimationModule} = NativeModules as {
PoseEstimationModule: PoseEstimationNativeModule;
};
export default {
detectPose(imageBase64: string): Promise<PoseLandmark[]> {
return PoseEstimationModule.detectPose(imageBase64);
},
};
export type {PoseLandmark};
RN 앱에서 사용
이러면 이제 이 모듈을 RN 앱에서 사용할 수 있다.
import {useFocusEffect} from '@react-navigation/native';
import React, {useCallback, useRef, useState, useEffect} from 'react';
import {StyleSheet, Text, View} from 'react-native';
import {Camera, useCameraDevice} from 'react-native-vision-camera';
import RNFS from 'react-native-fs';
import PoseEstimationModule from '../../custom_modules/PoseEstimationModule';
export default function BehaviorTestCamera() {
...
const captureAndAnalyze = useCallback(async () => {
if (!cameraRef.current || !isActive) return;
try {
const photo = await cameraRef.current.takeSnapshot();
const imageBase64 = await RNFS.readFile(photo.path, 'base64');
const landmarks = await PoseEstimationModule.detectPose(imageBase64);
...
}
...
}
이 구현은 RN의 브릿지 아키텍처를 활용하는데,
- 비동기 : 네이티브 모듈과의 모든 통신은 Promise를 통해 비동기적으로 이루어지고 이는 메인 스레드 블로킹을 방지한다.
- 직렬화 : 이미지 데이터는 base64 문자열로 변환되어 전달된다.
- 타입 안전성 : Typescript 인터페이스를 통해 타입 안전성을 확보한다.
- 네이티브에서 연산 : 이미지 처리와 같은 무거운 연산은 네이티브 측에서 처리되고 결과만 Javascript 측으로 전달된다.
새로운 아키텍처 (Fabric)
위에서 설명한 기존의 Bridge 아키텍처는 다음과 같은 한계점들이 있다 :
- Bridge에서의 모든 통신은 직렬화가 필요하다. 복잡한 애니메이션이나 이벤트 처리에서 많은 데이터가 오가야 할 때 모든 통신이 JSON 형태로 직렬화되어야 하므로 성능 저하가 발생할 수 있다.
- Bridge가 배치 처리를 할 때 UI 업데이트가 프레임을 건너뛰는 현상이 발생할 수 있다.
- 모든 메시지가 동일한 큐를 통과하므로 긴급한 메시지도 앞선 메시지들이 다 처리될 때까지 기다려야 한다. 만약 큐가 포화 상태가 된다면 전체 시스템의 성능이 저하될 수 있다.
여러 블로그에서 위의 문제점들이 Bridge의 `비동기성`때문에 발생한다고 한다. 근데 동기적으로 처리한다면 네이티브에서의 응답을 받기 전까지 JS실행을 멈춰야 하는데 비동기적으로 처리되는 건 오히려 성능 최적화를 위함이 아닌가..? 위의 문제들은 Bridge의 직렬화, 단일 큐 등 구조적 한계로 인해 발생하는 것 같은데 잘 아시는 분이 있다면 알려주세요..
새로운 아키텍처의 구성 요소들을 알아보자.
Javascript Interface(JSI)
JSI는 자바스크립트 객체가 C++의 참조를 보유하거나 그 반대가 가능한 인터페이스이다. 즉 네이티브 코드와도 직접 통신이 가능해진다. 이로 인해 직렬화/역직렬화가 불필요해지고, 기존엔 원본 객체 + 직렬화 문자열 + 새로운 네이티브 객체 메모리가 필요했었는데 이젠 원본 객체에 참조값을 저장하는 정도의 메모리만 필요하게 되어 메모리 효율적이다. 또한 기존의 비동기호출에 더불어 동기식 호출도 가능한데, 즉각적인 응답이 필요한 작업은 동기적으로 바로 처리되도록 하여 중요한 UI 업데이트가 다른 작업에 의해 지연되지 않게 되었다.
Fabric 아키텍처
이전엔 Shadow Tree가 Javascript 스레드에서 생성된 다음 네이티브로 전달되었는데, 여기선 네이티브 측에서 직접 관리되어 메모리 사용량이 줄어들고 성능이 향상되었다. 또한 우선순위가 높은 업데이트(사용자 입력에 대한 반응)를 우선순위가 낮은 업데이트(백그라운드 데이터 로딩)보다 먼저 처리할 수 있게 해 주어서 반응성을 향상했다.
Turbo Modules
필요한 모듈만 선택적으로 모듈을 로드하는 지연 로드 시스템이다. 더 나은 모듈 관리와 효율적인 리소스 사용을 가능하게 하여 메모리 사용량과 앱 시작 시간을 개선했다.
Codegen
빌드 타임에 인터페이스 코드를 생성하여 Javascript와 C++ 사이의 타입 안전성을 보장한다. 런타임 에러를 줄여주었다.
이 아키텍처의 도입으로 성능, 유지보수성, 개발자 경험 모두 개선되었다.
마무리
아키텍처를 이해하고 보니 RN의 장단점을 더 명확하게 파악할 수 있었다. RN은 Javascript 엔진의 초기화와 코드 실행 준비 과정 때문에 앱 시작시간이 네이티브 앱에 비해 약간 느리다. 또한 Javascript 엔진을 앱에 포함해야 하므로 약간의 추가 메모리가 필요하고 엔진에서의 최적화가 필요하기 때문에 CPU 사용량도 추가적으로 발생한다. 하지만 Hermes와 Fabric 아키텍처의 도입으로 대부분의 앱에서 사용자가 실제로 불편함을 느끼기는 어렵지 싶다. 처음에 네이티브 모듈을 React Native에서 사용하려고 하니 어떻게 해야 할지 감이 잡히지 않았는데 아키텍처를 이해하고 나니 되게 간단했다.
+ 전 웹 프론트엔드 위주로 공부했고 RN은 공모전을 위해 다뤄본 정도이기 때문에 혹시나 틀린 설명이 있다면 알려주심 감사합니당^~^
참고
https://velog.io/@2ast/React-Native-Hermes-engine
React Native) Hermes engine에 대한 고찰
Compile이란 > 컴파일이란 하나의 언어를 다른 언어로 변환하는 일련의 과정을 의미하지만, 일반적으로는 고수준 언어를 컴퓨터가 이해할 수 있는 저수준 언어로 변환하는 과정을 가리킨다. > Phase
velog.io
https://www.linkedin.com/pulse/react-native-new-architecture-what-changes-brings-react-poland/
React Native New Architecture - What changes it brings?
We have heard of React Native New Architecture since 2018. Finally, last year New Architecture rolled out.
www.linkedin.com
'프론트엔드' 카테고리의 다른 글
React 최적화 제대로 알고 쓰기 (0) | 2025.04.22 |
---|---|
웹 보안 취약점과 토큰 관리방식 알아보기 (0) | 2025.03.25 |
브라우저와 Node.js의 비동기 처리와 이벤트 루프 (0) | 2025.01.31 |
자바스크립트 깨알상식 (간단) (0) | 2025.01.26 |
실행 컨텍스트와 클로저 이해해보기 (0) | 2025.01.26 |