많은 개발자들이 특히 Android를 위해 React Native의 네이티브 빌드 의존성을 설치하고 설정하는 데 어려움을 겪는다. Create React Native App을 사용하면 Xcode나 Android Studio를 사용할 필요가 없으며, Linux나 Windows에서도 iOS 기기를 위한 개발이 가능하다. 이는 Expo 앱을 통해 이루어진다. Expo 앱은 순수 JavaScript로 작성된 CRNA 프로젝트를 네이티브 코드를 컴파일하지 않고도 로드하고 실행한다.
새로운 프로젝트를 생성해 보자(yarn이 설치되어 있다면 적절한 yarn 명령어로 대체 가능):
$ npm i -g create-react-native-app $ create-react-native-app my-project $ cd my-project $ npm start
이 명령어는 React Native 패키저를 시작하고 QR 코드를 출력한다. Expo 앱에서 이 QR 코드를 열어 JavaScript를 로드할 수 있다. console.log 호출은 터미널로 전달된다. 모든 표준 React Native API와 Expo SDK를 사용할 수 있다.
많은 React Native 프로젝트는 컴파일이 필요한 Java 또는 Objective-C/Swift 의존성을 포함한다. Expo 앱은 카메라, 비디오, 연락처 등의 API를 제공하며, Airbnb의 react-native-maps나 Facebook 인증과 같은 인기 라이브러리도 포함한다. 하지만 Expo가 제공하지 않는 네이티브 코드 의존성이 필요한 경우, 직접 빌드 환경을 구성해야 한다. Create React App과 마찬가지로 CRNA에서도 "이젝트(eject)" 기능을 지원한다.
npm run eject 명령어를 실행하면 react-native init으로 생성한 프로젝트와 유사한 구조를 얻을 수 있다. 이 시점부터는 react-native init으로 시작한 경우와 마찬가지로 Xcode나 Android Studio가 필요하다. react-native link로 라이브러리를 추가할 수 있으며, 네이티브 코드 컴파일 과정을 완전히 제어할 수 있다.
지난 1년 동안, 우리는 Animated 라이브러리를 사용한 애니메이션의 성능을 개선하기 위해 노력해 왔다. 애니메이션은 아름다운 사용자 경험을 만드는 데 매우 중요하지만, 올바르게 구현하기 쉽지 않다. 우리는 개발자가 성능 저하를 일으킬 수 있는 코드를 걱정하지 않고도 효율적인 애니메이션을 쉽게 만들 수 있도록 하고 싶다.
Animated API는 매우 중요한 제약 조건을 염두에 두고 설계되었다. 바로 직렬화 가능하다는 점이다. 이는 애니메이션이 시작되기 전에 모든 정보를 네이티브로 보낼 수 있음을 의미한다. 따라서 네이티브 코드가 UI 스레드에서 애니메이션을 수행할 때, 매 프레임마다 브리지를 거칠 필요가 없다. 이 기능은 매우 유용한데, 애니메이션이 시작된 후에도 JS 스레드가 블로킹되더라도 애니메이션이 부드럽게 실행될 수 있기 때문이다. 실제로 사용자 코드가 JS 스레드에서 실행되고 React 렌더링이 JS를 오랫동안 잠글 수 있기 때문에 이러한 상황은 자주 발생할 수 있다.
이 프로젝트는 약 1년 전 Expo가 Android용 li.st 앱을 개발하면서 시작되었다. Krzysztof Magiera는 Android에서 초기 구현을 담당했다. 결과는 성공적이었고, li.st는 Animated를 사용해 네이티브로 구동되는 애니메이션을 탑재한 첫 번째 앱이 되었다. 몇 달 후, Brandon Withrow가 iOS에서 초기 구현을 완성했다. 이후 Ryan Gomba와 나는 Animated.event 지원 같은 누락된 기능을 추가하고, 실제 프로덕션 앱에서 발견한 버그를 수정하는 작업을 진행했다. 이는 진정한 커뮤니티의 노력이었으며, 관련된 모든 분들과 개발의 상당 부분을 후원한 Expo에 감사드린다. 이제 이 기술은 React Native의 Touchable 컴포넌트와 최근 출시된 React Navigation 라이브러리의 네비게이션 애니메이션에서 사용되고 있다.
먼저, JS 드라이버를 사용하는 Animated로 애니메이션이 어떻게 동작하는지 살펴보자. Animated를 사용할 때는 수행하려는 애니메이션을 나타내는 노드 그래프를 선언한 후, 드라이버를 사용해 미리 정의된 곡선에 따라 Animated 값을 업데이트한다. 또한 Animated.event를 사용해 View의 이벤트에 Animated 값을 연결해 업데이트할 수도 있다.
애니메이션의 단계와 각 단계가 어디서 일어나는지 정리하면 다음과 같다:
JS: 애니메이션 드라이버는 requestAnimationFrame을 사용해 매 프레임마다 실행되고, 애니메이션 곡선을 기반으로 계산한 새로운 값으로 드라이브되는 값을 업데이트한다.
JS: 중간 값이 계산되어 View에 연결된 props 노드로 전달된다.
JS: setNativeProps를 사용해 View가 업데이트된다.
JS에서 Native로 브리지를 거친다.
Native: UIView 또는 android.View가 업데이트된다.
보이는 것처럼 대부분의 작업은 JS 스레드에서 일어난다. JS 스레드가 블로킹되면 애니메이션이 프레임을 건너뛰게 된다. 또한 매 프레임마다 JS에서 Native로 브리지를 거쳐 네이티브 뷰를 업데이트해야 한다.
네이티브 드라이버는 이 모든 단계를 네이티브로 옮긴다. Animated가 애니메이션 노드 그래프를 생성하기 때문에, 애니메이션이 시작될 때 한 번만 네이티브로 직렬화하여 전송할 수 있다. 이렇게 하면 JS 스레드로 다시 콜백할 필요가 없어지고, 네이티브 코드가 매 프레임마다 UI 스레드에서 직접 뷰를 업데이트할 수 있다.
애니메이션 값과 보간 노드를 직렬화하는 예제를 살펴보자 (정확한 구현은 아니고 예제일 뿐이다).
<Animated.ScrollView// <-- Animated ScrollView 래퍼 사용 scrollEventThrottle={1}// <-- 이벤트가 누락되지 않도록 1로 설정 onScroll={Animated.event( [{nativeEvent:{contentOffset:{y:this.state.animatedValue}}}], {useNativeDriver:true}// <-- 이 부분 추가 )} > {content} </Animated.ScrollView>
Native Animated에서 Animated의 모든 기능을 사용할 수 있는 것은 아니다. 주요 제한 사항은 레이아웃 속성이 아닌 프로퍼티만 애니메이션으로 처리할 수 있다는 점이다. 예를 들어 transform과 opacity는 작동하지만, 플렉스 박스와 위치 관련 속성은 지원하지 않는다. 또 다른 제한은 Animated.event인데, 이벤트 버블링이 아닌 직접 이벤트에서만 작동한다. 즉, PanResponder와는 호환되지 않지만 ScrollView#onScroll 같은 이벤트에서는 정상적으로 동작한다.
Native Animated는 React Native에 오랫동안 포함되어 있었지만, 실험적인 기능으로 간주되어 문서화되지 않았다. 따라서 이 기능을 사용하려면 React Native의 최신 버전(0.40 이상)을 사용해야 한다.
css-layout은 이미 레이아웃을 위해 start와 end 개념을 도입했다. Left-to-Right(LTR) 레이아웃에서 start는 left를 의미하고, end는 right를 의미한다. 하지만 RTL 레이아웃에서는 start가 right를, end가 left를 의미한다. 이를 통해 RN은 start와 end 계산에 의존해 position, padding, margin을 포함한 올바른 레이아웃을 계산할 수 있다.
또한 css-layout은 각 컴포넌트의 방향을 부모로부터 상속받도록 설계했다. 따라서 루트 컴포넌트의 방향을 RTL로 설정하면 전체 앱이 뒤집힌다.
안드로이드와 iOS 개발에서 RTL 레이아웃으로 변경하면, 제스처와 애니메이션이 LTR 레이아웃과 반대 방향으로 동작한다. 현재 리액트 네이티브(RN)에서는 제스처와 애니메이션이 RN 코어 코드 수준에서 지원되지 않고, 컴포넌트 수준에서 지원된다. 다행히 일부 컴포넌트는 이미 RTL을 지원하고 있으며, 예를 들어 SwipeableRow와 NavigationExperimental가 있다. 그러나 제스처를 사용하는 다른 컴포넌트들은 수동으로 RTL을 지원해야 한다.
초기 RTL 호환 앱 출시 이후에도 새로운 기능을 반복적으로 추가해야 할 경우가 많다. 개발 효율성을 높이기 위해 I18nManager는 테스트 기기의 언어 설정을 변경하지 않고도 빠르게 RTL을 테스트할 수 있는 forceRTL() 함수를 제공한다. 앱 내에 이 기능을 간단히 전환할 수 있는 스위치를 추가하는 것도 좋은 방법이다. 다음은 RNTester의 RTL 예제에서 가져온 코드다:
<RNTesterBlock title={'Quickly Test RTL Layout'}> <View style={styles.flexDirectionRow}> <Text style={styles.switchRowTextView}>forceRTL</Text> <View style={styles.switchRowSwitchView}> <Switch onValueChange={this._onDirectionChange} style={styles.rightAlignStyle} value={this.state.isRTL} /> </View> </View> </RNTesterBlock>; _onDirectionChange=()=>{ I18nManager.forceRTL(!this.state.isRTL); this.setState({isRTL:!this.state.isRTL}); Alert.alert( 'Reload this page', 'Please reload this page to change the UI direction! '+ 'All examples in this app will be affected. '+ 'Check them out to see what they look like in RTL layout.', ); };
새로운 기능을 개발할 때 이 버튼을 쉽게 토글하고 앱을 리로드해 RTL 레이아웃을 확인할 수 있다. 이 방법의 장점은 언어 설정을 변경하지 않고도 테스트가 가능하다는 점이다. 하지만 다음 섹션에서 설명하듯 일부 텍스트 정렬은 변경되지 않을 수 있다. 따라서 앱 출시 전에 반드시 RTL 언어로 테스트하는 것이 좋다.
RTL(오른쪽에서 왼쪽) 지원은 앱의 대부분의 사용자 경험을 커버해야 한다. 하지만 현재 몇 가지 제한 사항이 존재한다:
텍스트 정렬 방식이 Android와 iOS에서 다르게 동작한다.
iOS에서는 기본 텍스트 정렬이 활성 언어 번들에 따라 결정되며, 항상 한쪽으로 고정된다. Android에서는 텍스트 콘텐츠의 언어에 따라 달라진다. 예를 들어, 영어는 왼쪽 정렬, 아랍어는 오른쪽 정렬된다.
이론적으로는 플랫폼 간 일관성을 유지해야 하지만, 사용자가 앱을 사용할 때 한 동작을 다른 동작보다 선호할 수 있다. 텍스트 정렬에 대한 최적의 방식을 찾기 위해 더 많은 사용자 경험 연구가 필요할 수 있다.
"진짜" 왼쪽/오른쪽 개념이 없다.
앞서 논의한 것처럼, JS 측면에서 left/right 스타일을 start/end로 매핑한다. RTL 레이아웃에서 코드의 모든 left는 화면에서 "오른쪽"이 되고, 코드의 right는 화면에서 "왼쪽"이 된다. 이는 제품 코드를 크게 변경할 필요가 없어 편리하지만, 코드에서 "진짜 왼쪽"이나 "진짜 오른쪽"을 지정할 방법이 없다는 의미이다. 향후에는 언어와 무관하게 컴포넌트가 방향을 제어할 수 있도록 하는 것이 필요할 수 있다.
제스처와 애니메이션에 대한 RTL 지원을 개발자 친화적으로 개선한다.
현재 제스처와 애니메이션이 RTL과 호환되도록 하려면 여전히 프로그래밍 노력이 필요하다. 앞으로는 제스처와 애니메이션의 RTL 지원을 더 개발자 친화적으로 만드는 방법을 찾는 것이 이상적일 것이다.
React Native를 사용하면 React와 Relay의 선언적 프로그래밍 모델을 통해 JavaScript로 Android와 iOS 앱을 개발할 수 있다. 이를 통해 더 간결하고 이해하기 쉬운 코드를 작성할 수 있으며, 컴파일 과정 없이 빠르게 반복 작업을 진행할 수 있다. 또한 여러 플랫폼 간에 코드를 쉽게 공유할 수 있다. 이렇게 하면 더 빠르게 앱을 출시할 수 있고, 앱의 외관과 사용자 경험을 훌륭하게 만드는 데 집중할 수 있다. 성능 최적화는 이 과정에서 매우 중요한 부분이다. 다음은 React Native 앱의 시작 속도를 두 배로 향상시킨 과정에 대한 이야기이다.
더 빠르게 동작하는 앱은 콘텐츠를 신속하게 로드한다. 사용자는 더 많은 시간을 앱과 상호작용할 수 있고, 부드러운 애니메이션은 앱 사용을 즐겁게 만든다. 2011년형 폰과 2G 네트워크가 대부분인 신흥 시장에서는 성능에 초점을 맞추는 것이 앱 사용 가능 여부를 결정짓는 중요한 요소가 된다.
React Native를 iOS와 Android에 출시한 이후, 우리는 리스트 뷰 스크롤 성능, 메모리 효율성, UI 반응성, 앱 시작 시간을 지속적으로 개선해왔다. 시작 시간은 앱의 첫인상을 결정짓고 프레임워크의 모든 부분에 부담을 주기 때문에, 가장 보람있고 도전적인 문제다.
React Native의 목표는 최고의 개발자 경험을 제공하는 것이다. 그중 중요한 부분은 파일을 저장하고 변경 사항을 확인할 때까지 걸리는 시간이다. 앱이 커질수록 이 피드백 루프를 1초 미만으로 단축하는 것이 목표이다.
이 목표에 가까워지기 위해 세 가지 주요 기능을 도입했다:
자바스크립트를 사용해 컴파일 시간을 줄인다.
es6/flow/jsx 파일을 VM이 이해할 수 있는 일반 자바스크립트로 변환하는 Packager 도구를 구현했다. 이 도구는 서버로 설계되어 중간 상태를 메모리에 유지해 빠른 증분 변경을 가능하게 하고, 멀티코어를 활용한다.
파일 저장 시 앱을 다시 로드하는 Live Reload 기능을 구축했다.
현재 개발자들의 병목 현상은 앱을 다시 로드하는 시간이 아니라 앱 상태를 잃는 문제이다. 일반적인 시나리오는 런치 스크린에서 여러 화면을 거쳐야 하는 기능을 작업하는 경우이다. 매번 다시 로드할 때마다 동일한 경로를 반복해 클릭해야 하기 때문에 피드백 루프가 몇 초씩 길어지게 된다.
핫 리로딩은 Hot Module Replacement(HMR)라는 기능 위에 구축되었다. 이 기능은 webpack에서 처음 도입했고, React Native Packager 내부에 구현했다. HMR은 Packager가 파일 변경을 감지하고, 앱에 포함된 간단한 HMR 런타임으로 HMR 업데이트를 전송한다.
간단히 말해, HMR 업데이트는 변경된 JS 모듈의 새 코드를 포함한다. 런타임이 이를 받으면, 이전 모듈의 코드를 새 코드로 대체한다:
HMR 업데이트는 단순히 변경할 모듈의 코드만 포함하지 않는다. 코드를 교체하는 것만으로는 런타임이 변경 사항을 반영하기에 충분하지 않기 때문이다. 문제는 모듈 시스템이 이미 업데이트하려는 모듈의 _exports_를 캐시했을 가능성이다. 예를 들어, 다음과 같은 두 모듈로 구성된 앱이 있다고 가정하자:
앱이 번들링될 때, React Native는 __d 함수를 사용해 각 모듈을 모듈 시스템에 등록한다. 이 앱의 경우, 여러 __d 정의 중 log에 대한 정의가 하나 있을 것이다:
__d('log',function(){ ...// 모듈의 코드 });
이 호출은 각 모듈의 코드를 익명 함수로 감싸며, 이를 일반적으로 팩토리 함수라고 부른다. 모듈 시스템 런타임은 각 모듈의 팩토리 함수, 이미 실행되었는지 여부, 그리고 실행 결과(exports)를 추적한다. 모듈이 필요할 때, 모듈 시스템은 이미 캐시된 exports를 제공하거나 모듈의 팩토리 함수를 처음 실행하고 결과를 저장한다.
앱을 시작하고 log를 요청한다고 가정하자. 이 시점에서 log와 time의 팩토리 함수는 아직 실행되지 않았으므로 exports가 캐시되지 않았다. 그런 다음 사용자가 time을 수정해 날짜를 MM/DD 형식으로 반환하도록 변경한다:
// time.js functionbar(){ const date =newDate(); return`${date.getMonth()+1}/${date.getDate()}`; } module.exports= bar;
Packager는 time의 새 코드를 런타임으로 전송한다(1단계). 그리고 log가 결국 요청되면, time의 변경 사항이 반영된 상태로 exported 함수가 실행된다(2단계):
이제 log 코드가 time을 최상위 require로 요청한다고 가정하자:
const time =require('./time');// 최상위 require // log.js functionlog(message){ console.log(`[${time()}] ${message}`); } module.exports= log;
log가 요청되면, 런타임은 log와 time의 exports를 캐시한다(1단계). 그런 다음 time이 수정되면, HMR 프로세스는 단순히 time의 코드를 교체하는 것으로 끝날 수 없다. 그렇게 하면 log가 실행될 때 time의 캐시된 복사본(이전 코드)을 사용하게 된다.
log가 time의 변경 사항을 반영하려면, log의 캐시된 exports를 지워야 한다. 왜냐하면 log가 의존하는 모듈 중 하나가 핫 스왑되었기 때문이다(3단계). 마지막으로 log가 다시 요청되면, 팩토리 함수가 실행되면서 time을 요청하고 새 코드를 가져온다.
React Native의 HMR(Hot Module Replacement)은 hot 객체를 도입해 모듈 시스템을 확장한다. 이 API는 webpack의 HMR API를 기반으로 한다. hot 객체는 accept라는 함수를 제공하는데, 이 함수를 사용해 모듈이 핫 스왑될 때 실행될 콜백을 정의할 수 있다. 예를 들어, time의 코드를 다음과 같이 변경하면, time을 저장할 때마다 콘솔에 "time changed"가 출력된다:
// time.js functiontime(){ ...// 새로운 코드 } module.hot.accept(()=>{ console.log('time changed'); }); module.exports= time;
이 API를 직접 사용해야 하는 경우는 드물다. 대부분의 일반적인 사용 사례에서는 핫 리로딩이 기본적으로 동작한다.
앞서 살펴봤듯이, HMR 업데이트를 단순히 수락하는 것만으로는 충분하지 않다. 이미 실행된 모듈이 핫 스왑된 모듈을 사용하고 있을 수 있고, 해당 모듈의 임포트가 캐시된 상태일 수 있다. 예를 들어, 영화 앱 예제의 의존성 트리에서 최상위에 MovieRouter가 있고, 이 모듈이 MovieSearch와 MovieScreen 뷰에 의존하며, 이 뷰들은 이전 예제의 log와 time 모듈에 의존한다고 가정해 보자.
사용자가 영화 검색 뷰에 접근했지만 다른 뷰에는 접근하지 않았다면, MovieScreen을 제외한 모든 모듈의 익스포트가 캐시된다. time 모듈에 변경이 발생하면, 런타임은 log의 익스포트를 지워 time의 변경 사항을 반영해야 한다. 이 프로세스는 여기서 끝나지 않는다. 런타임은 모든 부모 모듈이 업데이트를 수락할 때까지 이 과정을 재귀적으로 반복한다. 따라서 log에 의존하는 모듈들을 가져와 업데이트를 시도한다. MovieScreen의 경우 아직 필요하지 않았기 때문에 중단할 수 있다. MovieSearch의 경우 익스포트를 지우고 부모 모듈을 재귀적으로 처리해야 한다. 마지막으로 MovieRouter에 대해 동일한 작업을 수행하고, 더 이상 의존하는 모듈이 없으므로 프로세스를 종료한다.
런타임은 의존성 트리를 탐색하기 위해 HMR 업데이트 시 Packager로부터 역의존성 트리를 받는다. 이 예제에서 런타임은 다음과 같은 JSON 객체를 받게 된다:
리액트 컴포넌트는 핫 리로딩과 함께 사용하기가 조금 더 까다롭다. 문제는 기존 코드를 새로운 코드로 단순히 교체할 수 없다는 점이다. 그렇게 하면 컴포넌트의 상태를 잃어버리기 때문이다. 리액트 웹 애플리케이션의 경우, Dan Abramov가 이 문제를 해결하기 위해 웹팩의 HMR API를 사용하는 바벨 트랜스폼을 구현했다. 간단히 말해, 그의 솔루션은 _트랜스폼 시점_에 모든 리액트 컴포넌트에 대한 프록시를 생성하는 방식으로 동작한다. 프록시는 컴포넌트의 상태를 유지하고, 실제 컴포넌트에 라이프사이클 메서드를 위임한다. 이 실제 컴포넌트가 핫 리로딩의 대상이 된다:
프록시 컴포넌트를 생성하는 것 외에도, 이 트랜스폼은 리액트가 컴포넌트를 리렌더링하도록 강제하는 코드를 포함한 accept 함수를 정의한다. 이 방식을 통해 앱의 상태를 잃지 않고 렌더링 코드를 핫 리로딩할 수 있다.
리액트 네이티브에 기본으로 포함된 트랜스포머는 babel-preset-react-native를 사용한다. 이 프리셋은 웹팩을 사용하는 리액트 웹 프로젝트와 동일한 방식으로 react-transform을 사용하도록 설정되어 있다.
리듀서를 변경하면, 해당 리듀서를 수락하는 코드가 클라이언트로 전송된다. 그런 다음 클라이언트는 리듀서가 스스로를 수락하는 방법을 모른다는 것을 인식하고, 해당 리듀서를 참조하는 모든 모듈을 찾아 수락하려고 시도한다. 결국, 이 흐름은 단일 스토어인 configureStore 모듈에 도달하여 HMR 업데이트를 수락하게 된다.
웹에서 React, 모바일에서 React Native가 출시되면서 개발자들이 제품을 구축할 수 있는 새로운 프론트엔드 프레임워크를 제공했다. 견고한 제품을 만들기 위한 핵심 요소 중 하나는 시각 장애가 있거나 다른 장애를 가진 사람들을 포함해 누구나 사용할 수 있도록 보장하는 것이다. React와 React Native의 접근성 API를 사용하면 시각 장애인을 위한 스크린 리더 같은 보조 기술을 사용하는 사람들도 React 기반 경험을 활용할 수 있다.
이 글에서는 React Native 앱에 초점을 맞춘다. React 접근성 API는 Android와 iOS API와 유사하게 디자인했다. Android, iOS 또는 웹을 위해 접근성 있는 애플리케이션을 개발해본 경험이 있다면 React AX API의 프레임워크와 용어에 익숙할 것이다. 예를 들어, UI 엘리먼트를 _accessible_로 설정해(따라서 보조 기술에 노출) 엘리먼트에 대한 문자열 설명을 제공하기 위해 _accessibilityLabel_을 사용할 수 있다:
<Viewaccessible={true}accessibilityLabel="This is simple view">
Facebook의 React 기반 제품 중 하나인 Ads Manager 앱을 살펴보며 React AX API를 조금 더 깊이 있게 적용하는 방법을 알아보자.