React Native 안드로이드 성능 최적화

I made React Native fast, you can too

이 글은 Harry Tonrney라는 개발자가 작성한 “I made React Native fast, you can too”를 번역한 글입니다.


최근 리액트 네이티브를 버리고 네이티브 안드로이드로 바꾸고 싶다(Tempted to Abandon React Native for Native Android)라는 제목의 글을 읽었다. 그 글의 작성자는 오래된 기기에서 이미지를 가져올 때 발생하는 성능 문제 때문에 리액트 네이티브를 버리고 싶다고 했다.

프로젝트에 이미지 추가 기능을 넣기 위해서 나는 이미지 선택기(image picker) 컴포넌트를 사용했다. 이미지를 추가할 때 가끔 사용자 인터페이스가 몇 초간 바뀌었다가 리로드되곤 했는데 내가 이미지를 추가하지 않은 페이지에서 발생하는 문제였다. 나는 메모리 문제가 원인일 것이라 짐작했다. 문제가 발생한 기기는 램 용량이 512MB 정도였고, 메모리가 1GB 이상인 기기에서는 이 문제가 발생하지 않았다.

링크한 글에서 설명하는 성능 문제는 리액트 네이티브 만의 문제가 아니다. 한 개 이상의 큰 이미지를 다루어야 하는 앱을 개발하려는 모바일 개발자라면 누구나 겪을 수 있는 문제인 것이다.

이 글에서 나는 네이티브 도구를 사용해 리액트 네이티브 앱에서 발생하는 성능 문제를 추적하고 수정하는 방법을 여러분에게 보여주려 한다.

방법론

내 의도를 잘 설명하기 위해서 나는 Flickr에서 가져온 이미지를 스크롤 목록에서 보여주는 앱을 만들었다. 이 글에서 나는 내 사고 과정을 설명하며, 내가 작성한 앱에 실행했던 여러 간단한 최적화 방법을 보여주는 풀 리퀘스트 링크와 성능 데이터를 함께 보여줄 것이다. 이 앱의 소스 코드는 여기서 볼 수 있으며 실제 작동 화면은 아래 gif 이미지와 같다.

성능은 이 글에서 다루기에는 광범 위한 주제이므로 나는 오버드로우(overdraw)GPU 렌더링 성능 쪽에 주력하려고 한다. 성능 측정은 모두 이미지 목록을 위쪽 끝부터 아래쪽 끝까지 천천히 스크롤링 하면서 했다. 또한 성능 측정에는 안드로이드 4.4 버전을 사용하는 넥서스 4 에뮬레이터와 안드로이드 5.1.1을 사용하는 넥서스 6 실제 기기가 사용되었다.

오버드로우란 무엇인가?

먼저 구글 개발자 문서를 보자.

오버드로우(Overdraw)란 시스템이 한 프레임을 렌더링할 때 화면에 한 점을 여러 번 그리는 현상을 말한다. 예를 들어 여러 벌의 스택 카드(stacked UI card)가 있고 각 카드는 자신보다 아래에 있는 다른 카드의 일부를 가린다고 생각해보자.

그런데 시스템은 쌓여있는 카드에서 숨겨지는 부분까지 그려야 한다. 이런 현상은 스택 카드가 페인터의 알고리듬(painter’s algorithm), 다시 말해 뒤에 있는 것부터 앞에 있는 것 순으로 렌더링하기 때문이다. 이러한 렌더링 순서덕분에 시스템은 그림자와 같은 반투명한 객체를 올바르게 그릴 수 있다.

모든 애플리케이션은 어느 정도의 오버드로우를 실행하지만 너무 많이 발생한다면 성능 문제가 생길 수 있다.

오버드로우는 어느 정도가 적당한가?

유명한 안드로이드 개발자인 Romain Guy가 했던 케이스 스터디에 따르면 다음과 같다.

오버드로우의 적당한 양은 기기마다 다르다.

경험에 비추어 보면 두 배 정도를 목표로 하는 게 좋았다. 즉 화면을 한 번 그릴 때, 그 위에 두 번의 드로우가 더해져 픽셀마다 3번씩 그리는 것이다.

오버드로우 측정 방법

구글은 어떤 앱에서 발생하는 오버드로우의 양을 측정하는 두 가지 방법을 제공한다. 기기에서 숫자 카운터로 표시하거나 오버드로우 되는 UI를 색상으로 하이라이팅 할 수 있다.

색상 하이라이트를 보려면 에뮬레이터나 디바이스에서 개발자 옵션을 켠 후 개발자 설정으로 들어가서 Debug GPU Overdraw(GPU 오버드로우 디버그)를 선택한 다음 Show overdraw areas(오버드로우 영역 보기)를 선택하면 된다.

이제 예제 앱 화면을 보면 오버드로우에 따라 여러 색상이 겹쳐 표시된 것을 볼 수 있다.

색상은 무엇을 의미하는가?

오버드로우 개발자 문서를 보자.

색상은 화면 각 픽셀에서 발생하는 오버드로우의 양을 알려준다.

원본 색상: 오버드로우 없음
파란색: 오버드로우 한 번
녹색: 오버드로우 두 번
분홍색: 오버드로우 세 번
빨간색: 오버드로우 네 번 이상

오버드로우 횟수를 숫자로 보고 싶다면 다시 개발자 설정으로 들어가서 Show overdraw counter(오버드로우 카운터 보기)를 선택하면 된다.

아래 이미지를 보면 이 앱에는 3.56번의 오버드로우도 있다. 이제 이 숫자를 줄이는 방법에 대해 알아보자.

오버드로우의 원인은?

오버드로우를 색상으로 표시했던 이미지를 보면 백그라운드 부분이 빨간색으로 칠해졌음을 알 수 있다. 백그라운드 컴포넌트를 없앤 뒤 어떻게 되는지 살펴보자. 여기서 내가 한 일은 내가 작성한 BgView 컴포넌트를 표준 컴포넌트인 View로 바꾼 것뿐이다.

  <BgView>
    <ScrollView contentContainerStyle={{justifyContent: 'center'}} style={[{height:height}]}>
      {cells}
    </ScrollView>
  </BgView>
  <View>
    <ScrollView contentContainerStyle={{justifyContent: 'center'}} style={[{height:height}]}>
      {cells}
    </ScrollView>
  </View>

이렇게 바꾼 후에는 오버드로우 횟수가 3.56번에서 1.81번으로 줄었으므로 성능 문제는 내가 작성한 백그라운드 컴포넌트에서 발생했다고 짐작할 수 있다.

이 앱에서 사용한 백그라운드 컴포넌트는 다음과 같다.

import React from 'react'
import { Image, View } from 'react-native'
import bgImage from './assets/bg_transparent.png'

import style from './Style'

export const BaseView = (props) => {
  return (
    <View
      {...props}
      style={[style.baseView, props.style]} />
  )
}

export const BgView = (props) => {
  const propStyle = {
    backgroundColor: 'transparent'
  }

  return (
    <Image
      source={bgImage}
      style={style.pageBackground}>
      <BaseView
        style={propStyle}
        {...props} />
    </Image>
  )
}

일단 눈에 띄는 것은 투명 배경색과 bg_transparent.png 이미지 파일을 사용한 부분이었다. bg_transparent.png는 투명 배경색 위에 몇 가지 문양을 보여주는 이미지다.

앱 디자인에 있어 투명 색상과 투명 배경색의 불필요한 사용은 성능 문제를 일으킬 수 있다. 이미지의 투명 배경색을 단색으로 바꾸면 어떻게 되는지 살펴보자. 결과에 따라 사용된 모든 투명 배경을 제거해야 할 수도 있다. 이 변화와 관련한 풀 리퀘스트는 여기에서 볼 수 있다.

아래에서 보듯이 오버드로우 횟수는 3.56에서 2.57로 줄었다. 배경만 바꾼 것치고는 상당한 개선이다.

GPU 렌더링 프로파일링

드로잉 성능을 측정할 수 있는 다른 편리한 도구로는 개발자 옵션에 있는 Profile GPU rendering(GPU 렌더링 프로파일) 옵션이 있다. 이 메뉴로 가서 In adb shell dumpsys gfxinfo를 선택하자.

이 옵션을 선택한 후 터미널에서 다음 명령어를 실행하면 앱의 드로잉 성능에 관한 상세 통계를 얻을 수 있다.

adb shell dumpsys gfxinfo com.performanceexample

방금 뭘 한 것인가?

안드로이드 개발자 문서를 살펴보자.

adb shell dumpsys gfxinfo 명령은 최근 120 프레임의 타이밍 정보를 여러 항목으로 구분하여 개별 탭에 출력한다. 이 데이터는 드로잉 파이프라인에서 어느 부분이 느린지 확인하는데 꽤 유용할 것이다.

넥서스 6에서 최적화 되지 않은 앱을 실행한 결과의 그래프는 여기에서 볼 수 있다. 방법론 절에서 언급했듯이 이 결과는 앱을 천천히 스크롤한 후 dumpsys를 실행하여 수집했다.

오버드로우 성능과 관련해 가공하지 않은 데이터는 여기에서 볼 수 있다. 아래 스크린샷은 최적화 적용 이전과 이후를 보여준다. 차트의 Y축은 밀리초(milliseconds) 단위의 시간이며, 색상 막대는 각각 프로세스(Process), 실행(Execute), 드로우(Draw) 시간을 나타낸다.


그래프의 의미는?

안드로이드 개발자 문서에 의하면 다음과 같다.

드로우(Draw)
뷰의 디스플레이 목록을 생성하고 업데이트하는데 소요된 시간을 나타낸다. 이 막대가 길면 뷰를 많이 드로잉하고 있거나 onDraw 메서드에서 많은 작업을 한다는 의미일 수 있다.

프로세스(Process)
GPU가 작업을 마칠 때까지 CPU가 대기하는 시간을 나타낸다. 이 막대가 길면 앱에서 GPU를 사용한 작업이 너무 많다는 뜻이 된다.

실행(Execute)
안드로이드의 2D 렌더러가 OpenGL을 사용해 디스플레이 목록을 그리고 다시 그리는 데 걸린 시간을 나타낸다. 이 막대의 길이는 각 목록을 실행하는 데 사용한 시간의 총합에 비례하며 디스플레이 목록이 많아질수록 빨간색 막대도 길어진다.

(역자 주: 안드로이드 개발자 문서에서는 실행 시간이 빨간 막대여서 빨간 막대가 길어진다고 설명되었지만 이 글의 그래프에서 실행 시간은 녹색 막대이므로 목록이 많아지면 녹색 막대가 길어집니다.)

렌더링을 부드럽게 60fps로 하려면 각 프레임은 16ms 이내에서 렌더링이 완료되어야 한다. 이제 이 수치를 개선할 방법을 알아보자.

예제 앱에서 이미지는 어떻게 렌더링되고 있는가?

여러 항목을 목록으로 나열하는 작업은 각 네이티브 플랫폼이 이를 효율적으로 실행하기 위한 전용 컴포넌트를 마련해두고 있을 정도로 모바일 앱에서는 흔한 사용 사례이다. 예컨대 iOS에는 UICollectionView가 있고 안드로이드에는 RecyclerView가 있다.

아래에서 보듯 예제 앱은 ScrollView 안에 모든 이미지를 한꺼번에 렌더링하고 있다.

import React from 'react'
import { Text, View, Button, Dimensions, Image, ScrollView } from 'react-native'

import { FlickrImages } from './FlickrImages'

import { BgView } from './Background'
import style from './Style'

class HomeScreen extends React.Component {
  renderCells (data) {
    return data.map((cell, index) => {
      const {uri} = cell
      return (
        <View key={index} style={style.cellContainer}>
          <Image style={style.imageContainer} source={{uri:uri}}>
          </Image>
        </View>
      )
    })
  }

  renderImageCells (cellData) {
    return (
      <View style={style.imageCellsContainer}>
        {this.renderCells(cellData)}
      </View>
    )
  }

  render() {
    const { height } = Dimensions.get('window')
    let cells = this.renderImageCells(FlickrImages)
    return (
      <BgView>
        <ScrollView contentContainerStyle={{justifyContent: 'center'}} style={[{height:height}]}>
          {cells}
        </ScrollView>
      </BgView>
    );
  }
}

export default HomeScreen

무엇이 문제인가?

이 코드는 사용자가 화면에서 현재 볼 수 없는 부분에 있는 이미지까지 모두 렌더링하므로 좋지 않다. 메모리가 제한적인 모바일 기기에서 이는 성능 저하로 이어질 수 있다. 게다가 이미지 목록이 기기의 가용 메모리를 초과하기라도 하면 앱이 아예 실행되지 않을 것이다.

이를 자세히 알아보기 위해 안드로이드 스튜디오(Android Studio)를 열고 예제 앱을 실행하는 동안 레이아웃 검사기(layout inspector)를 살펴보자. 레이아웃 검사기를 사용하면 주어진 시점에서 표시되는 뷰의 계층 구조를 볼 수 있다. 다음은 ScrollView로 구현한 예제 앱을 살펴본 화면이다.

사용자에게 보이지 않는 뷰까지 몽땅 표시되고 있음을 알 수 있다.

이 부분을 조금 더 자세히 살펴보기 위해 안드로이드 스튜디오의 메모리 검사 기능 화면을 스크린샷으로 찍었다. 보다시피 앱을 처음 시작할 때 7메가 이상의 메모리가 할당되고 있다.

그렇다면 리액티브 네이티브에서 목록을 더 효율적으로 표시할 방법은 무엇일까? 해답은 ListView를 사용하는 것이다.

ListView는 무엇인가?

리액트 네이티브 개발자 문서에 따르면 다음과 같다.

ListView 컴포넌트는 변화하는 구조적 데이터를 수직 스크롤 목록으로 표시한다.

ListView는 계속 항목의 갯수가 달라지는 긴 데이터 목록에 적합하다. 일반적인 ScrollView와는 달리 ListView는 현재 화면에 보이는 부분만 렌더링하고, 전체 항목을 한꺼번에 렌더링하지는 않는다.

쓸만해 보인다. ListView를 사용해서 예제 앱을 수정한 후 어떻게 달라지는지 확인해보자. 이번 변화와 관련한 풀 리퀘스트는 여기에서 볼 수 있다.

다음은 ListView 버전을 레이아웃 검사기에서 살펴본 것이다. 보다시피 전체 목록을 다 표시하지 않고 화면에 보이는 이미지 세 개만 표시했음을 알 수 있다.

다음 화면은 ListView를 적용한 후 앱의 초기 메모리 사용량이다. ScrollView를 사용했을 때 7.20MB였던 것이 5.69MB로 줄어들었다.

그렇다면 GPU 렌더링 성능은 어떻게 변했을까? 다음은 적용 전과 후를 비교한 스크린샷이다. 보다시피 ListView 버전에서 성능이 더 나아졌다.


이 변화에 관해 가공하지 않은 데이터는 여기에서 볼 수 있다.

네이티브 코드로 해보면?

만약 이미지 스크롤 목록을 네이티브 코드로 작성하면 어떻게 될까? 성능에 얼마나 이득이 있을까?

나는 안드로이드 RecyclerView를 래핑하여 리액트 네이티브 ListView의 성능과 비교해보기로 했다. 이와 관련한 풀 리퀘스트는 여기에서 볼 수 있다.

리액트 네이티브는 안드로이드에서 이미지를 읽어 들일 때 Fresco 라이브러리를 사용하므로 네이티브 코드에서도 이를 사용했다.

리액트 네이티브의 ListView와 네이티브 컴포넌트의 차이점은?

가장 큰 차이점은 리액트 네이티브의 ListView가 완전히 자바스크립트로 구현되어 있으며 네이티브 코드를 전혀 사용하지 않는다는 것이다. 덕분에 ListView는 매우 유연해져서 사용하기 편리해졌다. 텍스트나 이미지를 추가하는 순수 네이티브 부분은 자바스크립트만으로 구현할 수 없으며 각 플랫폼 별로 따로 구현되어야 했다.

다른 큰 차이점은 iOS의 UICollectionView나 안드로이드의 RecyclerView는 둘 다 뷰를 재활용한다는 점이다. 이는 리액트 네이티브의 ListView가 목록의 항목을 표현할 때 필요한 순간에 게으르게 그리는 것과 대조적이다.

리액트 네이티브의 ListView는 UICollectionView나 RecyclerView처럼 뷰 컨테이너를 재활용하지 않는다. 이 때문에 사용자가 스크롤을 하면 각 항목의 메모리를 더 많이 할당하고 해제하느라 CPU를 더 많이 사용한다.

참고로 이 주제에 관해서는 Tal Kol이 작성한 훌륭한 글이 있다. 이 글에서는 ListView 대신 네이티브 구현을 사용할 때의 트레이드 오프에 관해 설명하고 있으니 읽어봐도 좋다.

ListView는 네이티브 RecyclerView와 얼마나 차이 나는가?

아래의 성능 데이터에서 보듯이 ListView와 RecyclerView의 차이는 ListView와 ScrollView만큼 크지 않다.


이와 관련한 가공하지 않은 성능 데이터는 여기에서 볼 수 있다.

앱의 초기 메모리 사용량과 뷰 계층구조는 리액트 네이티브의 ListView와 네이티브 RecyclerView가 기본적으로 동일했다.

네이티브 코드를 작성할 필요가 있을까?

그렇다. 내가 작성한 예제 앱은 성능의 여러 측면을 살펴보려고 의도적으로 단순화한 것이다.

실무에서는 API를 통해 이미지 목록을 가져오는 무한 스크롤 앱을 작성해야 할지도 모른다. 이런 앱은 복잡한 멀티 제스쳐 인터페이스와 애니메이션, 복잡한 계산이 필요한 효과를 수반하기도 한다. 이럴 때는 약간의 네이티브 코드를 작성해야 할 수 있다.

Tal Kol은 성능 문제와 관련하여 리액트 네이티브 애니메이션을 최적화하는 방법에 관한 훌륭한 글도 작성한 바 있다. 실용적인 예제도 포함하고 있으니 원한다면 여기에서 읽어보도록 하자.

네이티브 코드를 작성해야 할 또 다른 사용예로는 오랫동안 백그라운드에서 작업해야 하거나 아직 리액트 네이티브가 지원하지 않는 네이티브 API를 사용해야 할 때가 있다.

마치며

애플과 구글은 모바일 앱을 프로파일링 할 수 있는 멋진 개발자 도구를 내놓았다. 나 같은 네이티브 개발자들은 이런 도구에 익숙하지만 웹에서 출발한 리액트 네이티브 개발자들에게는 새로울 수 있다.

이러한 도구는 사용하기 그리 어렵지 않으며 배우고 나면 앱의 성능을 개선하는 데 큰 도움이 될 것이다.

더 읽을 거리

리액트 네이티브 성능 문서
리액트 네이티브의 실험적인 FlatList 컴포넌트
Venmo: 안드로이드 성능 튜닝

Leave a Reply