UI State를 활용하면 상태 패턴인가?: UI State, StateHolder 그리고 상태 패턴의 State와의 차이

Gio·2025년 9월 16일
3

서론

Jetpack Compose를 공부하며, State Holder라는 개념에 대해 다루게 되었다. 이 과정에서 State Holder는 UI State와 무엇이 다른지 혼동되었다.

State Holder

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가 Jetpack Compose에 대한 의존성을 가져도 될까?

하지만 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에 의존하지 않으며 프로그램 또한 정상적으로 동작한다.

StateHolder vs UiState

코드를 보니 이제 무언가 어색해지기 시작했다. 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-elsewhen 같은 분기문이 사라지고, 상태별 동작이 각 상태 객체 안으로 캡슐화된다.

UI State

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,
    ...
)

상태 패턴의 State와의 차이점

  • 상태 패턴의 State는 객체의 행동을 결정하는 규칙
    • "어떤 메서드를 호출했을 때 어떻게 반응할까?"를 정의한다.
      • 문이 Locked 상태라면 open() 메서드 호출 시 아무 일도 일어나지 않음
    • 능동적(상태 전환의 주체)
  • UI State는 UI를 그리기 위한 데이터
    • "화면에 무엇을 보여줄까?"를 정의한다.
      • 버튼이 비활성화되어야 하는지, 로딩 스피너가 보이는지, 리스트에 어떤 아이템이 있는지 등
    • 수동적(상태 표현만 담당)

즉 아래의 코드는 상태 패턴의 State도 아니고, UI State도 아니다.

class CounterStateHolder {
    var count by mutableStateOf(0)
        private set

    fun increase() {
        count++
    }

    fun decrease() {
        count--
    }
}
  • count 값을 변화시킬 뿐 객체의 행동 방식(increase, decrease의 세부 구현)이 달라지지 않음
  • UI에 표현될 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에서 말하는 State

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를 불변 객체로 만든 후 Statevalue를 다른 객체로 변경하여 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

State Holder란 UI State를 저장하고 상태 변환 로직을 제공하는 객체이다.

  • UI State와의 차이점: 단순히 화면에 표시될 데이터를 담는 것이 아니라, 상태를 변경하는 기능을 가진다.
  • 상태 패턴의 State와의 차이점: 객체의 행동 규칙을 정의하지 않고, 단순히 상태 변화만 제공한다.
class CounterStateHolder {
    var count: Int = 0
        private set

    fun increase() {
        count++
    }

    fun decrease() {
        count--
    }
}

여기서 CounterUiState 대신 CounterStateHolder라는 이름을 사용한 이유는, UI State와 달리 값의 변화를 직접 수행할 수 있기 때문이다.

기존 View 시스템에서는 ViewModel에서 UiStateLiveData에 담아 UI 갱신을 유도했다. Compose에서 어떻게 활용할 수 있을까?

CounterStateHolder를 State에 담는 방법?

val stateHolder by remember { mutableStateOf(CounterStateHolder()) }

하지만 increase, decrease를 수행해도 State 내부 값의 변화는 없기에 UI가 변하지 않는다. 이래서 CounterStateHolder를 불변 객체로 만들었던 것이다. 하지만 이렇게 하면 CounterStateHolder 객체 자체를 교체해야 하므로 State Holder라는 개념이 갖는 의미와 맞지 않는다.

CounterStateHolder 내부에서 State를 사용하는 방법?

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에 의존한다는 문제점이 존재한다.

Best Practice: State Holder는 변하지 않으면서 State Holder가 순수하도록

지금까지 공부한 내용을 모두 적용하며, 위의 두 방식의 단점을 제거한 코드는 다음과 같다.

  1. UI State 정의

    data class CounterUiState(
        val count: Int,
    )
    • UI State는 순수하게 UI에 표시되어야 할 데이터를 표현하는 역할만 한다.
  2. 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)
        }
    }
    • State Holder는 UI State를 가지며, 이를 변환시킬 수 있다.
    • Compose나 특정 UI 프레임워크에 의존하지 않는다.
  3. 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("+") }
            }
        }
    }
    • Compose State를 통해 UI 변경을 감지하도록 연결한다.
    • State Holder는 값을 변경하고, Compose는 변경된 값을 화면에 반영하는 역할만 수행.
    • 이렇게 하면 State Holder가 Compose에 종속되지 않으면서도 UI 업데이트가 자연스럽게 이루어진다.
      // 기존 View + LiveData 예시
      val stateHolder = CounterStateHolder()
      val uiStateLiveData = MutableLiveData(stateHolder.uiState)
      
      // 버튼 클릭 시
      stateHolder.increase()
      uiStateLiveData.value = stateHolder.uiState // UI에 반영

State Holder의 순수성을 포기하면?

지금까지는 State Holder를 Compose나 특정 UI 프레임워크에 종속되지 않도록 설계했다.

하지만 프로젝트 환경에 따라 State Holder 내부에서 Compose State를 직접 사용해도 크게 문제가 없는 경우도 존재할 것이다.

class CounterStateHolder {
    var count by mutableStateOf(0)
        private set

    fun increase() { count++ }
    fun decrease() { count-- }
}
  • State Holder가 값 변경과 UI 업데이트를 동시에 처리
  • Compose가 변화를 감지해 UI를 자동으로 갱신

즉, 순수성을 포기하면 코드가 더 직관적이고 간결해진다.

다만, 다른 UI 프레임워크로 재사용하려면 다시 조정해야 하는 단점이 있다.

결론

  • 상태 패턴(State Pattern): 객체의 행동을 상태 객체에 위임하여 분기 처리 없이 상태 전환 및 행동을 수행하는 디자인 패턴
    • 상태 객체(State): 객체의 상태와 행동 방식을 정의
  • UI State: 화면에 표시될 데이터와 속성만을 담은 객체
  • Compose State: Compose에서 UI State를 감싸, 값이 변경되면 UI를 재구성하는 객체
  • State Holder: UI State를 보유하고, 상태 변경 로직을 제공하는 객체
  • ViewModel: State Holder 역할을 수행하며, View에 대한 의존 없이 데이터 바인딩을 통해 상태를 View에 전달하는 객체

REF

UI layer  |  App architecture  |  Android Developers

State holders and UI state  |  App architecture  |  Android Developers

State and Jetpack Compose  |  Android Developers

profile
틀린 부분을 지적받기 위해 업로드합니다.

2개의 댓글

comment-user-thumbnail
2025년 9월 16일

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를 만들어 관리한다면 해당 문제가 생기지 않을 것 같습니다.

1개의 답글