Jetpack Compose에서 상태 관리: remember, mutableStateOf, 그리고 그 이상

발행: (2026년 3월 2일 오전 09:20 GMT+9)
11 분 소요
원문: Dev.to

Source: Dev.to

번역하려는 전체 텍스트를 제공해 주시면, 요청하신 대로 한국어로 번역해 드리겠습니다.

Composition 및 Recomposition 이해

상태 관리를 시작하기 전에, Compose가 어떻게 작동하는지 이해하는 것이 중요합니다. composable 함수를 작성하면, 해당 함수는 Compose의 composition 프로세스의 일부로 실행됩니다. 상태가 변하면 Compose는 recomposes—즉, UI를 업데이트하기 위해 composable 함수를 다시 실행합니다.

핵심 과제: composable 함수 내에서 로컬로 생성된 값은 매 재구성마다 다시 생성됩니다. 여기서 상태‑관리 도구가 필요합니다.

Source:

기본 개념: remembermutableStateOf

remember: 재구성 간 값 보존

remember는 Compose에서 상태 관리를 위한 기본 토대입니다. 재구성 사이에 값을 보존할 수 있게 해줍니다:

@Composable
fun CounterExample() {
    var count by remember { mutableStateOf(0) }

    Button(onClick = { count++ }) {
        Text("Clicked $count times")
    }
}

위 예시에서 count는 재구성 사이에 유지됩니다. 버튼을 클릭하면 상태가 업데이트되고, 새로운 값을 반영하도록 재구성이 트리거됩니다.

mutableStateOf: 관찰 가능한 상태 생성

mutableStateOf는 Compose가 관찰하는 상태 객체를 생성합니다. 상태가 변경되면 해당 상태를 읽고 있는 모든 컴포저블이 재구성됩니다:

@Composable
fun LoginForm() {
    val emailState = remember { mutableStateOf("") }
    val passwordState = remember { mutableStateOf("") }

    Column {
        TextField(
            value = emailState.value,
            onValueChange = { emailState.value = it },
            label = { Text("Email") }
        )
        TextField(
            value = passwordState.value,
            onValueChange = { passwordState.value = it },
            label = { Text("Password") }
        )
    }
}

위임 구문(var … by)을 사용하면 더 간결하고 관용적입니다:

@Composable
fun LoginForm() {
    var email by remember { mutableStateOf("") }
    var password by remember { mutableStateOf("") }

    Column {
        TextField(
            value = email,
            onValueChange = { email = it },
            label = { Text("Email") }
        )
        TextField(
            value = password,
            onValueChange = { password = it },
            label = { Text("Password") }
        )
    }
}

rememberSaveable: 구성 변경을 통한 상태 보존

remember는 재구성 간에 상태를 보존하지만, 구성 변경(예: 화면 회전)에서는 생존하지 않습니다. 이러한 변경에도 지속하려면 rememberSaveable을 사용합니다:

@Composable
fun PersistentCounterExample() {
    var count by rememberSaveable { mutableStateOf(0) }

    Button(onClick = { count++ }) {
        Text("Clicked $count times (survives rotation)")
    }
}

rememberSaveable은 전통적인 Android 개발과 유사하게 Bundle 메커니즘을 활용하여 상태를 저장하고 복원합니다.

derivedStateOf: 상태에서 값 계산

때때로 상태 변화에 기반해 값을 계산해야 하지만, 모든 변화마다 재구성을 원하지 않을 때가 있습니다. derivedStateOf는 실제로 값이 변할 때만 옵저버에게 알리는 파생 상태를 생성합니다:

@Composable
fun TextSearchExample() {
    var searchQuery by remember { mutableStateOf("") }

    // This expensive computation only runs when searchQuery actually changes
    val searchResults by remember(searchQuery) {
        derivedStateOf { performExpensiveSearch(searchQuery) }
    }

    Column {
        TextField(
            value = searchQuery,
            onValueChange = { searchQuery = it },
            label = { Text("Search") }
        )
        LazyColumn {
            items(searchResults.size) { index ->
                Text(searchResults[index])
            }
        }
    }
}

검색어가 빠르게 업데이트되더라도, 파생된 값이 실제로 변할 때만 비용이 많이 드는 검색 작업이 실행됩니다.

상태 끌어올리기 (State Hoisting)

State hoisting은 상태를 공통 부모 composable로 옮기는 디자인 패턴입니다. 이렇게 하면 여러 자식 composable 간에 상태를 공유할 수 있고 테스트가 쉬워집니다:

@Composable
fun ParentComponent() {
    var sharedState by remember { mutableStateOf("") }

    Column {
        ChildComponentA(
            state = sharedState,
            onStateChange = { sharedState = it }
        )
        ChildComponentB(state = sharedState)
    }
}

@Composable
fun ChildComponentA(
    state: String,
    onStateChange: (String) -> Unit
) {
    TextField(
        value = state,
        onValueChange = onStateChange
    )
}

@Composable
fun ChildComponentB(state: String) {
    Text("Shared state: $state")
}

State hoisting을 사용하면 composable이 파라미터에만 의존하고 내부 상태에 의존하지 않기 때문에 재사용성이 높아지고 테스트가 더 쉬워집니다.

ViewModel vs. Local State: 언제 각각을 사용해야 할까

  • Local state (remember, mutableStateOf) – UI 전용 상태로, 프로세스 종료 시점까지 살아야 하거나 여러 화면에 공유될 필요가 없는 경우에 이상적입니다.
  • ViewModel‑backed state – 상태가 구성 변경을 넘어 살아야 하거나, UI의 서로 다른 부분에 있는 composable 간에 공유되어야 하거나, UI 수명 주기를 넘어 지속되어야 할 때(예: 레포지토리에서 가져온 데이터) 사용합니다.
class CounterViewModel : ViewModel() {
    private val _count = mutableStateOf(0)
    val count: State = _count

    fun increment() { _count.value++ }
}

@Composable
fun CounterScreen(viewModel: CounterViewModel = viewModel()) {
    val count by viewModel.count
    Button(onClick = { viewModel.increment() }) {
        Text("Clicked $count times")
    }
}

위 예제에서 countViewModel에 존재하기 때문에 구성 변경을 겪어도 유지됩니다.

요약

  • remember + mutableStateOf → UI‑local, recomposition‑aware 상태.
  • rememberSaveable → 구성 변경을 견디는 UI‑local 상태.
  • derivedStateOf → 불필요한 recomposition을 방지하는 효율적인 파생 값.
  • State hoisting → 재사용성 및 테스트 용이성 향상.
  • ViewModel → 공유되거나 장기간 살아야 하거나 프로세스까지 살아남아야 하는 상태를 처리.

이 도구들을 이해하고 적절히 적용하면 견고하고 유지보수가 쉬우며 성능이 뛰어난 Compose 애플리케이션을 만들 수 있습니다. 즐거운 Compose 개발 되세요!

remember를 사용하여 프로세스 종료 후에도 유지될 필요가 없는 UI‑관련 상태

일반적인 사용 사례

  • UI 요소의 가시성 토글
  • 편집 중 텍스트 필드 입력
  • 스크롤 위치

임시 UI 상태

@Composable
fun ToggleVisibility() {
    var isVisible by remember { mutableStateOf(true) }

    Button(onClick = { isVisible = !isVisible }) {
        Text(if (isVisible) "Hide" else "Show")
    }

    if (isVisible) {
        Text("Content is visible")
    }
}

ViewModel for Business Logic and Data That Should Survive Process Death

일반적인 사용 사례

  • 데이터베이스 또는 API에서 가져온 사용자 데이터
  • 앱 전체에 걸친 상태
  • 비즈니스 로직

앱 라이프사이클 전반에 걸쳐 지속되는 상태

class UserViewModel : ViewModel() {
    private val _userState = MutableStateFlow<User?>(null)
    val userState: StateFlow<User?> = _userState.asStateFlow()

    init {
        loadUser()
    }

    private fun loadUser() {
        viewModelScope.launch {
            _userState.value = userRepository.getUser()
        }
    }
}

@Composable
fun UserScreen(viewModel: UserViewModel = hiltViewModel()) {
    val user by viewModel.userState.collectAsState()

    user?.let {
        Text("Welcome, ${it.name}")
    }
}

ViewModel과 로컬 상태 결합하기

가장 견고한 패턴은 두 접근 방식을 모두 혼합합니다:

class ProductViewModel : ViewModel() {
    private val _products = MutableStateFlow<List<Product>>(emptyList())
    val products: StateFlow<List<Product>> = _products.asStateFlow()

    init {
        loadProducts()
    }

    private fun loadProducts() {
        viewModelScope.launch {
            _products.value = productRepository.getProducts()
        }
    }
}

@Composable
fun ProductListScreen(viewModel: ProductViewModel = hiltViewModel()) {
    val products by viewModel.products.collectAsState()
    var selectedProductId by remember { mutableStateOf<String?>(null) }

    Row {
        LazyColumn(modifier = Modifier.weight(1f)) {
            items(products) { product ->
                ProductItem(
                    product = product,
                    isSelected = selectedProductId == product.id,
                    onSelect = { selectedProductId = product.id }
                )
            }
        }

        selectedProductId?.let { id ->
            ProductDetails(
                product = products.find { it.id == id },
                modifier = Modifier.weight(1f)
            )
        }
    }
}

Best Practices for State Management

  • 필요한 만큼 높은 수준으로 상태를 끌어올리기 – 상태를 필요로 하는 컴포저블들의 가장 낮은 공통 부모로 이동합니다.
  • 상태를 사용되는 위치에 가깝게 유지하기 – 필요 이상으로 끌어올리지 마세요; 이는 재사용성을 유지합니다.
  • 지속적인 상태에는 ViewModel 사용하기 – 프로세스 종료 후에도 살아남아야 하는 데이터는 ViewModel에 두어야 합니다.
  • 가변 공유 상태를 피하기 – 불변 데이터 구조와 단방향 데이터 흐름을 선호하세요.
  • 상태 끌어올리기로 컴포저블 테스트하기 – 끌어올린 상태는 테스트 값을 주입할 수 있어 컴포저블을 더 쉽게 테스트할 수 있습니다.
  • UI 상태에는 rememberSaveable 사용하기 – UI 상태가 구성 변경을 견뎌야 할 때는 rememberSaveable로 감싸세요.

결론

Jetpack Compose에서 상태 관리는 핵심 도구들을 이해하면 간단해집니다:

  • remember / mutableStateOf → 로컬, 일시적인 UI 상태
  • rememberSaveable → 구성 변경을 견디는 UI 상태
  • ViewModel → 지속적인 비즈니스 로직 및 데이터

각 상황에 맞는 도구를 선택하세요: 일시적인 UI 문제에는 로컬 상태를 사용하고, UI보다 오래 살아야 하는 데이터에는 ViewModel을 사용하며, 컴포저블 간에 효율적으로 공유하려면 상태를 끌어올리세요.

모든 8개의 템플릿은 올바른 상태 관리를 보여줍니다.
https://myougatheax.gumroad.com

0 조회
Back to Blog

관련 글

더 보기 »