안드로이드 11에서 12로 업데이트가 자동적으로 진행되던 시기에, 새로 투입된 프로젝트의 심각한 이슈가 발생했었다.
MPAndroidChart 를 사용해서, 차트 16개를 동시에 그리는 화면에서 화면 처리 속도가 현저히 떨어지는 현상이 발생했다.
11버전과 비교했을 때 5배 정도 느리게 처리되고 있었다.
이 같은 현상을 파악하기 위해, 전체 과정을 역으로 뒤집어 가며 하나하나 분석해나갔지만 원인과 해결방법을 찾기가 여간 쉽지 않았다.
그러다 한참 후에 상당히 허무하게 원인이 발견되었는데, 그 문제의 지점은 bytearray 를 Stringbuilder 를 이용해 String 으로 변환하는 코드였다.
// data: ByteArray
val stringBuilder = StringBuilder(data.size)
for (byteChar in data) {
stringBuilder.append(String.format("%02X ", byteChar))
}
extraData = stringBuilder.toString()
왜 이코드가 안드로이드 12 버전에서 느려진걸까?
안드로이드 12부터는 메모리 관리 효율을 높이기 위해 세대별 GC 를 기본값으로 사용하거나, 더 공격적으로 운용한다고 한다. data.size 만큼 루프를 돌며 매번 String.format 으로 임시 객체를 만들면, 짧은 수명을 가진 객체가 배열의 길이만큼 힙(heap)에 쌓인다.
11 버전까지는 이 정도의 객체 생성을 JIT 컴파일러가 어느 정도 무마해 주거나 GC 가 조용히 처리했을 수 있지만, 12 에서는 보안 강화나 런타임 구조 변경으로 인해 루프마다 발생하는 객체 할당과 그에 따른 메모리 단편화가 CPU 성능을 잡아 먹는 병목 지점이 된것이 아닌가 추측한다.
그래서 그 때 당시 StringBuilder 대체로 다음과 같은 코드를 적용했다.
private val digits = "0123456789ABCDEF "
private fun bytesToHex(byteArray: ByteArray): String {
val hexChars = CharArray(byteArray.size * 3 -1)
for (i in byteArray.indices) {
val v = byteArray[i].toInt() and 0xff
hexChars[i * 3] = digits[v shr 4]
hexChars[i * 3 + 1] = digits[v and 0xf]
if(i < byteArray.size-1) {
hexChars[i * 3 + 2] = digits[digits.length - 1]
}
}
return String(hexChars)
}
charArray 를 한번만 할당하고 루프 안에서는 이미 만들어진 배열의 인덱스 값만 반환하게 한다. 그럼 GC 가 개입할 여지가 사라진다. 변환하고자 하는 문자열에서 인덱스로 문자를 직접 가져온다. 단순히 산술 연산과 메모리 참조만으로 끝나기 때문에 CPU 입장에서도 훨씬 가볍다. CharArray 에 정확한 크기를 선언해 불필요한 복사 과정이 제거된다.
언젠가 면접 과정에서, 안드로이드 버전 업데이트 시에 변경사항으로 생기는 이슈를 해결한 경험에 대해 질문을 받았을 때, 이슈와 해결 지점은 기억이 나지만, 원인과 해결 방법은 생각나지 않아서, 질문에 대한 답이 스스로 만족스럽지 않았더 경험이 있다.
많은 개발자들이 트러블 슈팅에 대한 기록을 왜 남기는지, 이 같은 기억을 회고하는게 스스로에게 어떤 도움이 되는지를 다시 한번 되새기는 시간이 되었다.