Tech/App-Android

[Android].Ch2-3. 상태 호이스팅

JSJH._. 2026. 3. 24. 09:14

[Android] Ch2-3. 상태 호이스팅 (State Hoisting)

1. 개요 및 기본 형식

// 상태를 위로 끌어올림
@Composable
fun ParentComposable() {
    var text by remember { mutableStateOf("") }  // 부모가 State 소유

    // State와 변경 함수를 자식에게 전달
    ChildComposable(
        text = text,  // 현재 값 전달
        onTextChange = { newText -> text = newText }  // 변경 방법 전달
    )
}

@Composable
fun ChildComposable(
    text: String,  // State 값만 받음 (읽기 전용)
    onTextChange: (String) -> Unit  // State 변경 함수
) {
    TextField(
        value = text,
        onValueChange = onTextChange  // 부모의 State 변경
    )
}
  • State를 사용하는 Composable에서 상위 Composable로 State를 올리는 패턴입니다.
  • 자식 컴포넌트는 State 값과 변경 함수만 매개변수로 받아 동작합니다.

2. 주요 구성 요소

1) Stateful Composable (상태 보유)

  • 내부에 자체적인 State를 직접 선언하고 가지고 있습니다.

  • 특정 상태 로직에 묶여 있으므로 재사용 및 모의(Mock) 테스트가 어렵습니다.

    @Composable
    fun StatefulCounter() {
    var count by remember { mutableStateOf(0) }  // 내부 State 존재
    
    Button(onClick = { count++ }) {
        Text("Count: $count")
    }
    }

2) Stateless Composable (상태 비보유)

  • State 객체를 직접 소유하지 않고 상위에서 매개변수를 통해 데이터 및 이벤트를 주입받습니다.
  • 구조가 단순하여 재사용과 테스트가 용이합니다.
    @Composable
    fun StatelessCounter(
    count: Int,  // 값만 받음
    onIncrement: () -> Unit  // 변경 로직만 받음
    ) {
    Button(onClick = onIncrement) {
        Text("Count: $count")
    }
    }

3) 호이스팅의 장점

  1. 단일 진실 공급원 (Single Source of Truth)
    • 상태 통제 체계가 한 곳에 집중되어 데이터 불일치 및 동기화 에러를 방지합니다.
  2. 재사용성
    • Stateless 디자인은 서로 다른 UI 및 데이터를 주입받아 여러 곳에서 유연하게 재사용할 수 있습니다.
  3. 테스트 용이성
    • 내부 상태 갱신 대신, 매개변수로 가상의 상태와 Mock 이벤트 핸들러를 주입할 수 있어 단위 테스트가 쉬워집니다.
  4. State 공유
    • 부모의 단일 State를 여러 하위 자식 Composable들이 나누어 사용할 수 있습니다.

3. 활용 방식

1) 구조 개선 비교 (Before & After)

호이스팅 적용 전 (Stateful)
내부에 자체 State가 귀속되어 외부 동작 제어나 값 공유가 불가합니다.

@Composable
fun TodoInput() {
    var text by remember { mutableStateOf("") }  

    TextField(
        value = text,
        onValueChange = { text = it }
    )
    Button(onClick = { 
        // 외부에서 해당 상태나 변수에 접근할 수 없습니다.
    }) {
        Text("추가")
    }
}

호이스팅 적용 후 (Stateless 설계 및 병합)
부모 스코프에서 모든 상태를 관리하며 컴포넌트 간 정보 교환이 수행됩니다.

@Composable
fun TodoInputScreen() {
    var text by remember { mutableStateOf("") }  // 통합 State 1
    var todos by remember { mutableStateOf(listOf<String>()) } // 통합 State 2

    Column {
        TodoInput(
            text = text,
            onTextChange = { text = it },
            onAddClick = {
                if (text.isNotEmpty()) {
                    todos = todos + text  // 부모의 리스트 상태를 갱신
                    text = ""  // 기존 입력 폼 상태의 동기적 초기화
                }
            }
        )

        TodoList(todos = todos)  // 동일한 리스트 상태를 다른 컴포넌트와 공유
    }
}

@Composable
fun TodoInput(
    text: String, 
    onTextChange: (String) -> Unit,  
    onAddClick: () -> Unit  
) {
    Row {
        TextField(
            value = text,
            onValueChange = onTextChange
        )
        Button(onClick = onAddClick) {
            Text("추가")
        }
    }
}

2) 여러 하위 컴포넌트 간 State 공유

@Composable
fun SharedStateExample() {
    var count by remember { mutableStateOf(0) }  // 루트 공유 State

    Column {
        // 동일한 1개의 State를 두 자식이 셰어함
        CounterDisplay(count = count)  // 렌더링(뷰) 권한만 위임
        CounterButtons(
            count = count,
            onIncrement = { count++ },
            onDecrement = { count-- }
        )  // 값 수정 권한 위임
    }
}

@Composable
fun CounterDisplay(count: Int) {
    Text("현재 카운트: $count", fontSize = 24.sp)
}

@Composable
fun CounterButtons(
    count: Int,
    onIncrement: () -> Unit,
    onDecrement: () -> Unit
) {
    Row {
        Button(onClick = onDecrement) { Text("-") }
        Text("$count", modifier = Modifier.padding(horizontal = 16.dp))
        Button(onClick = onIncrement) { Text("+") }
    }
}

3) 복합 State 호이스팅 제어

@Composable
fun LoginScreen() {
    var email by remember { mutableStateOf("") }
    var password by remember { mutableStateOf("") }
    var isLoading by remember { mutableStateOf(false) }

    LoginForm(
        email = email,
        onEmailChange = { email = it },
        password = password,
        onPasswordChange = { password = it },
        isLoading = isLoading,
        onLoginClick = {
            isLoading = true
            // 실제 로그인 비즈니스 로직 처리 진행
        }
    )
}

@Composable
fun LoginForm(
    email: String,
    onEmailChange: (String) -> Unit,
    password: String,
    onPasswordChange: (String) -> Unit,
    isLoading: Boolean,
    onLoginClick: () -> Unit
) {
    Column {
        TextField(
            value = email,
            onValueChange = onEmailChange,
            label = { Text("이메일") }
        )
        TextField(
            value = password,
            onValueChange = onPasswordChange,
            label = { Text("비밀번호") },
            visualTransformation = PasswordVisualTransformation()
        )
        Button(
            onClick = onLoginClick,
            enabled = !isLoading  // 로딩 상태에 연동된 버튼 비활성화
        ) {
            if (isLoading) {
                Text("로그인 중...")
            } else {
                Text("로그인")
            }
        }
    }
}
  • email, password, isLoading 세 가지의 다채로운 UI 데이터를 한 곳(부모)에서 명확하게 파악하고 제어할 수 있습니다.

4. 호이스팅 적용 가이드라인 (규칙)

1) State의 적절한 위치 설정

  • 원칙: 두 컴포넌트 이상이 공유해야 하는 정보라면, 해당 정보를 사용하는 가장 가까운 공통 부모(최저 공통 조상) 에 위치시켜야 합니다.
  • 단일 컴포넌트 내부에서만 파편화되어 사용되는 상태의 경우 호이스팅 시 구조가 무의미하게 비대해지므로 내부 State 형태로 유지하십시오.

2) 파라미터 분할 방식

  • 단순 렌더링 목적의 뷰 영역: 갱신 없이 읽기 전용으로 값(value)만 하향 전달합니다.
  • 트리거 및 이벤트 수신 영역: 동작을 통제하는 변경 함수(onValueChange)도 함께 전달합니다.

5. 부가 설명

네이밍 컨벤션

  • 상태 변경 함수는 보통 파스칼케이스의 이벤트 기반 네이밍 룰(onXxxChange, onXxxClick)을 따릅니다.
  • 예시: onTextChange, onCountChange, onAddClick

'Tech > App-Android' 카테고리의 다른 글

[Android].Ch2-5. Material Design 3  (0) 2026.03.24
[Android.]Ch2-4. remember vs rememberSaveable  (0) 2026.03.24
[Android].Ch2-2.TextField  (0) 2026.03.23
[Android].Ch2-1.LazyColumn & LazyRow  (0) 2026.03.23
[Android].Ch1-5.이벤트 처리  (0) 2026.03.20