Jetpack Compose의 상태 호이스팅(State Hoisting)
Jetpack Compose의 상태 호이스팅(State Hoisting)
상태 호이스팅(state hoisting)은 Jetpack Compose에서 예측 가능하고 재사용 가능하며 테스트하기 쉬운 UI 컴포넌트를 구축하기 위한 핵심 패턴 중 하나입니다. 기본 아이디어는 간단합니다. 컴포저블이 직접 상태를 소유하는 대신, 호출자(caller) 쪽으로 상태 소유권을 옮겨 해당 컴포저블을 stateless 함수로 만드는 것입니다. stateless 컴포저블은 현재 값을 매개변수로 받고, 사용자가 값을 변경하고자 할 때 이벤트를 방출합니다. 여기서 흥미로운 점은 호이스팅 대상이 단순한 텍스트 필드 값이 아니라 LazyListState와 같은 복잡한 객체일 때입니다. LazyListState는 스크롤 위치, 아이템 가시성, 프로그래밍 방식의 스크롤까지 전체 리스트를 제어하는 상태 객체이므로, 이런 상태를 어디에 호이스팅할지, 최소 공통 조상(lowest common ancestor) 규칙을 어떻게 적용할지, 그리고 상태가 ViewModel에 속해야 하는지 아니면 일반 상태 홀더(state holder)에 속해야 하는지를 정확히 이해하는 것이 잘 구조화된 Compose 코드를 작성하는 데 필수적입니다.
이번 면접 질문을 통하여 아래 내용들을 학습하실 수 있습니다.
- 상태 호이스팅 패턴과 컴포저블 트리에서 단일 진실 공급원(single source of truth)을 어떻게 확립하는지 설명할 수 있습니다.
- 최소 공통 조상 규칙을 이해하고, 상태가 어디에 위치해야 하는지 판단할 수 있습니다.
LazyListState내부에서 스크롤 위치,firstVisibleItemIndex, 프로그래밍 방식의 스크롤이 어떻게 연결되는지 추적할 수 있습니다.- 상태를 컴포저블에 호이스팅하는 것, 일반 상태 홀더 클래스에 두는 것, ViewModel에 두는 것 사이의 트레이드오프를 파악할 수 있습니다.
- 여러 컴포저블이 리스트 스크롤 상태에 공유 접근해야 하는 실제 시나리오에 상태 호이스팅을 적용할 수 있습니다.
상태 호이스팅의 동작 원리
Compose에서 remember { mutableStateOf(...) }를 통해 자체적으로 상태를 생성하고 보유하는 컴포저블을 stateful 컴포저블이라고 합니다. 상태 호이스팅은 이러한 내부 상태를 두 개의 매개변수로 대체하여 stateless 컴포저블로 변환하는 패턴입니다. 하나는 현재 상태를 나타내는 값(value)이고, 다른 하나는 사용자의 특정 행위에 의해 상태가 변경되어야 할 때 컴포저블이 호출하는 콜백 람다(일반적으로 onValueChange 또는 onCheckedChange와 같은 구체적인 이름을 사용)입니다.
가장 대표적인 예시는 텍스트 필드입니다.
// Stateful: 자체적으로 상태를 소유
@Composable
fun StatefulTextField() {
var text by remember { mutableStateOf("") }
TextField(value = text, onValueChange = { text = it })
}
// Stateless: 상태가 호출자로 호이스팅됨
@Composable
fun StatelessTextField(value: String, onValueChange: (String) -> Unit) {
TextField(value = value, onValueChange = onValueChange)
}
stateless 버전은 호출자가 상태의 저장, 유효성 검증, 공유 방식을 직접 제어하므로 재사용성이 훨씬 높습니다. 또한 출력이 입력의 순수 함수이므로 테스트하기도 용이합니다. 무엇보다 **단일 진실 공급원(single source of truth)**을 강제하는 효과가 있습니다. 텍스트 값을 보유하는 변수가 호출자 측에 단 하나만 존재하기 때문에, 두 개의 상태가 동기화되지 않는 문제를 원천적으로 차단할 수 있습니다.
이 패턴은 **단방향 데이터 흐름(unidirectional data flow)**을 만들어 냅니다. 상태는 부모에서 자식으로 매개변수 형태로 내려가고, 이벤트는 자식에서 부모로 람다 호출 형태로 올라갑니다. 부모가 이벤트를 처리하여 상태를 업데이트하면, Compose가 새로운 값으로 자식을 리컴포지션합니다. 자식은 절대로 상태를 직접 변경하지 않습니다. 면접에서 이 개념을 설명하실 때는 "상태는 아래로, 이벤트는 위로"라는 핵심 원칙을 먼저 언급하시면 좋습니다.
최소 공통 조상 규칙
여러 컴포저블이 동일한 상태에 접근해야 할 때, 핵심 질문은 "상태를 어디에 둘 것인가?"입니다. 규칙은 해당 상태를 읽거나 쓰는 **모든 컴포저블의 최소 공통 조상(lowest common ancestor)**에 상태를 호이스팅하는 것입니다. 이보다 더 낮은 곳에 호이스팅하면 일부 컴포저블이 상태에 접근할 수 없게 되고, 필요 이상으로 높은 곳에 호이스팅하면 상태와 무관한 트리 영역까지 상태가 노출되어 리컴포지션 범위가 넓어지고 소유권 모델이 불명확해집니다.
채팅 화면을 예시로 살펴보겠습니다. 세 개의 컴포넌트가 있다고 가정합니다. 메시지를 표시하는 LazyColumn인 MessagesList, 가장 최신 메시지로 리스트를 스크롤하는 JumpToBottom 버튼, 그리고 사용자가 위로 스크롤한 상태에서 새 메시지가 도착하면 나타나는 NewMessagesBanner입니다. 세 컴포넌트 모두 동일한 LazyListState에 접근해야 합니다.
MessagesList는 Compose가 스크롤 위치를 관리할 수 있도록LazyColumn에LazyListState를 전달합니다.JumpToBottom은 프로그래밍 방식으로 스크롤하기 위해listState.scrollToItem(0)을 호출합니다.NewMessagesBanner는 사용자가 하단에서 벗어나 스크롤했는지 판단하기 위해listState.firstVisibleItemIndex를 읽습니다.