서론
앱 성능에 관련해서 이런 얘기를 들어보신적이 있나요?
한 연구에 따르면 40%에 달하는 사용자들은 로드하는데 3초 이상 걸리는 웹사이트를 방문하는것을 포기합니다. 그리고 이런 사용자의 79%는 다시 그 사이트를 사용하고 싶지 않다고 합니다. 이렇게 부정적인 영향을 받은 사용자의 44%는 나쁜 경험을 주변 친구들과 공유한다고 합니다. 결국 낮은 성능은 고객 만족도에 부정적인 영향을 끼치는 것에 그치지 않고 나아가 브랜드 이미지를 손상시킵니다.
제가 개발하던 서비스 또한 느린 앱 스크롤로 인해서 부정적인 영향을 유저들에게 끼칠까봐 많은 고민의 시간을 보내게 되었습니다.
특히 홈화면을 개발하면서 버벅거리는 스크롤 문제를 지속적으로 겪었고, 이를 해결하기 위해 여러 가지 시도를 해보았습니다.
홈화면은 하나의 큰 RecyclerView 안에 다양한 뷰 타입을 모듈 방식으로 구성하여 개발했습니다. 이 접근 방식 덕분에 각 모듈의 순서를 변경하거나 새로운 모듈로 교체하는 작업이 훨씬 수월해졌습니다. 그러나 스크롤할 때 화면이 뚝뚝 끊기는 문제가 발생했고, 이로 인해 사용자 경험이 크게 저하되었습니다. 결국, 이 문제를 해결하고자 여러 자료를 찾아보고 다양한 방법을 시도했습니다. 오늘은 그 해결 과정을 공유해보려고 합니다.
우선 문제의 핵심은, 화면을 위아래로 스크롤할 때 발생하는 버벅거림이었습니다.
스크롤 시 화면이 마치 위와 같이 뚝뚝 끊기는 현상을 보였습니다.
이 문제를 정확히 파악하기 위해 프로파일러를 활용해 분석을 진행했으며, 아래에서 문제를 해결해 나간 과정을 공유하고자 합니다. (기존 프로젝트에서 예전 코드를 적용하여 프로파일러로 캡처한 화면입니다.)
랜더링이 오래 걸리는 부분이 지속적으로 발생하였고, RenderThread가 600ms가 넘는 경우가 스크롤시마다 발생되었습니다.
아래 표에서 볼 수 있듯이 16ms ~ 700ms사이에 있는 경우 슬로 프레임이라는 것으로 확인할 수 있습니다.
문제 해결 방법 고안
위 문제를 해결하기 위해서 내가 생각한 문제를 해결해 보려고 한 순서는 다음과 같습니다.
1. RecyclerView 어댑터 내부에서 문제점 파악
먼저, RecyclerView 어댑터 내부에서 발생할 수 있는 문제점을 찾아보았습니다. 어댑터가 아이템을 바인딩하거나 뷰를 재활용하는 과정에서 생길 수 있는 성능 저하 요소들을 중점적으로 분석했습니다.
2. 외부 요인 분석
어댑터 내부에서 명확한 원인을 찾지 못한 후, RecyclerView 외부에 존재할 수 있는 문제점을 탐색했습니다. 레이아웃 구성, 데이터 처리 방식, 또는 다른 시스템 자원과의 충돌 가능성을 고려하며 문제를 해결하고자 했습니다.
RecyclerView 어댑터 내부에서 문제점 파악
그래픽 객체 사용 최소화
먼저, RecyclerView에서 자원을 많이 소모하는 작업들을 찾아보기로 했습니다. 그중 하나로, Base Drawable을 사용해 색상을 변경하는 기능이 있었습니다. 특정 내부 RecyclerView에서는 다양한 색상 변경이 필요한 경우가 많았고, 이를 위해 ColorFilter를 자주 사용했습니다. 이 과정에서 drawable의 색상 변경 작업이 많은 연산을 소모한다고 판단했습니다.
이에 따라, 색상 변경 작업을 RecyclerView 외부로 옮기고, 미리 색상이 변경된 drawable들을 캐싱하여 재사용하도록 개선했습니다. 이를 통해 반복적으로 발생하는 연산 비용을 줄이고 성능을 최적화할 수 있었습니다.
예를 든 코드 )+
fun blendColorIntoDrawableOrNull(
context: Context,
@DrawableRes drawableResId: Int,
@ColorInt color: Int,
blendMode: BlendModeCompat
): Drawable? {
val drawable = AppCompatResources.getDrawable(context, drawableResId)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
drawable?.colorFilter =
BlendModeColorFilterCompat.createBlendModeColorFilterCompat(
color,
blendMode
)
} else {
drawable?.setColorFilter(color, PorterDuff.Mode.SRC_ATOP)
}
return drawable
}
Drawable에 색상을 입히는 코드를 리사이클러뷰 내부에서 계속 실행했으니, 얼마나 많은 연산이 필요했을지.. 모르겠습니다.
RecyclerView의 뷰 생성 및 바인딩 최적화
다음으로, RecyclerView 내부에서 여러 개의 뷰를 줄이고, 복잡한 뷰를 사용하지 않도록 최적화하려고 했습니다.
그러나 홈화면 자체가 리사이클러 뷰로 만들기로 했었고, RecyclerView 내부의 뷰들이 자주 변경되어야 했기 때문에, 뷰 생성과 바인딩 시 복잡한 뷰를 줄이는 작업은 현실적으로 어려웠습니다. 이로 인해 이 접근법은 적용하지 않기로 결정했습니다.
+) 나중에 기회가 된다면, 홈화면의 컴포넌트들을 RecyclerView가 아닌 하나의 부모 ConstraintLayout에서 구성했을 때 성능이 얼마나 달라지는지 테스트해 볼 계획입니다.
+) 문서에서 도움이 될만한 글
RecyclerView: Too much inflation or Create is taking too long
In most cases, the prefetch feature in RecyclerView can help work around the cost of inflation by doing the work ahead of time while the UI thread is idle. If you're seeing inflation during a frame and not in a section labeled RV Prefetch, be sure you're testing on a supported device and using a recent version of the Support Library. Prefetch is only supported on Android 5.0 API Level 21 and later.
If you frequently see inflation causing jank as new items come on screen, verify that you don't have more view types than you need. The fewer the view types in the content of a RecyclerView, the less inflation needs to be done when new item types come on screen. If possible, merge view types where reasonable. If only an icon, color, or piece of text changes between types, you can make that change at bind time and avoid inflation, which reduces your app's memory footprint at the same time.
If your view types look good, look at reducing the cost of your inflation. Reducing unnecessary container and structural views can help. Consider building itemViews with ConstraintLayout, which can help reduce structural views.
If you want to further optimize for performance, and your items hierarchies are simple and you don't need complex theming and style features, consider calling the constructors yourself. However, it's often not worth the tradeoff of losing the simplicity and features of XML.
RecyclerView: Bind taking too long
Bind—that is, onBindViewHolder(VH, int)— must be straightforward and take much less than one millisecond for everything but the most complex items. It must take plain old Java object (POJO) items from your adapter's internal item data and call setters on views inthe ViewHolder. If RV OnBindView is taking a long time, verify that you're doing minimal work in your bind code.
If you're using basic POJO objects to hold data in your adapter, you can completely avoid writing the binding code in onBindViewHolder by using the Data Binding Library.
위에서 언급한 것처럼, RecyclerView 뷰홀더의 생성 및 바인딩 과정에서 복잡한 뷰가 포함되면 처리 시간이 길어질 수 있습니다.
특정 뷰그룹에서 노드를 순회할 때 O(N^2)에 가까운 성능을 보일 수 있으며, 이는 ConstraintLayout을 사용해 복잡한 관계를 피해야 한다는 점에서도 드러납니다. 이러한 사실은 뷰가 복잡해질수록 RecyclerView의 바인딩 성능이 느려질 수 있음을 시사합니다.
따라서, 뷰의 복잡성을 최소화하는 것이 중요하며, 특히 홈화면과 같이 반복적으로 업데이트되는 뷰에서는 더욱 신경 써야 할 부분입니다.
RecyclerView 내에서 RecyclerView Pool 사용해보기
RecyclerView 내에서 RecyclerView의 RecycledViewPool을 사용해 보았습니다. 이 방법은 실제로는 눈에 띄는 드라마틱한 차이를 만들지는 않았지만, RecycledViewPool의 사용은 다음과 같은 장점을 제공합니다
- 뷰 재활용: RecycledViewPool을 사용하면 뷰를 미리 만들어 놓아 어댑터가 새로운 뷰를 그려달라고 요청하는 빈도를 줄일 수 있습니다.
- 뷰 공유: 여러 RecyclerView 인스턴스가 동일한 뷰타입을 재활용할 수 있도록 도와줍니다.
특히, 내부 RecyclerView에서 동일한 뷰를 여러 번 재활용하는 경우 RecycledViewPool을 만들어서 뷰를 공유할 수 있도록 설정했습니다. 이로 인해 여러 RecyclerView 인스턴스가 동일한 뷰를 재활용하는 데 도움이 되었습니다.
class OuterAdapter : RecyclerView.Adapter<OuterAdapter.ViewHolder>() {
private val sharedPool: RecyclerView.RecycledViewPool = RecyclerView.RecycledViewPool()
...
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
// Inflate inner item, find innerRecyclerView by ID.
val innerLLM = LinearLayoutManager(parent.context, LinearLayoutManager.HORIZONTAL, false)
innerRv.apply {
layoutManager = innerLLM
recycledViewPool = sharedPool
}
return OuterAdapter.ViewHolder(innerRv)
}
...
기본적으로 각각의 리사이클러 뷰들은 여러 개의 뷰들을 그려서 가지고 있게 되지만, RecyclerViewPool을 사용하게 된다면 여러 번의 뷰 생성을 막을 수 있고, 이를 재활용 할 수 있어, nested Recycler view에서 여러번 사용되는 뷰홀더를 묶어주면 좋아진다고 합니다.
- 하지만 그렇다고 해서 sharedpool을 적용하고 나서 문제를 해결하지는 못했습니다.
- 중첩되어 사용하는 recyclerview가 적었기 때문인지, 실제 눈에 띄는 성능개선은 체감하지 못할 정도였습니다.
- 실제로 sharedpool을 잘 사용하기 위해서는 중복되는 뷰가 많아야 눈에 띄는 성능 향상을 체감할 수 있으리라 사료 됩니다.
기본적으로 각 RecyclerView는 다양한 뷰를 그려서 사용하지만, RecycledViewPool을 사용하면 여러 번의 뷰 생성을 방지하고 재활용할 수 있습니다. 특히, 중첩된 RecyclerView에서 반복적으로 사용되는 뷰홀더를 재활용하는데 유용합니다.
- 하지만, sharedPool을 적용한 후에도 문제가 완전히 해결되지 않았습니다. 중첩된 RecyclerView의 수가 적었기 때문에, 실제로 눈에 띄는 성능 개선을 체감하기에는 부족했습니다.
참고글
리사이클러 뷰 외부에서 문제점 찾아보기
외부에서 문제점을 찾고자 하게된 계기는 위 동영상을 참고하면서 부터입니다. 그래픽 렌더링 과정에서 성능 문제가 발생할 수 있음을 시사해준 영상입니다.
Alpha Blending으로 인한 성능 저하 가능성
여러 요소들이 겹쳐 있는 경우, Alpha 블렌딩 때문에 성능이 저하될 수 있습니다. Alpha 블렌딩은 색상을 여러 번 다시 그려야 하므로 GPU의 성능을 크게 소모할 수 있습니다. 홈화면에서 알파값이 있는 뷰들이 겹치면서 렌더링을 위해 GPU 사용량이 증가하고, 이로 인해 화면이 중간에 끊기는 현상이 발생할 수 있습니다.
이 문제의 원인은 Z-buffer (depth buffer)를 사용하여 깊이 테스트를 수행하면, 특정 뷰(객체) 뒤에 있는 뷰는 그려지지 않지만, 투명한 알파값이 설정된 뷰는 모든 객체를 하나씩 그려야 하므로 반복적으로 렌더링 하게 되어 리소스 낭비가 발생할 수 있다는 점입니다.
불필요한 뷰 렌더링 확인
새로운 카드나 화면이 계속 위에 그려지는 경우, 보이지 않는 부분까지 그려야 하므로 레이아웃 아래에 새로 그려야 할 뷰가 많은지 확인할 필요가 있습니다. 예를 들어, 불필요한 배경 그리기와 같은 부분이 있는지 점검해야 합니다.
이렇게 레이아웃에 어떤 것들이 있는지 파악해 볼만합니다.
+) over draw에 관련된 글
Overdraw refers to the system's drawing a pixel on the screen multiple times in a single frame of rendering. For example, if you have a bunch of stacked UI cards, each card hides a portion of the one below it.
However, the system still needs to draw the hidden portions of the cards in the stack. This is because stacked cards are rendered according to the painter's algorithm—that is, in back-to-front order. This sequence of rendering lets the system apply proper alpha blending to translucent objects such as shadows.
그래픽 디버깅 툴 사용하기
다양한 디버깅 툴을 검토하던 중, HWUI 툴을 발견했습니다. 이 툴은 내부 리소스 사용량을 색상으로 시각화하여 문제를 쉽게 파악할 수 있도록 도와줍니다. 아래 이미지는 HWUI 툴의 구성 예시입니다.
HWUI 툴을 사용하여 어떤 요소들이 그래픽을 그릴 때까지 얼마나 많은 리소스를 사용하는지 확인할 수 있었습니다. 이를 통해 문제의 원인을 파악할 수 있었습니다.
제가 겪고 있던 문제의 핵심은 HWUI에서 초록색 바가 크게 그려진 것이었습니다. 이는 UI 스레드에서 병목 현상이 발생하고 있음을 의미했습니다. 이 문제를 해결하기 위해 UI 스레드에서 병목을 유발하고 있는 코드를 제거하였고, 결과적으로 렌더링 속도가 개선되었습니다.
왼쪽은 문제가 생겼을 때, 스크롤 시 버벅거림이 발생할 경우를 나타내는 막대그래프들이 보입니다. 오른쪽은 HWUI를 통해 문제를 해결한 뒤의 모습입니다.
문제를 개선한 후에는 스크롤 시 버벅거림이 확연히 줄어들었고, 그 결과 사용자들로부터 앱이 느리다는 피드백을 덜 받게 되었습니다. 이번 경험을 통해, 문제를 접근할 때 다양한 디버깅 툴을 적극적으로 활용하는 것이 얼마나 중요한지 깨닫게 되었고, 툴을 통해서 문제의 원인을 정확히 파악하고, 성능을 획기적으로 개선할 수 있음을 실감했습니다. 앞으로도 이러한 툴들을 잘 활용하여 더욱 매끄럽고 원활한 사용자 경험을 제공할 수 있도록 노력하고자 합니다.
최근댓글