Custom Saver로 Compose 상태 관리 개선하기

Seogi·2025년 3월 11일

Compose

목록 보기
8/8

Jetpack Compose에서 상태 관리는 앱의 핵심 요소이다. 특히, 복잡한 UI를 다룰 때는 상태를 효율적으로 관리하는 것이 중요하다. 이번 글에서는 State HolderCustom Saver를 사용하여 상태 관리를 개선하는 과정을 공유하고자 한다.

기존 방식의 문제점: Parcelable의 한계

이전에는 State Holder의 상태를 저장하기 위해 Parcelable을 사용했다. 특히, 람다와 같이 Bundle에 직접 저장할 수 없는 객체는 별도의 Parcelable 클래스로 감싸서 처리했다.

class ExampleTextFieldState(
    initialText: String = "",
    isLengthVisible: Boolean = false,
    val placeholder: String? = null,
    val maxLength: Int? = null,
    val validation: (String) -> Boolean = { true },
    val validation: (String) -> Boolean = { true },
) {
    // ...
    companion object {
        val Saver: Saver<ExampleTextFieldState, *> = listSaver(
            save = {
                listOf(
                    it.text,
                    it.isLengthVisible,
                    it.placeholder,
                    it.maxLength,
                    Validation(it.validation)
                )
            },
            restore = {
                ExampleTextFieldState(
                    initialText = it[0] as String,
                    isLengthVisible = it[1] as Boolean,
                    placeholder = it[2] as String?,
                    maxLength = it[3] as Int?,
                    validation = (it[4] as Validation).validation,
                )
            }
        )
    }
}

@Parcelize
class Validation(val validation: (String) -> Boolean) : Parcelable

위 코드처럼 람다를 Parcelable로 감싸서 Bundle에 저장하는 방식은 간단한 경우에는 유용했지만, 다른 프로젝트를 진행하면서 몇 가지 문제가 발생했다.

다음과 같은 State Holder를 저장해야 하는 상황에서 Parcelable의 한계가 명확하게 드러난 것이다.

class OfferingFiltersState(
    val filters: List<Filter>,
    initSelectedFilter: Filter? = null,
    private val onFilterClick: (Filter, Boolean) -> Unit,
)

OfferingFiltersStateinitSelectedFilterfilters라는 Filter 객체를 포함하며, onFilterClick이라는 람다를 가지고 있다. 그러나 Bundle은 기본적으로 원시 타입만 저장할 수 있어 해당 State Holder를 직접 저장할 수 없다. 이를 해결하기 위해 Parcelable을 사용해 객체를 직렬화하려 했지만, 다음과 같은 문제에 직면했다.

  1. 복잡성 증가: Filter처럼 Parcelable을 지원하지 않는 객체를 포함한 State Holder를 저장하려면, 관련된 모든 객체를 Parcelable로 변환해야 한다.

    data class Filter(
        val name: FilterName,
        val value: String,
        val type: FilterType,
    )

    Filter 객체를 Parcelable로 만들려면 FilterNameFilterTypeParcelable로 변환해야 한다.

  2. Parcelable 제약: Parcelableval 또는 var 프로퍼티만 지원하므로, 생성자 파라미터가 아닌 인자는 저장할 수 없다. 따라서 initSelectedFilter 같은 인자는 Parcelable로 직렬화할 수 없다.

    class OfferingFiltersState(
        val filters: List<Filter>,
        initSelectedFilter: Filter? = null, // compile error: 'Parcelable' constructor parameter should be 'val' or 'var'
        private val onFilterClick: (Filter, Boolean) -> Unit,
    )

Custom Saver의 도입

앞서 Parcelable의 한계 때문에 OfferingFiltersState처럼 복잡한 State Holder를 저장하는 데 어려움이 있었다는 점을 설명했다. 이제, 프로젝트에서 Custom Saver를 도입해 이를 어떻게 해결했는지 구체적인 구현 방법을 소개하겠다.

팩토리 메서드를 활용한 Custom Saver 구현

Jetpack Compose는 Saver 인터페이스를 직접 구현하는 대신, 간편하게 Saver 객체를 생성할 수 있는 함수를 제공한다.

fun <Original, Saveable : Any> Saver(
    save: SaverScope.(value: Original) -> Saveable?,
    restore: (value: Saveable) -> Original?
): Saver<Original, Saveable> {
    return object : Saver<Original, Saveable> {
        override fun SaverScope.save(value: Original) = save.invoke(this, value)

        override fun restore(value: Saveable) = restore.invoke(value)
    }
}

이 함수를 사용하여 saverestore 람다를 정의하면 Saver 객체를 쉽게 생성할 수 있다.

람다나 변환하기 힘든 복잡한 객체를 Custom Saver에 포함해야 하는 경우, 팩토리 메서드의 인자로 넘기는 방식을 사용할 수 있다. 다음은 OfferingFiltersStateCustom Saver를 적용한 코드이다.

class OfferingFiltersState(
    val filters: List<Filter>,
    initSelectedFilter: Filter? = null,
    private val onFilterClick: (Filter, Boolean) -> Unit,
) {
	// ...

    companion object {
        fun saver(
            filters: List<Filter>,
            onFilterClick: (Filter, Boolean) -> Unit,
        ): Saver<OfferingFiltersState, *> =
            Saver(
                save = {
                    it.selectedFilter?.value
                },
                restore = { savedValue ->
                    OfferingFiltersState(
                        initSelectedFilter = filters.find { it.value == savedValue },
                        filters = filters,
                        onFilterClick = onFilterClick,
                    )
                },
            )
    }
}

@Composable
fun rememberSavableOfferingFiltersState(
    filters: List<Filter>,
    initSelectedFilter: Filter? = null,
    onFilterClick: (Filter, Boolean) -> Unit,
): OfferingFiltersState {
    return rememberSaveable(
        initSelectedFilter,
        filters,
        onFilterClick,
        saver = OfferingFiltersState.saver(filters, onFilterClick),
    ) { OfferingFiltersState(filters, initSelectedFilter, onFilterClick) }
}

OfferingFiltersState.saver() 함수는 filtersonFilterClick 람다를 인자로 받아 Saver 객체를 생성한다. save 람다에서는 selectedFilter의 값만 저장하고, restore 람다에서는 저장된 값을 기반으로 filters 리스트에서 해당 Filter를 찾아 OfferingFiltersState를 복원한다.

이처럼 팩토리 메서드의 인자를 활용하면, 람다나 복잡한 객체도 Custom Saver에 효과적으로 포함할 수 있다.

기존 방식 개선: ExampleTextFieldState에 Custom Saver 적용

기존에 Parcelable을 사용했던 ExampleTextFieldStateCustom Saver를 사용하여 개선할 수 있다.

class ExampleTextFieldState(
    initialText: String = "",
    isLengthVisible: Boolean = false,
    val placeholder: String? = null,
    val maxLength: Int? = null,
    val validation: (String) -> Boolean = { true },
) {
    // ...

    companion object {
        fun saver(
            validation: (String) -> Boolean,
        ): Saver<ExampleTextFieldState, *> =
            Saver(
                save = {
                    listOf(
                        it.text,
                        it.isLengthVisible,
                        it.placeholder,
                        it.maxLength,
                    )
                },
                restore = {
                    ExampleTextFieldState(
                        initialText = it[0] as String,
                        isLengthVisible = it[1] as Boolean,
                        placeholder = it[2] as String?,
                        maxLength = it[3] as Int?,
                        validation = validation
                    )
                },
            )
    }
}

@Composable
fun rememberExampleTextFieldState(
    initialText: String = "",
    isLengthVisible: Boolean = false,
    placeholder: String? = null,
    maxLength: Int? = null,
    validation: (String) -> Boolean = { true },
): ExampleTextFieldState {
    return rememberSaveable(
        initialText, isLengthVisible, placeholder, maxLength, validation,
        saver = ExampleTextFieldState.saver(validation)
    ) {
        ExampleTextFieldState(
            initialText,
            isLengthVisible,
            placeholder,
            maxLength,
            validation
        )
    }
}

Custom Saver 사용 시 주의사항

Custom Saver 과용 금지

Custom Saver는 복잡한 객체나 람다를 저장하는 데 유용하지만, 과도한 사용은 직렬화/역직렬화 과정의 복잡성을 증가시키고 성능 저하를 초래할 수 있다.

결론

Parcelable을 사용한 기존 상태 저장 방식의 한계를 극복하기 위해 Custom Saver를 도입한 방법을 소개했다. Saver를 활용하면 Jetpack Compose의 rememberSaveable과 함께 상태를 보다 유연하게 관리할 수 있으며, 특히 Parcelable로 직렬화할 수 없는 람다나 복잡한 객체를 저장하는 데 유용하다.

그러나 Custom Saver는 만능 해결책이 아니다. 직렬화 과정에서 발생하는 오버헤드를 최소화하려면 꼭 필요한 데이터만 저장하도록 신중하게 설계하는 것이 중요하다.

0개의 댓글