Hanbit the Developer

[Kotlin] Android Splash 로딩 속도를 29% 개선하다 본문

Mobile/Android

[Kotlin] Android Splash 로딩 속도를 29% 개선하다

hanbikan 2022. 8. 19. 00:55

배경

Splash Screen에서 특정 작업을 마친 후에 앱으로 진입하게 되는데, 이 시간이 너무 긴 것처럼 느껴져서 개선을 진행하게 되었습니다.

분석

먼저 SplashActivity의 코드를 분석해보았고, 다음과 같은 사실을 알 수 있었습니다.

  1. API 호출과 Kakao Login 작업에서 가장 큰 시간 소요가 있었습니다.
  2. 두 작업이 순차적으로 진행되고 있었습니다.

따라서 두 작업을 병렬로 처리하는 것이 포인트입니다.

병렬 처리

사실 여러 개의 suspend function(Non-blocking)을 병렬로 처리하는 것은 awaitAll() 함수만 사용하면 꽤 쉽게 처리할 수 있습니다. 하지만 저의 경우에는 Kakao Login을 사용하고 있었고, 카카오에서 콜백 함수로 비동기 처리를 제공하고 있었습니다.(Asynchronous) 물론 KakaoSDK에서 작성한 코드를 깊게 파보면 결국 Retrofit2의 enqueue()로 작업을 진행하기 때문에 따로 코루틴을 생성해서 처리할 필요는 없었습니다.

따라서 저는 다음과 같이 병렬 처리를 구현하고자 하였습니다.

  1. 두 작업을 각각 Non-blocking 및 Asynchronous 호출
  2. StateFlow: success, failure를 ViewModel에 둠
  3. 각 작업에서 성공 및 실패한 부분에 대해 각각 ‘success를 1 증가’, ‘failure를 1 증가' 코드를 삽입
  4. StateFlow observe
    1. success: 2가 되면 성공 페이지로 이동
    2. failure: 1이 되면 실패 페이지로 이동

이에 대한 코드는 다음과 같습니다.

SplashViewModel.kt

/**
 * [SplashActivity]에서의 비동기 작업의 성공 및 실패 카운트 정보를 홀드합니다.
 */
class SplashViewModel: ViewModel() {
    private val _success = MutableStateFlow(0)
    val success: StateFlow<Int> = _success

    private val _failure = MutableStateFlow(0)
    val failure: StateFlow<Int> = _failure
    
    fun addSuccess() {
        _success.value += 1
    }

    fun addFailure() {
        _failure.value += 1
    }
}

SplashActivity.startJobToStartActivity()

fun startJobToStartActivity() {
    val activity = this
    job?.cancel()
    job = lifecycleScope.launch {
        refreshAccessToken()
        loginWithKakao()

        // 모두 성공했을 경우
        viewModel.success.observe(activity) {
            if (it == 2) startSuccessActivity()
        }

        // 하나라도 실패했을 경우
        viewModel.failure.observe(activity) {
            if (it >= 1) {
                cancel() // 다른 한 작업이 진행 중일 것이기 때문에 작업을 취소한다.
                startFailureActivity()
            }
        }
    }
}

로그를 찍어보면 다음과 같이 작업이 병렬 처리가 되는 것을 볼 수 있습니다.

 

개선 결과

작업을 시작하기 전과 작업을 마친 후에 로그를 찍어서 평균 속도가 얼마나 걸리는지를 10회씩 측정하였습니다.

 

기존 속도 측정

2010, 1397, 1172, 1589, 1266, 1348, 1254, 1227, 1290, 1411 → 1396.4ms

 

개선 후 속도 측정

1642, 1056, 1141, 1650, 897, 970, 965, 950, 1051, 1064 → 1138.6ms

 

→ API 작업 속도 약 27% 증가, 앱 진입 시간 258ms 감소

 

기대 효과

병렬 처리는 작업이 길고 많을수록 효과가 눈에 띄게 됩니다. 따라서 Splash에서 할 작업이 많아지더라도 어느 정도까지는 비슷한 속도로 커버가 될 것으로 예상됩니다. 가령 1초짜리 네트워킹 작업이 추가된다고 하면 기존 속도는 대략 2496ms 정도가 되겠지만, 개선 사항을 반영한 뒤라면 여전히 1138ms 정도가 소요될 것입니다.(1138 = max(250, 1138, 1000)) 또한 네트워크가 좋지 않은 환경을 고려한다면 더더욱 증가율이 높아집니다.

사실 이렇게 눈에 띄는 요소들 보다는, 네트워크 병렬 처리를 적절히 수행하는 코드 베이스가 쌓였다는 점에 더 큰 의의를 두고 싶습니다.

 

추가 개선 사항

추가로 위에서는 네트워킹 병렬 처리에 대해서만 다루었지만, 로그를 찍어보니 onCreate()를 한 뒤 네트워킹 작업을 시작할 때까지 약 300ms 정도가 소요되는 것을 확인하였습니다. 이에 따라 네트워킹 작업 이전에 배치된 코드들 중에 개선할 만한 것이 없는지 검토해보았고, 제가 시도해본 내용은 다음과 같습니다.

  1. ViewModel을 생성하지 않고 액티비티 내에 StateFlow를 선언하여 사용 → 유의미한 속도 개선이 일어나진 않았습니다. 실은 ViewModel을 통해 책임을 어느 정도 나누는 것이 맞지만, 성능을 위해 예외로 둘 수 있는 부분이라고 생각하여 시도를 하게 되었습니다.
  2. KakaoSdk.init()을 비동기 함수 내부로 이동 -> (22.10.24 수정) 카카오 로그인을 위해 따로 코루틴을 분기하지 않으므로 소용이 없는 작업입니다.
  3. SDK가 S 미만인 경우의 Splash 화면을 위해 따로 뷰를 그리는 방식으로 처리하고 있었으나 setContentView()를 SDK 버전에 상관없이 무조건 호출하고 있었고, SDK가 S 미만인 경우에만 뷰를 그리도록 if 문을 추가

해당 내용을 적용한 뒤 로그를 찍어보았을 때, 대략 180ms 정도가 소요되게 되었습니다!

 

→ 앱 진입 속도 약 29% 증가, 앱 진입 시간 378ms 감소

 

+ 추가: suspendCoroutine을 이용하여 콜백을 코루틴으로 관리할 수도 있다.

val kakaoDeferred: Deferred<Result<Unit>> = async {
    suspendCoroutine {
        LoginUtil.loginWithKakao(context, { _, _ ->
            it.resume(Result.success(Unit))
        }, { _, _ ->
            it.resume(Result.failure(RuntimeException("Kakao login failed")))
        })
    }
}
val serverDeferred = async { refreshAccessToken() } // 함수 개형: Result<Unit>

val kakaoResult = kakaoDeferred.await()
val serverResult = serverDeferred.await()

if (kakaoResult.isSuccess && serverResult.isSuccess) {
    // Start HomeActivity
} else {
    // Start LoginActivity
}

기존에는 관찰하는 코드가 다른 곳에 위치해있기도 하고 가독성도 좋지 않았는데, 이렇게 하면 코드도 이해하기 쉽고 간결해지는 것 같습니다.
참고: https://stackoverflow.com/questions/71025062/what-is-suspendcoroutine