본문 바로가기

[Android] Compose StateHoisting와 StateHolder

@hyeon.s2025. 7. 7. 12:33

Compose 의 선언형 UI와 상태기반 렌더링

Compose는 선언형 UI 프레임워크이다. 이 말은 곧 State(상태)를 기준으로 UI를 자동으로 다시 그려주는 방식임을 의미한다.

@Composable
fun Greeting(name : String){

    Text(text = "Hello, $name")
}

→ 해당 코드에서 name 이라는 상태 값이 변경되면, name을 사용하는 Text는 자동으로 recomposition된다.

이 때, 상태를 어디서 보관하고, 어떻게 UI에게 전달할 것인가? 라는 것이 StateHoisting, StateHolder의 핵심이다.

State Hoisting이란?

상태를 UI 구성요소 내부에서 외부로 끌어올리는 패턴이다.

즉 컴포저블 내부에서 상태를 가지지 않고, 상태를 파라미터로 받아서, 상태를 바꾸는 calback도 외부에서 주입을 받는다.

State Hoisting 예시

먼저 잘못된 예시부터 보겠다.

잘못된 예시

@Composable
    fun Counter(){
    var count by remember { mutableStateOf(0) }

    Button (onClick = {count++}){
        Text ("Count : $count")
        }        
}

현재 위 예시는 상태값이 내부에 존재하는 State Hoisting하지 않은 예시이다.

올바른 예시

@Composable 
fun Counter(
    count : Int,
    onCountChange : (Int)-> Unit 
) {
    Button (onClick = { conCountChange(count+1)}) {
                    Text ("Count : $count")
                    }
        }

→ 위 방법은, 상태가 외부에서 주입되고, 컴포저블이 UI에만 집중할 수 있도록 구성된다.

따라서 State Hoisting하게 작성된 예시라고 할 수 있다.

그렇다면, StateHoisting은 왜 필요할까?

왜 State Hoisting이 왜 필요할까?

State Hoisting의 장점

1. 재사용성에 좋다.

  • UI 상태를 외부로 분리하면 같은 UI 컴포넌트를 다른 상태와 함께 재사용 가능하다.
  • 만약에 SearchBar Component를 만들 경우, text와 onTextChange를 외부에서 주입 받을 때, 해당 컴포넌트는 검색 화면, 필터 검색, 채팅 검색 등.. 다양한 화면에서 UI로 활용 가능해 진다.
  • ⇒ 즉 재사용성이 좋다.

2. 테스트 용이

  • UI를 상태 없이 단순히 렌더링 테스트에 가능하게 만든다.
  • ComposeTestRule.setContent를 활용해서 Compose UI Test를 할 때, 상태를 외부에서 주입 가능하게 만들기 때문에, 테스트를 명확하게 할 수 있다.

3. 단방향 데이터 흐름

  • 상태는 위(부모)에서 아래(자식)으로만 흐르고, 변경은 콜백(이벤트)를 통해 위로 전달된다.

시각화된 구조

[ViewModel or StateHolder] -> [Compose UI] -> User Interaction -> [EventC Callback] -> [State Update] -> [Recomposition]

단방향 흐름이 중요한 이유

  • 상태의 출처가 명확해져 디버깅이 쉬워진다.
  • 양방향 데이터 바인딩보다 충돌이나 무한 루프의 가능성이 낮다 → 버그 감소
  • UI와 상태 책임의 분리로 테스트가 간결해진다 → 테스트 용이
  • 어떤 이벤트가 어떤 상태를 바꿨는지 추적이 가능하다 → 유지보수 쉬움

4. 상태 집중 관리

  • 여러 개의 상태가 여러 컴포저블에 흩어져 있으면, 유지보수가 어렵다 → 중앙 집중화가 필요하다!
  • ViewModel 등 외부의 한 곳에서 상태 관리가 가능해진다.
  • 만약 ViewModel에서 상태를 모두 관리할 경우, ViewModel을 통해 상태에 접근하여 일관성있게 공유할 수 있다.

5. Compose의 철학과 부합하다.

  • 선언형 UI의 원칙
    • UI = f(State)
    • UI는 현재 상태에 기반하여 그려지는 함수라고 한다.
    • 즉 상태가 바뀌면, Compose는 recomposition을 자동으로 트리거해, UI를 새롭게 그린다.

명령형과 비교

fun imperativeMethod() {
        textView.text = "Hello"
    }
  • 명령형은 우리가 직접 UI 업데이트 코드를 작성해야 한다.

선언형

fun Greeting(name :String){
    Text("hello, $name")
}
  • 선언형 UI에서는 상태가 바뀌면 자동 갱신되며, 명령없이 우리는 선언하면 된다.
  • ⇒ 이 철학을 따르기 위해서는, UI외부에서 상태가 관리되어야 하며
  • UI는 상태에만 반응하고, 자체적으로 상태를 들고 있어서는 안된다 !!
  • ⇒ 이것이 즉 State Hoisting 이 필요한 이유.

State Holder 패턴이란?

앞서 살펴본 State Hoisting은 상태를 끌어올린다.라는 개념이라면,

StateHolder는 끌어올린 상태를 담는 별도의 클래스이다.

보통은 ViewModel 또는 @Composable 이 아닌 class에 mutableStateOf, derivedStateOf 등을담아서 관리한다.

State Holder 예시

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

    fun increment(){
        count++
    }
}

이렇게 State를 선언한 클래스를 이후 컴포저블에서 아래와같이 사용한다.

@Composable
fun rememberCounterState(): CounterState {
    return remember { CounterState() }
}

@Composable
fun CounterScreen() {
    val state = rememberCounterState()

    Counter(
        count = state.count,
        onCountChange = {state.increment() }
    )
}

이렇게 되면, UI 상태와 로직이 분리되고, State는 StateHolder 클래스에 의해 관리된다.

StateHoisting + StateHolder

그렇다면 hoisitng 전략과 holder클래스를 결합한 예제는 이렇게 된다.

// State Holder Class 
class LoginState {
    var userName by mutableStateOf("")
    var password by mutableStateOf("")

    fun onUserNameChange(new : String){
        userName = new
    }

    fun onPasswordChange(new : String) {
        password = new
    }
}

// State Holder Factory 
@Composable
fun rememberLoginState() : LoginState {
    return remember { LoginState() }
}

// State Hoisting, => Stateless UI
@Composable
fun LoginForm(
    userName : String,
    onUserNameChange : (String) -> Unit,
    password : String,
    onPasswordChange : (String) -> Unit,
    onLoginClick() : () -> Unit,
) {
    Column {
    TextFeild(value = name, onValueChange = onUserNameChange, label = {Text("userName")})
    }
}

@Composable 
fun LoginScreen(){
    val state = rememberLoginState()

    LoginForm(
        userName = state.userName,
        onUserNameChange = state::onUserNameChange,
        password = state.passwrod,
        onPasswordChange = state::onPasswordChange
        onLoginClick = {
            Log.d("Login", "Login Click")
        }
  )
 }

기존 Android 기술과의 비교

MVVM

  • 상태 흐름 : ViewModel → View
  • UI 연결 방식 : dataBinding / Observer
  • 상태 변경 방식 : LiveData.postValue ..

Compose

  • 상태흐름 : 단방향 (State → UI)
  • UI 연결 방식 : 함수 파라미터로 전달
  • 상태 변경 방식 : state update → recomposition

⇒ Compose는 가장 예측 가능하고, 함수형 패턴에 가까운 구조를 따른다.

State Holder 와 MVI …

각각을 이해하고 보니 그렇다면, MVI 패턴 역시 State Holder 개념을 적용한 아키텍처 방식인가?라는 생각이 들었다.

정답은 맞다고 한다.

MVI 패턴을 살펴보면 = State Hoisting + State Holder + Intent/Reducer 가 합쳐진 개념이다.

쉽게 비유로 이해

  • State Hoisting : 부품을 재사용 가능하게 만드는 방법
  • State Holder : 부품을 조립할 때 상태를 담아두는 케이스
  • MVI : 전체 기계를 만드는 설계도 (부품 + 조립 방식 + 흐름 설계까지 모두 포함)

정리

State Holder, State Hoisting 은 UI 상태 관리를 위한 원칙 또는 구현 방식이고,

MVI는 이를 포함하여 앱 구조 전반을 설계한 아키텍처 패턴이라고 할 수 있다.

hyeon.s
@hyeon.s :: 개발로그
목차