Jetpack Compose - State, Android Codelab 정리

MUNGI JO·2024년 9월 9일

Android Jetpack Compose

목록 보기
7/7

Jetpack Compose의 상태

Compose의 이벤트

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

이벤트(Event): 사용자 또는 프로그램의 다른 부분에 의해 생성
상태 업데이트(Update State): 이벤트 핸들러가 UI에서 사용하는 상태를 변경
상태 표시(Display State): 새로운 상태를 표시하도록 UI를 업데이트

컴포지션

컴포지션이란 컴포저블 실행 시 빌드한 UI에 관한 설명이라고 나와 있다. 즉, 컴포지션은 컴포저블 함수가 실행될 때, Jetpack Compose가 UI 트리를 빌드하는 것을 말하며 리컴포지션은 리컴포지션은 상태가 변경될 때 컴포저블 함수를 다시 실행하여 UI를 업데이트하는 과정을 말한다.

상태가 변경되면 리컴포지션을 예약하고 스케줄링 시스템으로 적절한 시점에 발생하게 된다. 따라서 예약이라는 단어를 사용한다. 예약된 리컴포지션은 한 프레임 내에서 여러 상태 변경을 묶어서 처리해 불필요한 중복 리컴포지션을 방지한다.

주의할 것은 mutablestate를 가진 컴포저블 함수자체는 재실행 되지만 그 함수 내에서 변경되는 mutablestate값을 가지는 데이터가 변경된 자식 컴포저블 함수만이 리렌더링된다. 따라서 MutableState를 사용해도 리컴포지션으로 함수가 실행될 때 초기화 과정이 남아있다면 값은 변경 되었다가 초기화 되기 때문에 remember를 같이 사용하는 것이 일반적인 패턴이다.

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")
        }
    }
}

Compose에서 ViewModel 사용

ViewModel은 상태를 외부에서 저장하기 위한 방법으로 기존에도 많이 사용되는 방법이다. 상태를 한번에 모아서 관리할 수 있으므로 모든 컴포저블을 stateless형태로 구현할 수 있게된다.

특징 -

1. 상태 중앙 관리: ViewModel을 사용하면 상태를 한 곳에서 관리하고, 여러 컴포저블에 쉽게 공유할 수 있게되며 이렇게 하면 각 컴포저블에서 상태를 관리할 필요 없이, ViewModel에서 상태를 주입받아서 컴포저블을 Stateless로 유지할 수 있다.

2. 생명주기 안전성: ViewModel은 안드로이드 생명주기와 연결되어 있어서 화면 회전 등과 같은 구성 변경 시에도 상태가 유지된다. 물론 Compose에서 구성 변경 시 상태가 유지되는 건 rememberSaveable을 사용해도 되지만 상태를 중앙 집중화 하는 것이 목적이기 때문에 관점 차이라고 볼 수 있다.

3. 테스트 용이성: ViewModel을 사용하면 상태가 ViewModel에서 관리되기 때문에, 컴포저블 자체는 단순한 UI 로직만 포함하고, 상태 변화는 ViewModel에서 발생하기 때문에 테스트가 더 간단해진다. ViewModel을 모의 객체(mock)로 대체하거나 특정 상태를 주입하여 테스트할 수 있게된다.

WellnessViewModel.kt

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)

MainActivity.kt

@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는 복사본이 원본과 동일한 상태 참조를 가지기 때문에, 한쪽에서 상태가 변경되면 복사본에도 예상치 못한 변화가 발생할 수 있다. 이는 상태 변화를 예측하기 어려워 코드가 복잡해지고, 디버깅과 유지보수 과정이 더 어려워진다. 특히, 복사본과 원본 간의 상태 동기화 문제가 발생할 수 있어, 코드의 가독성이 떨어지고 의존성 관리가 복잡해지며, 유지보수 중에 예상하지 못한 동작이 발생할 가능성이 높아진다.

  • 동시성 문제: 여러 스레드에서 가변 상태를 가진 객체를 동시에 접근할 경우, 동시성 문제(데이터 손실이나 비정상 상태)가 발생할 수 있다. 불변 객체는 동시성 문제를 예방하는 데 유리하다.

참고 자료

Android Developer

profile
안녕하세요. 개발에 이제 막 뛰어든 신입 개발자 입니다.

0개의 댓글