Compose Side Effect에서 내가 잘 못 이해한 것들 🙅♀️
최근 회사에서도 사이드 프로젝트에서도 대부분의 UI를 Jetpack Compose 로 작성하고 있습니다. UI에 동작을 추가하기 위해 SideEffect 들을 자주 사용하게 되는데, 공식 문서만 읽었을 때 확실치 않은 부분들이 있어 LaunchedEffect, DisposableEffect 에서 궁금한 것들을 실험해보았습니다. 그 과정에서 Side Effect들에 대하여 잘 못 이해하고 있던 것들이 있어 이를 공유해보려합니다.
🤔 LaunchedEffect, DisposableEffect 는 언제 실행될까?
저는 LaunchedEffect(Unit) 를 딱 한 번 기록되어야 할 로깅 등 Composable 첫 시작시 1회 수행되어야 할 동작을 담곤 했습니다. 이 첫 1회 라는 개념이 애매하게 느껴져 여러 경우를 테스트 해보았습니다. 첫 1회라는 것이 Composable 이 로직 흐름상 처음 호출되는 순간일 수도, Compose UI 트리에 추가되는 순간일수도 있다고 생각했기 때문입니다.
그래서 Greeting 이라는 Composable 함수에 아래 같이 LaunchedEffect, DisposableEffect를 추가했습니다.
show 버튼으로 Greeting Composable의 visibility를, change버튼으로 Greeting 에 전달할 파라미터의 값을 변경할 수 있습니다. 화면으로는 아래 그림처럼 생겼습니다.
@Composable
fun Greeting(name: String) {
Text(text = "Hello $name!")
LaunchedEffect(Unit) {
Log.d("SideEffectTest", "LaunchedEffect")
}
DisposableEffect(name) {
Log.d("SideEffectTest", "DisposableEffect")
onDispose {
Log.d("SideEffectTest", "DisposableEffect - OnDispose")
}
}
}
Greeting 외부는 이렇습니다.
var showGreeting by remember { mutableStateOf(true) }
var name by remember { mutableStateOf(Math.random().toString()) }
Column {
if (showGreeting) {
Greeting(name)
}
Button(onClick = {
showGreeting = !showGreeting
Log.d("SideEffectTest", "Show Clicked!")
}) {
Text("show")
}
Button(onClick = {
name = Math.random().toString()
Log.d("SideEffectTest", "Change Clicked!")
}) {
Text("change")
}
}
예상했듯이 앱 실행 시에 Greeting 컴포넌트가 그려지면서 LaunchedEffect가 실행됩니다. 이 후 show 버튼이 클릭되어 dispose가 실행되고 다시 show 버튼이 클릭된 후 LaunchedEffect가 시작됩니다.
그 외에 눈여겨 볼 것은,
1. if 조건에 의해 Composable이 컴포즈 UI Tree에서 삭제되었다가 다시 추가될 경우 LaunchedEffect 가 기록된다.
이 부분이 제가 가장 잘 못 이해하고 있던 부분이라고 생각합니다. LaunchedEffect 기록 시점을 Composable 함수의 로직상 첫 호출이라고 생각해 if 조건에 의해서 LaunchedEffect는 찍히지 않을 것이라 생각했습니다. 하지만 show 버튼에 의해 화면에 보이지 않게 되면 onDispose 이벤트도 찍히고 다시 화면에 보이게 되면 LaunchedEffect가 찍히는 것을 볼 수 있습니다.
2. DisposableEffect의 onDispose 콜백 외부 로그(테스트 결과의 1, 6 라인)는 LaunchedEffect가 시작되는 시점에 같이 로깅되고 있습니다.
이는 DisposableEffect 내부 코드를 살펴보면 DisposableEffectImpl이 RememberObserver의 onForgotten 뿐만 아니라 onRemembered API를 활용하고 있는 것을 통해 코드로도 확인할 수 있습니다.
3. (그 외) 전체 스크린 사이즈 리사이징에 의한 화면 변화에는 configChanges 설정에 따라 LaunchedEffect가 실행될수도 안될 수도 있다.
처음 테스트에서는 앱을 분할화면으로 열어 앱 스크린 사이즈를 조절할 때 마다 onDispose와 LaunchedEffect가 찍혔습니다. 하지만 이는 스크린 크기 조정에 따른 configurationChange 로 Composable을 담은 Activity가 재시작되기 때문이었습니다. AndroidManifest 에서 configChanges 수정을 통해 스크린 사이즈에 의해 Activity가 재시작되지 않게 설정하면 LaunchedEffect는 실행되지 않습니다.
🤔 LaunchedEffect(Unit) 을 여러번 선언하면 같은 코루틴 스콥일까?
LaunchedEffect와 DisposableEffect의 차이점을 꼽자면 두 콜백의 실행시점도 있겠지만, coroutineScope이냐 아니냐도 있습니다. 아래 각 함수의 선언부에서 이 차이를 바로 볼 수 있습니다.
LaunchedEffect의 선언부
DisposableEffect의 선언부
여기에서 LaunchedEffect를 한 Composable 내에서 여러 번 선언할 경우 같은 코루틴 스콥을 공유하는지 궁금했습니다. 사실 이 부분은 LaunchedEffect 내부 코드를 보면 아니라는 것을 쉽게 알 수 있습니다.
아래와 같이 부모의 CoroutineContext를 사용하지만 LaunchedEffectImpl 클래스 생성 시마다 새 CoroutineScope을 생성하고 있습니다.
LaunchedEffectImpl 생성시 부모의 coroutienContext를 사용하는 모습
LaunchedEffectImpl 내부 변수로 CoroutineScope을 생성하는 모습
위에 사용하였던 Greeting Composable을 아래처럼 변형하여 Scope을 한 번더 로깅해보았습니다.
@Composable
fun Greeting(name: String) {
Text(text = "Hello $name!")
LaunchedEffect(Unit) {
Log.d("SideEffectTest", "LaunchedEffect 1")
Log.d("SideEffectTest", this.coroutineContext.toString())
}
LaunchedEffect(Unit) {
Log.d("SideEffectTest", "LaunchedEffect 2")
Log.d("SideEffectTest", this.coroutineContext.toString())
}
}
CoroutineContext에서 StandaloneCoroutine 객체만이 다른 것을 볼 수 있습니다.
테스트가 끝나고 보니 LaunchedEffect에 전달한 key값이 변할 때마다 해당 코루틴을 cancel하고 재실행해야 하는 LaunchedEffect의 기본 동작을 생각하면 코루틴 스콥을 공유하지 않아야 한다는 것을 쉽게 생각할 수 있었습니다.
✍️ 결론
궁금한 것들을 떠오르는 대로 살펴보다 보니 흐름이 자연스럽게 진행된 글은 아닌 것 같습니다. 그래도 여러 애매한 것들을 테스트하고 코드를 쫓아가다 보니 각 기능이 정확히 어떤 기능인지 더 이해하게 된 것 같습니다. 테스트해 본 SideEffect 들 외에도 derivedStateOf 등 도 파봐야겠네요!



