
Jetpack Compose에서 State는 시간이 지남에 따라 변경될 수 있는 값으로, 앱 UI의 동적인 측면을 나타냅니다. 예를 들어, 네트워크 오류에 대한 스낵바 메시지, 폼 입력, 사용자 상호작용에 의해 트리거되는 애니메이션 등이 있습니다. Compose와 같은 선언형 프레임워크에서 상태는 매우 중요합니다. 왜냐하면 상태가 UI 업데이트를 직접적으로 주도하기 때문입니다. Compose는 현재 상태에 따라 컴포저블을 평가하고, 상태가 변경되면 이를 다시 평가합니다.
상태 끌어올림은 컴포저블 내부의 상태를 상위 컴포넌트 또는 부모로 이동시키는 패턴입니다. 이 패턴은 현재 상태 값과 상태 업데이트를 위한 람다(lambda)를 컴포저블에 인자로 전달하는 방식으로 작동합니다. 상태 끌어올림은 단방향 데이터 흐름(unidirectional data flow)의 원칙을 따르며, UI를 더 쉽게 관리하고 확장할 수 있게 해줍니다.
Jetpack Compose에서는 rememberCoroutineScope를 사용하여 컴포저블 함수 내에서 안전하게 코루틴 스코프를 생성하고 관리하는 것이 권장됩니다. 이 API는 해당 스코프가 컴포지션에 종속되도록 보장하여 메모리 누수나 리소스 낭비를 방지합니다.
rememberCoroutineScope를 사용해야 할까?Jetpack Compose의 rememberCoroutineScope는 컴포지션에 종속된 코루틴 스코프를 제공합니다. 이 스코프는 컴포저블이 컴포지션에서 벗어날 때 자동으로 취소되므로, 수동으로 생명주기를 관리할 필요 없이 안전하게 코루틴을 실행할 수 있습니다.
rememberCoroutineScope는 컴포지션 생명주기에 종속된 가벼운 UI 관련 작업에만 사용해야 합니다.viewModelScope 또는 *lifecycleScope를 사용하는 것이 좋습니다.rememberCoroutineScope를 사용할 때도, 복잡한 비즈니스 로직을 직접 컴포저블 내에 작성하는 것은 피하고, 가능한 한 ViewModel 등 외부 계층으로 위임하세요.rememberCoroutineScope의 내부 동작컴포저블 함수에서 코루틴 스코프를 안전하게 생성하는 방법으로 rememberCoroutineScope를 알아보았으니, 이제 이 API가 내부적으로 어떻게 작동하는지도 살펴보겠습니다.
rememberCoroutineScope의 내부 구현@Composable
inline fun rememberCoroutineScope(
crossinline getContext: @DisallowComposableCalls () -> CoroutineContext =
{ EmptyCoroutineContext }
): CoroutineScope {
val composer = currentComposer
val wrapper = remember {
CompositionScopedCoroutineScopeCanceller(
createCompositionCoroutineScope(getContext(), composer)
)
}
return wrapper.coroutineScope
}
이 함수는 다음 두 핵심 구성 요소로 이루어져 있습니다:
CompositionScopedCoroutineScopeCanceller이 클래스는 코루틴 스코프가 컴포지션 생명주기를 인식할 수 있도록 도와줍니다.
internal class CompositionScopedCoroutineScopeCanceller(
val coroutineScope: CoroutineScope
) : RememberObserver {
override fun onRemembered() { /* 생략 */ }
override fun onForgotten() {
coroutineScope.cancel(LeftCompositionCancellationException())
}
override fun onAbandoned() {
coroutineScope.cancel(LeftCompositionCancellationException())
}
}
주요 특징:
RememberObserver를 구현하여 컴포지션 상태를 감지합니다.onForgotten() 또는 onAbandoned() 메서드가 호출되며, 이 때 코루틴 스코프가 자동으로 취소(cancel) 됩니다.createCompositionCoroutineScope이 함수는 실제로 새로운 코루틴 스코프를 생성합니다:
internal fun createCompositionCoroutineScope(
coroutineContext: CoroutineContext,
composer: Composer
): CoroutineScope {
if (coroutineContext[Job] != null) {
return CoroutineScope(
Job().apply {
completeExceptionally(
IllegalArgumentException(
"CoroutineContext supplied to rememberCoroutineScope may not include a parent job"
)
)
}
)
} else {
val applyContext = composer.applyCoroutineContext
return CoroutineScope(applyContext + Job(applyContext[Job]) + coroutineContext)
}
}
주요 특징:
rememberCoroutineScope는 독립적인 코루틴 스코프를 만들도록 강제합니다.Job을 코루틴 컨텍스트에 추가하여 스코프를 생성합니다.composer.applyCoroutineContext와 결합하여 composition-aware 스코프를 형성합니다.Jetpack Compose는 컴포저블의 리컴포지션 범위를 벗어나는 작업을 처리할 수 있도록 여러 사이드 이펙트 핸들러 API51를 제공합니다. 이러한 API는 Android 프레임워크와의 상호작용, 컴포지션 이벤트 관리, 상태 변경에 따른 효과 발생 등과 같은 시나리오를 처리하는 데 필수적입니다. 주요 사이드 이펙트 핸들러 API는 다음 세 가지입니다: LaunchedEffect, DisposableEffect, SideEffect.
LaunchedEffect: 컴포저블 범위 내에서 suspend 함수를 실행LaunchedEffect는 컴포저블의 컴포지션 안에서 실행되는 코루틴을 시작하는 데 사용됩니다. 이 코루틴은 전달된 키 값이 변경되면 취소되고 다시 실행됩니다. 데이터 페칭, 애니메이션 시작, 사용자 이벤트 감지 등 컴포저블이 컴포지션에 들어갈 때 실행되어야 하는 작업에 유용합니다.
주요 특징:
var selectedPoster: Poster by remember { mutableStateOf(null) }
LaunchedEffect(key1 = selectedPoster) {
// ViewModel로 이벤트를 보내 네트워크에서 추가 정보 가져오기
}
또한 LaunchedEffect를 사용하여 Flow를 안전하게 관찰할 수 있습니다. 이때 코루틴은 컴포저블이 컴포지션을 벗어날 때 자동으로 취소됩니다. 재컴포지션 중에 다시 실행되지 않으며, 호출 지점의 생명주기에 맞추어 효과를 제어하고 싶다면 Unit 또는 true 같은 상수를 key로 전달하면 됩니다.
LaunchedEffect(key1 = Unit) {
stateFlow
.distinctUntilChanged()
.filter { it.marked }
.collect { .. }
}
DisposableEffect: 정리가 필요한 이펙트DisposableEffect는 리스너, 옵저버, 구독 등 자원 관리 또는 정리가 필요한 작업을 위해 사용됩니다. LaunchedEffect와는 다르게 onDispose 람다를 포함한 DisposableEffectScope를 제공합니다.
주요 특징:
onDispose 콜백을 통해 자원을 안전하게 해제예를 들어, 생명주기 이벤트 기반으로 분석 이벤트를 전송해야 하는 경우 LifecycleObserver를 등록/해제할 수 있습니다.
@Composable
fun HomeScreen(
lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current,
onStart: () -> Unit,
onStop: () -> Unit
) {
val currentOnStart by rememberUpdatedState(onStart)
val currentOnStop by rememberUpdatedState(onStop)
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_START) {
currentOnStart()
} else if (event == Lifecycle.Event.ON_STOP) {
currentOnStop()
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
}
}
}
SideEffect: Compose 상태를 Compose 외부 코드에 반영SideEffect는 매 리컴포지션 직후 실행되며, Compose 상태를 ViewModel 또는 외부 라이브러리 같은 Compose 외부 시스템에 동기화할 때 유용합니다.
주요 특징:
예를 들어 Firebase Analytics와 같은 외부 분석 도구에 사용자 타입을 설정하려면 다음과 같이 사용할 수 있습니다.
@Composable
fun rememberFirebaseAnalytics(user: User): FirebaseAnalytics {
val analytics: FirebaseAnalytics = remember {
FirebaseAnalytics()
}
SideEffect {
analytics.setUserProperty("userType", user.userType)
}
return analytics
}
또 다른 예로, Lottie 애니메이션을 리컴포지션 이후 시작하고 싶다면 다음과 같이 사용합니다.
SideEffect {
lottieAnimationView.playAnimation() // 리컴포지션 후에만 실행됨
}
각 사이드 이펙트 핸들러 API는 다음과 같은 고유 목적을 갖습니다:
LaunchedEffect: suspend 함수 기반 작업을 시작하거나 키 변경 시 다시 시작DisposableEffect: 컴포지션에 종속된 리스너, 옵저버 등의 자원 정리SideEffect: 매 리컴포지션 직후 Compose 외부 시스템과 동기화rememberUpdatedState 함수는 컴포지션 내에서 상태 업데이트를 안전하게 처리할 수 있도록 도와주는 유틸리티 함수입니다. 이 함수는 이전 리컴포지션 시점에 생성된 람다나 콜백 내부에서 최신 값을 사용할 수 있도록 보장합니다.
컴포저블에서 콜백이나 람다를 생성할 때, 해당 함수가 이미 컴포즈된 경우에는 그 안에서 참조하는 상태 값이 자동으로 업데이트되지 않을 수 있습니다. 이런 경우에 rememberUpdatedState를 사용하면 최신 상태 값을 항상 사용할 수 있는 메커니즘을 제공합니다. 이는 오래된 상태로 인한 버그를 방지하는 데 유용합니다.
rememberUpdatedState는 다음과 같은 경우에 특히 유용합니다:
LaunchedEffect, DisposableEffect, 또는 리컴포지션을 초과하여 유지되는 애니메이션과 함께 사용할 때.rememberUpdatedState의 동작이 복잡하게 느껴질 수도 있지만, 내부 구현은 매우 간단합니다.
@Composable
fun <T> rememberUpdatedState(newValue: T): State<T> =
remember { mutableStateOf(newValue) }.apply { value = newValue }
위 코드에서 보듯이, rememberUpdatedState는 mutableStateOf(newValue)를 기억하고, apply 스코프 함수로 매 리컴포지션마다 새로운 값을 설정합니다. 즉, remember된 상태를 유지하면서 newValue를 갱신하는 구조입니다.
snapshotFlow55는 Compose의 상태를 Flow로 변환하는 함수입니다. 이 함수는 Compose가 내부적으로 상태 변경을 효율적으로 관리하고 관찰하기 위해 사용하는 Snapshot 시스템 내의 변경을 관찰합니다. 관찰된 상태가 변경되면, Flow는 해당 변경된 값을 내보냅니다(emits).
간단히 말해, snapshotFlow는 Compose 상태의 변경을 감지하여 Flow로 내보냅니다. 이를 통해 상태 업데이트를 관리하거나 반응하기 위한 코루틴 기반의 표준 연산을 사용할 수 있습니다.
Flow를 수집하는 코루틴이 취소되면 관찰도 자동으로 중단되어 컴포지션 인식적입니다.derivedStateOf의 목적은 무엇이며, 어떻게 리컴포지션을 최적화하나요?derivedStateOf는 하나 이상의 상태 객체로부터 유도된 값을 계산하는 데 사용되는 컴포저블 API입니다. 이 API는 종속 상태들 중 하나라도 변경되었을 때만 해당 유도 값을 재계산하여, 리컴포지션을 최적화하는 데 효과적입니다.
derivedStateOf의 주요 기능 중 하나는 종속 상태가 자주 업데이트되더라도 계산된 값 자체가 변경되지 않는다면 리컴포지션을 방지해 성능을 최적화한다는 것입니다. 이러한 특성 덕분에 derivedStateOf는 리컴포지션 방지가 중요한 시나리오에서 성능 개선에 매우 유용합니다.
derivedStateOf는 중복된 리컴포지션을 방지하도록 설계되어 있지만, 계산 오버헤드가 있기 때문에 꼭 필요한 상황에서만 사용하는 것이 좋습니다. 성능 향상이 그 오버헤드를 상회할 수 있는 경우에만 사용하는 것이 권장됩니다.
derivedStateOf를 사용할까?컴포저블 함수는 Android View나 Activity처럼 전통적인 생명주기를 따르지 않습니다. 대신, Jetpack Compose 런타임에 의해 구동되는 컴포지션 인식 생명주기를 따릅니다.
![그림 생략]
Figure 272. composable-lifecycle
이 생명주기는 UI 상태가 변경될 때 컴포저블 함수가 호출되고, 다시 컴포즈되고, 제거되는 과정을 효율적으로 관리합니다. 아래는 컴포저블 함수의 생명주기 주요 단계를 설명한 것입니다.
이것은 컴포저블 함수가 처음 실행되는 초기 단계입니다. 이 단계에서 다음이 발생합니다:
LaunchedEffect, remember와 같은 사이드 이펙트가 초기화되며 이후의 리컴포지션에서도 유지됩니다.예를 들어, 아래의 Greeting 함수는 상태에 의해 처음 컴포지션될 때 호출됩니다.
@Composable
fun Greeting(name: String) {
Text(text = "Hello, $name!")
}
컴포저블 함수가 의존하는 상태가 변경될 경우 리컴포지션이 발생합니다. 이 단계에서는 다음이 이루어집니다:
remember와 같은 사이드 이펙트는 리컴포지션 중에도 계속 유지됩니다.아래 예제는 count가 변경될 때 버튼 내 Text만 리컴포즈되는 예시입니다.
@Composable
fun Counter() {
var count by remember { mutableStateOf(0) }
Button(onClick = { count++ }) {
Text(text = "Clicked $count times")
}
}
컴포저블 함수가 컴포지션에서 제거되면, 컴포지션을 떠나는 단계로 진입합니다. 예: 다른 화면으로 이동 시. 이 단계에서는 다음이 수행됩니다:
DisposableEffect 등의 사이드 이펙트는 제거되며, 자원 해제를 위해 onDispose 콜백이 실행됩니다.예를 들어 아래의 DisposableEffect 블록은 컴포지션에 진입하고 이탈할 때 로그를 출력합니다:
@Composable
fun DisposableExample() {
DisposableEffect(Unit) {
println("Entering composition")
onDispose {
println("Leaving composition")
}
}
Text(text = "Composable in use")
}
remember, derivedStateOf, 안정적인 상태 관리 등을 통해 성능을 최적화합니다.LaunchedEffect의 코루틴이나 DisposableEffect의 구독, remember로 생성된 상태 등은 반드시 정리되어야 합니다. 이 모든 것은 컴포지션 인식적으로 처리됩니다.mutableStateListOf와 mutableStateMapOf는 무엇인가요?State는 Compose의 스냅샷 시스템(snapshot system)과 함께 작동하며, 값이 변경되면 자동으로 리컴포지션을 트리거하여 UI를 동적으로 업데이트할 수 있게 해줍니다. 하지만 List나 Map 같은 컬렉션을 사용할 때는 기본 제공 수정 메서드로 항목을 추가하거나 삭제해도 Compose가 해당 변경을 감지하지 못합니다. 즉, 컬렉션 내부 항목의 변경은 자동으로 UI 리컴포지션을 트리거하지 않습니다.
다음 예제를 보세요:
val mutableList by remember { mutableStateOf(mutableListOf("skydoves", "android")) }
LazyColumn {
item {
Button(
onClick = { mutableList.add("kotlin") } // 이건 리컴포지션을 발생시키지 않음
) {
Text(text = "Add")
}
}
items(items = mutableList) { item ->
Text(text = item)
}
}
이 경우 "kotlin" 항목이 리스트에 추가되더라도, mutableList의 내부 항목 변경은 Compose가 감지하지 못해 UI가 업데이트되지 않습니다.
이 문제를 해결하기 위해 Compose는 mutableStateListOf 와 mutableStateMapOf 라는 특수한 상태 보유 컬렉션(state-holding collections)을 제공합니다. 이 컬렉션들은 Compose의 스냅샷 시스템과 통합되어, 컬렉션 항목의 변경도 자동으로 감지하여 리컴포지션을 트리거합니다.
mutableStateListOfmutableStateListOf는 SnapshotStateList를 생성합니다. 이 객체는 일반적인 MutableList처럼 동작하지만, Compose에 최적화되어 항목이 변경되면 자동으로 관련 UI를 리컴포즈합니다.
val items = mutableStateListOf("android", "kotlin", "skydoves")
리스트를 수정하면 Compose는 이를 감지하고 UI를 자동으로 업데이트합니다:
items.add("Jetpack Compose")
items.removeAt(0)
mutableStateMapOfmutableStateMapOf는 SnapshotStateMap을 생성합니다. 이 객체는 일반적인 MutableMap처럼 동작하면서도 상태 및 UI를 안전하게 동기화합니다.
val userSettings = mutableStateMapOf("theme" to "dark", "notifications" to "enabled")
맵 값을 업데이트하면 해당 값과 연관된 UI가 자동으로 리컴포지션됩니다:
userSettings["theme"] = "light"
userSettings.remove("notifications")
mutableStateListOf와 mutableStateMapOf는 UI 관련 상태를 보관하면서도 Compose의 리액티브 시스템과 자연스럽게 동작하게 해줍니다. 이들은 Compose의 snapshot 시스템과 통합되어 있어 필요한 UI 컴포넌트만 효율적으로 리컴포즈할 수 있게 해줍니다.
예: ViewModel 내 사용 예
class UserViewModel : ViewModel() {
private val mutableUserList = mutableStateListOf("skydoves", "kotlin", "android")
val userList: StateFlow<List<String>> = MutableStateFlow(mutableUserList) // 외부에 안전하게 노출
fun addUser(user: String) {
mutableUserList.add(user)
}
fun removeUser(user: String) {
userList.remove(user)
}
}
@Composable
fun UserList() {
val userViewModel = viewModel<UserViewModel>()
val userList by userViewModel.userList.collectAsStateWithLifecycle()
// ...
}
class SettingsViewModel : ViewModel() {
private val mutableSettingMap = mutableStateMapOf("theme" to "light", "language" to "English")
val settings: StateFlow<Map<String, String>> = MutableStateFlow(mutableSettingMap) // 외부에 안전하게 노출
fun updateTheme(theme: String) {
mutableSettingMap["theme"] = theme
}
fun removeSetting(key: String) {
mutableSettingMap.remove(key)
}
}
mutableStateListOf와 mutableStateMapOf는 Jetpack Compose에서 효율적인 리컴포지션을 가능하게 하는 상태 인식 컬렉션(state-aware collections)을 제공합니다. 이 컬렉션들은 리스트나 맵 형태의 상태를 관리할 때 자주 사용되며, 일반적으로 네트워크 응답이나 데이터베이스 쿼리와 같은 도메인 로직에서 가져온 데이터를 담는 데 활용됩니다. 이로 인해 컴포저블 함수 내에서 자연스럽고 원활한 상태 관찰 및 UI 업데이트가 가능해집니다.