이벤트란 애플리케이션 외부 또는 내부에서 생생되는 입력.
버튼 누르기 등으로 UI와 상호작용하는 사용자, 새 값을 전송하는 센서 또는 네트워크 응답

이벤트(Event): 사용자 또는 프로그램의 다른 부분에 의해 생성
상태 업데이트(Update State): 이벤트 핸들러가 UI에서 사용하는 상태를 변경
상태 표시(Display State): 새로운 상태를 표시하도록 UI를 업데이트
컴포지션이란 컴포저블 실행 시 빌드한 UI에 관한 설명이라고 나와 있다. 즉, 컴포지션은 컴포저블 함수가 실행될 때, Jetpack Compose가 UI 트리를 빌드하는 것을 말하며 리컴포지션은 리컴포지션은 상태가 변경될 때 컴포저블 함수를 다시 실행하여 UI를 업데이트하는 과정을 말한다.
상태가 변경되면 리컴포지션을 예약하고 스케줄링 시스템으로 적절한 시점에 발생하게 된다. 따라서 예약이라는 단어를 사용한다. 예약된 리컴포지션은 한 프레임 내에서 여러 상태 변경을 묶어서 처리해 불필요한 중복 리컴포지션을 방지한다.
주의할 것은 mutablestate를 가진 컴포저블 함수자체는 재실행 되지만 그 함수 내에서 변경되는 mutablestate값을 가지는 데이터가 변경된 자식 컴포저블 함수만이 리렌더링된다. 따라서 MutableState를 사용해도 리컴포지션으로 함수가 실행될 때 초기화 과정이 남아있다면 값은 변경 되었다가 초기화 되기 때문에 remember를 같이 사용하는 것이 일반적인 패턴이다.
remember는 리컴포지션 시 컴포저블에 사용될 상태를 기억하기 위해 사용하는 키워드지만 저장된 상태는 그 컴포저블이 포지션 트리에 포함되는 동안에만 유지된다. 이는 컴포지션 트리구조로 UI를 관리하기 때문인데 리컴포지션 되지 않거나 리컴포지션 중 해당 코드 블록에 포함되지 않는다면 초기화 트리에서 제거하는 구조다. mutablestate와 상호의존적인 관계이며 구성 변경에서는 데이터 유지가 되지 않는다.(아예 화면을 종료한다던가 가로모드로 화면이 변경되는 경우)
구성 변경시에는 rememberSaveable을 사용하여 Bundle객체에 저장할 수 있는 모든 값을 저장하게 되므로 애플리케이션을 완전히 종료하기 전까지 그 데이터가 유지된다. (Bundle이 앱 프로세스 수명 주기 동안만 유지되기 때문에)
remember를 사용하여 객체를 저장하는 컴포저블에는 내부 상태가 포함되며 이는 컴포저블을 스테이트풀(Stateful)로 만드는데 이는 호출자가 상태를 제어할 필요가 없고 상태를 직접 관리하지 않아도 상태를 사용할 수 있는 경우에 유용하다.
다만, 내부 상태를 갖는 컴포저블은 재사용 가능성이 적고 테스트하기가 더 어려운 경향이 있다. 어째서일까?
내부 상태를 가진 컴포저블은 상태를 자체적으로 관리하기 때문에 특정 동작에 종속되어 재사용성과 테스트가 어려워집니다. 상태를 외부에서 제어하거나 주입할 수 없으므로, 상태 초기화나 상태 변화에 따른 UI 테스트도 까다로워진다.
🤔 그렇다면 어떻게 기존의 statefull의 기능을 유지하며 stateless로 만들 수 있을까?
상태를 보유하지 않는 컴포저블을 만들고 상태를 호출자로 옮기면 상태는 내려가게 되고 이벤트는 호출자로 올라가게되는 단방향 데이터 흐름(UDF)패턴을 가지게 되는데 이 아키텍처를 compose에서 구현한 것이 상태 호이스팅이며 이 방식으로 스테이트리스(stateless) 컴포저블로 상태 변화가 필요한 컴포저블을 제어할 수 있다.
이렇게 상태 호이스팅 패턴을 구성하게되면 아래와 같은 중요한 속성이 생긴다.
1. 단일 소스 저장소: 상태를 복제하는 대신 옮겼기 때문에 소스 저장소가 하나만 있게되며 버그 방지에도 도움이 된다.
2. 공유 가능: 끌어올린 상태를 여러 컴포저블과 공유할 수 있다.
3. 가로채기 가능: 스테이트리스(Stateless) 컴포저블의 호출자는 상태를 변경하기 전에 이벤트를 무시할지 수정할지 결정이 가능하다.
4. 분리: 구성 가능한 스테이트리스(Stateless) 함수의 상태는 어디에든(예: ViewModel) 저장할 수 있다.
따라서 두 stateful과 stateless에 대해서 비교해보면
스테이트리스(Stateless) 컴포저블은 상태를 소유하지 않는 컴포저블로 새 상태를 보유하거나 정의하거나 수정하지 않는다.
스테이트풀(Stateful) 컴포저블은 시간이 지남에 따라 변할 수 있는 상태를 소유하는 컴포저블로 실제 앱에서는 컴포저블의 기능에 따라 컴포저블을 100% 스테이트리스(Stateless)로 하는 것은 어려울 수 있지만 컴포저블이 가능한 한 적게 상태를 소유하고 적절한 경우 컴포저블의 API에 상태를 노출하여 상태를 끌어올릴 수 있도록 컴포저블을 디자인하는 것이 좋다.
// 상태를 가지는 컴포저블 - Stateful
@Composable
fun StatefulCounter(modifier: Modifier = Modifier) {
Column(modifier = Modifier) {
// 이벤트 발생 시 증가된 count값을 저장
var waterCount by remember { mutableStateOf(0) }
var juiceCount by remember { mutableStateOf(0) }
// 이벤트 발생 시 count증가
StatelessCounter(waterCount, { waterCount++ })
StatelessCounter(juiceCount, { juiceCount++ })
}
}
// 상태가 없는 컴포저블 - Stateless
@Composable
fun StatelessCounter(count: Int, onIncrement: () -> Unit, modifier: Modifier = Modifier) {
Column(modifier = modifier.padding(16.dp)) {
// count 상태값을 받아서 사용,
if (count > 0) {
Text("You've had $count glasses.")
}
// onclick 이벤트를 상위로 전달.
Button(onClick = onIncrement, Modifier.padding(top = 8.dp), enabled = count < 10) {
Text("Add one")
}
}
}
ViewModel은 상태를 외부에서 저장하기 위한 방법으로 기존에도 많이 사용되는 방법이다. 상태를 한번에 모아서 관리할 수 있으므로 모든 컴포저블을 stateless형태로 구현할 수 있게된다.
특징 -
1. 상태 중앙 관리: ViewModel을 사용하면 상태를 한 곳에서 관리하고, 여러 컴포저블에 쉽게 공유할 수 있게되며 이렇게 하면 각 컴포저블에서 상태를 관리할 필요 없이, ViewModel에서 상태를 주입받아서 컴포저블을 Stateless로 유지할 수 있다.
2. 생명주기 안전성: ViewModel은 안드로이드 생명주기와 연결되어 있어서 화면 회전 등과 같은 구성 변경 시에도 상태가 유지된다. 물론 Compose에서 구성 변경 시 상태가 유지되는 건 rememberSaveable을 사용해도 되지만 상태를 중앙 집중화 하는 것이 목적이기 때문에 관점 차이라고 볼 수 있다.
3. 테스트 용이성: ViewModel을 사용하면 상태가 ViewModel에서 관리되기 때문에, 컴포저블 자체는 단순한 UI 로직만 포함하고, 상태 변화는 ViewModel에서 발생하기 때문에 테스트가 더 간단해진다. ViewModel을 모의 객체(mock)로 대체하거나 특정 상태를 주입하여 테스트할 수 있게된다.
class TodoViewModel : ViewModel() {
// 상태 관리
private val _todoList = mutableStateListOf<TodoItem>()
val todoList: List<TodoItem>
get() = _todoList
fun addTodo(taskName: String) {
if (taskName.isNotEmpty()) {
_todoList.add(TodoItem(taskName))
}
}
fun removeTodo(item: TodoItem) {
_todoList.remove(item)
}
}
// data class가 아닌 일반 클래스 사용
class TodoItem(val taskName: String, var isCompleted: Boolean = false)
@Composable
fun TodoScreen(todoViewModel: TodoViewModel = viewModel()) {
Column {
val newTask = remember { mutableStateOf("") }
TextField(
value = newTask.value,
onValueChange = { newTask.value = it },
label = { Text("New Task") }
)
Button(onClick = {
todoViewModel.addTodo(newTask.value)
newTask.value = ""
}) {
Text("Add Task")
}
LazyColumn {
items(todoViewModel.todoList) { todo ->
Row {
Text(todo.taskName)
IconButton(onClick = { todoViewModel.removeTodo(todo) }) {
Icon(Icons.Default.Delete, contentDescription = "Delete")
}
}
}
}
}
}
위의 예시에서 data class가 아닌 일반 클래스를 사용하여 구현했는데 그 이유는 다음과 같다.
data class는 보통 불변 데이터를 관리할 때 사용된다. 이때 equals, hashCode, copy() 같은 기본 메서드는 객체의 모든 속성을 기준으로 동작하는데, 만약 변경 가능한 상태(MutableState)를 포함하게 되면 예측 불가능한 동작이 발생할 수 있다. 예를 들어, mutableStateOf를 사용하면 상태가 변할 때 equals와 hashCode의 일관성이 깨질 수 있으며, copy()를 통해 복사된 객체가 동일한 상태 참조를 유지하기 때문에 상태 변경 시 원본과 복사본 모두에 영향을 미친다.
data class WellnessTask(
val id: Int,
val label: String,
val checked: MutableState<Boolean> = mutableStateOf(false)
)
val originalTask = WellnessTask(1, "Task 1")
val copiedTask = originalTask.copy()
// 원본의 상태 변경
originalTask.checked.value = true
// 복사본도 같은 참조를 유지하여 상태가 변경됨
println(copiedTask.checked.value) // 출력: true
이게 문제가 되는 이유를 간략히 보면 아래와 같다.
예측 가능성 상실 및 유지보수 어려움: 가변 상태를 가진 data class는 복사본이 원본과 동일한 상태 참조를 가지기 때문에, 한쪽에서 상태가 변경되면 복사본에도 예상치 못한 변화가 발생할 수 있다. 이는 상태 변화를 예측하기 어려워 코드가 복잡해지고, 디버깅과 유지보수 과정이 더 어려워진다. 특히, 복사본과 원본 간의 상태 동기화 문제가 발생할 수 있어, 코드의 가독성이 떨어지고 의존성 관리가 복잡해지며, 유지보수 중에 예상하지 못한 동작이 발생할 가능성이 높아진다.
동시성 문제: 여러 스레드에서 가변 상태를 가진 객체를 동시에 접근할 경우, 동시성 문제(데이터 손실이나 비정상 상태)가 발생할 수 있다. 불변 객체는 동시성 문제를 예방하는 데 유리하다.