본문으로 바로가기
반응형

Photo by unsplash

안드로이드에서 연락처를 읽기 위해 contact provider를 사용하던 도중 대용량의 데이터를 로드하면 속도가 눈에 띄게 느려지는 현상이 발생했습니다. 물론 말 그대로 대용량이기 때문에 느리기도 하지만, 이런 경우 어떤 방향으로 속도 개선을 해야 할지에 대해 기술해 보려고 합니다.

테스트를 위해 연락처를 4.7만개 넣은 contact의 정보를 읽는 작업을 진행해 보니 시간이 어마어마하게 느립니다. 이는 어떤 단말에서 테스트했는지에 따라 다르고 하나의 연락처에 전화번호를 기본 두 개 정도씩은 가지고 있고, 주소, 이메일을 랜덤 하게 가지고 있는 무거운 데이터로 구성된 연락처 입니다.

만약 "contacts" table을 읽기 위해서는 아래와 같이 query 했다고 가정합니다.

fun getContacts(): ArrayList<ContactModel> {
    val retList = ArrayList<ContactModel>()
    val projection = arrayOf(
            ContactsContract.Contacts._ID,
            ContactsContract.Contacts.DISPLAY_NAME_PRIMARY,
            ContactsContract.Contacts.HAS_PHONE_NUMBER,
            ContactsContract.Contacts.STARRED,
            ContactsContract.Contacts.PHOTO_URI
    )
    val order = ContactsContract.Contacts.DISPLAY_NAME_PRIMARY

    contentResolver.query(ContactsContract.Contacts.CONTENT_URI, projection,
                                                      null, null, order)?.use {
        while (it.isAfterLast() == false) {
            //1. query에서 넘어온 정보를 읽어 ContactModel 생성
            val newContact = ContactModel(it.getLong(0),
                                    it.getString(1),
                                    it.getInt(2),
                                    it.getInt(3),
                                    it.getString(4))
            //2. list에 add
            retList.add(newContact)
            //3. 다음 row (item)으로 이동
            it.moveToNext()
        }
    }
    return retList
}

class ContactModel(val id: Long,
                   val name: String,
                   val hasPhoneNumber: Int,
                   val starred: Int,
                   val photoUri: String)

연락처가 4.7만 개라고 얘기했으니, "contacts" table을 읽으면 4.7만 개의 row가 올라옵니다. 즉, while문은 4.7만 번을 반복합니다.

연락처를 읽기 위해서는 Provider를 사용해야 하기 때문에 contentResolver를 사용해야 합니다. 따라서 query 자체를 실행하고 cursor를 얻어오는데 걸린 시간은 차치하고(어차피 필요한 시간이기 때문에), 나머지 iteration을 도는 시간 중에 가장 많은 시간이 차지하는 곳은 주석 1, 2, 3번 중 어디일까요?

답은 3번 it.moveToNext() 함수입니다.

단말과 상황에 따라서 다르겠지만 while문이 총5초 정도가 걸렸고 it.moveToNext()에서만 3초가 넘게 걸립니다.

즉 어이없게도 약 70%의 속도를  Cursor의 moveToNext()에서 먹는 거죠.

DB operation에 대한 기본적인 속도 개선 

우선 위 문제점에 대한 분석을 시작하기 전에 대용량 처리 (몇만 건의 데이터 처리)가 아니라는 가정하에, 속도를 개선하는 방법은 아래와 같습니다.

  • Projection에서 꼭 필요한 column만 명시하여 뽑아낸다.
  • 반복문 안에서 query를 하지 않는다. query 횟수는 가능한 적게, 한방 쿼리로 필요한 데이터를 한 번에 끌어올린다.
  • 반복문 안에서 update, delete, insert를 하지 않는다.
    • ContentProvider를 사용할 경우 applyBatch, bulkInsert 등을 사용.
    • 내 DB인 경우 transaction으로 묶어서 처리.
  • 내 DB라면 사용 scene에 맞게 index를 잘 생성해 준다. (남용은 금지...)

위 방법은 DB의 access시 가장 보편적으로 속도를 개선할 수 있는 방법입니다. 하지만 처음에 언급했듯이 대용량인 경우 (몇 만 건의 데이터를 처리할 경우)에는 위 방법보다는 다른 전략으로 접근해야 합니다.

SQLiteCursor

우리는 기본적으로 framework에서 반환해 주는 SQLiteCursor를 사용합니다. [2]

실제 contentResolver.query를 통해서 넘겨받는 Cursor는 interface이기는 하지만 내부적으로는 SQLiteCursor가 반환됩니다. 문제는 이 커서의 비효율적인 동작으로 인하여, 대용량 데이터 처리 시 속도가 저하되는 현상이 발생합니다. 이에 대한 분석에 대한 내용은 2016년에 이미 분석되어 있는 자료를 기반으로 의역 및 번역하여 설명합니다. [1]

Query의 pause / resume

SQLiteCusor는 보통 2MB의 CursorWindow를 가지고 고정된 크기로 데이터를 읽어 올립니다. 따라서 읽어야 할 전체의 데이터 중에 2MB 정도만 끌어올리고 그 데이터들을 기반으로 moveToPosition()이나 moveToNext()를 통해 하나씩 읽어온 데이터를 반환해 줍니다.

끌어올린 데이터의 마지막에서 다시 moveToNext()를 만나면 CusorWindow을 밀어서 다음 2MB를 끌어올려야 합니다. 이때 CusorWindow를 다음 부분으로 바로 밀어서 읽어는 게 아니라, 다시 전체 데이터의 처음부터 쭉 읽어오면서 앞쪽 데이터는 버리다가, 실제 읽어야 하는 부분을 만나면 window에 담습니다.

이는 SQLiteCursor가 query의 pause / resume을 하지 못하기 때문입니다. 예를 들어 10만 개의 데이터를 읽어야 하는데 CursorWindow가 1만 개씩만 읽을 수 있다면 10001번 데이터를 읽으려면 다시 1번부터 읽어오면서 10000개의 데이터를 버리고 10001~20000번 까지의 데이터를 읽어옵니다. 20001번을 읽을 때는 처음부터 앞에 2만 개를 버리면서 와야 되겠죠? 따라서 window를 뒤로 밀수록 속도는 느려집니다.

getCount()부터 시작

SQLiteCursors는 일단 getCount() 함수부터 호출하고 시작합니다. 이는 moveXXX() 함수에서 이동 가능한 범위를 체크하기 때문입니다. 즉. 전체 데이터의 개수를 알아야 "다음으로, 처음으로, 끝으로" 등 "이동시에 데이터가 더 있는지 / 이동이 가능한지"를 판단할수 있고, 이를 moveXXX 함수에서 내에서 check 하기 때문입니다. [3]

@Override
public int getCount() {
    if (mCount == NO_COUNT) {
        fillWindow(0);
    }
    return mCount;
}

getCount() 함수는 위와 같이 되어 있습니다. window를 무조건 0번 위치로 이동시키도록 되어 있기 때문에 만약 내가 필요한 게 세 번째 window의 내용이지만 일단 counting을 위해 0번으로 이동됩니다. 하지만 0번 부분을 사용하지 않을 거라서, 로드된 부분을 버리고 다시 세번째 window까지 이동해야 합니다.

CursorWindow의 데이터 로딩 범위 문제

또 한가지의 특이한 동작을 보자면 moveToPosition(int position) 함수가 호출되면 window에는 position부터 데이터가 채워지지 않습니다. position에서 1/3은 앞쪽의 데이터를 채우고 나머지는 position 뒤쪽의 데이터가 들어갑니다. 예상컨데, 스크롤을 하게 되면 앞/뒤로 할 수 있기 때문에 앞쪽 일부 데이터를 가져오도록 하기 위한 의도로 보입니다.

하지만 맨 처음 예제처럼 데이터 전체를 읽고 지나가는 경우, 앞쪽의 데이터가 필요 없지만 window가 이동할 때마다 불필요하게 데이터가 겹쳐져서 로딩됩니다. Window가 가지고 있는 전체 2MB 중 650KB는 이미 읽은 데이터로 다시 window가 채워지는 거죠.

따라서 데이터가 많을수록(window을 여러 번 밀어야 할 만큼 데이터가 많다면) 점점 효율면에서는 손해가 발생합니다.

참고로 moveToNext() 등의 moveXXX 함수는 대부분 내부적으로 moveToPosition(currentPosition +1) 같은 형태로 내부에서 moveToPosition()을 호출합니다.

다행히도 API 28 이후부터는 window가 앞쪽 데이터를 읽지 않도록 setFillWindowForwardOnly() 설정할 수 있습니다. [5] 하지만 이 api 역시 내가 DB를 가지고 있을 때만 설정 가능하고 다른 곳에서 contentProvider를 통해서 가져와야 하는 경우라면 사용할 수 없습니다.

CursorWindow의 position 계산 문제

window를 채울 때 1/3은 앞쪽 데이터를 넣기 위해서는 cursor에 담기는 row가 몇 개인지 알아야 1/3 지점을 계산해서 끌어올 수 있습니다. 따라서 SQLiteCursor는 첫번째 window가 채워질때 "아.. window 하나당 row개 몇개 올라오는구나!"를 계산해서 추정 값으로 사용합니다. 만약 읽어 올리는 filed가 TEXT 같은 가변 field라면, 그리고 여기에 이메일 본문처럼 크기가 무쌍한 데이터가 담겨있다면 처음 계산해 놓은 Row / Window는 매우 부정확한 수치가 됩니다.

따라서 새로운 window 로딩 시 이 부정확한 수치로 계산하여 1/3의 row부터 불러왔는데, 운 없게도 여기에 용량이 어마어마한 이메일 본문 데이터가 들어있었다면 중복된 데이터가 대부분의 window의 용량을 차지하고, 실제 새로운 데이터는 window 끝에 쪼금만 올라올 수 있습니다.

데이터 유실 문제

SQLiteCursor는 일단 window가 로드되면 그 이후에 해당 데이터가 바뀌더라도 추적하지 않습니다. 예를 들어 첫 번째 window가 로딩된 상태에서 0번째 row에 데이터를 추가하면 이 데이터는 다음 window를 읽어올 때도 당연히 포함되지 않습니다. 영영 못 읽게 되는 거죠.(물론 아예 코드에서 다시 쿼리 하면 올라오겠지만..)

반대로 로딩된 window 이후에 존재하는 데이터를 삭제해 버리면 해당 row를 읽을 때 "어?? 읽어야 되는데 데이터가 없네?? 응.. 그럼 일단 Exception을 날리자!!"로 처리되어 exception이 발생됩니다.

 

해결책

위에 언급한 window에 대한 부적절한 동작은 대용량의 데이터를 읽어야 할 때 window가 이동하면서 발생합니다. 따라서 한 개의 window 크기가 넘지 않을 만큼의 query를 분할하여 가져오는 작업으로 회피할 수 있습니다.

문제는, 얼마만큼을 나눠서 읽어야 2MB를 넘지 않게 한 개의 window만 쓸지를 개발자가 알기는 어렵다는 겁니다. 읽어오는 column의 개수와 형태에 따라서 달라지기 때문에 정해진 공식 같은 게 존재하지 않고, query의 종류와 내용에 따라서 달라집니다. 그렇기 때문에 실제로 여러 개로 분할해 보고 시간을 측정해서 분할 범위를 유추해 보는 방법밖에는 없습니다.

이 문제를 분석했던 원문에서는 paging 할 때, Room과 연계되어 사용할 수 있는 paging library를 쓰는 것을 또 하나의 해결책으로 제시합니다. [6] 이는 "paging library가 알아서 잘 잘라서 쿼리 해 줄 거야!"를 내포하고 있는데, 실제 paging을 하는 부분에서만 사용할 수 있고, 맨 처음 예제처럼 전체를 로드해야 하는 상황에서는 사용하기 어렵겠죠?

따라서 "이렇게 하면 됩니다!!"라는 정답 같은 해결책을 제시하기는 어렵습니다. 다만 제 경우에는 읽어야 할 연락처를 만개 정도의 단위로 분할하여 동시에 query 하는 (contactProvider는 thread-safe 합니다.) 형태로 병렬 처리를 하였으나, 이는 병렬처리를 위한 데이터 분산과 이후 결과를 조합하는 부수적인 작업이 들어가야 하며, 동시성 이슈가 발생하지 않도록 데이터를 신경써서 분할해야 하는 복잡성이 높아지는 코드가 생성됩니다. 또한 해당 query를 몇개로 분할할지에 대한 테스트를 여러번 진행하고 테스트 결과에 맞는 단위로 병렬처리를 하였습니다.

테스트 시간에 따른 개발 시간 증대와, 코드 복잡도 증가는 어떻게든 줄이기가 어렵습니다. 하지만 단순히 "데이터가 많아서 느린가 보다" 보다는 데이터가 늘더라도 일정 개수까지는 linear 하게 속도가 증가하지 않고, 반대로 대용량에서는 데이터가 늘어감에 따라 속도 증가가 왜 exponential 하게 증가하는지에 대한 해답은 얻었다고 생각됩니다. 이유를 알면 본인 코드에 맞는 솔루션을 찾고 적용하는데, 조금이라도 개발 시간을 단축할 수 있을 것으로 믿습니다.

References

[1] https://medium.com/androiddevelopers/large-database-queries-on-android-cb043ae626e8

[2] https://developer.android.com/reference/android/database/sqlite/SQLiteCursor.html

[3] https://android.googlesource.com/platform/frameworks/base/+/fee4546fd648b519ad828ea1f950554c1054699d/core/java/android/database/sqlite/SQLiteCursor.java#130

[4] https://android.googlesource.com/platform/frameworks/base/+/master/core/java/android/database/Cursor.java

[5] https://developer.android.com/reference/android/database/sqlite/SQLiteCursor#setFillWindowForwardOnly(boolean)

[6] https://developer.android.com/topic/libraries/architecture/paging.html

반응형