Skip to main content
Version: Next

성능 개요

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

이 가이드는 성능 문제 해결에 도움이 되는 기본적인 내용을 설명하고, 일반적인 문제 원인과 제안된 해결책을 논의하기 위해 작성되었다.

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

우리 할아버지 세대는 영화를 "움직이는 그림(moving pictures)"이라고 불렀다. 그 이유는 비디오에서 현실적인 움직임이 일정한 속도로 빠르게 바뀌는 정지 이미지에 의해 만들어지는 착시 현상이기 때문이다. 우리는 이러한 각 이미지를 프레임이라고 부른다. 초당 표시되는 프레임 수는 비디오(또는 사용자 인터페이스)가 얼마나 부드럽고 실제처럼 보이는지에 직접적인 영향을 미친다. iOS 기기는 초당 최소 60프레임을 표시하는데, 이는 사용자가 화면에서 볼 정지 이미지(프레임)를 생성하기 위해 필요한 모든 작업을 수행할 수 있는 시간이 최대 16.67ms임을 의미한다. 만약 주어진 시간 내에 프레임을 생성하는 데 필요한 작업을 완료하지 못하면 "프레임을 놓치게(drop a frame)" 되고, 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 문은 JavaScript 스레드에서 큰 병목 현상을 일으킬 수 있다. 이는 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를 구현해 컴포넌트가 리렌더링해야 하는 정확한 조건을 지정할 수 있다. 순수 컴포넌트(render 함수의 반환값이 props와 state에만 의존하는 컴포넌트)를 작성한다면 PureComponent를 활용해 이 작업을 자동화할 수 있다. 다시 말하지만, 불변 데이터 구조는 이 과정을 빠르게 유지하는 데 유용하다. 큰 객체 리스트를 깊게 비교해야 한다면 전체 컴포넌트를 리렌더링하는 편이 더 빠를 수 있으며, 코드도 훨씬 간결해진다.

JavaScript 스레드에서 과도한 작업으로 인한 FPS 저하

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

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

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

주의 사항:

  • LayoutAnimation은 일회성 애니메이션("정적" 애니메이션)에만 작동한다. 애니메이션을 중단 가능하게 해야 한다면 Animated를 사용해야 한다.

화면에서 뷰 이동 시(스크롤, 이동, 회전) UI 스레드 FPS가 떨어지는 현상

이 문제는 특히 텍스트가 투명한 배경 위에 이미지 위에 위치하거나, 각 프레임마다 뷰를 다시 그리기 위해 알파 합성(alpha compositing)이 필요한 상황에서 더 두드러진다. 이 경우 shouldRasterizeIOS 또는 renderToHardwareTextureAndroid를 활성화하면 문제를 크게 개선할 수 있다.

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

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

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

TouchableX 컴포넌트의 반응성이 떨어지는 경우

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

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

느린 네비게이션 트랜지션

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

이 문제를 해결하는 한 가지 방법은 자바스크립트 기반 애니메이션을 메인 스레드로 옮기는 것이다. 위 예제와 같은 상황에서 이 접근 방식을 사용한다면, 트랜지션을 시작할 때 새로운 장면의 모든 x축 오프셋을 계산해 메인 스레드로 보내 최적화된 방식으로 실행하도록 할 수 있다. 이제 자바스크립트 스레드는 이 책임에서 해방되므로, 장면을 렌더링하는 동안 몇 프레임을 놓치더라도 큰 문제가 되지 않는다. 사용자는 멋진 트랜지션에 집중하기 때문에 이를 거의 눈치채지 못할 것이다.

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