State Hoisting은 Jetpack Compose의 패턴으로, 상태를 자식 Composable 함수에서 부모로 이동하고 자식은 매개변수를 통해 데이터를 받고 콜백을 통해 변경 사항을 알립니다. 이는 단방향 데이터 흐름(UDF) 원칙의 구현으로, 상태는 위로 올라가고 이벤트는 아래로 내려갑니다. Google Android Developers, 2026에 따르면, State Hoisting은 컴포넌트를 재사용 가능하고, 테스트 가능하며, 예측 가능하게 만듭니다.
주요 내용
State Hoisting은 Composable 함수가 상태를 소유하지 않고 외부에서 받는 패턴입니다. 함수 내부에서 var를 사용하는 대신, 표시할 값과 변경을 처리하는 람다 콜백이라는 두 개의 매개변수가 사용됩니다. 기술적으로 이는 자식 컴포넌트가 스테이트리스(자체 상태 없음)가 되고 부모가 스테이트풀(상태 소유)이 됨을 의미합니다.
예: Material3의 TextField 컴포넌트는 입력된 텍스트를 내부에 저장하지 않습니다. value: String과 onValueChange: (String) -> Unit을 받아들입니다. TextField를 호출하는 부모는 var value by remember { mutableStateOf("") }를 선언하고 value와 onValueChange를 전달합니다. 이것이 고전적인 State Hoisting입니다: TextField는 덤 컴포넌트(단순히 표시하고 입력을 보고)이고 부모는 스마트(상태 소유)합니다.
스테이트리스 vs 스테이트풀: 스테이트리스 컴포넌트는 테스트하기 쉽습니다—내부 상태에 의존하지 않으며, 그 동작은 입력 매개변수에 의해 완전히 결정됩니다. 스테이트풀 컴포넌트는 빠른 프로토타이핑에 편리하지만 재사용이 어렵습니다: 단일 데이터 소스에 강하게 결합됩니다. State Hoisting은 선택권을 줍니다: 상태를 위로 이동하여 모든 컴포넌트를 스테이트리스로 만들 수 있습니다.
UDF(단방향 데이터 흐름)은 데이터가 한 방향으로 이동하는 아키텍처 원칙입니다: 진실의 소스(ViewModel 또는 부모 Composable)에서 UI로, 이벤트는 반대 방향으로 흐릅니다. State Hoisting은 개별 컴포넌트 수준에서 UDF를 구현한 것입니다. 각 컴포넌트가 언제, 어떻게 자신의 상태를 변경할지 결정하는 대신, 부모에게 이벤트를 알리고 부모가 상태 변경 방법을 결정합니다.
UDF의 장점: 예측 가능성— 상태가 한 곳에서만 변경되어 경쟁 상태를 제거합니다; 추적 가능성— 호출 스택을 통해 변경 체인을 재구성할 수 있습니다; 테스트— 스테이트풀 로직을 별도 클래스로 추출하여 UI 없이 테스트할 수 있습니다. 대규모 프로젝트에서 State Hoisting과 결합된 UDF는 사실상의 표준입니다.
단일 진실 소스(Single Source of Truth)는 UDF에 수반되는 또 다른 원칙입니다. 상태의 각 부분에는 정확히 하나의 소스가 있습니다. 두 컴포넌트가 동일한 상태를 사용하는 경우, 소스는 공유되어야 합니다(ViewModel 또는 공통 부모 수준에서). State Hoisting은 소스가 계층 구조의 위에 있고 상태 중복이 발생하지 않도록 보장합니다.
| 방향 | 전달되는 것 | 구현 방법 |
|---|---|---|
| 아래로 (부모 → 자식) | 표시할 값 | 매개변수 value: T |
| 위로 (자식 → 부모) | 변경 이벤트 | 매개변수 onValueChange: (T) -> Unit |
기본 규칙: 상태는 이를 필요로 하는 모든 컴포넌트에 충분한 최소 가능 수준으로 끌어올려져야 합니다. 상태가 하나의 컴포넌트 내에서만 사용되는 경우—로컬로 유지합니다. 두 인접 컴포넌트가 동일한 상태를 필요로 하는 경우—공통 부모로 끌어올립니다. 상태가 전체 화면에 필요한 경우—ViewModel으로 끌어올립니다.
최소 끌어올리기 규칙은 불필요한 복잡성을 방지합니다. 텍스트 필드 상태가 하나의 화면 내에서만 사용되고 Activity 재생성 시 유지되지 않는 경우 ViewModel로 끌어올릴 필요가 없습니다. 화면 회전에서 살아남아야 하지만 비즈니스 로직에 필요하지 않은 UI 상태의 경우, ViewModel이 아닌 화면 부모 수준에서 rememberSaveable을 사용하세요.
ViewModel로 끌어올려야 하는 경우: 상태가 Activity 재생성 후에도 유지되어야 하는 경우, 여러 화면에서 필요한 경우, 상태 변경이 비즈니스 로직(네트워크 요청, 데이터베이스)을 트리거하는 경우. ViewModel 수준의 State Hoisting은 MVVM 아키텍처의 표준 패턴으로, UI 계층은 스테이트리스이고 ViewModel은 스테이트풀입니다.
// ❌ 나쁨: 컴포넌트가 자체 상태를 소유
@Composable
fun BadTextField(label: String) {
var text by remember { mutableStateOf("") }
TextField(value = text, onValueChange = { text = it }, label = { Text(label) })
}
// ✅ 좋음: State Hoisting — 상태가 부모에
@Composable
fun GoodTextField(value: String, onValueChange: (String) -> Unit, label: String) {
TextField(value = value, onValueChange = onValueChange, label = { Text(label) })
}
// 용도: 부모가 상태를 소유
@Composable
fun Form() {
var name by rememberSaveable { mutableStateOf("") }
GoodTextField(value = name, onValueChange = { name = it }, label = "Name")
}
두 개의 필드(이메일, 비밀번호)와 버튼이 있는 로그인 화면을 고려해보세요. 세 컴포넌트 모두 State Hoisting을 통해 상태를 받습니다: 이메일과 비밀번호는 부모가 관리하고, 버튼은 활성화 상태를 값으로 받습니다.
// 화면 수준의 State Hoisting
@Composable
fun LoginScreen(viewModel: LoginViewModel) {
val uiState by viewModel.uiState.collectAsState()
Column(modifier = Modifier.padding(16.dp)) {
// 이메일 필드 — 람다를 통한 State Hoisting
EmailField(
email = uiState.email,
onEmailChange = { viewModel.onEmailChanged(it) }
)
// 비밀번호 필드 — 동일하게
PasswordField(
password = uiState.password,
onPasswordChange = { viewModel.onPasswordChanged(it) }
)
// 버튼 — enabled만 받음(읽기 전용)
LoginButton(enabled = uiState.isFormValid, onClick = viewModel::login)
}
}
// 스테이트리스 컴포넌트: 이메일 + 콜백 수신
@Composable
fun EmailField(email: String, onEmailChange: (String) -> Unit) {
OutlinedTextField(
value = email,
onValueChange = onEmailChange,
label = { Text("이메일") },
singleLine = true
)
}
// 스테이트리스 버튼 컴포넌트
@Composable
fun LoginButton(enabled: Boolean, onClick: () -> Unit) {
Button(onClick = onClick, enabled = enabled) {
Text("로그인")
}
}
EmailField와 PasswordField는 완전히 스테이트리스입니다. 모든 데이터 소스에 연결하여 모든 화면에서 재사용할 수 있습니다. LoginButton은 enabled를 읽기 전용으로 받습니다—이는 상태가 끌어올려지지 않고(버튼이 스스로 활성화될 수 없음) 준비된 상태로 전달되는 또 다른 형태의 State Hoisting입니다. 이 접근 방식은 최소한의 컴포넌트 결합으로 최대 유연성을 제공합니다.
모든 상태를 끌어올릴 필요는 없습니다. 로컬 상태(Composable 내부의 State)는 다음과 같은 경우에 정당화됩니다: 데이터가 하나의 컴포넌트 내에서만 필요하고, 형제 요소에 영향을 미치지 않으며, 특정 섹션의 재구성에서 살아남을 필요가 없는 경우. 예를 들어, 애니메이션 상태, 입력 필드 포커스, 현재 스크롤 위치—이러한 것은 로컬로 유지하는 것이 합리적입니다.
State Hoisting이 필요한 경우: 상태가 여러 자식 컴포넌트에서 사용됨; 한 자식의 변경이 다른 자식에 반영되어야 함; 상태 변경 로직을 UI와 별도로 테스트해야 함; 상태가 Activity 재생성 후에도 유지되어야 함. 이러한 경우 로컬 상태는 데이터 중복과 불일치를 만듭니다.
하이브리드 접근법: 최소 상태는 로컬로 유지하고 나머지는 끌어올리세요. Compose의 규칙: “필요한 만큼 높이, 가능한 한 낮게 상태를 끌어올리세요.” 실제로는 로컬 remember로 시작하고 다른 컴포넌트에서 접근이 필요할 때만 수준을 끌어올리는 것을 의미합니다. State Hoisting을 예방적으로 적용하지 마세요—필요 없이 코드를 복잡하게 만듭니다.
자주 묻는 질문
State Hoisting은 UI 컴포넌트 수준의 패턴입니다. ViewModel은 비즈니스 로직을 위한 아키텍처 계층입니다. State Hoisting은 상태를 부모 Composable 수준, 화면 수준 또는 ViewModel로 끌어올릴 수 있습니다. ViewModel은 Activity 재생성 후에도 유지되어야 하는 상태의 최고 끌어올리기 지점입니다.
스테이트리스 컴포넌트는 단순히 값을 전달하여 테스트합니다. 필요한 매개변수로 Composable을 호출하고 ComposeTestRule을 사용하여 표시를 확인합니다. 상태 변경은 부모 또는 ViewModel 수준에서 테스트됩니다—UI와 별도로. 이는 테스트를 크게 단순화합니다: 컴포넌트 내에서 재구성을 시뮬레이션할 필요가 없습니다.
네, 일반적인 관행입니다. 컴포넌트가 수정 능력 없이 데이터만 표시해야 하는 경우—State<T>(읽기 전용)를 전달하세요. 컴포넌트는 변경 사항을 구독하지만 시작할 수는 없습니다. 이는 캡슐화를 강화하고 데이터를 원치 않는 변형으로부터 보호합니다.
깊은 전달을 위해 CompositionLocal을 사용하거나 부모 Composable 매개변수를 통해 전달하세요. 상태가 전체 화면에 필요한 경우—ViewModel로 추출하고 collectAsState()를 사용하세요. 5+ 수준을 통한 전달은 잘못된 아키텍처의 신호입니다; 컴포넌트 계층 구조를 재고하세요.
State Hoisting은 부모의 변경이 모든 자식을 재구성할 수 있으므로 재구성 횟수를 약간 증가시킬 수 있습니다. 변경을 필터링하려면 derivedStateOf를, 대상 업데이트에는 LazyColumn의 keys를 사용하세요. 대부분의 시나리오에서 State Hoisting의 오버헤드는 유지보수성의 이점에 비해 무시할 만합니다.
요약
턴키 방식의 모바일 애플리케이션을 개발해 드립니다
IT Sectr는 2017년부터 스타트업과 기업을 위한 iOS 및 Android 애플리케이션을 만듭니다. 저희가 상담해 드리고 최적의 솔루션을 제안하겠습니다.