-
Jetpack Compose 상태 관리 한눈에 보기 — State와 StateFlow 제대로 알기카테고리 없음 2026. 3. 27. 01:11

왜 상태 관리가 중요한가
안드로이드 개발에서 Jetpack Compose가 주류가 되면서, 기존 XML 기반 View 시스템과는 전혀 다른 상태 관리 방식이 요구되고 있습니다.
Compose는 선언형 UI 패러다임을 따르기 때문에, 화면을 직접 조작하는 대신 상태(State)를 변경해 UI를 리컴포지션(recomposition)시킵니다.
상태 관리를 제대로 이해하지 못하면 불필요한 리컴포지션, 메모리 누수, 예측 불가능한 UI 동작으로 이어지기 때문에 Compose를 다루는 개발자라면 반드시 숙지해야 할 핵심 개념입니다.remember와 mutableStateOf — 가장 기본적인 상태 선언
Compose에서 상태를 선언하는 가장 기본적인 방법은
remember와mutableStateOf를 조합하는 것입니다.remember는 컴포저블 함수가 리컴포지션될 때 이전 값을 기억하도록 해주며,mutableStateOf는 해당 값이 변경되면 UI를 자동으로 다시 그리도록 트리거합니다.
두 가지를 함께 사용하지 않으면 리컴포지션 시 상태가 초기화되거나, 상태가 바뀌어도 UI가 갱신되지 않는 문제가 발생합니다.@Composable fun CounterScreen() { var count by remember { mutableStateOf(0) } Column(horizontalAlignment = Alignment.CenterHorizontally) { Text(text = "Count: $count", style = MaterialTheme.typography.headlineMedium) Spacer(modifier = Modifier.height(16.dp)) Button(onClick = { count++ }) { Text("증가") } } }위 예시에서
count가 변경되면 Compose는 해당 컴포저블만 선택적으로 리컴포지션하여 성능을 최적화합니다.
단,remember는 컴포저블이 Composition에 존재하는 동안만 값을 유지하며, 화면이 백스택에서 제거되거나 재생성되면 상태가 초기화됩니다.rememberSaveable — 화면 회전과 프로세스 재시작 대응
단순한
remember는 구성 변경(화면 회전 등)이 발생할 때 상태를 잃어버립니다.
이를 방지하기 위해rememberSaveable을 사용하면 Bundle에 직렬화 가능한 상태를 저장해 화면 재생성 후에도 값을 복원할 수 있습니다.
기본 타입(Int, String, Boolean 등)은 자동으로 지원되며, 커스텀 객체는Saver를 구현해야 합니다.@Composable fun TextInputScreen() { var inputText by rememberSaveable { mutableStateOf("") } OutlinedTextField( value = inputText, onValueChange = { inputText = it }, label = { Text("입력하세요") } ) }화면 회전 후에도
inputText가 유지되는 것을 확인할 수 있습니다.
사용자가 입력 중인 폼 데이터, 스크롤 위치, 탭 선택 상태 등에 활용하면 UX를 크게 향상시킬 수 있습니다.StateFlow와 ViewModel — 실무 패턴의 핵심
실제 앱에서는 단순한 UI 로컬 상태 외에도 비즈니스 로직과 연계된 상태 관리가 필요합니다.
ViewModel과StateFlow를 조합하면 데이터 레이어에서 UI 레이어까지 상태를 단방향 데이터 흐름(Unidirectional Data Flow)으로 전달할 수 있습니다.collectAsStateWithLifecycle()을 사용하면 생명주기를 고려해 불필요한 상태 수집을 방지하고 메모리 누수를 예방할 수 있습니다.// ViewModel class CounterViewModel : ViewModel() { private val _uiState = MutableStateFlow(CounterUiState()) val uiState: StateFlow<CounterUiState> = _uiState.asStateFlow() fun increment() { _uiState.update { it.copy(count = it.count + 1) } } } data class CounterUiState(val count: Int = 0) // Composable @Composable fun CounterScreen(viewModel: CounterViewModel = viewModel()) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() Column { Text("Count: ${uiState.count}") Button(onClick = { viewModel.increment() }) { Text("증가") } } }MutableStateFlow는 ViewModel 내부에서만 변경 가능하도록private으로 선언하고, 외부에는 읽기 전용StateFlow만 노출하는 것이 좋은 캡슐화 관행입니다.
이 패턴을 따르면 테스트 시 ViewModel 단독으로 상태 로직을 검증할 수 있어 유지보수성이 높아집니다.UiState 패턴 — 로딩·성공·에러 통합 관리
실무에서 API 호출이나 DB 접근이 포함된 화면은 로딩, 성공, 에러 세 가지 상태를 동시에 관리해야 합니다.
이를 sealed class 또는 data class로 모델링하면 UI에서when분기로 각 상태를 명확하게 처리할 수 있습니다.
상태를 여러 개의 개별 Boolean 플래그로 관리하는 대신 하나의 UiState 객체로 묶으면 상태 조합 오류를 원천 차단할 수 있습니다.sealed interface UserUiState { data object Loading : UserUiState data class Success(val users: List<User>) : UserUiState data class Error(val message: String) : UserUiState } @Composable fun UserListScreen(uiState: UserUiState) { when (uiState) { is UserUiState.Loading -> CircularProgressIndicator() is UserUiState.Success -> LazyColumn { items(uiState.users) { user -> UserItem(user) } } is UserUiState.Error -> Text("오류: ${uiState.message}", color = Color.Red) } }sealed interface를 사용하면when표현식에서 모든 케이스를 강제로 처리하도록 컴파일러가 경고를 주기 때문에, 상태 누락으로 인한 버그를 방지할 수 있습니다.마무리 및 핵심 정리
Jetpack Compose의 상태 관리는 단순한 API 사용법을 넘어, 어느 계층에서 상태를 소유하고 어떻게 흘려보낼 것인지에 대한 설계 철학을 요구합니다.
로컬 UI 상태는remember/rememberSaveable로, 비즈니스 로직과 연계된 상태는 ViewModel +StateFlow로 관리하는 것이 현재 가장 널리 권장되는 패턴입니다.
단방향 데이터 흐름과 불변 UiState 패턴을 함께 적용하면 버그를 줄이고, 테스트 용이성과 코드 가독성을 동시에 확보할 수 있습니다.상태 관리를 탄탄히 이해한 뒤에는
derivedStateOf,snapshotFlow,produceState같은 고급 API로 확장하면 더 정교한 성능 최적화가 가능합니다.
이 글이 Compose 상태 관리의 전체 그림을 잡는 데 도움이 되셨으면 합니다.
더 궁금한 내용은 댓글로 남겨주세요!