[Android] Compose 상태 호이스팅 위치 정하기
Compose 애플리케이션에서 UI 상태를 어디에 위치시켜야 할까?
Compose 애플리케이션에서 UI 상태를 어디에 위치시킬지(호이스팅할지) 는 UI 로직 또는 비즈니스 로직이 필요하냐에 따라 결정됩니다.
이 문서는 두 가지 주요 시나리오를 설명합니다.
- UI 로직
- 비즈니스 로직
권장사항 (Best Practice)
1️⃣ UI 상태는 해당 상태를 읽고 수정하는 모든 컴포저블의 최소 공통 부모(Lowest Common Ancestor)로 끌어올려야 합니다.
2️⃣ 상태는 최대한 상태를 소비하는 곳과 가깝게 유지해야 합니다.
3️⃣ 상태를 소유한 컴포넌트(State Owner)는 불변(Immutable) 상태와 이를 수정할 수 있는 이벤트를 외부에 노출해야 합니다.
꼭 UI 상태를 Composition 내부에서만 관리할 필요는 없습니다.
비즈니스 로직이 개입된다면, ViewModel에서 상태를 관리하는 것이 더 적절할 수 있습니다. 예를 들어, 네트워크 요청을 통해 데이터를 가져오는 경우, ViewModel에서 상태를 관리하고 UI에서 이를 구독하는 것이 일반적인 패턴입니다.
UI 상태(UI State)와 UI 로직(UI Logic)의 유형
이 문서에서는 UI 상태와 UI 로직의 여러 유형을 정의하여, 각각을 어떻게 관리하고 어디에 위치시켜야 할지 설명합니다.
UI 상태(UI State)
UI 상태란 UI를 설명하는 속성을 의미합니다.
UI 상태는 크게 두 가지 유형으로 나뉩니다.
1️⃣ 스크린 UI 상태(Screen UI State)
- 화면에 표시해야 할 모든 데이터를 포함하는 상태입니다.
- 예를 들어, NewsUiState 클래스는 뉴스 기사 목록과 UI 렌더링에 필요한 정보를 포함할 수 있습니다.
- 일반적으로 앱의 다른 계층(데이터 계층, 도메인 계층)과 연결되며, 앱 데이터를 포함하는 경우가 많습니다.
2️⃣ UI 요소 상태(UI Element State)
- 개별 UI 요소의 속성을 나타내는 상태입니다.
- 예를 들어, UI 요소가 보이거나 숨겨질 수 있고, 글꼴, 글자 크기, 색상 등의 속성을 가질 수 있습니다.
- Android View 시스템에서는 View 자체가 상태를 관리하며,
- 예) TextView의 getText() 및 setText() 메서드로 상태를 수정 가능
- Jetpack Compose에서는 UI 상태가 컴포저블 외부에 존재하며, 컴포저블 호출 함수나 상태 보관자(State Holder)로 끌어올릴 수도 있습니다.
애플리케이션 로직(Logic)
애플리케이션 로직은 비즈니스 로직과 UI 로직 두 가지로 구분됩니다.
1️⃣ 비즈니스 로직(Business Logic)
- 앱의 데이터와 관련된 제품 요구사항을 구현하는 로직입니다.
- 예제: 뉴스 앱에서 사용자가 "북마크" 버튼을 눌렀을 때, 기사를 북마크하는 기능
- 이 로직은 보통 파일 저장, 데이터베이스(DB) 저장, 네트워크 요청 처리 등을 담당합니다.
- 도메인 계층(Domain Layer) 또는 데이터 계층(Data Layer) 에 위치하는 것이 일반적이며, 상태 보관자(State Holder)는 이 계층과 연동하여 데이터를 처리합니다.
2️⃣ UI 로직(UI Logic)
- UI 상태를 화면에 어떻게 표시할지를 결정하는 로직입니다.
- 예제:
- 사용자가 카테고리를 선택하면 적절한 검색 바 힌트가 표시됨
- 리스트에서 특정 항목으로 자동 스크롤
- 버튼 클릭 시 특정 화면으로 네비게이션
- Compose에서는 UI 로직도 상태를 변경하는 방식으로 표현될 수 있습니다.
UI 로직 (UI Logic)
UI 로직이 상태를 읽거나 수정해야 할 경우, UI의 생명주기(Lifecycle)에 따라 상태를 범위(Scope) 지정해야 합니다.
이를 위해 상태를 적절한 수준에서 끌어올리거나(State Hoisting),
UI 생명주기에 맞춘 상태 보관자(State Holder) 클래스를 사용할 수 있습니다.
이 문서에서는 두 가지 해결 방법을 설명하고, 각각을 언제 사용해야 하는지 설명합니다.
UI 로직과 UI 요소 상태(UI Element State)를 컴포저블 내부에서 관리하는 것은 좋은 접근 방식입니다.
특히, 상태와 로직이 간단할 경우 컴포저블 내부에서만 상태를 유지하는 것이 더 적절할 수 있습니다.
이 경우, 상태를 컴포저블 내부에 남겨두거나, 필요할 때만 끌어올릴 수 있습니다.
1. 상태 끌어올리기(State Hoisting)가 필요하지 않은 경우
모든 경우에 상태를 끌어올릴 필요는 없습니다.
만약 특정 상태가 다른 컴포저블에서 제어할 필요가 없다면,
그 상태를 컴포저블 내부에서만 관리하는 것이 더 적절할 수 있습니다.
아래 예제에서는, 사용자가 클릭하면 메시지의 세부 정보를 표시하거나 숨기는 기능을 구현했습니다.
@Composable
fun ChatBubble(
message: Message
) {
var showDetails by rememberSaveable { mutableStateOf(false) } // Define the UI element expanded state
ClickableText(
text = AnnotatedString(message.content),
onClick = { showDetails = !showDetails } // Apply simple UI logic
)
if (showDetails) {
Text(message.timestamp)
}
}
동작 방식
- showDetails 변수는 이 컴포저블 내부에서만 사용되는 상태입니다.
- 다른 컴포저블이 이 상태를 제어할 필요가 없으므로 상태를 끌어올릴 필요가 없습니다.
- 상태 변경 로직이 매우 간단하기 때문에 이 상태를 내부에 유지하는 것이 최적의 방법입니다.
- 이 컴포저블이 showDetails의 소유자이자 단일 진실 공급원(Single Source of Truth)이 됩니다.
핵심 포인트:
컴포저블 함수 내부에서 UI 요소 상태를 유지하는 것은 허용 가능한 방법입니다.
상태와 로직이 간단하고, UI 계층의 다른 부분에서 이 상태를 필요로 하지 않는 경우 적절한 해결책이 됩니다. 애니메이션 상태(Animation State) 같은 경우에도 컴포저블 내부에서 관리하는 것이 일반적입니다.
2. 컴포저블 내부에서 호이스팅
UI 요소 상태(UI Element State)를 다른 컴포저블과 공유해야 하거나, 여러 곳에서 UI 로직을 적용해야 하는 경우,
상태를 더 높은 UI 계층으로 끌어올려야 합니다.
이를 통해 컴포저블을 더 재사용 가능하고, 테스트하기 쉽게 만들 수 있습니다.
아래 예제에서는 채팅 애플리케이션이 다음 두 가지 기능을 구현합니다.
1️⃣ "Jump to Bottom" 버튼 → 메시지 목록을 맨 아래로 스크롤
2️⃣ 새 메시지를 보낸 후 자동 스크롤 → UserInput에서 메시지를 입력하면 리스트가 맨 아래로 이동
아래는 채팅 애플리케이션의 컴포저블 계층 구조입니다.
앱이 UI 로직을 실행하고 상태를 필요로 하는 모든 컴포저블에서 상태를 읽을 수 있도록 LazyColumn 상태가 대화 화면(ConversationScreen)으로 호이스팅됩니다.
최종적으로 컴포저블은 다음과 같습니다.
코드는 다음과 같습니다.
@Composable
private fun ConversationScreen(/*...*/) {
val scope = rememberCoroutineScope()
val lazyListState = rememberLazyListState() // ✅ LazyListState가 ConversationScreen으로 호이스팅됨
MessagesList(messages, lazyListState) // ✅ 같은 상태가 MessagesList에서 재사용됨
UserInput(
onMessageSent = { // ✅ lazyListState에 UI 로직이 적용됨
scope.launch {
lazyListState.scrollToItem(0) // 새 메시지를 보내면 리스트 맨 위로 스크롤
}
},
)
}
lazyListState는 ConversationScreen으로 호이스팅되며, 여러 컴포저블에서 공유됩니다. 상태 Composable 함수 내에서 초기화되었기 때문에 Composition 내에서 저장되며, 그 생명주기를 따릅니다.
@Composable
private fun MessagesList(
messages: List<Message>,
lazyListState: LazyListState = rememberLazyListState() // ✅ 기본값 설정 (LazyListState를 필요할 때만 전달)
) {
LazyColumn(
state = lazyListState // ✅ 호이스팅된 상태가 LazyColumn에 전달됨
) {
items(messages, key = { message -> message.id }) { item ->
Message(/*...*/)
}
}
val scope = rememberCoroutineScope()
JumpToBottom(onClicked = {
scope.launch {
lazyListState.scrollToItem(0) // ✅ JumpToBottom 버튼 클릭 시 리스트 맨 아래로 이동
}
})
}
MessagesList에서 lazyListState를 매개변수로 받으며, 기본값(rememberLazyListState())이 제공됩니다.
이렇게 하면 컴포저블이 더 재사용 가능하고 유연해집니다. 테스트 및 프리뷰(Preview) 환경에서 상태를 직접 전달하지 않아도 사용할 수 있습니다. (기본 값으로 정의했기 때문에)
핵심포인트:
상태 "최소 공통 상위 요"으로 호이스팅하고, 불필요한 상태 전달을 피하고, 필요로 하는 곳에서만 상태가 사용됩니다.
일반 State Holder 클래스를 상태 소유자로 사용
컴포저블이 여러 개의 UI 상태 필드를 포함하거나 복잡한 UI 로직을 다룰 경우,
이러한 책임을 일반 State Holder 클래스(상태 홀더 클래스) 에 위임하는 것이 좋습니다.
이렇게 하면 컴포저블의 로직을 독립적으로 테스트할 수 있고, 복잡도를 줄일 수 있습니다.
또한, 관심사 분리(Separation of Concerns) 원칙을 따르는 구조를 만들 수 있습니다.
- 컴포저블은 UI 요소를 렌더링하는 역할만 담당하고,
- 일반 State Holder 클래스는 UI 로직과 UI 요소 상태를 관리합니다.
일반 State Holder 클래스는 컴포저블 호출자가 직접 로직을 구현하지 않도록 편리한 함수 제공합니다.
즉, 이러한 클래스는 Composition에서 생성되고 기억(remembered)되며, Compose의 생명주기를 따릅니다.
아래는 Compose의 LazyColumn 또는 LazyRow의 UI 복잡도를 관리하는 일반 State Holder 클래스(LazyListState)의 구현 예제입니다.
// LazyListState.kt
@Stable
class LazyListState constructor(
firstVisibleItemIndex: Int = 0,
firstVisibleItemScrollOffset: Int = 0
) : ScrollableState {
/**
* 현재 스크롤 위치를 저장하는 클래스
*/
private val scrollPosition = LazyListScrollPosition(
firstVisibleItemIndex, firstVisibleItemScrollOffset
)
suspend fun scrollToItem(/*...*/) { /*...*/ }
override suspend fun scroll() { /*...*/ }
suspend fun animateScrollToItem() { /*...*/ }
}
LazyListState는 LazyColumn의 상태를 캡슐화하여 관리하는 일반 StateHolder입니다.
현재 스크롤 위치(scrollPosition)를 저장하고 유지하며, 스크롤 위치를 변경할 수 있는 API 제공 (예: scrollToItem(), animateScrollToItem())합니다.
컴포저블의 책임이 많아질수록 상태 보관자가 필요해집니다.
- UI 로직이 많아질 경우 (예: 복잡한 애니메이션, 스크롤 상태 관리)
- 다양한 UI 상태를 추적해야 하는 경우
따라서, 복잡한 UI 상태 또는 로직이 있는 경우, 일반 State Holder 클래스로 위임하여 관리하는 것이 좋습니다.
참고:
일반 State Holder 클래스에 Activity 또는 프로세스 종료 후에도 유지해야 하는 상태가 있다면, rememberSaveable과 Saver를 사용하여 상태를 저장해야 합니다.
비즈니스 로직 (Business Logic)
컴포저블과 Plain State Holder 클래스가 UI 로직과 UI 요소 상태(UI Element State)를 담당한다면,
스크린 수준의 상태 보관자(Screen-Level State Holder) 는 다음과 같은 역할을 수행합니다.
1️⃣ 애플리케이션의 비즈니스 로직에 접근 제공
- 일반적으로 비즈니스 로직은 비즈니스 계층(Business Layer) 또는 데이터 계층(Data Layer) 에 위치합니다.
- 상태 보관자는 이러한 계층과 연결되어 UI에서 비즈니스 로직을 호출할 수 있도록 합니다.
2️⃣ 화면에 데이터를 표시할 수 있도록 가공 (Screen UI State 생성)
- 앱의 원본 데이터를 UI에서 사용할 수 있도록 변환하여 제공
- 변환된 데이터는 UI 상태(Screen UI State) 가 됩니다.
ViewModel을 상태 소유자로 사용 (ViewModels as State Owner)
Android 개발에서 AAC ViewModel은 다음과 같은 이유로 비즈니스 로직을 처리하는 상태 보관자로 적합합니다.
- Activity/Fragment의 생명주기를 넘어서 데이터 유지 가능
- 비즈니스 로직을 처리하고, UI에서 사용할 데이터를 변환하여 제공
- UI와 분리된 상태 관리가 가능하여, 앱의 구조가 명확해짐
ViewModel에서 UI 상태를 호이스팅하면 상태가 컴포지션 외부로 이동됩니다.
ViewModel은 컴포지션의 일부로 저장되지 않습니다. ViewModel은 프레임워크에 의해 제공되며, ViewModelStoreOwner(Activity, Fragment 등)에 따라 스코프(Scope)가 결정됩니다.
그러면, ViewModel은 UI 상태를 관리하는 단일 진실 공급원(Single Source of Truth)이자 최소 공통 상위 요소가 됩니다.
화면 UI 상태(Screen UI State)
앞서 정의한 바와 같이, 화면 UI 상태(Screen UI State) 는 비즈니스 규칙을 적용하여 생성된 상태입니다.
화면 수준의 상태 Holder(Screen-Level State Holder) 가 이를 담당하므로, 보통 ViewModel에서 화면 UI 상태가 호이스팅됩니다.
아래는 채팅 애플리케이션의 ConversationViewModel이 화면 UI 상태를 노출하고 이벤트를 처리하는 방식을 보여줍니다.
class ConversationViewModel(
channelId: String,
messagesRepository: MessagesRepository
) : ViewModel() {
val messages = messagesRepository
.getLatestMessages(channelId)
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = emptyList()
)
// 비즈니스 로직: 메시지 전송
fun sendMessage(message: Message) { /* ... */ }
}
컴포저블은 ViewModel에서 호이스팅된 화면 UI 상태를 소비합니다.
이를 위해, 화면 수준의 컴포저블에는 ViewModel 인스턴스를 삽입하여 비즈니스 로직에 대한 액세스를 제공해야 합니다.
다음은 화면 수준의 컴포저블에 사용된 ViewModel의 예입니다.
@Composable
private fun ConversationScreen(
conversationViewModel: ConversationViewModel = viewModel()
) {
val messages by conversationViewModel.messages.collectAsStateWithLifecycle()
ConversationScreen(
messages = messages,
onSendMessage = { message: Message -> conversationViewModel.sendMessage(message) }
)
}
@Composable
private fun ConversationScreen(
messages: List<Message>,
onSendMessage: (Message) -> Unit
) {
MessagesList(messages, onSendMessage)
/* ... */
}
위 코드와 같이 ConversationScrren()은 ViewModel에서 호이스팅된 화면 UI상태를 소비하게 됩니다.
참고:
1. ViewModel 인스턴스를 다른 컴포저블로 직접 전달하지 말 것.
ViewModel은 스크린 수준(Screen-Level)에서만 사용해야 하며,하위 컴포저블에는 UI 상태만 전달해야 합니다.
2. ViewModel이 시스템 프로세스 종료 후에도 상태를 유지해야 한다면 SavedStateHandle을 사용
시스템에 의해 프로세스가 종료되었을 때도 데이터를 유지하려면 SavedStateHandle을 활용해야 합니다.
Property Drilling (속성 전달)
"Property Drilling" 은 여러 개의 중첩된 하위 컴포넌트(Composable)에서 데이터를 전달하여 사용하는 방식을 의미합니다.
Compose에서 Property Drilling이 발생하는 경우
- 화면 수준 상태 보관자(Screen-Level State Holder) 를 최상위에서 주입한 후,
상태와 이벤트를 여러 계층을 거쳐 하위 컴포저블에 전달할 때 Property Drilling이 발생할 수 있습니다. - 이 방식은 컴포저블 함수의 시그니처(매개변수 목록)를 길게 만들 수도 있습니다.
Property Drilling의 장점과 단점
✔️ 장점
1️⃣ 각 컴포저블이 어떤 역할을 수행하는지 명확하게 보여줌
- 개별 람다(onClick, onValueChange 등)를 매개변수로 노출하면
해당 컴포저블이 수행하는 기능을 한눈에 파악할 수 있습니다.
2️⃣ 컴포저블에 필요한 데이터만 전달하도록 유도됨
- 상태와 이벤트를 래퍼 클래스에 감싸지 않고 개별 매개변수로 전달하면,
각 컴포저블이 필요한 값만 전달받도록 강제할 수 있습니다. - 이는 불필요한 데이터 전달을 방지하는 모범 사례(Best Practice)입니다.
⚠️ 단점
1️⃣ 함수 시그니처가 길어질 수 있음
- 개별 이벤트를 람다로 제공하면, 컴포저블 함수의 매개변수 개수가 많아질 수 있습니다.
- 하지만, 이 방식이 컴포저블의 역할을 명확하게 해주기 때문에
별도의 래퍼 클래스를 만들어 상태와 이벤트를 한 곳에 모으는 방식보다 권장됩니다.
2️⃣ 계층이 깊어질수록 데이터 전달이 많아짐
- 상위에서 ViewModel이 관리하는 상태를 여러 컴포저블을 거쳐 전달해야 할 수도 있습니다.
- 하지만, 이는 컴포저블을 더 재사용 가능하게 만들기 위한 필요 비용으로 볼 수 있습니다.
네비게이션 이벤트도 Property Drilling 방식으로 전달하는 것이 권장됩니다.
결론
✅ Property Drilling은 함수 시그니처가 길어질 수 있지만, 컴포저블의 책임을 명확하게 보여줌
✅ 별도의 래퍼 클래스를 만들기보다는 개별 상태와 이벤트를 직접 전달하는 것이 권장됨
✅ 네비게이션 이벤트도 같은 방식으로 전달하는 것이 모범 사례(Best Practice)
UI 요소 상태(UI Element State)
UI 요소 상태는 비즈니스 로직이 이를 읽거나 수정할 필요가 있는 경우, 화면 수준의 StateHolder로 호이스팅될 수 있습니다.
그룹 채팅에서 사용자가 @를 입력하면, 사용자 추천 목록이 표시되는 기능을 구현하는 경우를 생각해봅시다.
- 추천 목록은 데이터 계층(Data Layer)에서 가져옴.
- 추천 목록을 계산하는 로직은 비즈니스 로직으로 간주됨.
이 기능을 구현하는 ViewModel은 다음과 같습니다.
class ConversationViewModel(/*...*/) : ViewModel() {
// ✅ 호이스팅된 상태 (TextField 입력값)
var inputMessage by mutableStateOf("")
private set
// ✅ 화면 UI 상태 (추천 목록)
val suggestions: StateFlow<List<Suggestion>> =
snapshotFlow { inputMessage }
.filter { hasSocialHandleHint(it) } // `@` 기호와 힌트가 포함된 경우 필터링
.mapLatest { getHandle(it) } // 사용자 핸들 추출
.mapLatest { repository.getSuggestions(it) } // 데이터 계층에서 추천 목록 가져오기
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = emptyList()
)
// ✅ 입력값 업데이트
fun updateInput(newInput: String) {
inputMessage = newInput
}
}
inputMessage는 TextField 상태를 저장하는 변수입니다. 사용자가 새 입력을 입력할 때마다 앱이 비즈니스 로직을 호출하여 suggestions를 생성합니다.
참고:
만약 inputMessage가 단순히 UI 상태로만 사용되고 비즈니스 로직과 관련이 없다면, 화면 수준의 State Holder로
호이스팅하지 말고 UI 내부에서 관리해야 합니다.
즉, 사용자 추천 목록을 만들 필요가 없었다면, inputMessage는 컴포저블 내부에서 관리하는 것이 적절합니다.
suggestions는 화면 UI 상태(Screen UI State)이며, StateFlow를 통해 UI에서 구독됩니다.
참고:
화면 수준의 컴포저블에서 ViewModel과 일반 State Holder를 함께 사용할 수도 있습니다.
ViewModel은 비즈니스 로직을 제공하는 역할을 하고,
Plain State Holder 클래스는 UI 요소 상태 및 UI 로직을 관리하는 역할을 할 수 있습니다.
즉, 한 화면에 ViewModel과 Plain State Holder가 함께 존재할 수도 있습니다.
ViewModel State Holder와 일반 StateHolder를 함께 사용할 때 주의점
일부 Compose UI 요소 상태(UI Element State) 를 ViewModel로 호이스팅할 경우,
특별한 고려 사항이 필요할 수 있습니다.
예를 들어, Compose UI 요소의 상태 보관자(State Holder) 는상태를 변경할 수 있는 메서드를 제공하며, 이 중 일부는 애니메이션을 트리거하는 suspend 함수일 수 있습니다.
이러한 suspend 함수는 Composition과 연결된 CoroutineScope에서 호출하지 않으면 예외가 발생할 수 있습니다.
앱의 드로어(Drawer)의 콘텐츠가 동적으로 변경되며, 드로어를 닫은 후 데이터 계층에서 새로운 내용을 가져와야 한다고 가정해 봅시다.
이 경우, 드로어 상태(DrawerState)를 ViewModel로 호이스팅하면, 화면 상태 보관자(State Owner)에서 UI 로직과 비즈니스 로직을 모두 처리할 수 있습니다.
하지만, 문제가 발생할 수 있습니다.
viewModelScope를 사용하여 DrawerState.close() 메서드를 호출하면,
다음과 같은 런타임 예외(Runtime Exception) 가 발생합니다.
IllegalStateException: a MonotonicFrameClock is not available in this CoroutineContext
DrawerState.close()는 애니메이션을 트리거하는 suspend 함수이며,
Composition과 연결된 CoroutineScope(예: rememberCoroutineScope())에서 실행되어야 합니다.
그러나 viewModelScope는 Composition과 연결되지 않기 때문에, 애니메이션이 정상적으로 실행되지 않고 예외가 발생합니다.
이를 해결하기 위해 viewModelScope 대신, Composition과 연결된 CoroutineScope로 컨텍스트를 변경하여 실행해야 합니다.
class ConversationViewModel(/*...*/) : ViewModel() {
val drawerState = DrawerState(initialValue = DrawerValue.Closed)
private val _drawerContent = MutableStateFlow(DrawerContent.Empty)
val drawerContent: StateFlow<DrawerContent> = _drawerContent.asStateFlow()
fun closeDrawer(uiScope: CoroutineScope) {
viewModelScope.launch {
withContext(uiScope.coroutineContext) { // Use instead of the default context
drawerState.close()
}
// Fetch drawer content and update state
_drawerContent.update { content }
}
}
}
// in Compose
@Composable
private fun ConversationScreen(
conversationViewModel: ConversationViewModel = viewModel()
) {
val scope = rememberCoroutineScope()
ConversationScreen(onCloseDrawer = { conversationViewModel.closeDrawer(uiScope = scope) })
}
끝.
참고자료
상태를 호이스팅할 대상 위치 | Jetpack Compose | Android Developers
이 페이지는 Cloud Translation API를 통해 번역되었습니다. 상태를 호이스팅할 대상 위치 컬렉션을 사용해 정리하기 내 환경설정을 기준으로 콘텐츠를 저장하고 분류하세요. Compose 애플리케이션에서
developer.android.com