이번에는 Jetpack Compose에서 상태를 사용하고 조작하는 방법을 배울 것이다.
기본적으로 상태는 시간이 지남에 따라 변경될 수 있는 모든 값을 말한다. 광범위한 정의인데 Room DB에서 클래스의 변수까지 모든 것을 포함한다.
모든 Android 앱은 사용자에게 상태를 표시해준다. 그 예로,
위 예시 말고도 너무나 많다. TODO 앱을 알아보며 학습할 것이다.
Android View 시스템을 사용하는 단방향 데이터 흐름의 개념에 대해 알아볼 것이다.
Android 앱에서 상태는 이벤트에 대한 응답으로 업데이트된다. 이벤트는 OnClickListener를 호출하는 버튼을 탭하는 사용자, afterTextChanged를 호출하는 EditText나 새 값을 보내는 Accelerometer(가속도계)와 같이 앱 외부에서 생성된 입력이다.
모든 Android 앱에는 핵심 UI 업데이트 루프가 있다.

Compose에서 상태를 관리하는 것은 상태와 이벤트가 상호 작용하는 방식을 이해하는 것이다.
Compose를 시작하기 전에 Android View 시스템에서 이벤트와 상태를 보면, "Hello, World" 상태로 사용자가 자신의 이름을 입력할 수 있는 hello world Activity를 만들 것이다.
만드는 한 가지 방법은 이벤트 콜백이 TextView에서 상태를 직접 설정하도록 하는 것이며 ViewBinding을 사용한다. ViewBinding 사용법
ViewBinding 예시
class HelloCodelabActivity : AppCompatActivity() {
private lateinit var binding: ActivityHelloCodelabBinding
var name = ""
override fun onCreate(savedInstanceState: Bundle?) {
/* ... */
binding.textInput.doAfterTextChanged {text ->
name = text.toString()
updateHello()
}
}
private fun updateHello() {
binding.helloText.text = "Hello, $name"
}
}
작은 규모에서는 이런 구조가 괜찮지만 UI가 커질수록 관리하기 어려워질 수 있다.
이렇게 빌드된 Activity에 더 많은 이벤트와 상태를 추가하면 문제가 발생할 수 있다.
View와 엮여있기 때문에 테스트하기 어려울 수 있음앞서 말한 비정형 상태 문제를 해결하기 위해 ViewModel과 LiveData가 포함된 Android Architecture Components를 도입했다.
ViewModel을 사용하면 UI에서 상태를 추출하고 UI가 해당 상태를 업데이트하기 위해 호출할 수 있는 이벤트를 정의할 수 있다. ViewModel을 사용하여 작성된 동일한 Activity 예시를 살펴보겠다.
ViewModel 사용 예시
class HelloCodelabViewModel: ViewModel() {
// LiveData는 UI에서 관찰되는 상태를 유지
// (상태는 ViewModel에서 아래로 흐른다.)
private val _name = MutableLiveData("")
val name: LiveData<String> = _name
// onNameChanged는 UI가 호출할 수 있도록 정의하는 이벤트
// (이벤트는 UI에서 위로 흐른다.)
fun onNameChanged(newName: String) {
_name.value = newName
}
}
class HelloCodeLabActivityWithViewModel : AppCompatActivity() {
private val helloViewModel by viewModels<HelloCodelabViewModel>()
override fun onCreate(savedInstanceState: Bundle?) {
/* ... */
binding.textInput.doAfterTextChanged {
helloViewModel.onNameChanged(it.toString())
}
helloViewModel.name.observe(this) { name ->
binding.helloText.text = "Hello, $name"
}
}
}
이번 예시에서는 상태를 Activity에서 ViewModel로 이동시켰다. ViewModel에서 상태는 LiveData로 표현된다. LiveData는 observable(관찰 가능한) 상태 보유자(State Holder)이다. 이 말은 상태의 변경사항을 관찰하는 방법을 누구에게나 제공할 수 있음을 의미한다. UI에서 상태가 변경될 때마다 UI를 업데이트 하기위해 observe 메서드를 사용한다.
Observable은 누구나 해당 상태의 변경 사항을 수신할 수 있는 방법을 제공하는 상태 객체이다. LiveData, StateFlow, Flow, Observable 모두 해당된다.
ViewModel은 onNameChanged라는 하나의 이벤트도 노출한다. 이 이벤트는 EditText의 텍스트가 변경될 때마다 발생하는 것과 같은 사용자 이벤트에 대한 응답으로 UI에서 호출된다.
이전에 말한 UI 업데이트 루프로 돌아가서 ViewModel이 이벤트와 상태에 어떻게 맞춰지는지 알 수 있다.
onNameChanged가 호출됨onNameChanged가 처리를 수행한 다음 _name의 상태를 설정name의 observer가 호출됨이런 방식으로 구성하면 ViewModel을 향해 "위로" 흐르는 이벤트를 생각할 수 있다. 이벤트에 대한 응답으로 ViewModel이 일부 처리를 수행하고 상태를 업데이트할 수 있다. 상태가 업데이트되면 Activity를 향해 "아래로" 흐른다.

이런 패턴을 단방향 데이터 흐름이라고 한다. 단방향 데이터 흐름은 상태는 아래로, 이벤트는 위로 흐른다. 단방향 데이터 흐름은 여러 장점이 있는데,
ViewModel과 Activity를 모두 테스트하는 것이 더 쉬움ViewModel)에서만 업데이트할 수 있으므로 UI가 커짐에 따라 Partial State Update 버그가 발생할 가능성이 적음따라서 이 패턴은 코드가 추가되긴 하지만 복잡한 상태와 이벤트를 처리하는 데 더 쉽고 안정적이다.
- 단방향 데이터 흐름은 이벤트가 위로, 상태가 아래로 흐른다.
ViewModel에서는 상태가LiveData를 사용하여 아래로 흐르는 동안 UI의 메서드 호출과 함께 이벤트가 전달된다.ViewModel을 설명하는 용어가 아니다. 이벤트가 위로 흐르고 상태가 아래로 흐르는 모든 디자인은 단방향이다.
이제 Viewmodel을 사용하여 Compose에서 단방향 데이터 흐름을 사용하는 방법을 살펴보겠다. 간단한 TODO 앱을 만들어 보려한다.
코드랩을 통해 다운로드한 프로젝트를 살펴보면 수정해서 사용할 여러 컴포저블이 있다. 크게 두 개의 파일로 분할되어 있는데,
TodoScreen.kt : 이러한 컴포저블은 상태와 직접 상호 작용하며 상태를 탐색하며 이 파일을 수정` TodoComponents.kt : 이 컴포저블은 TodoScreen을 빌드하는 데 사용할 UI의 재사용 가능한 비트를 정의한다.TodoScreen.kt 파일을 열어 TodoScreen 컴포저블을 보면 TODO 목록을 표시하지만 자기 자신의 상태는 없다. 상태는 변경할 수 있는 모든 값이지만 TodoScreen에 대한 인수는 수정할 수 없다.
@Composable
fun TodoScreen(
items: List<TodoItem>,
onAddItem: (TodoItem) -> Unit,
onRemoveItem: (TodoItem) -> Unit
) {
/* ... */
}
items : 화면에 표시하지만 변경할 수 없는 item 리스트onAddItem : 사용자가 item 추가를 요청할 때의 이벤트onRemoveItem : 사용자가 item 제거를 요청할 때의 이벤트TodoScreen 컴포저블은 stateless이다. 전달된 item 리스트만 표시되며 리스트를 직접 편집할 수 있는 방법은 없다. 대신 변경을 요청할 수 있는 두 개의 이벤트 onRemoveItem과 onAddItem이 전달된다.
stateless composable은 상태를 직접 변경할 수 없는 컴포저블이다.
stateless는 State Hoisting을 사용해 수정 가능한 리스트를 표시할 수 있다. State Hoisting은 구성 요소를 stateless로 만들기 위해 상태를 위로 이동하는 패턴이다. Stateless 구성요소는 테스트하기 쉽고 버그가 적으며 재사용 기회가 더 많다.
이런 매개변수의 조합은 caller가 컴포저블에서 상태를 hoist할 수 있도록 한다. TodoScreen 컴포저블의 UI 업데이트 루프를 보면서 어떻게 작용하는지 알아보겠다.
TodoScreen은 onAddItem을 호출한다.TodoScreen caller는 상태를 업데이트하여 이벤트에 응답할 수 있다.TodoScreen이 새 item과 함께 다시 호출되고 화면에 표시할 수 있다.caller는 상태를 유지하는 위치와 방법을 알아낼 책임이 있다. 그러나 메모리에 item을 저장하거나 Room DB에서 item을 읽을 수 있다. TodoScreen은 상태가 관리되는 방식과 완전히 분리된다.
State Hoisting은 구성 요소를 stateless 상태로 만들기 위해 상태를 위로 이동하는 패턴이다.
컴포저블에 적용할 때 컴포저블에 2개의 매개변수를 도입하는 것을 의미한다.
value: T: 표시할 현재 값onValueChange: (T) -> Unit: 값 변경을 요청하는 이벤트,T는 제안된 새로운 값
TodoViewModel.kt 파일을 열고 ViewModel을 보면 상태 변수 1개, 2개의 이벤트가 정의되어 있음을 알 수 있다.
class TodoViewModel : ViewModel() {
// state: todoItems
private var _todoItems = MutableLiveData(listOf<TodoItem>())
val todoItems: LiveData<List<TodoItem>> = _todoItems
// event: addItem
fun addItem(item: TodoItem) {
/* ... */
}
// event: removeItem
fun removeItem(item: TodoItem) {
/* ... */
}
}
이 ViewModel을 사용하여 TodoScreen에서 상태를 hoist할 것이다. 아래와 같은 단방향 데이터 흐름 디자인이 나오게 된다.

TodoScreen을 TodoActivity에 통합하려면 TodoActivity.kt를 열고 새 @Composable 함수 TodoActivityScreen(TodoViewModel: TodoViewModel)을 정의하고 onCreate의 setContent에서 호출하면 된다.
아래 코드는 ViewModel을 TodoScreen에 연결하지 않았기 때문에 버튼을 클릭해도 아무런 일도 일어나지 않는다.
class TodoActivity : AppCompatActivity() {
private val todoViewModel by viewModels<TodoViewModel>()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
StateCodelabTheme {
Surface {
TodoActivityScreen(todoViewModel)
}
}
}
}
}
@Composable
private fun TodoActivityScreen(todoViewModel: TodoViewModel) {
val items = listOf<TodoItem>() // in the next steps we'll complete this
TodoScreen(
items = items,
onAddItem = { }, // in the next steps we'll complete this
onRemoveItem = { } // in the next steps we'll complete this
)
}
TodoActivityScreen 컴포저블은 ViewModel에 저장된 상태와 프로젝트에 이미 정의된 TodoScreen 컴포저블 사이의 다리 역할을 해준다. ViewModel을 직접 사용하도록 TodoScreen을 변경할 수 있지만 TodoScreen은 재사용성이 떨어진다. List<TodoItem>과 같은 단순 매개변수를 선호하여 TodoScreen은 상태가 hoist되는 특정 위치에 연결되지 않는다.
이제 필요한 모든 구성요소(ViewModel, 브릿지 컴포저블 TodoActivityScreen)가 있으므로 단방향 데이터 흐름을 사용하여 동적 리스트를 표시하기 위해 모두 연결해보겠다.
TodoActivityScreen에서 ViewModel에서 addItem과 removeItem을 전달한다.
@Composable
private fun TodoActivityScreen(todoViewModel: TodoViewModel) {
val items = listOf<TodoItem>()
TodoScreen(
items = items,
onAddItem = { todoViewModel.addItem(it) },
onRemoveItem = { todoViewModel.removeItem(it) }
)
}
TodoScreen에 전달된 이벤트는 Kotlin 람다 구문을 사용한다.
TodoScreen이 onAddItem이나 onRemoveItem을 호출하면 ViewModel의 올바른 이벤트에 대한 호출을 전달할 수 있다.
메서드 reference 구문을 사용하여 단일 메서드를 호출하는 람다를 생성할 수 있다. 메서드 reference 구문을 사용하여 위의
onAddItem을onAddItem = todoViewModel::addItem으로 표현할 수 있다.
단방향 데이터 흐름의 이벤트를 연결했으니 이제 상태를 전달해야 한다.
observeAsState를 사용하여 todoItems LiveData를 관찰하도록 TodoActivityScreen을 편집하겠다.
@Composable
private fun TodoActivityScreen(todoViewModel: TodoViewModel) {
val items: List<TodoItem> by todoViewModel.todoItems.observeAsState(listOf())
TodoScreen(
items = items,
onAddItem = { todoViewModel.addItem(it) },
onRemoveItem = { todoViewModel.removeItem(it) }
)
}
LiveData를 관찰하고 현재 값을 List<TodoItem>으로 직접 사용하도록 만든다.
코드를 분석해보겠다.
val items: List<TodoItem>은 List<TodoItem>타입의 변수 items를 선언한다.todoViewModel.todoItems는 ViewModel의 LiveData<List<TodoItem>>이다..observeAsState는 LiveData<T>를 관찰하고 State<T> 객체로 변환하여 Compose가 값 변경에 반응할 수 있도록 한다.listOf()는 LiveData가 초기화되기 전에 가능한 null 결과를 피하기 위한 초기값이다. 전달되지 않은 경우 items는 nullable인 List<TodoItem>?이 된다.by는 Kotlin의 속성 대리자 구문이다. by를 사용해서 자동으로 State<List<TodoItem>>을 observeAsState에서 일반 List<TodoItem>으로 래핑 해제할 수 있다.
observeAsState는LiveData를 관찰하고LiveData가 변경될 때마다 업데이트되는State객체를 반환한다.
컴포저블이 컴포지션에서 제거되면 자동으로 관찰을 중지한다.
이제 앱을 실행하면 동적으로 업데이트된다. 하단의 버튼을 클릭하면 새 item이 추가되고 item을 클릭하면 제거된다.

ViewModel을 사용해 Compose에서 단방향 데이터 흐름 디자인을 구축하는 방법과 State Hoisting을 사용해 stateless 컴포저블 사용하여 stateful UI를 표시하는 방법도 알아보았다.