본문 바로가기

Android/Jetpack Compose

[Jetpack] Compose 사용하기 - 2. Side Effect와 Coroutine 1

728x90

본 게시글은 이전 글에 이어서 작성된 부분입니다.

2022.06.07 - [Android/Jetpack] - [Jetpack] Compose 사용하기 - 1. remember와 MutableState

 

기존의 코드에서도 코루틴을 많이 사용하기 때문에, Compose를 사용할 때도 코루틴을 사용해야 한다.

하지만 역시 기존에 사용하던 방식으로 Compose코드 내부에서 코루틴을 사용할 수 없으며, Effect API를 사용하여 코루틴을 구현할 수 있다.

 

안드로이드 공식 사이트를 보면 다음과 같은 설명이 나와있다.

 

컴포저블에는 부수효과가 없어야 합니다. 앱 상태를 변경해야 하는 경우 이러한 부수 효과가 예측 가능한 방식으로 실행되도록 Effect API를 사용해야 합니다.

 

여기서 부수 효과(Side Effect)란, Compose function 외부에서 발생하는 앱 상태의 변화를 말한다.

즉, Compose 함수 내부에서 외부에 있는 변수나 동작에 영향을 주도록 구성이 되어서는 안 되며, 이러한 동작을 하기 위해서는 Effect라는 API를 사용해서 작업을 진행해야 한다는 것이다.

 

이에 따른 내용을 안드로이드 공식 사이트에서 설명되어있는 함수를 토대로, Effect API를 사용하는 방법을 살펴보고자 한다.


우선, 맨 처음에 나오는 항목은

LaunchedEffect이다.

LaunchedEffect는 Composable에서 Suspend functions을 실행시켜주는 함수.

 

간단하게 말하면 Composable에서 비동기 관련된 처리할 때 사용할 수 있는 Effect API 중 하나로, 다음과 같이 구현되어 있다.

 

 

인자로 Key 값을 받고, key 값이 변경될 때마다 이하 block에 있는 CoroutineScope에서 선언된 작업들을 수행하게 되는 것이다.

 

간단하게 필자가 예시로 만들어 본 코드는 다음과 같다.

 

LaunchedEffect(isGo) {
    try {
        scaffoldState.snackbarHostState
            .showSnackbar("input text : go")
    } catch (e: CancellationException) {
        Log.e("CancelText", "in catch")
    }
}

 

isGo라는 값을 key값으로 가지고, 최초에 해당 함수에 접근했을 때 실행되는 경우를 제외하고 isGo 값이 변경될 때마다 해당 함수가 호출되게 된다.

 

여기서 key 값은 any 타입이기 때문에 어느 타입이 와도 상관없으며, 또한 몇 개의 key 값이 들어가도 상관없다.

 

fun LaunchedEffect(
    vararg keys: Any?,
    block: suspend CoroutineScope.() -> Unit
) {
    val applyContext = currentComposer.applyCoroutineContext
    remember(*keys) { LaunchedEffectImpl(applyContext, block) }
}

 

이처럼 vararg 타입으로 오버로딩된 함수가 존재하기 때문이다.

 

하지만, key가 변경될 때마다 해당 함수가 재 호출되어 이하의 작업을 진행하기 때문에, 여러 개의 key 값을 사용하는 경우 정상적인 동작을 수행하는지 체크가 반드시 필요할 것으로 보인다.

 

다음으로는,

rememberCoroutineScope()이다.

Composable 외부에 있지만, Composable이 종료되면 자동으로 취소되도록 범위가 지정된 Coroutine

 

외부에 있다는 의미가 잘 이해가 가지 않을 수 있다.

쉽게 예로 들어서, Button 아이템에서 onClick 이하의 함수를 생각해보면 된다.

onClick 이하에 들어가는 작업들은 Composable 함수에서 동작하는 것이 아니라 외부에 있는 함수를 호출해서 사용한다.

함수 내부에 직접 코드를 작성해서 사용하는 것을 보면 Composable 함수 내부가 아닌가 싶겠지만, 이하에서 동작하는 것들은 Composable에 관련된 것들이 포함될 수 없다.

해당 블록 안에서 Composable 함수를 호출시킬 수 없는 것을 생각하면 좀 더 직관적으로 이해가 될 것이다.

 

이렇게 Composable 외부에서 Composable과 동일한 Lifecycle을 갖는 코루틴을 생성할 때 사용하는 것 rememberCoroutineScope라고 생각하면 된다.

 

그렇다면 어떻게 사용하는가?

 

coroutineScope.launch {
    scaffoldState.snackbarHostState
        .showSnackbar("Show Snackbar $coroutineScope")
}

 

코드를 보면 알 수 있겠지만, 지금까지 kotlin에서 coroutine을 사용했을 때와 거의 동일한 방법으로 사용하면 되기 때문에 사용하기에 아주 쉬울 것으로 생각된다.

 

그렇다면 Composable이 종료되면 자동으로 취소되는 것을 확인하기 위해서, coroutine에 들어가는 값을 다르게 해서 테스트를 진행해 보았다.

 

@Composable
fun CoroutineScreen(
    scaffoldState: ScaffoldState,
    changeState: () -> Unit,
    coroutineScope: CoroutineScope = rememberCoroutineScope(),
) { ... }

 

이처럼 Composable를 선언하였다.

세 번째 인자를 보면, 아무런 인자를 받지 않는 경우 rememberCoroutineScope를 통하여 해당 Composable과 동일한 lifecycle을 갖는 코루틴을 생성한다.

 

when (changeUI) {
    1 -> {
        // scope를 넣지 않으면 FirstScreen 생성 시 rememberCoroutineScope()를 호출함.
        CoroutineScreen(scaffoldState,
            changeState = {
                changeUI = 2
            }
        )
    }
    2 -> {
        // lifecycleScope은 Activity의 생명주기를 따름.
        CoroutineScreen(scaffoldState,
            changeState = {
                changeUI = 3
            },
            lifecycleScope
        )
    }
    else -> {
        CoroutineTempScreen()
    }
}

 

그 후, 호출 부분을 이처럼 조건을 주어서 확인하도록 하였다.

1일 때는 세 번째 인자를 주지 않고, 2일 때는 lifecycleScope를 넣어 Activity와 동일한 생명주기를 가지도록 한 후 테스트를 진행했다.

 

 

CoroutineScreen에는 2개의 버튼을 가지고 있으며, 각각 스낵바를 생성하고 다음 화면으로 변경하는 기능을 가지고 있다.

1번의 경우 스낵바를 생성하고 다음 화면으로 이동했을 때, 스낵바는 해당 Composable과 동일한 생명주기를 갖고 있기 때문에 화면이 변경되면 스낵바는 바로 제거된다.

하지만, 2번의 경우 스낵바를 생성하고 다음 화면으로 이동했을 때, Composable이 아닌 Activity와 생명주기를 공유하기 때문에 스낵바는 사라지지 않고 그대로 유지되다 일정 시간이 지나면 사라지게 된다.

이처럼, 주어지는 CoroutineScope에 따라서 해당 Composable을 벗어나는 상황에서의 생명주기를 컨트롤할 수 있다.

* 해당 부분은 하단에 표기된 예제에서 확인할 수 있다.

 

Composable을 사용하게 되면 재구현이 상당히 자주 이루어지게 된다.

여기서 Composable 내부에서 사용하는 CoroutineScope를 어떤 것을 선택하냐에 따라서 문제가 생길 가능성이 있다.

예로 들어, 위의 예제에서 2번에 해당하는 것처럼 Activity와 생명주기를 공유하는 경우에 재구현이 이루어져도 이전에 생성된 코루틴이 남아있는 상태에서 새로운 코루틴이 생성되게 된다.

재구현이 상당히 자주 일어나는 화면에서 이와 같은 Scope를 설정해 두었다면, 많은 코루틴이 생성되어 심할 경우 앱 크래시를 발생시켜 앱이 죽을 수 있다.

 

그렇기 때문에, 필요에 따라 올바른 CoroutineScope를 설정하여 관리해주는 것이 좋다.


세 번째로 확인할 것은

rememberUpdateState이다.

 

해당 부분의 안드로이드 공식 문서를 확인하면 다음과 같이 나와있다.

값이 변경되는 경우 다시 시작되지 않아야 하는 효과에서 값 참조를 위해 사용

 

필자는 해당 문구와 나와있는 예제를 보았을 때, 이게 무슨 소리인가 싶었다.

 

이해가 되지 않아, 다른 글을 확인해보고 다시 한번 확인해보니 어느 정도 이해할 수 있었다.

해당 글에서 다음과 같이 설명이 되어있다.

 

remember a mutableStateOf and update its value to newValue on each recomposition of the rememberUpdatedState call.

 

즉, 재구성이 될 때마다 rememberUpdateState로 래핑 되어있는 값을 최신 값으로 갱신한다는 소리이다.

이 말을 보고 다시 안드로이드 공식 페이지에 있는 설명을 확인해 보자.

 

onTimeout 람다에 LandingScreen이 재구성된 최신 값이 항상 포함되도록 하려면 rememberUpdatedState로 onTimeout을 래핑해야 합니다.

 

결국, 인자로 받은 onTimeout 람다의 내용을 재구성 시 최신 값으로 사용하기 위해서는 rememberUpdatedState를 사용하라는 게 된다.

 

어느 정도 이해는 되었지만 명확하게 잡히지 않는다.

따라서 예제를 만들어서 확인해 보도록 하자.

 

@Composable
fun LaunchedScreen(viewModel: LaunchedEffectViewModel) {
    var textState by remember { mutableStateOf("Default") }

    Column(modifier = Modifier.padding(16.dp)) {
        TextField(
            value = textState,
            onValueChange = { change ->
                textState = change
                viewModel.onChangeText(textState)
            },
            label = { Text("Input go") }
        )
        
        RememberUpdateTestText(textState)
    }
}

@Composable
fun RememberUpdateTestText(text: String) {
    val rememberText by remember { mutableStateOf(text) }
    val rememberUpdatedText by rememberUpdatedState(text)

    Text("RememberText : $rememberText")
    Text("UpdatedText : $rememberUpdatedText")
}

 

필자가 만들어본 샘플 예제의 일부이다.

TextField에 입력한 값을 textState에 갱신하면서 저장하고, 해당 값을 RememberUpdateTestText에 넣어서 Text로 보여주도록 하는 부분이다.

 

여기서, 비교를 하기 위해 remember와 rememberUpdateState 두 가지를 사용하여 값을 저장하도록 하였다.

LaunchedScreen에서는 remember를 사용하여 변경된 값을 저장하여 재구성 시 최신 값으로 화면을 보여주도록 되어있다.

그렇기 때문에 해당 값을 가져온 text를 다시 remember로 래핑 하여 사용한다면 동일한 역할을 할 수 있을 것이라고 생각할 수 있는데 그렇지 않다.

 

우선, textState는 by 키워드를 사용하여 get/set 할 수 있는 권한을 위임받았다. 그렇기 때문에 onValueChange에서 textState = change를 통해 값을 갱신할 수 있는 것인데, 이것은 사실 textState 자체를 변경하는 것이 아닌, textState.value의 값을 갱신하는 것이다. by 키워드를 사용하지 않는 경우를 생각하면 이해가 될 것이다.

 

RememberUpdateTestText를 확인해보자. rememberText에 remember 키워드를 사용하여 default 값으로 text를 넣고 있다.

val 타입의 text 값을 기본 값으로 넣고 있고, 그 이외의 set 작업은 이루어지고 있지 않기 때문에 우리가 원하는 동작을 수행하지 않는다.

 

위의 코드를 수행한 화면을 확인해보자.

 

 

Default라는 값이 기본으로 입력되도록 했기 때문에, 해당 값의 일부를 지우고 텍스트를 확인해 보았다.

설명한 것처럼, remember 키워드를 사용한 경우 제대로 갱신되지 않고, rememberUpdateState를 사용하는 경우 정상적으로 갱신되었다.

 

안드로이드 공식 문서에서의 설명은 람다식으로 되어있지만, 결국 rememberUpdateState로 래핑 한 인자의 값을 재구성 시 최신 데이터로 유지시켜줘야 할 때 해당 키워드를 사용하면 되는 것이다.

 

여기서,

remember 키워드를 사용해서 동일한 효과를 볼 수 없는 것은 아니다.

 

val rememberText by remember { mutableStateOf(text) }
    .apply {
        value = text
    }

 

 

value에 대한 갱신을 추가해주면 간단하게 동일한 효과를 볼 수 있다.


 

안드로이드 공식 문서에 나와있는 Effect API에 관련해서 작성을 하다 보니 상당히 글이 길어지는 것 같다.

 

간략하게 어느 정도 생략해서 작성하기에는 상당히 중요한 부분이라고 생각이 되고, 필자 스스로도 반드시 숙지하고 있어야 하는 부분이라고 생각하여 조금 길게 작성이 되는 것으로 보인다.

 

네 번째 항목부터 이후의 항목은 다음 게시글에 이어서 작성하도록 하겠다.

 

 

+ 24.05.06 추가.

해당 API들을 실제로 사용하면서 재 정리하여 글을 작성하였습니다.

2024.05.06 - [Android/Jetpack Compose] - [Compose] Side Effect 관련 API 재 정리

 

728x90