주맨의 개발노트

[Compose] 상태 호이스팅, 왜 해야 할까? 본문

안드로이드/Jetpack Compose

[Compose] 상태 호이스팅, 왜 해야 할까?

JooMan 2026. 7. 24. 12:58

Compose를 배우면 상태 호이스팅(state hoisting)을 "왜 해야 하느냐"는 질문에 대해 대개 같은 답을 듣습니다. 재사용성이 좋아지고, 테스트가 쉬워지고, 관심사가 분리되고, 단방향 데이터 흐름(UDF)을 따르고, 단일 진실 공급원(SSOT)을 보장한다는 것입니다.

전부 맞는 말이고, 상태 호이스팅을 하는 이유도 결국 여기에 있습니다. 요구사항이 바뀌어도 고쳐야 할 코드가 적고, 한 번 만든 컴포넌트를 여러 화면에서 다시 쓰고, UI를 손보는 일과 상태 로직을 손보는 일이 서로 발목 잡지 않는 — 그런 코드를 만들기 위해서입니다.

다만 이 답은 "무엇이 좋아지는가"까지만 말해 줍니다. 그 효과가 어떤 원리로 생기는지, 그리고 이 원칙을 어디까지 적용해야 하는지는 한 걸음 더 들어가야 보입니다. 그 지점을 몇 가지 질문으로 짚어 보겠습니다.

  • 상태를 "위로" 올린다는데, 정확히 무엇을 향해 어디까지 올리는 걸까?
  • stateless하면 UDF가 성립한다는데, 둘 사이엔 무슨 관계가 있을까?
  • stateless하게 만들면 구체적으로 무엇을 얻을까? 재사용성과 관심사 분리는 어디서 나오는 걸까?
  • 그렇다면 모든 상태를 다 올려야 할까? 아니라면 어디서 멈춰야 할까?

이 질문들의 답을 따라가다 보면, 상태 호이스팅은 결국 "이 상태의 소유자는 누구인가"라는 하나의 질문으로 모입니다.

상태를 "위로" 올린다는 건 어디로 올리는 걸까?

교과서적 설명은 이렇게 시작합니다. "상태는 부모 컴포저블에서 관리한다. 호이스팅을 적용하는 컴포저블은 상태를 직접 가져서는 안 된다."

그런데 MVVM이나 MVI를 쓰면 상태는 컴포저블이 아니라 ViewModel에서 관리합니다. 그럼 여기서 말하는 "부모 컴포저블"은 무엇을 가리키는 걸까요?

핵심은 호이스팅이 목적지가 아니라 방향을 가리킨다는 데 있습니다. "위로 올린다"는 건 "이 컴포저블에 두지 않고, 이 상태의 정당한 소유자에게 보낸다"는 뜻입니다. 그 소유자는 부모 컴포저블일 수도, ViewModel일 수도 있습니다. 교과서가 "부모 컴포저블"이라고 하는 건 개념을 한 계층으로 단순하게 그린 것일 뿐, 소유자가 반드시 부모 컴포저블이어야 한다는 뜻은 아닙니다.

그래서 던져야 할 질문은 "상태를 부모로 올려야 하나?"가 아니라 "이 상태의 소유자는 누구인가?"입니다. 이 질문으로 바꾸면, 뒤에서 다룰 "어디까지 올리고 어디서 멈추나"도 같은 기준으로 답할 수 있습니다.

stateless하면 왜 UDF가 될까?

"stateless 컴포저블이라서 단방향 데이터 흐름이 성립한다"는 말은 자주 나오지만, 둘 사이의 인과관계는 잘 드러나지 않습니다. 코드 두 개를 나란히 놓으면 관계가 분명해집니다.

먼저 상태를 스스로 가진 컴포저블입니다.

SearchField.kt · stateful Kotlin
@Composable
fun SearchField() {
    var query by remember { mutableStateOf("") }
    TextField(value = query, onValueChange = { query = it })
}

이 컴포저블은 자기 상태를 자기가 읽고 자기가 씁니다. 데이터 흐름이 함수 안에서 닫혀 있어서, 바깥에서는 안에서 무슨 일이 일어나는지 알 수도, 개입할 수도 없습니다. 흐름의 "방향"을 이야기할 여지 자체가 없습니다.

이제 상태를 걷어낸 버전입니다.

SearchField.kt · stateless Kotlin
@Composable
fun SearchField(
    query: String,
    onQueryChange: (String) -> Unit,
) {
    TextField(value = query, onValueChange = onQueryChange)
}

이 컴포저블은 상태를 바꿀 능력 자체가 없습니다. query는 읽기 전용 파라미터라, 사용자가 입력해도 할 수 있는 일은 onQueryChange로 "이런 일이 있었다"고 위로 알리는 것뿐입니다. 상태를 실제로 바꾸는 건 소유자의 몫이고, 바뀐 값은 다시 query 파라미터로 내려옵니다.

이 관계를 소유자컴포저블 두 주체로 놓고 보면, 둘 사이에 오가는 것은 딱 두 방향뿐입니다. 상태는 위에서 아래로 내려가고, 이벤트는 아래에서 위로 올라갑니다.

소유자 · State Holder ViewModel · 상위 컴포저블

query를 쥐고, 그 값을 바꾸는 유일한 주체입니다.

상태 내려주기 query: String
이벤트 올려보내기 onQueryChange(it)
stateless 컴포저블 SearchField

받은 query를 화면에 그리고, 입력이 생기면 콜백으로 알릴 뿐입니다. 상태를 직접 바꾸지 못합니다.

두 화살표가 서로 반대 방향으로 한 바퀴를 돕니다. 이 관계에서 한 번의 입력이 어떻게 도는지, 순서대로 따라가 보겠습니다.

Step 1 · 사용자 입력 사용자가 SearchField에 무언가를 입력합니다.
Step 2 · 이벤트는 위로 onQueryChange로 "값이 바뀌었다"를 소유자에게 알립니다.
Step 3 · 상태 변경 소유자가 자신이 쥔 query를 새 값으로 바꿉니다.
Step 4 · 상태는 아래로 바뀐 query가 파라미터로 다시 내려와 화면이 그려집니다.

상태를 걷어내면 흐름은 "이벤트는 위로, 상태는 아래로"라는 한 가지 형태밖에 남지 않습니다. UDF는 이 컴포저블이 착하게 지키기로 한 약속이 아니라, 상태를 가지고 있지 않아서 자연히 그 길밖에 남지 않은 결과입니다. 이것이 stateless와 UDF의 관계입니다.

stateless로 만들면 실제로 무엇을 얻을까?

앞서 만든 stateless SearchField는 상태를 갖지 않는 대신 queryonQueryChange를 파라미터로 받습니다. 상태를 품은 쪽보다 손이 더 가는 것처럼 보이는데, 바로 그 대가로 두 가지를 얻습니다.

재사용성

표시할 값과 콜백을 바깥에서 주입받으니, 컴포저블은 그대로 두고 호출부만 바꿔 끼우면 다른 맥락에서도 그대로 씁니다.

관심사 분리

컴포저블에는 "어떻게 보이는가"만 남고, "값이 무엇이어야 하는가"는 소유자 쪽으로 빠집니다. UI 수정과 상태 로직 수정이 서로 다른 자리에서 벌어집니다.

하나는 재사용성입니다. 상태를 품은 SearchField는 자기가 쥔 query 하나에 묶여 있습니다. 화면 A에서는 입력값을 그대로 쓰고, 화면 B에서는 대문자로 바꿔 보여주고 싶다면, 상태를 내부에 가둔 컴포저블로는 방법이 없어 결국 비슷한 컴포저블을 하나 더 만들게 됩니다. 반면 stateless 버전은 컴포저블을 그대로 두고 호출부만 바꿔 끼우면 됩니다.

호출부 — 같은 컴포저블, 다른 요구사항 Kotlin
// 화면 A — 입력값을 그대로 사용
var query by remember { mutableStateOf("") }
SearchField(query = query, onQueryChange = { query = it })

// 화면 B — 같은 컴포저블에 대문자 변환만 얹기
var query by remember { mutableStateOf("") }
SearchField(query = query.uppercase(), onQueryChange = { query = it })

같은 SearchField가 요구사항에 따라 다르게 동작합니다. 컴포저블이 특정 상태나 특정 로직에 묶이지 않았기 때문입니다.

다른 하나는 관심사 분리입니다. stateless 컴포저블에는 "어떻게 보이는가"만 남습니다. "값이 무엇이어야 하는가" — 대문자로 바꿀지, 숫자만 걸러낼지, 서버로 보낼지 — 는 전부 소유자 쪽으로 빠집니다. 그래서 UI를 손보는 일과 상태 로직을 손보는 일이 서로 다른 자리에서 벌어집니다. 검색창 디자인을 바꾸면서 검색 로직을 건드릴 일이 없고, 그 반대도 마찬가지입니다.

두 이점은 모두 상태 호이스팅 덕분에 생깁니다. 컴포저블이 상태 소유를 내려놓았기 때문에, 그 자리에 무엇을 꽂을지가 온전히 호출부의 몫이 됩니다. 소유를 내려놓는 것이 곧 재사용성과 관심사 분리를 얻는 길입니다.

UDF와 SSOT는 같은 말일까?

장점 목록에는 UDF(단방향 데이터 흐름)와 SSOT(단일 진실 공급원)가 나란히 등장합니다. 이름만 다른 같은 말처럼 보이지만, 사실 서로 다른 축의 이야기입니다.

UDF · 흐름의 방향

상태는 한 방향으로 내려오고 이벤트는 한 방향으로 올라갑니다. 데이터가 어떻게 움직이는가에 관한 이야기입니다.

SSOT · 소유권의 유일성

어떤 상태에 대한 정답은 한 곳에만 존재합니다. 데이터를 누가 소유하는가에 관한 이야기입니다.

그리고 둘은 이렇게 이어집니다. 상태를 소유자에게 호이스팅하면, 그 상태의 사본이 여러 컴포저블에 흩어지지 않습니다(SSOT). 소유자가 하나로 정해지면, 하위 컴포저블은 그 값을 읽어서 그리고 이벤트만 되돌려 보낼 수 있을 뿐입니다(UDF). 호이스팅을 하면 SSOT가 만들어지고, SSOT가 있으면 UDF가 가능해집니다. 서로 다른 두 성질이 호이스팅이라는 하나의 행위에서 함께 따라 나오는 것이지, 같은 말을 두 번 하는 게 아닙니다.

그럼 모든 상태를 올리면 될까?

재사용성·테스트·UDF가 그렇게 좋다면, 극단적으로는 "모든 상태를 ViewModel로 올리면 되는 것 아닌가?" 하는 결론이 나올 법합니다. 하지만 실제로 화면을 짜 보면 그렇지 않습니다.

장바구니 화면을 떠올려 보겠습니다. 이 화면에는 성격이 다른 두 종류의 상태가 있습니다.

하나는 ViewModel이 소유하는 상태입니다. 담긴 상품 목록, 총 금액, 로딩 여부 같은 것들입니다. 이건 화면이 구독하는 UiState로 모입니다.

CartUiState.kt Kotlin
data class CartUiState(
    val isLoading: Boolean = false,
    val items: List<CartItem> = emptyList(),
    val totalPrice: Int = 0,
)

다른 하나는 화면이 소유하는 로컬 상태입니다. "정말 삭제하시겠어요?" 다이얼로그가 열려 있는지 같은 것입니다.

CartScreen.kt Kotlin
@Composable
fun CartScreen(viewModel: CartViewModel = hiltViewModel()) {
    val uiState by viewModel.uiState.collectAsStateWithLifecycle()

    // 서버/도메인까지 올라가지 않고 화면에 머무는 상태
    var showRemoveConfirmDialog by rememberSaveable { mutableStateOf(false) }

    CartContent(
        uiState = uiState,
        onRemoveClick = { showRemoveConfirmDialog = true },
    )

    if (showRemoveConfirmDialog) {
        RemoveConfirmDialog(
            onConfirm = {
                showRemoveConfirmDialog = false
                viewModel.removeSelectedItem()
            },
            onDismiss = { showRemoveConfirmDialog = false },
        )
    }
}

items, totalPrice는 ViewModel로 올렸는데, showRemoveConfirmDialog(다이얼로그가 열려 있는지)는 왜 화면에 남겨 뒀을까요? "무조건 최대한 위로"라는 규칙으로는 이 차이를 설명하기 어렵습니다. 하지만 앞서 세운 질문 — "이 상태의 소유자는 누구인가?" — 으로 보면 자연스럽게 갈립니다.

  • items는 서버에 원본이 있는 도메인 상태입니다. 다른 화면과 공유되고, 세션 내내 반응해야 하고, ViewModel의 비즈니스 로직이 갱신합니다. 소유자는 ViewModel입니다.
  • 다이얼로그의 열림/닫힘은 순수한 UI 상태입니다. 이 화면 밖의 누구도 이 값을 알 필요가 없고, 도메인적 의미도 없습니다. Configuration Change는 rememberSaveable이 덮어 주고, 다른 화면 갔다 오면 닫혀 있는 게 자연스러운 모바일 UX입니다. 소유자는 화면 자신입니다.

그래서 호이스팅을 어디서 멈출지는 이렇게 물어보면 됩니다.

이 상태를, 이 UI 말고 다른 누군가가 알아야 하거나 그에 반응해야 하는가?

  • 그렇다 → 그 소유자(대개 ViewModel)까지 올린다.
  • 아니다(이 UI 안에서만 의미가 있다) → 로컬에 둔다.

같은 기준이 더 작은 컴포넌트에도 똑같이 적용됩니다. 리스트의 스크롤 위치(rememberLazyListState), 드롭다운 메뉴의 펼침 여부, 애니메이션 진행값 — 이런 건 그 컴포넌트 밖의 누구도 알 필요가 없으니 로컬에 둡니다. 굳이 ViewModel까지 끌어올리면 오히려 소유권을 잘못 놓은 셈이 됩니다.

결국 호이스팅은 "높이 올릴수록 좋은 것"이 아니라 상태를 정당한 소유자에게 정확히 배치하는 일입니다. 도메인 상태를 컴포저블 안에 가두는 것도, UI 전용 상태를 ViewModel까지 끌어올리는 것도, 둘 다 소유자를 잘못 정한 것입니다.

정리하며

이 질문들의 답은 결국 "이 상태의 소유자는 누구인가"라는 한 지점으로 모입니다.

호이스팅은 목적지가 아니라 방향입니다. 상태를 그 정당한 소유자에게 보내는 것이고, 소유자는 ViewModel일 수도 화면일 수도 있습니다. 컴포저블에서 상태를 걷어내면 그 자리에는 UDF라는 흐름밖에 남지 않으니, stateless는 UDF의 원인인 셈입니다. 그래서 판단 기준은 "얼마나 높이 올릴까"가 아니라 "이 상태를 누가 소유해야 하는가"이고, 도메인 상태는 ViewModel로, 순수 UI 상태는 로컬로 갑니다.

그럼 처음 질문으로 돌아가서, 상태 호이스팅은 왜 해야 할까요? 결국 그 효과들을 얻기 위해서입니다. 상태 호이스팅은 "화면이 어떻게 보이는가"와 "그 상태를 누가 소유하는가"를 분리해 줍니다. 그렇게 분리해 두면 UI가 바뀌어도 소유권은 흔들리지 않고, 소유권이 바뀌어도 렌더링은 영향을 받지 않습니다. 재사용성·테스트·관심사 분리 같은 효과가 전부 여기서 나옵니다.

좋은 소프트웨어 설계가 변경 비용을 낮추는 일이라면, 상태 호이스팅은 Compose에서 그 원칙을 구체화하는 한 가지 방법입니다. 그래서 저는 상태 호이스팅을, 바뀌는 요구사항에 적은 수정으로 대응하고 컴포넌트를 여기저기 다시 쓰기 위한 도구라고 생각합니다.

Key Points
  • 호이스팅은 목적지가 아니라 방향입니다. 상태를 그 정당한 소유자에게 보내는 것이고, 소유자는 ViewModel일 수도 화면일 수도 있습니다.
  • stateless는 UDF의 원인입니다. 상태를 걷어내면 "이벤트는 위로, 상태는 아래로"라는 흐름밖에 남지 않습니다.
  • UDF와 SSOT는 다른 축입니다. 호이스팅이 SSOT를 만들고, SSOT가 UDF를 가능하게 합니다.
  • 재사용성과 관심사 분리는 소유를 내려놓은 대가입니다. 무엇을 꽂을지가 호출부의 몫이 되기 때문입니다.
  • 모든 상태를 올리는 게 정답은 아닙니다. 도메인 상태는 ViewModel로, 순수 UI 상태는 로컬로 — 판단 기준은 "이 상태를 누가 소유해야 하는가"입니다.
Comments