Jetpack Compose를 공부하며, State Holder라는 개념에 대해 다루게 되었다. 이 과정에서 State Holder는 UI State와 무엇이 다른지 혼동되었다.
State Holder란 UI에 표시될 상태와 로직의 묶음이다. 이를 별도로 분리함으로써, UI 렌더링과 상태 관리 로직을 분리할 수 있다.
class CounterStateHolder {
var count by mutableStateOf(0)
private set
fun increase() {
count++
}
fun decrease() {
count--
}
}
여기서 말하는 UI 상태가 바로 UI State다. UI State는 화면에 표시될 데이터와, UI를 설명하는 속성들의 집합으로 구성된다.
하지만 State Holder가 Compose에 의존해도 되는지 의문이 들었다. UI State는 화면에 대한 추상화된 정보일 뿐, 특정한 UI 프레임워크에 종속될 필요는 없었다.
따라서 State Holder는 순수하게 상태와 그에 대한 로직만을 갖기로 했다.
class CounterStateHolder {
var count: Int = 0
private set
fun increase() {
count++
}
fun decrease() {
count--
}
}
그리고 이를 컴포즈를 사용하는 곳에서 State
의 형태로 만들어주는 것이다.
@Composable
fun Counter(modifier: Modifier = Modifier) {
val stateHolder by remember { mutableStateOf(CounterStateHolder()) }
Row(
modifier = modifier,
verticalAlignment = Alignment.CenterVertically,
) {
Button(onClick = stateHolder::decrease) {
Text("-")
}
Text(text = stateHolder.count.toString())
Button(onClick = stateHolder::increase) {
Text("+")
}
}
}
@Preview
@Composable
private fun CounterPreview() {
Counter()
}
하지만 이렇게 사용하니, mutableStateOf
에 담긴 객체가 변하지 않아 리컴포지션이 발생하지 않았다. 즉, count
값이 바뀌어도 Compose는 이를 감지할 수 없었다.
따라서 State Holder를 완전한 불변 객체로 만들고, copy
메서드를 사용해 새로운 객체로 상태를 갱신하도록 했다.
data class CounterStateHolder(
val count: Int = 0,
) {
fun increase(): CounterStateHolder = copy(count = count + 1)
fun decrease(): CounterStateHolder = copy(count = count - 1)
}
@Composable
fun Counter(modifier: Modifier = Modifier) {
var stateHolder by remember { mutableStateOf(CounterStateHolder()) }
Row(
modifier = modifier,
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
Button(onClick = { stateHolder = stateHolder.decrease() }) {
Text("-")
}
Text(text = stateHolder.count.toString())
Button(onClick = { stateHolder = stateHolder.increase() }) {
Text("+")
}
}
}
이제 State Holder는 Compose에 의존하지 않으며 프로그램 또한 정상적으로 동작한다.
코드를 보니 이제 무언가 어색해지기 시작했다. State Holder는 이름 그대로 State를 담고 있어야 하지 않나? 하지만 내 코드는 반대로 mutableStateOf
함수에 State Holder 인스턴스를 전달하고 있었다.
CounterStateHolder
코드를 보면 상태와 그에 대한 변경 기능을 갖고 있다. 단순히 이 객체의 이름을 CounterUiState
로 바꾸면 더욱 자연스러울 것 같다.
data class CounterUiState(
val count: Int = 0,
) {
fun increase(): CounterUiState = copy(count = count + 1)
fun decrease(): CounterUiState = copy(count = count - 1)
}
@Composable
fun Counter(modifier: Modifier = Modifier) {
var state by remember { mutableStateOf(CounterUiState()) }
Row(
modifier = modifier,
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
Button(onClick = { state = state.decrease() }) {
Text("-")
}
Text(text = state.count.toString())
Button(onClick = { state = state.increase() }) {
Text("+")
}
}
}
이쯤 되니 혼란스러움이 극에 달했다. State Holder가 결국 UI State인가?
State Holder는 상태와 해당 상태를 변경시키는 메서드를 갖고 있다. 이제 보니 상태 패턴의 상태와도 같아 보인다. 그렇다면 왜 State Holder라는 이름을 쓴걸까? State Holder와 UI State는 무엇이 다른걸까?
각 용어를 정확히 정리해야 용어 간 혼동이 생기지 않을 것 같다. 상태 패턴과 State, UI State, 컴포즈의 State, State Holder, 마지막으로 State, State Holder, ViewModel의 관계까지 알아보자.
상태 패턴(State Pattern)은 객체 지향 방식으로 상태 기계를 구현하는 디자인 패턴이다.
객체가 내부 상태에 따라 행동을 다르게 하도록 설계하고, 조건문을 사용해 분기하는 대신 상태 자체를 객체로 캡슐화하여 행동을 위임한다.
즉, 상태 패턴에서 말하는 상태란 객체가 가지고 있는 현재 조건과 상황이다. 상태는 수행 가능한 행동을 정의하고, 그 행동에 따라 다음 상태가 무엇인지를 스스로 결정한다.
문의 상태는 열려있는 상태
, 닫혀있는 상태
, 잠겨있는 상태
총 3가지로 구성했다.
interface DoorState {
fun open(): DoorState
fun close(): DoorState
fun lock(): DoorState
}
각 상태 객체는 자신이 어떤 행동을 허용할지, 다음 상태를 무엇으로 바꿀지를 스스로 알고 있다. 상태가 바뀌지 않는 경우에는 this
를 그대로 반환한다.
class Open : DoorState {
override fun open() = this
override fun close() = Closed()
override fun lock() = this
}
class Closed : DoorState {
override fun open() = Open()
override fun close() = this
override fun lock() = Locked()
}
class Locked : DoorState {
override fun open() = this
override fun close() = this
override fun lock() = this
}
예를 들어 Closed
상태에서 lock()
을 호출하면 Locked
상태로 전환된다. 반면, 이미 Locked
상태라면 lock()
을 다시 호출해도 아무 일도 일어나지 않으며 그대로 Locked
상태를 유지한다.
Door
클래스는 단순히 현재 상태를 들고 있고, 요청이 들어올 때 현재 상태 객체에 동작을 위임한 뒤 반환된 상태로 교체한다.
class Door {
var state: DoorState = Closed()
private set
fun open() {
state = state.open()
}
fun close() {
state = state.close()
}
fun lock() {
state = state.lock()
}
}
즉, 상태 전환 로직은 Door
안에 있지 않고 상태 객체 안에 있다. Door
는 그저 “지금 상태가 뭘로 바뀌었는지” 받아서 반영할 뿐이다.
이를 통해 if-else
나 when
같은 분기문이 사라지고, 상태별 동작이 각 상태 객체 안으로 캡슐화된다.
UI State는 보여져야 하는 것들을 의미한다.
따라서 UI는 UI State의 시각적 표현이고, UI State가 변하면 이는 UI에 반영되어야 한다.
data class NewsUiState(
val isSignedIn: Boolean = false,
val isPremium: Boolean = false,
val newsItems: List<NewsItemUiState> = listOf(),
val userMessages: List<Message> = listOf()
)
data class NewsItemUiState(
val title: String,
val body: String,
val bookmarked: Boolean = false,
...
)
Locked
상태라면 open()
메서드 호출 시 아무 일도 일어나지 않음즉 아래의 코드는 상태 패턴의 State도 아니고, UI State도 아니다.
class CounterStateHolder {
var count by mutableStateOf(0)
private set
fun increase() {
count++
}
fun decrease() {
count--
}
}
count
값을 변화시킬 뿐 객체의 행동 방식(increase
, decrease
의 세부 구현)이 달라지지 않음count
뿐 아니라 해당 값을 변경하는 역할까지 포함하고 있음같은 이유로 아래의 코드 또한 상태 패턴의 State도 아니고, UI State도 아니다. 값과 그 값을 변형하는 함수를 함께 담아둔 단순한 불변 객체일 뿐이다.
data class CounterStateHolder(
val count: Int = 0,
) {
fun increase(): CounterStateHolder = copy(count = count + 1)
fun decrease(): CounterStateHolder = copy(count = count - 1)
}
Compose에서 UI를 업데이트하려면 새로운 UI State를 인자로 동일한 컴포저블을 호출해야 한다. 즉 이 상태가 업데이트될 때마다 재구성(recomposition)을 발생시켜야한다.
이를 위해 Compose에서는 State
객체를 활용한다. Compose에서는 State
가 갖고 있는 값(value
)이 변경되면, 해당 State
를 사용하는 UI만 다시 그리는 재구성을 진행한다.
@Stable
public interface State<out T> {
public val value: T
}
따라서 UI State를 State
객체로 감싸면, UI State가 변할 때 UI에 반영되도록 할 수 있다.
이 때문에 CounterStateHolder
를 불변 객체로 만든 후 State
의 value
를 다른 객체로 변경하여 UI에 반영되도록 한 것이다.
data class CounterUiState(
val count: Int = 0,
) {
fun increase(): CounterUiState = copy(count = count + 1)
fun decrease(): CounterUiState = copy(count = count - 1)
}
@Composable
fun Counter(modifier: Modifier = Modifier) {
var state by remember { mutableStateOf(CounterUiState()) }
Row(
modifier = modifier,
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
Button(onClick = { state = state.decrease() }) {
Text("-")
}
Text(text = state.count.toString())
Button(onClick = { state = state.increase() }) {
Text("+")
}
}
}
위 코드에서 CounterUiState
는 이름상 UI State를 의미하지만, 실제로는 값을 변경하는 메서드(increase
, decrease
)까지 포함하고 있다. 이런 구조는 개발자 간의 혼동을 야기할 수 있다.
State Holder란 UI State를 저장하고 상태 변환 로직을 제공하는 객체이다.
class CounterStateHolder {
var count: Int = 0
private set
fun increase() {
count++
}
fun decrease() {
count--
}
}
여기서 CounterUiState
대신 CounterStateHolder
라는 이름을 사용한 이유는, UI State와 달리 값의 변화를 직접 수행할 수 있기 때문이다.
기존 View
시스템에서는 ViewModel
에서 UiState
를 LiveData
에 담아 UI 갱신을 유도했다. Compose에서 어떻게 활용할 수 있을까?
val stateHolder by remember { mutableStateOf(CounterStateHolder()) }
하지만 increase
, decrease
를 수행해도 State
내부 값의 변화는 없기에 UI가 변하지 않는다. 이래서 CounterStateHolder
를 불변 객체로 만들었던 것이다. 하지만 이렇게 하면 CounterStateHolder
객체 자체를 교체해야 하므로 State Holder라는 개념이 갖는 의미와 맞지 않는다.
class CounterStateHolder {
var count by mutableStateOf(0)
private set
fun increase() {
count++
}
fun decrease() {
count--
}
}
이 방법은 State Holder가 내부적으로 UI State를 보유하고, Compose의 State 객체를 통해 UI 변경을 감지하도록 한 방식이다.
다만 State Holder가 Compose에 의존한다는 문제점이 존재한다.
지금까지 공부한 내용을 모두 적용하며, 위의 두 방식의 단점을 제거한 코드는 다음과 같다.
UI State 정의
data class CounterUiState(
val count: Int,
)
State Holder 정의
class CounterStateHolder(
initialCount: Int = 0,
) {
var uiState = CounterUiState(initialCount)
fun increase() {
uiState = uiState.copy(count = uiState.count + 1)
}
fun decrease() {
uiState = uiState.copy(count = uiState.count - 1)
}
}
Compose에서 State 객체로 감싸 UI와 연결
@Composable
fun Counter(modifier: Modifier = Modifier) {
val stateHolder = remember { CounterStateHolder() }
var uiState by remember { mutableStateOf(stateHolder.uiState) }
Column(
modifier = modifier,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(text = uiState.count.toString())
Row {
Button(onClick = {
stateHolder.decrease()
uiState = stateHolder.uiState // UI에 반영
}) { Text("-") }
Button(onClick = {
stateHolder.increase()
uiState = stateHolder.uiState // UI에 반영
}) { Text("+") }
}
}
}
State
를 통해 UI 변경을 감지하도록 연결한다.State Holder
가 Compose에 종속되지 않으면서도 UI 업데이트가 자연스럽게 이루어진다.// 기존 View + LiveData 예시
val stateHolder = CounterStateHolder()
val uiStateLiveData = MutableLiveData(stateHolder.uiState)
// 버튼 클릭 시
stateHolder.increase()
uiStateLiveData.value = stateHolder.uiState // UI에 반영
지금까지는 State Holder를 Compose나 특정 UI 프레임워크에 종속되지 않도록 설계했다.
하지만 프로젝트 환경에 따라 State Holder 내부에서 Compose State
를 직접 사용해도 크게 문제가 없는 경우도 존재할 것이다.
class CounterStateHolder {
var count by mutableStateOf(0)
private set
fun increase() { count++ }
fun decrease() { count-- }
}
즉, 순수성을 포기하면 코드가 더 직관적이고 간결해진다.
다만, 다른 UI 프레임워크로 재사용하려면 다시 조정해야 하는 단점이 있다.
UI layer | App architecture | Android Developers
State holders and UI state | App architecture | Android Developers
Compose Runtime 자체가 충분히 StateHolder 의 역할을 할 수 있다고 생각합니다.
Compose를 UI만을 위한 프레임워크라고 생각하지 않고, Compose Runtime을 상태 관리 라이브러리로도 볼 수 있다고 생각하기 때문에(실제로 개발 초기에 둘을 별도의 네이밍으로 분리하려고 했다네요)
molecule 부터 시작해서 Resaca, Circuit, Rin, Soil 등등의 라이브러리들이 위의 철학을 따르고 있구요.
2024, 2025 DroidKaigi 레포지토리가 Compose Runtime을 정말 극한으로 활용한 예시가 아닌가 싶습니다.
물론 AAC ViewModel 기반의 ViewModel을 사용 할 경우엔 View 관련 의존성이 포함되는걸 테스트를 위해서라도 지양해야할 순 있겠지만, AAC ViewModel 기반의 ViewModel 을 사용하지않고, Composable 기반의 Presenter 등을 통해 StateHolder를 만들어 관리한다면 해당 문제가 생기지 않을 것 같습니다.