본문 바로가기

Jetpack Compose

[Jetpack Compose Side-effects] 1. LaunchedEffect

서론

최근 팀원 한 분이 코드 리뷰로 derivedStateOf를 사용해 코드의 중복 제거와 상태 관리를 안전하고 편하게 하는 방법을 알려주셔서 Compose의 Side-effects를 잘 알아두고 적재적소에 활용하면 불필요한 리컴포지션을 줄이고 코드를 간결하게 만들 수 있을 것 같아 정리해보려고 합니다. 자주 사용하는 LaunchedEffect 부터 생소한 snapshotFlow 등 다양한 Side-effects 들을 다룰 예정입니다.

 

Side-effects 란?

먼저 Side-effects가 무엇인지부터 알고 넘어가려고 합니다. Compose에서는 데이터의 값이 변하면 새로운 데이터 값을 이용해 Composable을 다시 호출합니다. 다시 호출하면서 함수가 recomposition 되며, 새로운 데이터가 UI에 반영됩니다. 실제로 Compose를 사용하다보면 Composable 함수 안에 다시 Composable 함수를 사용하는 형태로 많이 쓰게 되는데, State는 상위 Composable에서 하위 Composable로 전달되도록 설계하는 것이 일반적입니다. 

 

하지만 하위 Composable에서 상위 Composable에 있는 State를 변경하게 될 수도 있고, Compose State가 아닌 ViewModel에 있는 요소의 값과 같은 Composable 외부의 값에 영향을 미쳐 의존성이 생기기도 합니다. 이 때 발생하는 Effect를 Side-effects라고 합니다.  Side-effects에 의해 발생하는 상태 변화는 예상할 수 없는 recomposition을 일으킬 수 있기 때문에 주의해서 사용해야 합니다.

 

안드로이드 공식 문서에 따르면 Side-effects가 없는 상태를 유지하도록 권장하고 있습니다. 하지만 구글에서 제공하는 Effect API를 사용하여 Side-effects를 잘 관리하면, 이벤트를 트리거하거나 특정 상태일 때의 구현을 쉽게 하도록 도와줄 수 있기 때문에 오히려 유용하게 사용할 수 있습니다.

 

LaunchedEffect

LaunchedEffect는 LaunchedEffect 함수를 포함하고 있는 Composable UI가 화면에 그려질 때, 첫 실행이 됩니다. LaunchedEffect는 key를 파라미터로 받으며, key의 값이 변할 때마다 LaunchedEffect가 실행됩니다. 따라서 key를 어떻게 지정하느냐에 따라 LaunchedEffect의 실행을 제어할 수 있습니다. key는 가변인자로도 넣을 수 있기 때문에, 개수에 구애받지 않고 계속 추가할 수 있습니다. 추가적으로 if문 안에 LaunchedEffect가 존재해 특정 조건에서 LaunchedEffect 구문이 다시 실행되는 경우에도 LaunchedEffect가 재실행 됩니다.

 

key를 Unit으로 주면, Composable이 그려지는 최초 1번 LaunchedEffect 내부의 스코프가 호출됩니다. 만약 최초에 1번만 LaunchedEffect를 실행하고 싶은 경우, Unit 외에 true나 false 같은 변하지 않을 상수를 넣어도 되지만 다른 사람들이 코드를 봤을 때 의미상 헷갈릴 수 있기 때문에 Kotlin에서 void의 의미를 지닌 Unit을 보편적으로 사용합니다. 

 

다음은 버튼을 클릭하면 Greeting() 이 화면에 그려졌다 사라지면서 Greeting() 내부의 Launched Effect가 언제 최초로 실행되는지 테스트하는 화면과 코드입니다.

 

Greeting()이 그려질 때마다 LaunchedEffect가 호출되는 모습

 

@Composable
fun SideEffectScreen() {
    Surface(
        modifier = Modifier.fillMaxSize(),
        color = Color.White
    ) {
        Column(
            modifier = Modifier.fillMaxSize(),
            horizontalAlignment = Alignment.CenterHorizontally,
            verticalArrangement = Arrangement.Center
        ) {
            var showGreeting by remember { mutableStateOf(false) }

            if (showGreeting) {
                Greeting()
            }

            Button(
                modifier = Modifier.wrapContentSize(),
                onClick = { showGreeting = !showGreeting }
            ) {
                Text("Click!")
            }
        }
    }
}

@Composable
fun Greeting() {
    val context = LocalContext.current
    val tag = "side-effects"

    LaunchedEffect(Unit) {
        Toast.makeText(context, "LaunchedEffect executed!", Toast.LENGTH_SHORT).show()
        Timber.tag(tag).d("LaunchedEffect")
    }

    Text("Hello I am Peter")
}

 

LaunchedEffect의 내부 구현을 보면 다음과 같습니다.

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

 

LaunchedEffect의 스코프는 coroutineScope로 구성되어 있고 Main Dispatcher에서 작동하므로 Composable 내에서 suspend fun 을 호출해야 하는 경우에 LaunchedEffect를 사용하라고 구글에서 권장하고 있습니다. 실제로 비동기 작업 시, 따로 코루틴 스코프를 생성 및 관리하지 않고 LaunchedEffect로 처리할 수 있어서 편리합니다.

 

LaunchedEffect 코드 블록에서는 코루틴이 실행되고 LaunchedEffect가 컴포지션을 종료하면 코루틴이 자동으로 취소됩니다. 따라서 여러 개의 LaunchedEffect를 선언하더라도 각자 다른 coroutineScope에서 실행됩니다.