코루틴과 LaunchedEffect

손현수·2024년 3월 24일

안드로이드 Compose

목록 보기
11/25

안드로이드 애플리케이션이 처음 시작될 때 런타임 시스템은 단일 스레드를 생성하며, 기본적으로 그 안에서 모든 애플리케이션 컴포넌트를 시행한다. 이 스레드는 일반적으로 메인 스레드라 불린다. 메인 스레드의 주요한 역할은 사용자 인터페이스 관점에서의 이벤트 핸들링과 상호작용의 관점에서 사용자 인터페이스를 다루는 것이다.
메인 스레드를 이용해 시간이 걸리는 태스크를 실행하는 것은 해당 태스크가 완료될 때까지 전체 애플리케이션이 잠긴 것처럼 보이게 만들어 바람직하지 않다. 이를 위해 코틀린은 코루틴이라는 경량의 대안적인 형태를 제공한다.

코루틴이란?

  • 코루틴은 자신이 실행된 스레드를 정지시키지 않고 비동기적으로 실행되는 코드 블록이다. 코루틴은 복잡한 멀티태스킹을 구현하거나 직접 다중 스레드를 관리하는 것에 대한 걱정 없이 구현할 수 있다.
  • 코루틴을 사용하면 스레드와 관련된 이벤트와 결과를 다루기 위한 콜백 없이 순차적으로 코드를 작성할 수 있어 이해 및 유지보수하기가 훨씬 쉽다.

코루틴 스코프

  • 모든 코루틴은 명시적인 스코프 안에서 실행됨으로써 개별 코루틴이 아닌 그룹으로 관리되어야 한다. 코루틴들이 더 이상 필요하지 않게 되면 해당 코루틴을 특정한 스코프에 할당해 일괄로 취소할 수 있다.
  • 코틀린과 안드로이드에서는 기본 내장 스코프를 제공하며, CoroutineScope 클래스를 이용하면 사용자가 임의의 스코프를 지정할 수 있다. 내장 스코프를 요약하면 다음과 같다.
    • Global Scope: Global Scope를 사용하면 애플리케이션 라이프사이클 전체와 관련된 최상위 코루틴을 실행할 수 있다. 이 스코프의 코루틴은 액티비티가 종료되었을 때도 잠재적으로 실행될 가능성이 있으므로 안드로이드 애플리케이션에서는 사용을 권장하지 않는다.
    • viewModelScope: viewModel 인스턴스 안에서의 사용을 명시적으로 제공한다. viewModel 인스턴스 안에서 이 스코프로 실행된 코루틴들은 해당 viewModel 인스턴스가 파기되는 시점에 코틀린 런타임 시스템에 의해 자동으로 취소된다.
    • lifecycleScope: 모든 라이프사이클 소유자는 하나의 lifecycleScope와 연관되어 있다. 이 스코프는 해당 라이프사이클 소유자가 파기될 때 취소되며, 이는 컴포저블과 액티비티 안에서 코루틴을 실행할 때 매우 유용하다.
  • 대부분의 컴포저블 안에서 코루틴 스코프에 접근하는 최고의 방법은 rememberCoroutineScope() 함수를 호출하는 것이다.

일시 중단 함수(suspend function)

  • suspend function은 코루틴 코드를 포함하는 특수한 유형의 코틀린 함수이다.
  • 이 함수는 메인 함수를 막지 않는 상태로 실행되면서 오랜 시간 동안 계산을 할 수 있는 함수이다.

코루틴 디스패처

  • 코틀린은 다양한 유형의 비동기 처리를 위한 스레드를 유지한다.
  • 한 코루틴을 실행할 때 다음 중 특정한 디스패처를 명시할 수 있다.
    • Dispatchers.Main: 메인 스레드에서 해당 코루틴을 실행한다. UI를 변경하거나 경량의 태스크를 실행하기 위한 일반적인 목적의 코루틴에 적합하다.
    • Dispatchers.IO: 네트워크, 디스크, 데이터베이스 작업을 수행하는 코루틴에 적합하다.
    • Dispatchers.Default: 데이터 정렬, 복잡한 계산 수행과 같이 많은 CPU를 수행하는 태스크에 효과적이다.
    coroutineScope.launch(Dispatchers.IO) {
        performSlowTask()
    }
  • 디스패처는 코루틴들을 적절한 스레드에 할당하고, 라이프사이클 동안 해당 코루틴들을 중지하고 재시작하는 책임을 진다.

코루틴 빌더

  • 코루틴 빌더는 컴포넌트들을 포함해 코루틴을 실행한다. 이를 위해 코틀린에서는 다음의 여섯 가지 빌더를 제공한다.
    • launch: 현재 스레드를 중단하지 않고 코루틴을 시작하며 호출자에게 결과를 반환하지 않는다. 일반적인 함수 내에서 suspend 함수를 호출할 때, 그리고 해당 코루틴의 결과를 처리할 필요가 없을 때 이 빌더를 사용한다. 호출하고 잊어버리는 코루틴이라고도 한다.
    • async: 하나의 코루틴을 시작하고 호출자가 await() 함수를 이용해 결과를 기다리게 한다. 현재 스레드를 중지시키지 않는다. 여러 코루틴을 동시에 실행해야 할 때는 async를 사용한다. async 빌더는 다른 suspend 함수 안에서만 사용할 수 있다.
    • withContext: 부모 코루틴에서 사용된 것과 다른 컨텍스트에서 코루틴을 실행할 수 있다. 예를 들어, Main 컨텍스트를 사용해 실행된 코루틴은 이 빌더를 사용해 Default 컨텍스트 안에서 자식 코루틴을 실행할 수 있다. withContext 빌더는 한 코루틴으로부터 결과를 반환할 때 async의 유용한 대안을 제공한다.
    • coroutineScope: 중지되어 있는 함수가 여러 코루틴을 동시에 실행하면서 동시에 모든 코루틴이 완료되었을 때만 특정한 액션을 발생시켜야 하는 상황에 적합하다. coroutoneScope 빌더를 사용해 이런 코루틴들을 실행하면, 호출 함수는 모든 자식 코루틴이 완료된 뒤 결과를 반환한다. coroutineScope를 사용하는 경우, 하위 코루틴들 중 어느 하나에서라도 실패가 발생하면 모든 코루틴을 취소한다.
    • supervisorScope: coroutineScope와 유사하나, 한 코루틴에서 실패가 발생하더라도 다른 모든 자식 코루틴을 취소하지 않는다.
    • runBlocking: 한 코루틴을 실행하고 해당 코루틴이 완료될 때까지 현재 스레드를 중지시킨다. 이는 일반적으로 코루틴을 사용하고자 하는 의도에 반하지만 코드를 테스트하거나, 레거시 코드 또는 라이브러리를 통합하는 경우 유용하다. 그 외의 경우에는 사용하지 않는 것이 좋다.

코루틴: 중지 및 재시작

val coroutineScope = rememberCoroutineScope()

Button(onClick = {
    coroutineScope.launch {
        performSlowTask()
    }
}) {
    Text(text = "Click Me")
}

suspend fun performSlowTask() {
    println("performSlowTask before")
    delay(5000)
    println("performSlowTask after")
}
  • 위의 코드는 많은 시간이 소요되는 상황을 시뮬레이션하고 있으며, 5초 동안의 지연 전후로 메시지를 출력한다. 실제로 5초의 지연이 발생하지만, 메인 스레드가 블록되지 않으므로 사용자 인터페이스는 여전히 반응할 것이다.
  • 이때 내부적으로 발생하는 일은 다음과 같다.
    • 버튼을 클릭하면 performSlowTask() 함수를 코루틴으로 호출한다. 이 함수에서 delay()를 호출하며, delay() 함수는 코틀린에서 suspend 함수로 구현되어 있으므로 코틀린 런타임 환경에 의해 코루틴으로 실행된다.
    • 코드 실행은 중지 지점에 도달하고, 이는 performSlowTask() 코루틴을 delay 코루틴이 실행되는 동안 정지되어 있게 한다. 결과적으로 performSlowTask()가 실행되던 스레드를 릴리스하고 통제를 메인 스레드로 반환하여 UI에는 영향을 주지 않는다.
    • delay 함수가 완료되면, 정지되었던 코루틴이 재시작되고 풀에서 스레드로 원복된다. 그 후에 로그 메시지를 표시한다.

부작용 이해하기

  • 코루틴을 부모 컴포저블의 범위 안에서 실행하는 것은 안전하지 않다.
@Composable
fun Greeting(name: String) {

    val coroutineScope = rememberCoroutineScope()

    coroutineScope.launch() {
        performSlowTask()
    }
}
  • 위의 코드를 컴파일하면 에러가 발생한다.
  • 코루틴은 컴포저블 안에서 위와 같은 방식으로 호출할 수 없으며, 호출 시 부작용이 일어난다.
  • 위와 같이 비동기적인 코드가 해당 컴포저블의 라이프사이클을 고려하지 않고 다른 스코프로부터 컴포저블의 상태를 변경하고자 할 때, 부작용이 발생한다.
  • 다시 말해, 코루틴이 여전히 실행되면서 다음 컴포저블이 코루틴을 실행할 때 그 상태를 변경하는 것이 문제가 된다.
  • 이러한 문제를 해결하기 위해서는 LaunchedEffect 또는 SideEffect 컴포저블 바디 안에서 코루틴을 실행해야 한다. 이 두 컴포저블은 부모 컴포저블의 라이프사이클을 인식하기 때문에 안전하게 코루틴을 실행할 수 있다.
  • 코드를 다음과 같이 수정한다.
@Composable
fun Greeting(name: String) {

    val coroutineScope = rememberCoroutineScope()

    LaunchedEffect(key1 = Unit) {
        coroutineScope.launch() {
            performSlowTask()
        }
    }
}
  • key 파라미터값은 재구성을 통해 코루틴의 동작을 통제한다. key 파라미터 값이 변경되지 않는 한, LaunchedEffect는 해당 부모 컴포저블의 여러 재구성 과정에서도 동일한 코루틴을 유지한다. key 값이 변경되면 LaunchedEffect는 현재 코루틴을 취소하고 새로운 코루틴을 실행한다.
  • 위의 코드에서 Unit 인스턴스를 key로 전달하였다. 이는 재구성 과정에서 해당 코루틴을 재생성하지 않음을 의미한다.
  • SideEffect는 key 파라미터를 받지 않으며, 부모 컴포저블이 재구성될 때마다 수행된다.

정리

  • Button의 onClick 핸들러 같은 이벤트 핸들러 안에서 코루틴을 직접 실행할 수 있지만, Composable의 main 바디 안에서 코루틴을 직접 실행하는 것은 안전하지 않다. 이러한 상황에서는 LaunchedEffect 또는 SideEffect를 사용한다.
profile
안녕하세요.

0개의 댓글