멀티 모듈에서 Recomposition 최소화하기 1 - data class

KEH·2024년 11월 27일
post-thumbnail

최근 회사에서 Recomposition 을 최소화하는 작업을 진행하며 마주했던 이슈에 대해 기록하고자 합니다.

이슈 내용

data class Domain (
    val text: String,
    val isClicked: Boolean = false,
)

data class MainUiState(
    val domainList: List<Domain> = listOf(
        Domain("test1"),
        Domain("test2"),
        Domain("test3"),
        Domain("test4"),
        Domain("test5"),
        Domain("test6"),
        Domain("test7"),
        Domain("test8"),
        Domain("test9"),
        Domain("test10"),
        Domain("test11"),
        Domain("test12"),
        Domain("test13"),
        Domain("test14"),
        Domain("test15"),
        Domain("test16"),
        Domain("test17"),
        Domain("test18"),
        Domain("test19"),
        Domain("test20"),
    )
)

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        enableEdgeToEdge()

        setContent {
            var uiState by remember { mutableStateOf(MainUiState()) }

            MultiModuleRecompositionTheme {
                LazyColumn(
                    modifier = Modifier
                        .fillMaxSize(),
                    contentPadding = PaddingValues(vertical = 16.dp),
                    horizontalAlignment = Alignment.CenterHorizontally
                ) {
                    items(
                        items = uiState.domainList,
                    ) {
                        MyButton(
                            data = it,
                            onClick = { data ->
                                uiState = uiState.copy(
                                    domainList = uiState.domainList.toMutableList().apply {
                                        set(
                                            index = indexOf(data),
                                            element = data.copy(isClicked = !data.isClicked)
                                        )
                                    }
                                )
                            }
                        )
                    }
                }
            }
        }
    }
}

@Composable
fun MyButton(
    data: Domain,
    onClick: (Domain) -> Unit
) {
    Button(
        onClick = { onClick(data) },
        colors = ButtonDefaults.buttonColors(
            containerColor = if (data.isClicked) Color.Red else Color.Transparent
        )
    ) {
        Text(
            text = data.text,
            color = if (data.isClicked) Color.White else Color.Black
        )
    }
}


위와 같이 MyButton 컴포넌트를 item 으로 가지는 LazyColumn 이 존재합니다.
item 을 클릭하면 버튼의 색상이 빨간색으로 변경되고, 다시 한번 클릭하면 원래 상태로 돌아옵니다.

Layout Inspector 를 사용하여 버튼이 클릭됐을 때 Recomposition 을 확인해보았습니다.

현재 클릭되고 있는 버튼은 "test6" 입니다. 따라서 "test6" 버튼만 Recomposition 이 발생해야 합니다.
하지만 Layout Inspector 를 확인해봤을 때 모든 버튼에서 Recomposition 이 발생하고 있는 것을 확인할 수 있었습니다.

원인

제가 담당하고 있는 앱은 clean architecture 기반 멀티 모듈 구조로 설계되어 있습니다.
Domain 클래스는 domain 모듈에 존재합니다.

원인 분석을 위해 여러 가지 상황을 테스트 하던 중 아래와 같이 presentation 모듈에 Presentation 이란 데이터 클래스를 생성하고, MyButton composable 함수에서 Domain 이 아닌 Presentataion 객체를 매개변수로 전달 받도록 수정해 보았습니다.
(MainActivityMyButton 모두 presentation 모듈에 존재합니다.)

/* presentation 모듈 */
data class Presentation(
    val text: String,
    val isClicked: Boolean = false,
)

data class MainUiState(
    val presentationList: List<Presentation> = listOf(
        Presentation("test1"),
        Presentation("test2"),
        Presentation("test3"),
        Presentation("test4"),
        Presentation("test5"),
        Presentation("test6"),
        Presentation("test7"),
        Presentation("test8"),
        Presentation("test9"),
        Presentation("test10"),
        Presentation("test11"),
        Presentation("test12"),
        Presentation("test13"),
        Presentation("test14"),
        Presentation("test15"),
        Presentation("test16"),
        Presentation("test17"),
        Presentation("test18"),
        Presentation("test19"),
        Presentation("test20"),
    )
)

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        enableEdgeToEdge()

        setContent {
            var uiState by remember { mutableStateOf(MainUiState()) }

            MultiModuleRecompositionTheme {
                LazyColumn(
                    modifier = Modifier
                        .fillMaxSize(),
                    contentPadding = PaddingValues(vertical = 16.dp),
                    horizontalAlignment = Alignment.CenterHorizontally
                ) {
                    items(uiState.presentationList) {
                        MyButton(
                            data = it,
                            onClick = { data ->
                                uiState = uiState.copy(
                                    presentationList = uiState.presentationList.toMutableList().apply {
                                        set(
                                            index = indexOf(data),
                                            element = data.copy(isClicked = !data.isClicked)
                                        )
                                    }
                                )
                            }
                        )
                    }
            }
        }
    }
}

@Composable
fun MyButton(
    data: Presentation,
    onClick: (Presentation) -> Unit
) {
    Button(
        onClick = { onClick(data) },
        colors = ButtonDefaults.buttonColors(
            containerColor = if (data.isClicked) Color.Red else Color.Transparent
        )
    ) {
        Text(
            text = data.text,
            color = if (data.isClicked) Color.White else Color.Black
        )
    }
}


presentation 모듈에 존재하는 Presentation 데이터 클래스로 변경하자 클릭된 "test12" 버튼만 Recomposition 이 발생하는 것을 확인할 수 있습니다.

지금까지 추측되는 원인은 두가지 입니다.

  1. Domain 데이터 클래스가 MainActivityMyButton 이 존재하는 presentation 모듈이 아닌 domain 모듈에 존재해서
  2. domain 모듈이 순수 kotlin 모듈이기 때문에 compose 사용할 수 없어서

정확한 원인 파악을 위해 data 모듈에 compose 라이브러리를 추가하고, MyButton composable 함수의 매개변수를 Presentation 에서 data 모듈에 존재하는 Data 데이터 클래스로 변경합니다.
(data 모듈은 Android Library 모듈이기 때문에 compose 라이브러리를 사용할 수 있습니다.)

/* data 모듈 */
data class Data (
    val text: String,
    val isClicked: Boolean = false,
)

/* presentation 모듈 */
data class MainUiState(
    val dataList: List<Data> = listOf(
        Data("test1"),
        Data("test2"),
        Data("test3"),
        Data("test4"),
        Data("test5"),
        Data("test6"),
        Data("test7"),
        Data("test8"),
        Data("test9"),
        Data("test10"),
        Data("test11"),
        Data("test12"),
        Data("test13"),
        Data("test14"),
        Data("test15"),
        Data("test16"),
        Data("test17"),
        Data("test18"),
        Data("test19"),
        Data("test20"),
    )
)

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        enableEdgeToEdge()

        setContent {
            var uiState by remember { mutableStateOf(MainUiState()) }

            MultiModuleRecompositionTheme {
                LazyColumn(
                    modifier = Modifier
                        .fillMaxSize(),
                    contentPadding = PaddingValues(vertical = 16.dp),
                    horizontalAlignment = Alignment.CenterHorizontally
                ) {
                    items(uiState.dataList) {
                        MyButton(
                            data = it,
                            onClick = { data ->
                                uiState = uiState.copy(
                                    dataList = uiState.dataList.toMutableList().apply {
                                        set(
                                            index = indexOf(data),
                                            element = data.copy(isClicked = !data.isClicked)
                                        )
                                    }
                                )
                            }
                        )
                    }
                }
            }
        }
    }
}

@Composable
fun MyButton(
    data: Data,
    onClick: (Data) -> Unit
) {
    Button(
        onClick = { onClick(data) },
        colors = ButtonDefaults.buttonColors(
            containerColor = if (data.isClicked) Color.Red else Color.Transparent
        )
    ) {
        Text(
            text = data.text,
            color = if (data.isClicked) Color.White else Color.Black
        )
    }
}

Data 객체 또한 클릭된 "test5" 버튼만 Recomposition 이 발생하고, 나머지 버튼은 Recomposition 이 발생하지 않는 것을 확인할 수 있습니다.

따라서 이번 이슈의 원인은 compose 라이브러리를 갖지 않는 모듈의 data class 를 composable 함수에서 사용하는 상황에서 데이터가 변경되었을 때 Compose 에서 Stable 하지 않다고 판단하여 변경 내용을 구분하지 못하고 리스트 전체가 변경되었다고 판단해버렸기 때문이라고 정리할 수 있습니다.

해결 방법

단순히 모듈에 compose 라이브러리를 주입하는 것은 적절한 방법이 될 수 없습니다.
domain 모듈과 같이 순수 java/kotlin 모듈일 경우 compose 와 같은 android library 를 가질 수 없기 때문입니다.
또한 단순히 Recomposition 을 최소화 하기 위해 android library 모듈에 compose 라이브러리를 추가하는 것도 좋지 않은 방법입니다.

Compose Compiler 플러그인의 stabilityConfigurationFile 옵션이나 enableStrongSkippingMode 옵션을 사용하여 해결할 수 있습니다.

stabilityConfigurationFile

stabilityConfigurationFile 은 Compose 가 stable 하지 않다고 판단하는 객체들을 stable 하다고 판단할 수 있도록 하는 옵션입니다.

프로젝트 최상단에 stability_config.conf 라는 파일을 생성합니다.

package com.study.domain

data class Domain (
    val text: String,
    val isClicked: Boolean = false,
)

그리고 stability_config.conf 라는 파일에 Domain 데이터 클래스를 패키지명을 포함하여 작성합니다.

com.study.domain.Domain

presentation 모듈의 build.gradle.kts 에 아래와 같이 설정합니다.

android {
	...
}

composeCompiler {
    stabilityConfigurationFile = rootProject.layout.projectDirectory.file("stability_config.conf")
}

enableStrongSkippingMode

enableStrongSkippingMode 는 composable 파라미터가 불안정할 때 skippable 하게 변경하는 옵션입니다.
여기서 skippable 이란 이전 상태와 동일한 값을 가지면 Compose 가 Recomposition 을 생략할 수 있도록 합니다.

stabilityConfigurationFile 와 마찬가지로 build.gradle.kts 에 아래와 같이 설정합니다.

android {
	...
}

composeCompiler {
    stabilityConfigurationFile = rootProject.layout.projectDirectory.file("stability_config.conf")
}
profile
:P

0개의 댓글