Skip to main content

성능 개요

웹뷰 기반 도구 대신 React Native를 사용해야 하는 중요한 이유는 초당 60프레임 이상의 성능을 달성하고 앱에 네이티브 느낌을 제공하기 위해서다. 가능한 경우 React Native가 자동으로 최적화를 처리하도록 설계하여, 개발자가 성능 걱정 없이 앱 개발에 집중할 수 있게 한다. 하지만 아직 그 수준에 도달하지 못한 부분도 있고, React Native(네이티브 코드를 직접 작성하는 것과 유사하게)가 최적의 접근 방식을 결정할 수 없는 경우도 있다. 이런 상황에서는 수동 개입이 필요하다. 기본적으로 매끄러운 UI 성능을 제공하려고 노력하지만, 항상 가능한 것은 아니다.

이 가이드는 성능 문제 해결을 위한 기본 사항을 설명하고, 일반적인 문제 원인과 제안된 해결 방법에 대해 논의한다.

프레임에 대해 알아야 할 사항

우리 할아버지 세대가 영화를 "움직이는 그림"이라고 부른 데는 이유가 있다. 비디오에서 사실적인 움직임은 정지된 이미지를 일정한 속도로 빠르게 바꿔가며 만드는 환상이다. 우리는 이 각각의 이미지를 프레임이라고 부른다. 초당 표시되는 프레임 수는 비디오(또는 사용자 인터페이스)가 얼마나 부드럽고 생생하게 보이는지에 직접적인 영향을 미친다. iOS 기기는 초당 최소 60프레임을 표시한다. 이는 사용자가 화면에서 볼 정지 이미지(프레임)를 생성하는 데 필요한 모든 작업을 수행할 수 있는 시간이 최대 16.67ms임을 의미한다. 만약 주어진 시간 내에 프레임을 생성하는 데 필요한 작업을 완료하지 못하면 "프레임 드롭"이 발생하고, UI가 반응하지 않는 것처럼 보인다.

이제 조금 더 복잡한 내용을 살펴보자. 앱에서 개발자 메뉴를 열고 Show Perf Monitor를 토글해 보자. 그러면 두 가지 다른 프레임 속도가 표시되는 것을 확인할 수 있다.

JS 프레임 속도 (JavaScript 스레드)

대부분의 React Native 애플리케이션에서 비즈니스 로직은 JavaScript 스레드에서 실행된다. 이 스레드는 React 애플리케이션이 동작하는 곳으로, API 호출이 이루어지고 터치 이벤트가 처리된다. 네이티브 뷰에 대한 업데이트는 이벤트 루프의 각 반복이 끝날 때, 프레임 데드라인 전에 일괄적으로 네이티브 측으로 전송된다. 만약 JavaScript 스레드가 한 프레임 동안 응답하지 않으면, 해당 프레임은 누락된 것으로 간주된다. 예를 들어, 복잡한 애플리케이션의 루트 컴포넌트에서 this.setState를 호출했을 때, 계산이 복잡한 컴포넌트 하위 트리를 리렌더링하게 되면, 이 작업이 200ms가 걸려 12개의 프레임이 누락될 수 있다. 이 동안 JavaScript로 제어되는 애니메이션은 멈춘 것처럼 보일 것이다. 일반적으로 100ms 이상 걸리는 작업은 사용자가 체감할 수 있다.

이러한 현상은 Navigator 전환 중에 자주 발생한다. 새로운 라우트를 푸시할 때, JavaScript 스레드는 해당 화면에 필요한 모든 컴포넌트를 렌더링하고, 네이티브 측에 뷰를 생성하기 위한 적절한 커맨드를 보내야 한다. 이 작업이 몇 프레임을 소모하면서 jank를 유발하는 경우가 흔하다. 특히 전환이 JavaScript 스레드에 의해 제어되기 때문이다. 때로는 컴포넌트가 componentDidMount에서 추가 작업을 수행하여 전환 중 두 번째 끊김 현상을 일으킬 수도 있다.

또 다른 예로는 터치 응답이 있다. JavaScript 스레드에서 여러 프레임에 걸쳐 작업을 수행 중이라면, TouchableOpacity와 같은 컴포넌트의 응답이 지연되는 것을 느낄 수 있다. 이는 JavaScript 스레드가 바쁘기 때문에 메인 스레드에서 전송된 원시 터치 이벤트를 처리할 수 없기 때문이다. 결과적으로 TouchableOpacity는 터치 이벤트에 반응할 수 없고, 네이티브 뷰에 투명도를 조정하라는 커맨드를 보낼 수 없다.

UI 프레임 속도 (메인 스레드)

많은 사람들이 NavigatorIOS의 성능이 기본적으로 Navigator보다 더 뛰어나다는 점을 눈치챘다. 그 이유는 전환 애니메이션이 메인 스레드에서 완전히 처리되기 때문이다. 따라서 JavaScript 스레드에서 프레임 드랍이 발생해도 애니메이션이 중단되지 않는다.

마찬가지로, JavaScript 스레드가 잠겨 있는 상태에서도 ScrollView를 자유롭게 위아래로 스크롤할 수 있다. 이는 ScrollView가 메인 스레드에서 동작하기 때문이다. 스크롤 이벤트는 JavaScript 스레드로 전달되지만, 스크롤이 발생하기 위해 이벤트 수신이 반드시 필요하지는 않다.

성능 문제의 주요 원인

개발 모드에서 실행 (dev=true)

JavaScript 스레드 성능은 개발 모드에서 실행할 때 크게 저하된다. 이는 피할 수 없는 현상이다. 더 나은 경고와 오류 메시지를 제공하기 위해 런타임에서 훨씬 더 많은 작업을 수행해야 하기 때문이다. 따라서 성능 테스트는 반드시 릴리스 빌드에서 진행해야 한다.

console.log 문 사용 시 주의사항

번들된 앱을 실행할 때, console.log 문은 자바스크립트 스레드에 큰 병목 현상을 일으킬 수 있다. 이는 redux-logger와 같은 디버깅 라이브러리에서 호출하는 경우도 포함된다. 따라서 번들링 전에 반드시 제거해야 한다. 모든 console.* 호출을 제거하는 바벨 플러그인을 사용할 수도 있다. 먼저 npm i babel-plugin-transform-remove-console --save-dev 명령어로 플러그인을 설치한 후, 프로젝트 디렉토리 아래에 있는 .babelrc 파일을 다음과 같이 수정한다:

json
{
"env": {
"production": {
"plugins": ["transform-remove-console"]
}
}
}

이 설정은 프로젝트의 릴리스(프로덕션) 버전에서 모든 console.* 호출을 자동으로 제거한다.

프로젝트에서 console.* 호출을 사용하지 않더라도 이 플러그인을 사용하는 것이 좋다. 서드파티 라이브러리에서 호출할 가능성도 있기 때문이다.

ListView의 초기 렌더링이 느리거나 큰 리스트에서 스크롤 성능이 좋지 않은 경우

새로운 FlatListSectionList 컴포넌트를 사용한다. 새로운 리스트 컴포넌트는 API를 단순화할 뿐만 아니라, 특히 행의 수와 관계없이 거의 일정한 메모리 사용량을 유지하는 등 상당한 성능 향상을 제공한다.

FlatList의 렌더링 속도가 느리다면, 렌더링된 아이템의 측정을 건너뛰어 렌더링 속도를 최적화하기 위해 getItemLayout을 구현했는지 확인한다.

리렌더링 시 JS FPS가 급감하는 문제 해결

ListView를 사용할 때는 rowHasChanged 함수를 제공해야 한다. 이 함수는 행을 리렌더링할 필요가 있는지 빠르게 판단해 불필요한 작업을 줄일 수 있다. 불변 데이터 구조를 사용한다면, 단순히 참조 동등성만 확인하면 된다.

비슷하게 shouldComponentUpdate를 구현해 컴포넌트를 리렌더링할 정확한 조건을 명시할 수 있다. 순수 컴포넌트(렌더 함수의 반환값이 props와 state에만 의존하는 컴포넌트)를 작성한다면, PureComponent를 활용해 이 작업을 자동으로 처리할 수 있다. 이 경우에도 불변 데이터 구조가 유용하다. 큰 객체 리스트를 깊은 비교해야 한다면, 전체 컴포넌트를 리렌더링하는 것이 더 빠를 수 있고, 코드도 훨씬 간결해진다.

JavaScript 스레드 작업 과부하로 인한 FPS 저하

"네비게이션 전환 지연"이 가장 흔한 사례지만, 다른 경우에도 발생할 수 있다. InteractionManager를 사용하는 것이 좋은 접근 방식이지만, 애니메이션 중 작업을 지연시키는 것이 사용자 경험에 너무 큰 영향을 미친다면 LayoutAnimation을 고려해볼 만하다.

현재 Animated API는 useNativeDriver: true로 설정하지 않는 한 JavaScript 스레드에서 각 키프레임을 실시간으로 계산한다. 반면 LayoutAnimation은 Core Animation을 활용하므로 JavaScript 스레드와 메인 스레드의 프레임 드롭에 영향을 받지 않는다.

이를 활용한 사례로는 모달 애니메이션(위에서 아래로 슬라이드하며 반투명 오버레이가 페이드인)을 적용하면서, 동시에 여러 네트워크 요청을 초기화하고 응답을 받고, 모달 내용을 렌더링하고, 모달이 열린 뷰를 업데이트하는 경우가 있다. LayoutAnimation 사용 방법에 대한 자세한 내용은 애니메이션 가이드를 참고한다.

주의사항:

  • LayoutAnimation은 일회성 애니메이션("정적" 애니메이션)에만 적용된다. 애니메이션을 중간에 중단할 수 있어야 한다면 Animated를 사용해야 한다.

화면에서 뷰를 이동할 때(스크롤, 이동, 회전 등) UI 스레드의 FPS가 떨어지는 현상이 발생할 수 있다. 특히 텍스트가 투명한 배경 위에 이미지와 겹쳐져 있거나, 각 프레임마다 뷰를 다시 그리기 위해 알파 합성(alpha compositing)이 필요한 경우에 이런 현상이 두드러진다. 이때 shouldRasterizeIOSrenderToHardwareTextureAndroid를 활성화하면 성능을 크게 개선할 수 있다.

하지만 이 기능을 과도하게 사용하면 메모리 사용량이 급증할 수 있으니 주의해야 한다. 이 속성을 사용할 때는 반드시 성능과 메모리 사용량을 프로파일링해야 한다. 더 이상 뷰를 이동할 계획이 없다면 이 속성을 비활성화하는 것이 좋다.

이미지 크기 애니메이션이 UI 스레드의 FPS를 떨어뜨리는 문제

iOS에서는 Image 컴포넌트의 너비나 높이를 조정할 때마다 원본 이미지에서 다시 자르고 크기를 조정한다. 특히 큰 이미지의 경우 이 작업이 매우 부담스러울 수 있다. 대신 transform: [{scale}] 스타일 속성을 사용해 크기를 애니메이션 처리하는 것이 좋다. 이 방법은 이미지를 탭했을 때 전체 화면으로 확대하는 경우에 유용하다.

TouchableX 뷰의 반응성이 떨어질 때

터치에 반응하는 컴포넌트의 투명도나 강조 효과를 조정하는 동일한 프레임에서 액션을 수행하면, onPress 함수가 반환된 후에야 그 효과가 나타날 수 있다. onPresssetState를 호출하고 이로 인해 많은 작업이 발생해 몇 프레임이 누락되면 이런 현상이 발생한다. 이 문제를 해결하려면 onPress 핸들러 내부의 모든 액션을 requestAnimationFrame으로 감싸면 된다:

tsx
handleOnPress() {
requestAnimationFrame(() => {
this.doExpensiveAction();
});
}

느린 네비게이션 트랜지션

앞서 언급했듯이, Navigator 애니메이션은 자바스크립트 스레드에서 제어된다. "오른쪽에서 밀어넣기"라는 장면 전환을 상상해 보자. 각 프레임마다 새로운 장면이 오른쪽에서 왼쪽으로 이동하며, 화면 밖(예를 들어 x-offset이 320인 위치)에서 시작해 최종적으로 x-offset이 0인 위치에 도달한다. 이 전환 과정에서 매 프레임마다 자바스크립트 스레드는 새로운 x-offset을 메인 스레드로 전송해야 한다. 만약 자바스크립트 스레드가 잠겨 있다면, 이 작업을 수행할 수 없고, 해당 프레임에서는 업데이트가 발생하지 않아 애니메이션이 끊기게 된다.

이 문제를 해결하는 한 가지 방법은 자바스크립트 기반 애니메이션을 메인 스레드로 옮기는 것이다. 위의 예제와 동일한 작업을 이 방식으로 수행한다면, 전환을 시작할 때 새로운 장면의 모든 x-offset을 계산한 후 이를 메인 스레드로 전송해 최적화된 방식으로 실행할 수 있다. 이제 자바스크립트 스레드는 이 책임에서 해방되므로, 장면을 렌더링하는 동안 몇 프레임을 놓치더라도 큰 문제가 되지 않는다. 아마도 멋진 전환 효과에 너무 집중해 있어서 이를 눈치채지 못할 것이다.

이 문제를 해결하는 것은 새로운 React Navigation 라이브러리의 주요 목표 중 하나다. React Navigation의 뷰는 네이티브 컴포넌트와 Animated 라이브러리를 사용해 네이티브 스레드에서 최소 60 FPS의 애니메이션을 제공한다.