ByteArray to Hex Performance Comparison in Kotlin

최근, BLE 관련 프로젝트를 진행하다가 Notify로 받아온 ByteArray 를 Hex String 로 변환하는 과정에서 딜레이가 발생하는 일이 있었다.

따라서 이번에는 구글로 찾아보면 나오는 ByteArray to Hex 에 대해 여러 방법에 대해 알아보고, 간단한 벤치마크를 작성하여 어느 방법이 성능이 더 잘 나오는지 비교해보려 한다.

비교 대상

비교 대상은 총 5개로, 코틀린 코드와 JVM 디컴파일 결과는 다음과 같다. 괄호 안의 텍스트는 벤치마크 코드 / 결과에 있어서의 이름으로 간주된다.

StringBuilder + String.format (BuilderFormat)

fun toHexString(byteArray: ByteArray): String {
    val sbx = StringBuilder()
    for (i in byteArray.indices) {
        sbx.append(String.format("%02X", byteArray[i]))
    }
    return sbx.toString()
}

StringBuilder 를 매번 메서드 실행할 때 마다 생성하고, 각 바이트를 %02X, 즉 Hex 형태로 format 시키는 단순한 코드이다.

String.format (StringFormat)

fun toHexString(byteArray: ByteArray): String {
    var target = ""
    for (i in byteArray.indices) {
        target += String.format("%02X", byteArray[i])
    }
    return target
}

위 방법에서 StringBuilder 만 해제한 것이다.

다만 JVM으로 컴파일 되었을 때 똑같이 StringBuilder 로 컴파일 되므로 차이가 없다고 봐도 무방하다.

Right Shift (CharArrayRightShift)

private val digits = "0123456789ABCDEF"

fun toHexString(byteArray: ByteArray): String {
    val hexChars = CharArray(byteArray.size * 2)
    for (i in byteArray.indices) {
        val v = byteArray[i].toInt() and 0xFF
        hexChars[i * 2] = digits[v.ushr(4)]
        hexChars[i * 2 + 1] = digits[v and 0x0F]
    }

    return String(hexChars)
}

StringBuilder 를 사용하지 않는 대신, 바이트의 2배 길이만큼 가진 CharArray를 만들고 각 쌍의 첫번째 인자에는 digits 에서 해당 바이트 >>> 4 한 만큼의 위치값, 두번째 인자에는 해당 바이트 & 0x0F 한 만큼의 위치값을 넣는다. 마지막으로 CharArray 를 String 로 만들어 반환한다.

StringBuilder + Shift (BuilderShift)

private val digits = "0123456789ABCDEF"

fun toHexString(byteArray: ByteArray): String {
    val buf = StringBuilder(byteArray.size * 2)
    for (i in byteArray.indices) {
        val v = byteArray[i].toInt() and 0xff
        buf.append(digits[v shr 4])
        buf.append(digits[v and 0xf])
    }
    return buf.toString()
}

StringBuilder 를 사용하지만,  String.format 를 사용하는 것이 아닌 해당 바이트 >> 4 와 해당 바이트 & 0xF 한 값을 각각 append 하여 String로 만든다.

CharArray + Shift (CharArrayShift)

private val digits = "0123456789ABCDEF"

fun toHexString(byteArray: ByteArray): String {
    val hexChars = CharArray(byteArray.size * 2)
    for (i in byteArray.indices) {
        val v = byteArray[i].toInt() and 0xff
        hexChars[i * 2] = digits[v shr 4]
        hexChars[i * 2 + 1] = digits[v and 0xf]
    }
    return String(hexChars)
}

기본 접근 방법은 CharArray + Right Shift 와 동일하나, 여기에서는 Right Shift 가 아닌 일반 Shift 가 사용되었다.

성능 비교 방법

먼저, 측정에 들어가기 전 32의 사이즈를 가진 ByteArray 를 랜덤으로 생성하고, 지정된 카운트 만큼 반복하여 걸린 시간을 나노초 단위로 구한다.

private fun measure(block: () -> Unit): String {
    val nano = measureNanoTime {
        for (i in 0 until count) {
            block()
        }
    }

    val ms = nano / 1000000.0
    return String.format("%.3f", ms.toFloat())
}

일반적으로 Kotlin 에서 제공되는 measureNanoTime 를 약간 변형하여 block 에는 toHexString 메서드가 실행되도록 만들었다.

결과 분석

결과에 쓰여진 기기의 사양은 다음과 같다.

OS: Apple macOS 10.13.5 (High Sierra) build 17F77
Processors: Intel(R) Core(TM) i7-4770HQ CPU @ 2.20GHz
Total Memory: 16GB
Time: 2018-10-08 AM 02:39:56

벤치마크를 구동했을 때, 아래와 같은 결과가 나온다.

먼저, 코드 상에서 StringBuilder가 있음과 없음은 그렇게 많이 차이나지않는다는 것이다.  중요한 것은 String.format 가 있는가 없는가의 차이로, 최대 58배 이상 시간이 소요된다.

비트 연산을 진행하는 3개 방법의 경우 큰 차이점은 보이지 않으나, 대체적으로 마지막 방법인 CharArray + Shift 가 다소 빠른 측정 결과를 보여주었다.

코드 공유

벤치마크 구동에 있어 작성된 코드는 WindSekirun/ByteArrayToHexPerformance 레포지토리에서 찾아볼 수 있고, 구동시 출력되는 결과는 다음과 같다.

Please enter the number you want to execute.
10
Please enter the length of bytes to be generated.
32

==== ByteArray to Hex String Benchmark ====
OS: Apple macOS 10.13.5 (High Sierra) build 17F77
Processors: Intel(R) Core(TM) i7-4770HQ CPU @ 2.20GHz
Total Memory: 16GB
Time: 2018-10-08 AM 03:02:10

Starting with count as 10 with length of ByteArray as 32
Measure in progress...

BuilderFormat 9.632ms
StringFormat 5.407ms
CharArrayRightShift 0.321ms
BuilderShift 0.338ms
CharArrayShift 0.273ms

Finished! in 15.971ms

Android 7.0 – BLE Scan abuse protection 관련 해결

고객사에서 7.0 이상 안드로이드 기기에서 BLE 연결이 되지 않는다 란 요청을 받아서 검색해보니 악용 사례를 막기 위한 변경점이 있다는 것을 알아냈다.

해당 이슈 설명

diff -> https://android-review.googlesource.com/#/c/215844/

악용을 방지하기 위해 두 가지 검사가 추가되었는데, 내용은 아래와 같다.

  • 특정 시간대에 너무 자주 검색하지 않기
  • 너무 오래 검색하지 않기

정리해보자면 7.0 DP4에서 BLE 스캐닝을 이용한 취약점을 방지하기 위해 30초동안 5번 이상 스캐닝을 중지하고 시작하는 것을 방지하는 것이다.

해결방법

스캔 주기를 6초 이상으로 설정하면 된다.

실로 간단한 해결법이나 문제가 있다면 문서화가 안되었다는 점이다.

(지금은 없어진) DP4 변경점에 있었으나 최종 변경점 노트에는 포함되지 않았다.

왜 문서화가 안되었는지 명확한 이유는 모르겠지만 그래도 심각한 문제가 아님에 다행이라 생각했다.

2017. 09. 09 추가

약 2개월 뒤 이 문제를 좀 더 살펴볼 기회가 있어 살펴보았으나, 정확히는 아래의 조건인 것 같다.

synchronized boolean isScanningTooFrequently() {
        if (lastScans.size() < NUM_SCAN_DURATIONS_KEPT) {
            return false;
        }

        return (System.currentTimeMillis() - lastScans.get(0).timestamp) <
            EXCESSIVE_SCANNING_PERIOD_MS;
    }

AppScanStats.java 변경점

지난 스캔한 시간 리스트의 크기가 5개 미만일 경우에는 false를 리턴하고, 5개 이상이라면 처음으로 스캔한 시간과 현재 시간이 30,000ms 이상 차이나면 그런 것 같다.

스캔 주기를 늘린다고 하기 보다는 스캔하고, 바로 뒤 다시 스캔하는 등의 코드만 작성하지 않으면 문제는 없을 것 같다.