어언 몇 년 동안 Android 개발의 UI 프레임워크로 사랑받고 있는 Compose는 이제 거의 모든 곳에서 사용될 정도로 널리 보급되었습니다. 오늘은 Compose를 활용하면서 발생할 수 있는 부수효과(사이드 이펙트)에 대해 알아보려고 합니다.
Compose의 부수효과를 알아보기 전에 Compose의 Composition에 대한 개념을 살짝 짚고 넘어가겠습니다.
Compose에서 Composable함수는 모든 UI 구성의 기본 단위이며 모든 UI가 트리 형태로 표현이 됩니다.
@Composable
fun NamePlate(name: String) {
var greetingName by remember { mutableStateOf(name) }
Column(modifier = Modifier.padding(16.dp)) {
Text(text = greetingName)
Button(onClick = {
greetingName = "Hello $greetingName"
}) {
Text("Hello 추가")
}
}
}
NamePlate
├── Column
│ ├── Text (greetingName)
│ └── Button
└── Text ("Hello")
NamePlate에서 Column을 기준으로 하위에 Text 노드, Button 노드가 생성됩니다.
이렇게 처음 UI를 구성하는 과정을 Composition(컴포지션)이라고 부릅니다.
그런데, 사용자가 Button을 클릭하면 greetingName 값이 변경되고, 이 상태 변화가 greetingName을 사용하고 있는 Text 컴포넌트의 Recomposition(리컴포지션)이 발생하게 됩니다.
이 예제를 통해 Compose에서는 상태 변화에 따라 컴포넌트들이 여러 번 Recomposition될 수 있으며, 그 실행 순서가 항상 일정하지 않을 수 있다는 중요한 특성을 확인했습니다.
이제 Compose에서 발생할 수 있는 부수효과(사이드 이펙트)에 대해서 알아보도록 하겠습니다.
안드로이드 공식 문서에 따르면 부수효과는 Composable 함수의 범위 밖에서 발생하는 앱 상태에 대한 변경사항 이라고 설명 합니다.
위에서 살펴본 것처럼 Recomposition으로 인해 실행 순서가 일정하지 않을 수 있기 때문에, Composable 함수에서는 가능한 부수효과를 피하는 것이 바람직합니다.
하지만 부수효과가 필요한 상황이 있습니다.
예를들어 오래 걸리는 작업, 애니메이션, 토스트 메시지 및 일회성 이벤트 및 로깅 등이 있습니다.
그럼 부수효과를 어떻게 사용하는지 알아보겠습니다.
LaunchedEffect는 Composition 라이프사이클에 맞춰 key 값이 변경되면 코루틴을 시작하고 Composition에서 제거될 때 자동으로 코루틴을 취소합니다.
즉, 사용자가 설정한 key(Any 타입)값이 변하면 기존에 작업하고 있던 코루틴을 취소하고 코루틴을 다시 시작합니다.
그리고 Composition에서 제거될 때(컴포넌트가 UI에서 사라질 때) 자동으로 코루틴을 취소합니다.
@Composable
@NonRestartableComposable
@OptIn(InternalComposeApi::class)
fun LaunchedEffect(
key1: Any?,
block: suspend CoroutineScope.() -> Unit
) {
val applyContext = currentComposer.applyCoroutineContext
remember(key1) { LaunchedEffectImpl(applyContext, block) }
}
key 값을 파라미터로 받고, 해당 key가 변경될 때마다 CoroutineScope 내에서 작업을 수행합니다.
internal class LaunchedEffectImpl(
parentCoroutineContext: CoroutineContext,
private val task: suspend CoroutineScope.() -> Unit
) : RememberObserver {
private val scope = CoroutineScope(parentCoroutineContext)
private var job: Job? = null
override fun onRemembered() {
// This should never happen but is left here for safety
job?.cancel("Old job was still running!")
job = scope.launch(block = task)
}
override fun onForgotten() {
job?.cancel(LeftCompositionCancellationException())
job = null
}
override fun onAbandoned() {
job?.cancel(LeftCompositionCancellationException())
job = null
}
}
key 값이 변경됐을 땐 onRemembered를 통해 기존의 coroutine이 취소되고 새로운 coroutine을 시작합니다.
만약 Composition에서 제거되거나( onForgotten ) 만약 key 값은 바뀌었지만 필요 없는 객체( onAbandoned )의 경우엔 코루틴을 취소하게 됩니다.
@Composable
fun NamePlate(name: String) {
val context = LocalContext.current
var changedName by remember { mutableStateOf(name) }
var counter by remember { mutableIntStateOf(0) }
LaunchedEffect(counter) {
changedName = name + "$counter"
Toast.makeText(context, changedName, Toast.LENGTH_SHORT).show()
}
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(text = changedName)
Button(onClick = {
counter++
}) {
Text("counter 추가")
}
}
}
/** 실행 결과 **/
// Jay0
// 토스트 메시지 Jay0
/** 버튼 클릭 시 **/
// Jay1 ...
// 토스트 메시지 Jay1 ...
사용자가 버튼을 클릭하면 counter 상태 값이 증가합니다.
counter는 LaunchedEffect의 key로 사용되고, 값이 변경될 때마다 내부 코루틴 블록이 재실행됩니다.
changedName 상태를 업데이트하고 기존의 이름에 counter 값을 추가합니다.
토스트 메시지를 표시하여 사용자에게 상태 변경을 알립니다.
LaunchedEffect는 Composition 라이프사이클과 연동되어 key 값이 변경될 때마다 코루틴을 자동으로 관리합니다.
UI 상태 변화에 따른 부수 효과를 Composable 함수 내에서 안전하게 처리할 수 있습니다.
DisposableEffect는 정리가 필요한 효과로서 Composable 함수가 Composition에서 제거될 때 정리 작업을 수행합니다.
DisposableEffect 또한 key 값을 기반으로 효과가 실행되며 필수로 onDispose 블록을 반환해야 합니다.
DisposableEffect는 메인 스레드에서 실행되므로 무거운 작업은 피하는 것이 좋습니다.
@Composable
@NonRestartableComposable
fun DisposableEffect(
key1: Any?,
effect: DisposableEffectScope.() -> DisposableEffectResult
) {
remember(key1) { DisposableEffectImpl(effect) }
}
interface DisposableEffectResult {
fun dispose()
}
key 값에 따라 DisposableEffect 효과가 실행됩니다.
DisposableEffect는 반드시 DisposableEffectResult를 반환해야 하는데, 일반적으로 이것은 onDispose {} 함수를 통해 구현합니다.
private class DisposableEffectImpl(
private val effect: DisposableEffectScope.() -> DisposableEffectResult
) : RememberObserver {
private var onDispose: DisposableEffectResult? = null
override fun onRemembered() {
onDispose = InternalDisposableEffectScope.effect()
}
override fun onForgotten() {
onDispose?.dispose()
onDispose = null
}
override fun onAbandoned() {
// Nothing to do as [onRemembered] was not called.
}
}
key 값이 변경되면 onRemembered() 메서드를 통해 효과가 다시 실행됩니다.
그리고 Composable 함수가 Composition에서 제거되거나 key가 변경되어 이전 효과가 더 이상 필요 없을 때 onForgotten() 메서드가 호출되어 dispose() 함수를 실행함으로써 정리 작업을 수행합니다.
@Composable
fun MusicPlayerScreen() {
val musicPlayer = remember { MusicPlayer() }
DisposableEffect(Unit) {
musicPlayer.play()
onDispose {
musicPlayer.stop()
}
}
Text("음악 재생 중...")
Button(onClick = { /* 다음 곡 */ }) {
Text("다음 곡")
}
}
MusicPlayerScreen이 화면에 표시되면 자동으로 음악이 시작됩니다.
사용자가 뒤로 가기를 누르거나 다른 화면으로 이동해서 MusicPlayerScreen이 화면에서 사라지면, DisposableEffect의 onDispose()가 정리 작업을 수행하여 음악이 자동으로 중지됩니다.
DisposableEffect는 Composable 함수에서 리소스 관리가 필요한 작업을 처리하고, Composition에서 제거될 때 리소스를 정리 작업을 수행하는 효과입니다.
특히 onDispose 블록을 통해 리소스 해제나 이벤트 리스너 제거 같은 리소스 정리 로직을 구현해야 합니다.
SideEffect는 Composition이 되거나 ReComposition이 성공적으로 완료되고 변경 사항을 적용시킨 뒤 실행되는 효과입니다.
쉽게 설명하자면, SideEffect는 Composable 라이프사이클 내에서 Composition 단위로 "안전하게" 외부 상태를 업데이트할 수 있는 방법입니다.
@Composable
@NonRestartableComposable
@ExplicitGroupsComposable
@OptIn(InternalComposeApi::class)
fun SideEffect(
effect: () -> Unit
) {
currentComposer.recordSideEffect(effect)
}
@InternalComposeApi
fun recordSideEffect(effect: () -> Unit)
현재 Composer ( 현재 Composition ) 상태에서 effect를 등록하면 Composition이 됐을 때나 ReComposition이 됐을 때 등록 했던 effect를 실행하게 됩니다.
이 때, effect는 외부에게 알리는 역할을 하고 외부 상태를 업데이트 합니다.
@Composable
fun rememberFirebaseAnalytics(user: User): FirebaseAnalytics {
val analytics: FirebaseAnalytics = remember {
FirebaseAnalytics()
}
SideEffect {
analytics.setUserProperty("userType", user.userType)
}
return analytics
}
rememberFirebaseAnalytics가 Composition될 때마다 FirebaseAnalytics에 user 정보의 userType을 전송하게 됩니다.
즉, rememberFirebaseAnalytics가 호출될 때마다 Composition or Recomposition되기 때문에 최신의 analytics 정보를 전달할 수 있게 됩니다.
SideEffect는 Composable 함수가 Composition 또는 Recomposition이 성공적으로 완료된 후에 실행되는 효과입니다.
Composable 외부 상태를 변경하는데 효과적인 부수효과 입니다.
이번 포스팅에서는 Jetpack Compose의 부수효과(Side Effect)와 이를 효과적으로 핸들링하는 방법에 대해 알아보았습니다.
다음 포스팅에서는 이러한 부수효과를 활용할 때 유용한 상태 변환 함수들에 대해 알아보겠습니다.
작성자는 어떠한 피드백도 환영 합니다.
정리가 깔끔하네요