Skip to main content

Command Palette

Search for a command to run...

[Android Library]Image Picker Android library 배포하기(5)

성능개선 그리고 회고. 다음 버전엔 뭘 넣을까?

Updated
9 min read
[Android Library]Image Picker Android library 배포하기(5)

이전 포스팅(Image Picker Android library 배포하기(4))에서는 직접 작성한 안드로이드 라이브러리를 Jitpack을 통해 배포한 경험을 작성하였다. 이번 포스팅에서는 애초 계획과 실제 구현사항의 비교와 회고 다음 버전 계획에 대해 써보겠다.

배포는 마쳤다. 원래 계획과 실제 구현 사항을 비교해보자

아래는 이전 포스팅에서 작성한 내용을 그대로 가져온 것이다. 구현한 내용은 취소선을 그어 처리하겠다.

기능적 요구사항

  1. 기본 기능

    • 이미지 다중 선택

      • 유저에게 선택할 이미지 개수를 받아 ViewModel 에 관련 로직 작성
    • 이미지 개수 제한

      • 유저에게 선택할 이미지 개수를 받아 ViewModel 에 ListAdapter 의 onClick 로직 작성
    • 길게 클릭 시, 이미지 미리보기

      • 다음 버전에 넣을 계획이다
  2. 고급 기능

    • 선택된 이미지 상태 복원*

      • 상태를 저장하거나 저장하지 않도록 설정할 수 있는 api 를 제공했다
    • 선택된 순서 넘버링

      • 선택된 이미지 목록을 관리하고 선택/해제 시 마다 넘버링을 수행한다
  3. 사용자 인터페이스

    • Bottom sheet dialog 사용으로 사용성 개선

      • Fragment 를 확장하는 클래스 사용방법을 배웠다
    • 인터페이스 테마 변경 가능

      • 유저가 커스텀할 수 있는 부분을 여럿 제공한다
    • 간편한 사용자 경험을 위한 직관적 디자인

    • 개발자가 사용하기 쉬운 api 제공

비기능적 요구사항

  1. 성능 요구사항

    • 큰 이미지 목록을 빠르고 효율적으로 로드할 수 있어야 한다

      • 이미지 목록의 썸네일을 실제 크기보다 작은 해상도로 로드해 성능 개선

        • Glide 의 thumbnail()는 deprecated 되었다. sizeMultiplier() 를 사용해 구현하였다
      • RecyclerView 가 사용하는 holder 의 개수를 화면에 필요한 수보다 많게 사용하도록 해 성능 개선

        • 이번 배포에서는 holder 의 개수를 조절하는 것이 아닌 캐싱하는 뷰의 숫자를 조절했다. 다음 버전에서는 holder 의 개수를 조절할 계획이다.
      • 동적으로 뷰를 렌더링하면서 상태를 변경하지 않고 벡터 이미지를 최대한 활용

        • 직접 Sketch 를 활용해 인디케이터 디자인을 벡터파일로 만들었다
  2. 호환성 요구사항

    • API 28 이상을 지원해야 한다

      • 버전에 따라 다른 권한 요청을 한다
    • 가로모드를 지원해야 한다

      • 가로 모드는 더 많은 아이템을 한 열에 보여준다

몇몇 구현이 계획과 달라지긴 했지만 다음 버전에서 보완 및 개선하자

우선 구현 사항에 대해 평을 해보자면 주요하게 생각했던 부분들은 모두 구현을 한 것 같다. 이미지 미리보기 같은 부분은 바로 다음 프로젝트를 진행하려고 해서 스코프를 좀 줄이게 되었다.

되려 프로젝트를 하다보니 신경이 더 많이 쓰였던 부분은 주요 기능구현 보다 성능을 개선하는 부분이 더 신경이 많이 쓰이고 관심이 생긴 것 같다.

개선의 여지가 있다고 생각한 부분이 몇 군데 있었는데,

우선 수천~수십만장의 이미지 데이터를 다룬다는 것과 그 이미지를 사용자에게 보여야한다는 점이었다.

우선 나름? 많은 데이터를 다룬다고 생각했기 때문에 이미지를 처리하고 있는 데이터 클래스 10만개가 메모리에 올라갔을 때의 용량을 계산해보려고 했다.

data class Image(
    val uri: String,
    val isSelected: Boolean = false,
    val selectionOrder: Int = -1
)

용량측정.. 그거 가능해?

우선 코틀린은 자바와의 100프로 상호 운용성을 고려한 언어이므로 사실 자바의 확장판이라고 볼 수도 있을 것 같다. 다만 코틀린은 자바를 감싼 언어이면서도 성능 저하문제를 없애기위해 많은 최적화 기법을 사용한다고 한다.

때문에 사이즈를 측정한다는 것은 플랫폼 종속적이라는 한계가 있고 최근의 언어는 이전의 언어들보다 그 타입을 관리하기 위한 오버헤드들이 많이 생긴다고 한다.

때문에 정확한 사용되는 메모리를 측정할 수 없는 것으로 판단하고 Android Studio의 Profiler 기능을 활용해 Image 객체를 생성한 후 heap dump 를 따서 메모리가 어떻게 올라갔나 확인을 해보려고한다.

이것이 정말 유요한 측정일지는 잘 모르겠지만 측정 자체가 플랫폼 종속적이라고 하니 내가 사용하고 있는 플랫폼에서 어느정도의 용량을 차지하고 있는지를 대략적으로 확인하는데 의의를 두겠다.

class MainActivity : AppCompatActivity() {

    private lateinit var binding: ActivityMainBinding
    private lateinit var data:Image
    private lateinit var dataList:List<Image>

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(binding.root)
        binding.btnShow.setOnClickListener {
            data = Image("/path/to/image1.jpg", true, 1)
            dataList = generateImageData()
        }
    }

    private fun generateImageData(): List<Image> {
        val imageData = mutableListOf<Image>()
        for (i in 1..100) {
            val uri = "/path/to/image$i.jpg"
            imageData.add(Image(uri, i%2==0, i))
        }
        return imageData
    }
}

data class Image(
    val uri: String,
    val isSelected: Boolean = false,
    val selectionOrder: Int = -1
)

위는 Image data class 의 객체를 101개 생성하고 heap dump 를 따서 확인한 결과와 그 코드이다.

Profiler 로 분석한 결과를 분석해보자

우선 몇 가지 신기한 점이 있었다.

구글의 Inspect your app's memory usage with Memory Profiler 문서를 보면 Shallow size 는 "Size of this instance in Java memory." 즉 자바 메모리에 올라간 인스턴스의 사이즈이다. Retained Size는 "Size of memory that this instance dominates (as per the dominator tree)" 무슨 말인지 잘 모르겠어서 찾아보니 해당 객체가 가비지 컬렉션(GC)에 의해 해제될 때 함께 해제되는 모든 객체의 총 메모리 크기라고 한다. (각 단위는 byte 이다.)

Profiler는 Image instance 의 크기를 두 창에서 확인 할 수 있게 했다. 다시 한번 보자.

좌측의 인스턴스의 shallow size 는 17이 나온다. 하지만 우측의 디테일을 보면 각 필드의 shallow size 를 확인할 수 있는데 url(String)이 16, isSelected(Boolean) 이 1, selectionOrder 가 4로 도합 21이다. (일부러 중복 참조를 하지 않게끔 객체의 초기화 값을 모두 동적으로 변경되도록 두었는데 premitivie type 을 최적화 하는 좋은 방법이 더 있나 싶다.)

또 Image 객체만 저장한 변수와 리스트 내에 들어간 Image 객체는 그 depth 가 달랐다.

Retained Size 는 객체가 GC에 의해 해제될 때 얻을 수 있는 메모리라고 하는데 Image 타입만 저장된 변수보다 리스트 내 저장된 객체가 해제될 때 얻을 수 있는 메모리가 크기가 더 큰 것을 알 수 있었다.

그럼 위 결과 알게된 내용으로 Image 객체가 10만개가 메모리의 용량을 대략적으로 측정해보면,

21 바이트 * 100,000 = 2,100,000 bytes = 2.1 MB 이다.

실제로는 객체 헤더, 메모리 정렬, 객체 참조 오버헤드, 플랫폼에 따라 크게 달라질 수 있어 유의미한 측정이라고 보기는 어려울 것 같다.

어쨌든 그 오버헤드 때문에 용량이 2배가 된다고 하더라도 크게 상관없겠다는 판단이 들었다.

그래서 데이터의 메모리 최적화는 넘어가도록 하고 화면 렌더링 측면의 최적화를 고민했다.

수 만장의 이미지 리스트 어떻게 보여줄 건데?

일단 이미지를 로드하기 위해 많이 사용하는 라이브러리인 Coil과 Gilde를 비교했다.

하지만 문제는 각 라이브러리의 성능을 직접적으로 측정하기 위한 방법을 고안하려면(대략적으로라도) 너무 많은 시간이 들어갈 것이라고 생각해 경험적 판단을 하기로 했다.

이렇게 결정하니 선택은 쉬웠던 게 Glide 의 승리가 자명했다. 또한 Glide 지원하는 이미지 랜더링 관련 많은 기능을 라이브러리 유저에게 커스텀 할 수 있도록 제공할 수 있다는 것 또한 큰 장점이 됐다.

실제로 불러오는 이미지의 Thumbnail resize 와 Crop 등의 기능을 활용했다.

이미지 로드는 됐는데 그럼 RecyclerView 최적화는?

수 많은 이미지 목록 중 옛날에 찍은 사진을 업로드하기 위해선 엄청난 속도로 스크롤을 내려 찾으려는 이미지가 있는 시점 부근을 찾으려고 하게된다.

이때 아직 RecyclerView 의 ViewHolder에 이미지가 로드되지 않은 채로 화면에 보이게 된다.

이 부분은 유저의 사용성을 크게 해친다고 생각했다. (기준으로 삼는 공식 Photo Picker 는 거의 이런 일이 없다.)

따라서 이전 프로젝트들을 통해 알게된 RecyclerView 의 내부 구현을 염두해 개선 방안을 찾았다.

RecyclerView 는 실제로 화면에 보이는 개수보다 좀 더 많은 ViewHolder 를 관리한다. 누군가는 화면에 보이는 수 만큼 관리한다고 하는데 실제로는 RecyclerView의 영역을 채우는데 필요한 개수보다 5개 더 많은 holder를 관리한다.

위 사진을 보면 RecyclerView 아래에 15개의 ConstraintLayout 이 유지되는 것을 볼 수 있다.

관리되는 ViewHolder 의 개수와 실제 화면에 그려지는 Item View 의 개수는 다르다.

이전 사진의 Layout Inspector 탭을 보게되면 마지막 막 잘린 15번째 item까지 15개가 보이는 것을 알 수 있다. 그리고 그대로 Component Tree에는 15개의 item view 를 확인할 수 있다.

그럼 아까 얘기한 화면에 필요한 item 의 개수보다 5개를 더 관리한다는 말은 어떻게 된건가?

내가 한 말은 사실이다. 왼쪽 Logcat 탭을 보자. 20개의 홀더가 카운트 됐다.

package com.opensource.bottomsheet

import android.util.Log
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView
import com.opensource.bottomsheet.databinding.ItemImageBinding

class ImageAdapter : ListAdapter<Image, ImageAdapter.ImageViewHolder>(ImageDiffCallback()) {
    private var viewHolderCount = 0
    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ImageViewHolder {
        viewHolderCount++
        Log.d("RecyclerView", "ViewHolder created. Total count: $viewHolderCount")
        val binding = ItemImageBinding.inflate(LayoutInflater.from(parent.context), parent, false)
        return ImageViewHolder(binding)
    }
    ...
}

위 코드는 뷰 홀더가 생기는 시점에 로깅을 하는 RecyclerView의 Adapter 코드이다.

그래서 ViewHolder 의 개수가 RecyclerView 의 성능에 어떻게 관여한다는 건데?

ViewHolder의 사용은 그 자체로 매번 새로운 View를 inflate하고 (findViewById를 여전히 사용한다면)하위 뷰를 찾지 않아도 되는 이점이 있다.

하지만 이걸로는 부족하다.

RecyclerView와 LinearLayoutManager가 제공하는 기능을 테스트하며 쓸만한 옵션이 있는지 확인해봤다.

  • setMaxRecycledViews: Pool 내부 타입별 유지할 holder 개수 설정

    • 유지에 관한 기능은 새 이미지 로드 딜레이를 개선하지 못함
  • setItemViewCacheSize: 오프스크린 뷰 캐시 설정

    • 화면에 보이는 것보다 많은 ViewHolder 생성
  • setInitialPrefetchItemCount: 미리 불러오기 개수 설정

    • 중첩된 RecyclerView 중 내부에서만 동작한다고함 (참고)

다 쓸모가 없다... 아래는 삽질하며 적은 내용을 적어두긴 했다.

setMaxRecycledViews() 를 사용해보자

우리는 위에서 holder 개수와 미리 불러올 아이템 개수를 조절하여 RecyclerView 의 item 이 로드되지 않은 채로 유저에게 보여지지 않도록 사용자 경험을 개선해보겠다.

setMaxRecycledViews 의 설명을 보면

Sets the maximum number of ViewHolders to hold in the pool before discarding. Params: viewType – ViewHolder Type max – Maximum number

풀에서 뷰 홀더가 폐기되기 전의 최대 개수를 설정한다고 되어있다. (초기값은 5였다.)

recycledViewPool.setMaxRecycledViews(R.layout.item_image, 30)

위 처럼 세팅을 하고 adapter의 onCreateViewHolder()에 로그를 찍어보니 30의 ViewHolder가 추가 생성되고 있지 않았고 이전과 동일하게 20(15+5)개 뿐이었다.

하지만 생성되는 ViewHolder의 개수를 늘리지 않는다면 풀에 얼마나 ViewHolder를 보관하는 것은 의미가 없을 일이 아닌가.

생성되는 ViewHolder 개수를 늘리려면

먼저 setItemViewCacheSize()의 설명을 보자.

Set the number of offscreen views to retain before adding them to the potentially shared recycled view pool. The offscreen view cache stays aware of changes in the attached adapter, allowing a LayoutManager to reuse those views unmodified without needing to return to the adapter to rebind them. Params: size – Number of views to cache offscreen before returning them to the general recycled view pool

RecyclerViewHolder Pool로 반환되기 전 최대 몇개의 화면 밖 뷰의 개수를 유지할 것인지 설정한다고 한다.

setItemViewCacheSize(20) 설정 후 로그를찍어보니 기존 사용하던 20개에 추가로 18(20-2)개의 viewholder를 더 생성하는 것을 볼 수 있었다.

해당 함수로 초기화하는 값의 기본값은 DEFAULT_CACHE_SIZE(2)였다.

(추가된 20개의 ViewHolder 가 이후에도 계속 운용되고 있는지는 확인하지 않았다. 지금은 일단 생성한 건 계속 사용할 거라고 믿겠다.)

이제 ViewHolder 는 넉넉하게 사용하고 있으니 ItemView 를 미리 불러와야 되는데...

setInitialPrefetchItemCount 는 중첩 RecyclerView 의 내부 RecyclerView에서만 사용가능한 옵션이라고 한다. (왜 굳이 이렇게 만든거지..?)

결국 다른 방식을 사용했다.

LinearLayoutManager를 커스텀했다. 생각해보니 사용하는 아이템을 미리 로드하려면 사용하는 viewholder 의 개수를 늘리고 늘린 ViewHolder 개수만큼 미리 ItemView 를 불러오는 방법도 있겠지만

RecyclerView가 화면에 보이는 것보다 더 큰 영역을 가지고 있다고 알고 있게 하는 방법도 있었다.(이게 훨씬 쉽다.)

LinearLayoutManager는 이런 기능을 제공하고 있었고 이를 커스텀 하는 방식으로 아이템을 미리 불러올 수 있게 개선했다.

이 방식이면 내부적으로 모두 처리가 될테니까 앞에서 논의한 모든 설정을 사용하지 않아도 됐다. 아래가 그 코드다.

class CustomLayoutManager(context: Context) : LinearLayoutManager(context) {
    override fun calculateExtraLayoutSpace(state: RecyclerView.State, extraLayoutSpace: IntArray) {
        super.calculateExtraLayoutSpace(state, extraLayoutSpace)
        extraLayoutSpace[0] = 500  // 상단/좌측 추가 높이
        extraLayoutSpace[1] = 1000 // 하단/우측 추가 높이
    }
}

추가 아이디어

스크롤 가속도를 제한해 썸네일 로딩 시간을 확보하고 RecyclerView의 스크롤러에 FastScroller을 부착해보는것도 좋을 것 같다. 다음 버전에 추가해봐야겠다.

마무으리...(회고)

이렇게 수 만개의 데이터 클래스를 다루는 것에 대한 메모리 수준의 염려(슬랙 코틀린 커뮤니티에서 답변달아주신 외국인 분들께 감사드린다.)와 그 이미지를 로드하는데 있어 사용자 경험을 저해하는 요소들을 개선하려는 각고의 노력을 해봤다.

(여전히 이미지 목록을 다루는 방식을 개선할 필요가 있다고 생각한다. 고민해 둔 방법이 있어 이후 버전에서 적용해 볼 생각이다 그리고 ItemView의 상태 변화 로직도 개선할 생각이다. Glide가 적용된 View가 padding이 적용되면 깜박임이 생긴다. 해결 방법도 생각해 뒀다.)

구글의 PhotoPicker가 제공하지 않는 기능을 필요하다고 여기고 여러 버전에 대해 동일한 유저 경험을 위한 나만의 Image Picker를 만든 결심을 한 나를 칭찬한다.

그덕에 이번 오픈소스 라이브러리 프로젝트를 통해

  • 수 만개 이상의 데이터 처리(유저의 로컬 사진 데이터)

  • 간결하고 직관적인 코드 구현(PermissionRequester 구현, MVI 패턴 사용)

  • 커스텀 뷰 구현 방법(ListAdapter 내 로직을 간결하게 하기 위한 LinearLayout을 확장한 CustomItemView)

  • 안드로이드 라이브러리 배포 방법(Jitpack을 통한 라이브러리 배포)

  • 라이브러리 api 제공을 위한 로직 구현 방법(Builder 패턴, Fragment 초기화 문제)

  • 메모리 수준의 고민(수 만개 데이터 처리 시 메모리에 가용되는 용량 수준 확인)

  • 유저 사용성 개선을 위한 고민(RecyclerView 최적화)

등 많은 내용을 배웠다.

사실 목표를 잡고 크고 작은 문제를 만나면서 어떤 창의적인 아이디어로 이번 문제를 해결할지를 고민하는 것은 고되다기 보다는 재미난 일이라고 느껴진다. 또 더욱이 안 하면 안했지 대충은 하지말자라는 생각을 가지고 있는 사람으로서 계획한 목표와 기획된 유저 경험을 타협하지 않고 구현해낸 그 쾌감은 이루 말할 수 없다.

아 참. 오픈소스와는 별개로 이 오픈소스 프로젝트를 기획하게 했던 CampingMate 앱도 배포가 되었다. 조만간 팀 유지보수 미팅 때 CampingMate에도 이번에 개발한 라이브러리를 적용하고 싶다고 얘기해봐야겠다.

좀 더 일반적으로 많이 쓰이는 라이브러리의 문제를 찾아서 해결할 문제였으면 하는 바램을 가지고 이번 포스팅은 여기서 마치겠다.


https://github.com/KeunyoungSong/SelectSaveImagePicker/tree/main

OpenSource

Part 1 of 5

A blog series where I share experiences and insights gained from contributing to and developing open-source projects.

Up next

[Android Library]Image Picker Android library 배포하기(4)

배포 완료! 이제 끝...? 아니 유지보수 드가자~

More from this blog

정적 코드 분석이란?

정적 코드 분석(Static Code Analysis)은 소스 코드를 실행하지 않고 분석하는 기법이다. 코드 품질을 개선하고 버그, 보안 취약점, 코드 표준 위반 등을 조기에 발견하는 데 사용된다. 코드 정적 분석은 개발 초기 단계에서 오류를 발견해 수정 비용을 줄이고 전반적인 코드 품질을 향상시키는 데 도움을 준다. 주요 특징 및 장점 자동화 도구 사용: 정적 분석 도구로 소스 코드를 자동으로 분석해 문제점을 발견한다. 코드 품질 향상: ...

Jun 17, 20242 min read16
정적 코드 분석이란?

안드로이드 앱 개발의 CI/CD Overview

이전 포스팅(CI/CD, DvOps의 이해)에서 소프트웨어 분야의 CI/CD, DevOps 에 대해 간략히 알아봤다. 이번 포스팅에서는 회사 기술 블로그등을 통해 최근 안드로이드 분야의 CI/CD 가 어떻게 구성되어 있는지 확인해보고 이후 포스팅에서 각각의 기술을 좀 더 상세히 학습해보려고 한다. 그럼 시작하자. 사전 조사 우선 이전 라인의 안드로이드 CI&Unit Test 포스팅 에서 알게된 키워드를 위주로 다른 회사들의 사례를 찾아봤다. 그...

Jun 14, 20243 min read194
안드로이드 앱 개발의 CI/CD Overview

GreenBot

58 posts