[Compose] derivedStateOf 에 대해 잘못 알고 있던 부분

easyhooon·2025년 9월 1일
2
post-thumbnail

서두

derivedStateOf 에 대해 잘못 알고 있던 부분들을 바로 잡고, 기억보단 기록을 위해 글을 작성해본다.

문제 발생


위와 같은 웨이팅 신청을 위한 다이얼로그를 개발하였고, 모든 입력에 대한 form validation 조건을 만족하면, 웨이팅 신청 버튼이 활성화되도록 요구사항에 맞춰 구현하였다.

기존에 derivedStateOf를 통해서 버튼의 enabled를 관리하기 위해 작성했었던 코드는 다음과 같았다.

@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable
fun WaitingDialog(
    boothName: String,
    waitingCount: Long,
    phoneNumberState: TextFieldState,
    partySize: Long,
    isPrivacyPolicyClicked: Boolean,
    onDismissRequest: () -> Unit,
    onDialogWaitingButtonClick: () -> Unit,
    // ...
    modifier: Modifier = Modifier,
) {
    val isButtonEnabled by remember {
        derivedStateOf {
            val isPartySizeValid = partySize in 1..10
            val phoneNumber = phoneNumberState.text.toString().replace("-", "")
            val isPhoneNumberValid = phoneNumber.matches(RegexConstants.PHONE_NUMBER)
            
            isPartySizeValid && isPhoneNumberValid && isPrivacyPolicyClicked
        }
    }
    
    BasicAlertDialog(
        // ...
    ) {
        Column(
			// ...
        ) {
            // ...
            // isButtonEnabled 를 사용하는 컴포넌트 
            UnifestButton(
                onClick = onDialogWaitingButtonClick,
                containerColor = if (isButtonEnabled) MaterialTheme.colorScheme.primary
                else MaterialTheme.colorScheme.surfaceVariant,
                enabled = isButtonEnabled,
                modifier = Modifier
                    .fillMaxWidth()
                    .padding(horizontal = 16.dp),
            ) {
                Text(
                    text = stringResource(id = R.string.waiting_dialog_telephone_button),
                    style = Title4,
                )
            }
            // ...
        }
    }

'derivedStateOf 를 사용하면 내부 블럭의 계산을 매번하지 않고, 무분별한 recomposition 을 방지할 수 있어서 좋겠지~'라는 막연한 생각에 위와 같이 구현 후, 뿌듯한 마음으로 앱을 업데이트 하였다.

But

WaitingDialog 에 인원 수를 조건에 맞게 선택하고(partySize), 핸드폰 번호를 입력한 후(phoneNumberState), 개인정보 제공 동의(isPrivacyPolicyClicked)를 체크하여도 웨이팅 등록 버튼이 활성화 되지 않았다...

Compose 를 처음 접하고, 사용해온지 이제 2~3년 정도가 되었는데, 부끄럽지만 위의 상황에서 왜 버튼이 활성화가 되지 않는지 제대로 이해하지 못했다.

문제 해결

결론부터 말하면 위의 코드를 다음과 같이 수정해주면, 의도한 대로 버튼이 활성화 된다.

// remember 의 key 로 의존성을 명시 
val isButtonEnabled by remember(partySize, isPrivacyClicked) {
    derivedStateOf {
        val isPartySizeValid = partySize in 1..10
        val phoneNumber = phoneNumberState.text.toString().replace("-", "")
        val isPhoneNumberValid = phoneNumber.matches(RegexConstants.PHONE_NUMBER)

        isPartySizeValid && isPhoneNumberValid && isPrivacyClicked
    }
}

참고로 TextFieldState.text 의 경우 TextFieldState 클래스의 value 변수를 외부로 노출하는 읽기 전용인 backing property 이다.
value 변수가 mutableStateOf로 관리되기 때문에, 따라서 TextFieldState.text는 Compose State 이다.

TextFieldState.kt

internal var value: TextFieldCharSequence by
    mutableStateOf(TextFieldCharSequence(initialText, initialSelection))
    private set

val text: CharSequence
    get() = value.text 

derivedStateOf가 무엇인가

파생된 + 상태 + ~의
-> ~로부터 파생된 상태(~에서 유도된 상태) 라고 직역할 수 있으며, 다른 상태를 기반으로 파생된(derived) 상태를 정의하여, 원본 상태가 바뀔 때만 파생 상태를 업데이트하고, 변경 전파를 최소화 하기 위해 사용된다.(flow 의 distinctUntilChanged와 동작 비슷)

언제 사용하면 되는가

입력으로 들어오는 상태들이 결과의(파생된) 상태보다 더 자주 변경될 때 사용하면 이득을 볼 수 있다.
(ex. 리스트 스크롤의 임계값 체크, form validation 등등...)

derivedStateOf의 내부 구현 및 동작 분석은 해당 글을 참고하면 좋을 듯 하다.

그렇다면 왜 remember의 key 를 지정해야 하는가?

처음 작성한 코드에서는 remember에 key를 지정하지 않았기 때문에, derivedStateOf가 의존해야 할 값(partySize, isPrivacyPolicyClicked)이 바뀌어도 재계산이 일어나지 않았다.

그 이유로는 partySize, isPrivacyPolicyClicked 과 같은 값들은 Compose State가 아닌 일반 변수이기 때문에, Compose의 snapshot System 내에서 자동으로 추적되지 않기 때문이다.

즉, 내부 계산 로직은 여전히 이전(초기) 상태를 바라보고 있었고, 버튼의 enabled 값이 갱신되지 않았던 것이다.

따라서 Compose State 가 아닌 일반 변수를 기반으로 파생 상태를 만들고 싶다면, 반드시 remember에 key로 지정해줘야 한다.

그래야 Compose는 이 값이 바뀌었다는 것을 인지하고 derivedStateOf를 다시 계산해 결과를 갱신할 수 있다.

그럼에도 derivedStateOf를 사용하는게 이득인가?

여기서 드는 한가지 의문은

"remember 의 key를 통해 의존성에 대한 계산이 다시 이뤄지는데, remember만 사용해도 되는거 아닌가?" 인데,

remember(Key) + derivedStateOf 조합과 remember(Key)의 차이를 Layout Inspector를 통해 확인해보았다.

실험 조건은 동일하게 설정해야하기 때문에 플로우를 다음과 같이 설정하였다

  1. 인원수를 5로 올리고(1 -> 5, + 버튼을 4번 클릭)
  2. 전화번호를 입력한 뒤(11자리)
  3. 개인정보 처리방침 동의 체크

예상되는 Recomposition 횟수는 4 + 11 + 1 로 16이다.

1. remember(Key) + derivedStateOf

val isButtonEnabled by remember(partySize, isPrivacyClicked) {
    derivedStateOf {
        val isPartySizeValid = partySize in 1..10
        val phoneNumber = phoneNumberState.text.toString().replace("-", "")
        val isPhoneNumberValid = phoneNumber.matches(RegexConstants.PHONE_NUMBER)
        
        isPartySizeValid && isPhoneNumberValid && isPrivacyClicked
    }
}

결과: isButtonEnabled 정상 동작
Recomposition: phoneNumberState.text 를 변경했을 때, Recomposition 발생 X

2. remember(Key)

val isButtonEnabled = remember(partySize, phoneNumberState.text, isPrivacyClicked) {
    val isPartySizeValid = partySize in 1..10
    val phoneNumber = phoneNumberState.text.toString().replace("-", "")
    val isPhoneNumberValid = phoneNumber.matches(RegexConstants.PHONE_NUMBER)
    
    isPartySizeValid && isPhoneNumberValid && isPrivacyClicked
}

결과: isButtonEnabled 정상 동작
Recomposition: phoneNumberState.text 를 변경할 때마다 Recomposition 발생 O

번외 3. remember(Key) + TextFieldState.text Key에서 제외

val isButtonEnabled = remember(partySize, isPrivacyClicked) {
    val isPartySizeValid = partySize in 1..10
    val phoneNumber = phoneNumberState.text.toString().replace("-", "")
    val isPhoneNumberValid = phoneNumber.matches(RegexConstants.PHONE_NUMBER)
    
    isPartySizeValid && isPhoneNumberValid && isPrivacyClicked
}

결과: isButtonEnabled 정상 동작 X
Recomposition: phoneNumberState.text 를 변경했을 때, Recomposition 발생 X

인원수 입력 -> 전화번호 입력 -> 개인정보 동의 체크 후 isButtonEnabled가 true 로 변한 이후에, phoneNumberState.text 를 변경할 경우(빈 값으로 변경), 여전히 isButtonEnabled가 true 로 유지됨(추적이 정상적으로 이뤄지지 않음)
-> 마지막에 개인정보 동의 체크를 했기에, recomposition이 되어 isButtonEnabled가 다시 계산됨, phoneNumberState.text 는 추적되지 않으므로, 이후 전화번호를 지워도 isButtonEnabled 의 값 유지.

결론

결론적으로 derivedStateOf 내부 블럭에서 읽는 값 중에 Compose State가 하나라도 존재하면, Recomposition을 줄일 수 있어, 성능 측면에서 이득을 볼 수 있다.

그렇지 않고 전부 일반 변수(non-Compose State)라면 derivedStateOf 를 사용해도 그닥 이득을 볼 수 없다는 것을 알 수 있었다. 이땐 remember(key) 조합만 써도 될 듯하다.

이 경우엔 UiState의 Computed Property를 도입하는 방식이 더 나을 것 같기도 하다.

data class BoothDetailUiState(
    val isLoading: Boolean = false,
    val boothDetailInfo: BoothDetailModel = BoothDetailModel(),
    val isWaitingDialogVisible: Boolean = false,
    val boothPinNumberError: Boolean = false,
    val waitingPartySize: Long = 1,
    val waitingTel: TextFieldState = TextFieldState(),
    val waitingTeamNumber: Long = 0,
    val privacyConsentChecked: Boolean = false,
    val myWaitingList: ImmutableList<MyWaitingModel> = persistentListOf(),
    // ...
) {
    val isWaitingRegisterButtonEnabled: Boolean
        get() {
            val phoneNumber = waitingTel.text.toString().replace("-", "")
            val isPhoneNumberValid = phoneNumber.matches(RegexConstants.PHONE_NUMBER)
            val isPartySizeValid = waitingPartySize in 1..10
            
            return isPartySizeValid && isPhoneNumberValid && privacyConsentChecked
        }
}

// 화면 내에 사용 
if (uiState.isWaitingDialogVisible) {
    WaitingDialog(
        boothName = uiState.boothDetailInfo.name,
        waitingCount = uiState.waitingTeamNumber,
        phoneNumberState = uiState.waitingTel,
        partySize = uiState.waitingPartySize,
        isPrivacyClicked = uiState.privacyConsentChecked,
        // uiState 의 isWaitingRegisterButtonEnabled 파라미터 추가
        isButtonEnabled = uiState.isWaitingRegisterButtonEnabled,
        onDismissRequest = { onAction(BoothDetailUiAction.OnWaitingDialogDismiss) },
        onWaitingMinusClick = { onAction(BoothDetailUiAction.OnWaitingMinusClick) },
        onWaitingPlusClick = { onAction(BoothDetailUiAction.OnWaitingPlusClick) },
        onDialogWaitingButtonClick = { onAction(BoothDetailUiAction.OnDialogWaitingButtonClick) },
        onPolicyCheckBoxClick = { onAction(BoothDetailUiAction.OnPolicyCheckBoxClick) },
        onPrivacyPolicyClick = { onAction(BoothDetailUiAction.OnPrivatePolicyClick) },
    )
}

이와 같이 사용하였을 때, 정상 동작 함을 확인하였고, remember(key) + derivedStateOf 처럼 phoneNumberState.text가 변할때는 recomposition이 발생하지 않는 것을 알 수 있었다.

derivedStateOf를 사용하는 경우에 대해 잘못 알고 있던 부분을 바로 잡을 수 있었다.

추가적으로 고민해볼 부분

  1. partySize랑 isPrivacyPolicyCheck 를 UiState로 관리하지 않고 WaitingDialog Composable 내에서 MutableStateOf 로 관리하면 어떻게 될까?
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable
fun WaitingDialog(
    boothName: String,
    waitingCount: Long,
    phoneNumberState: TextFieldState,
    onDismissRequest: () -> Unit,
    onDialogWaitingButtonClick: (partySize: Long) -> Unit,
    onPrivacyPolicyClick: () -> Unit,
    modifier: Modifier = Modifier,
) {
    var partySize by remember { mutableLongStateOf(1L) }
    var isPrivacyClicked by remember { mutableStateOf(false) }

    val isButtonEnabled by remember {
        derivedStateOf {
            val phoneNumber = phoneNumberState.text.toString().replace("-", "")
            val isPhoneNumberValid = phoneNumber.matches(RegexConstants.PHONE_NUMBER)
            val isPartySizeValid = partySize in 1..10

            isPartySizeValid && isPhoneNumberValid && isPrivacyClicked
        }
    }
    
    val coroutineScope = rememberCoroutineScope()
    
    Box(modifier = Modifier.fillMaxSize()) {
        LazyColumn(
            state = scrollState,
            modifier = Modifier.fillMaxSize()
        ) {
            items(100) { index ->
                Card(
                    modifier = Modifier
                        .fillMaxWidth()
                        .padding(horizontal = 16.dp, vertical = 8.dp)
                ) {
                    Text(
                        text = "Item $index",
                        modifier = Modifier.padding(16.dp)
                    )
                }
            }
        }
        
        // AnimatedVisibility로 FAB 표시/숨김이 스크롤에 따라 자동으로 동작
        AnimatedVisibility(
            visible = showScrollToTop,
            enter = fadeIn() + scaleIn(),
            exit = fadeOut() + scaleOut(),
            modifier = Modifier.align(Alignment.BottomEnd)
        ) {
            FloatingActionButton(
                onClick = {
                    coroutineScope.launch {
                        scrollState.animateScrollToItem(0)
                    }
                },
                modifier = Modifier.padding(16.dp)
            ) {
                Icon(
                    imageVector = Icons.Default.KeyboardArrowUp,
                    contentDescription = "Scroll to top"
                )
            }
        }
    } 
}

결과

  1. 그러고보니 TextFieldState.text 의 업데이트를 UI에 반영하려면 Recomposition 이 발생을 해야할텐데, 어떻게 업데이트가 되었는데도 불구하구 Layout Inspector 상에 Recomposition Count가 증가하지 않는가...
    Smart Recomposition 이 적용되서 WaitingDialog 의 Recomposition 이 발생하지 않는다 해도, 직접적인 영향을 받는 BasicTextField는 Recomposition 이 발생해야할텐데,,,
    이는 TextFieldState의 내부 동작과 Compose UI 트리에 미치는 영향을 추가적으로 확인해봐야 알 수 있을듯 하다!

답변 추가

Recomposition의 정의가 Composition 단계부터 다시 시작하는 것인데, Layout 또는 Drawing 단계부터 다시 시작하는 경우 Recomposition 이 발생하지 않는다. 따라서 Layout Inspector 에도 Recomposition 집계가 되지 않는 것이다...!

TextFieldState 의 경우 mainBuffer를 직접 조작하고, 대부분의 타이핑/커서 이동은 Drawing 단계만 다시 시작되기 때문에, Recomposition 없이, UI 의 업데이트를 이뤄낼 수 있다.

  1. Compose State 는 상위 컴포넌트로 부터 전달 받더라도, 자동으로 추적된다.
@Composable
fun ScrollableScreen(
    scrollState: LazyListState  // ← 상위에서 전달받은 Compose State
) {
    // remember 의 key 를 지정하지 않아도 정상 동작
    val showScrollToTop by remember {
        derivedStateOf {
            // Compose State 읽기
            scrollState.firstVisibleItemIndex > 0 
        }
    }
    
        val coroutineScope = rememberCoroutineScope()
    
    Box(modifier = Modifier.fillMaxSize()) {
        LazyColumn(
            state = scrollState,
            modifier = Modifier.fillMaxSize()
        ) {
            items(100) { index ->
                Card(
                    modifier = Modifier
                        .fillMaxWidth()
                        .padding(horizontal = 16.dp, vertical = 8.dp)
                ) {
                    Text(
                        text = "Item $index",
                        modifier = Modifier.padding(16.dp)
                    )
                }
            }
        }
        
        // AnimatedVisibility로 FAB 표시/숨김이 스크롤에 따라 자동으로 동작
        AnimatedVisibility(
            visible = showScrollToTop,
            enter = fadeIn() + scaleIn(),
            exit = fadeOut() + scaleOut(),
            modifier = Modifier.align(Alignment.BottomEnd)
        ) {
            FloatingActionButton(
                onClick = {
                    coroutineScope.launch {
                        scrollState.animateScrollToItem(0)
                    }
                },
                modifier = Modifier.padding(16.dp)
            ) {
                Icon(
                    imageVector = Icons.Default.KeyboardArrowUp,
                    contentDescription = "Scroll to top"
                )
            }
        }
    }
}

전달 받은 값인지가 중요한게 아니라, derivedStateof 내부에서 읽히는 값의 타입이 무엇이냐(Compose State vs non-Compose State)가 중요하다.

reference)
https://www.youtube.com/watch?v=_bb0PVBe3eQ
https://haeti.palms.blog/compose-snapshot-system
https://thinking-face.tistory.com/387
https://medium.com/androiddevelopers/jetpack-compose-when-should-i-use-derivedstateof-63ce7954c11b

profile
실력은 고통의 총합이다. Android Developer

2개의 댓글

comment-user-thumbnail
2025년 9월 15일

이번 글을 읽으면서 예전에 iOS 개발할 때 RxSwift로 Validation을 체크하고, 회원가입 버튼 활성/비활성화를 제어했던 경험이 떠올랐습니다! 여러 입력 값을 CombineLatest로 묶어서 변경 사항이 생길 때마다 최신 값들을 결합하여 버튼을 상태를 갱신했었는데, Compose의 derivedStateOf도 이와 유사한 패던인 것 같군요 👀

1개의 답글