반응형
Notice
Recent Posts
Recent Comments
Link
«   2025/02   »
1
2 3 4 5 6 7 8
9 10 11 12 13 14 15
16 17 18 19 20 21 22
23 24 25 26 27 28
Tags
more
Archives
Today
Total
관리 메뉴

안드로이드 개발자 노트

Compose의 Side-effects 본문

Android

Compose의 Side-effects

어리둥절범고래 2025. 2. 9. 13:53
반응형

부수 효과(Side Effect)는 컴포저블 함수의 범위를 벗어난 앱 상태 변경을 의미하며, 예측할 수 없는 리컴포지션으로 인해 가급적 피하는 것이 좋습니다.

 

Composable을 사용하면 여러 개의 Composable을 겹쳐서 쓸 수 있고, 시스템은 각 Composable의 Lifecycle을 관리하며, 필요할 때만 재구성합니다. Composable은 기본적으로 바깥쪽에서 안쪽으로 상태를 내려줘 단방향 의존성이 형성됩니다. 하지만 안쪽 Composable이 바깥쪽 상태를 변경하거나, Composable이 앱 상태를 변경하면 양방향 의존성이 생기며 예측할 수 없는 부수 효과(Side Effect)가 발생할 수 있습니다.

 

Side Effect란 Composable에서 자신이 아닌 외부의 State(상태)에 영향을 만드는 것을 뜻합니다.

 

 

 

상태 및 효과 사용 사례


컴포저블은 부수 효과 없이 동작해야 하지만, 앱 상태 변경이 필요한 경우 Effect API를 사용해 예측 가능하게 실행해야 합니다.

effect는 화면에 직접적으로 표시되는 UI를 만들지 않고, 컴포지션(구성 과정)이 끝난 후에 특정 작업(부수 효과)을 실행하는 함수입니다. 예를 들어, 화면에 표시되는 UI 외에 다른 동작을 처리하는 함수라고 생각하면 됩니다.

 

다양한 effect를 사용할 수 있지만, UI와 관련된 작업만 실행하고 단방향 데이터 흐름을 방해하지 않도록 해야 합니다.

 

 

 

LaunchedEffect: 컴포저블의 범위에서 정지 함수 실행

 

LaunchedEffect는 컴포저블에서 정지(suspend) 함수를 실행하는 기능을 제공합니다. 컴포지션이 시작되면 코루틴이 실행되고, 컴포지션이 종료되면 취소됩니다. 만약 LaunchedEffect가 재구성되면 기존 코루틴은 취소되고 새로운 코루틴이 실행됩니다.

// 사용자에게 시간이 부족할 경우 속도를 높일 수 있도록 펄스 속도를 설정할 수 있게 허용
var pulseRateMs by remember { mutableStateOf(3000L) }
val alpha = remember { Animatable(1f) }

LaunchedEffect(pulseRateMs) { // 펄스 속도가 변경될 때 효과를 재시작
    while (isActive) {
        delay(pulseRateMs) // 사용자에게 알리기 위해 alpha 값을 매 펄스 속도마다 변경
        alpha.animateTo(0f)
        alpha.animateTo(1f)
    }
}

 

 

 

rememberCoroutineScope: 컴포지션 인식 범위를 확보하여 컴포저블 외부에서 코루틴 실행

 

rememberCoroutineScope는 컴포저블 외부에서 코루틴을 실행할 수 있는 범위를 제공합니다. LaunchedEffect는 컴포저블 함수이므로 컴포저블 내에서만 사용 가능하지만, rememberCoroutineScope를 사용하면 컴포저블 외부에서도 자동 취소되는 범위에서 코루틴을 실행할 수 있습니다. 이 함수는 컴포지션 종료 시 범위를 취소하며, 코루틴 수명 관리를 수동으로 해야 할 때 유용합니다. 예를 들어, 사용자가 버튼을 클릭할 때 Snackbar를 표시하는 데 사용할 수 있습니다.

@Composable
fun MoviesScreen(snackbarHostState: SnackbarHostState) {

    // MoviesScreen의 라이프사이클에 바인딩된 CoroutineScope를 생성합니다.
    val scope = rememberCoroutineScope()

    Scaffold(
        snackbarHost = {
            SnackbarHost(hostState = snackbarHostState)
        }
    ) { contentPadding ->
        Column(Modifier.padding(contentPadding)) {
            Button(
                onClick = {
                    // 이벤트 핸들러에서 새로운 코루틴을 생성하여 스낵바를 표시합니다.
                    scope.launch {
                        snackbarHostState.showSnackbar("Something happened!")
                    }
                }
            ) {
                Text("Press me")
            }
        }
    }
}

 

 

 

rememberUpdatedState: 값이 변경되는 경우 다시 시작되지 않아야 하는 effect에서 값 참조

 

rememberUpdatedState는 값이 변경되어도 effect가 다시 시작되지 않도록 할 때 사용합니다. 주로 비용이 큰 작업이나 오래 지속되는 작업에서 유용하며, 예를 들어 시간이 지나면 사라지는 LandingScreen에서 재구성 후에도 시간 경과를 알리는 effect과가 다시 시작되지 않도록 할 때 사용됩니다.

@Composable
fun LandingScreen(onTimeout: () -> Unit) {

    // 이것은 항상 LandingScreen이 리컴포지션 될 때 최신의 onTimeout 함수 참조를 유지합니다.
    val currentOnTimeout by rememberUpdatedState(onTimeout)

    // LandingScreen의 생명주기에 맞는 효과를 생성합니다.
    // LandingScreen이 리컴포지션 되더라도 지연이 다시 시작되지 않습니다.
    LaunchedEffect(true) {
        delay(SplashWaitTimeMillis)
        currentOnTimeout()
    }

    /* Landing screen content */
}

변경되지 않는 상수(Unit 또는 true)를 매개변수로 전달하여 호출 사이트의 수명 주기와 일치하는 effect를 만듭니다. 최신 onTimeout 값을 항상 포함하려면 rememberUpdatedState로 람다를 래핑하고, 반환된 State인 currentOnTimeout을 effect에서 사용해야 합니다.

 

 

 

 

DisposableEffect: 정리가 필요한 effect


키가 변경되거나 컴포저블이 종료될 때 정리해야 하는 부수 효과에는 DisposableEffect를 사용해야 합니다.

DisposableEffect는 키가 변경되면 현재 effect를 정리하고, 새로운 effect를 설정합니다.
예를 들어, LifecycleObserver를 사용하여 라이프사이클 이벤트 기반으로 애널리틱스를 전송할 때, DisposableEffect로 관찰자를 등록하고 해제할 수 있습니다.

@Composable
fun HomeScreen(
    lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current,
    onStart: () -> Unit, // '시작' 분석 이벤트 전송
    onStop: () -> Unit // '중지' 분석 이벤트 전송
) {
    // 새 람다가 제공되면 현재 람다를 안전하게 업데이트합니다.
    val currentOnStart by rememberUpdatedState(onStart)
    val currentOnStop by rememberUpdatedState(onStop)

    // `lifecycleOwner`가 변경되면 효과를 삭제하고 재설정합니다.
    DisposableEffect(lifecycleOwner) {
        // 분석 이벤트를 전송하는 기억된 콜백을 트리거하는 관찰자를 생성합니다.
        val observer = LifecycleEventObserver { _, event ->
            if (event == Lifecycle.Event.ON_START) {
                currentOnStart()
            } else if (event == Lifecycle.Event.ON_STOP) {
                currentOnStop()
            }
        }

        // 생명 주기에 관찰자를 추가합니다.
        lifecycleOwner.lifecycle.addObserver(observer)

        // 효과가 컴포지션을 떠날 때 관찰자를 제거합니다.
        onDispose {
            lifecycleOwner.lifecycle.removeObserver(observer)
        }
    }

    /* 홈 화면 콘텐츠 */
}

 

 

 

 

produceState: 비 Compose 상태를 Compose 상태로 변환


produceState는 외부 상태를 Compose 상태로 변환하는 코루틴을 실행하며, 반환된 State로 값을 푸시합니다.

예를 들어 Flow, LiveData, RxJava와 같은 외부 구독 기반 상태를 Compose 상태로 변환할 때 사용합니다.

produceState가 컴포지션을 시작하면 프로듀서가 실행되고, 종료되면 취소됩니다. 반환된 State는 합성되며, 같은 값으로 설정해도 리컴포지션이 트리거되지 않습니다.

정지되지 않는 데이터 소스를 관찰할 때도 사용할 수 있으며, 구독을 취소하려면 awaitDispose를 사용합니다.

예를 들어, produceState를 사용하여 네트워크에서 이미지를 로드하는 할 수 있으며, 반환된 State는 다른 컴포저블에서 사용할 수 있습니다.

@Composable
fun loadNetworkImage(
    url: String,
    imageRepository: ImageRepository = ImageRepository()
): State<Result<Image>> {
    // Result.Loading을 초기값으로 갖는 State<T>를 생성합니다.
    // `url`이나 `imageRepository`가 변경되면 실행 중인 프로듀서는 취소되고 새로운 입력값으로 다시 실행됩니다.
    return produceState<Result<Image>>(initialValue = Result.Loading, url, imageRepository) {
        // 코루틴 내에서 suspend 호출을 할 수 있습니다.
        val image = imageRepository.load(url)

        // State를 Error 또는 Success 결과로 업데이트합니다.
        // 이로 인해 이 State가 읽히면서 리컴포지션이 트리거됩니다.
        value = if (image == null) {
            Result.Error
        } else {
            Result.Success(image)
        }
    }
}

 

 

 

derivedStateOf: 상태 객체를 다른 상태로 변환

Compose에서는 상태 객체나 입력이 변경될 때마다 재구성이 발생하지만, UI가 실제로 업데이트되어야 하는 것보다 더 자주 변경되면 불필요한 재구성이 일어날 수 있습니다.

 

derivedStateOf는 입력이 너무 자주 변경될 때 사용합니다.

이는 스크롤 위치처럼 자주 변경되지만, 컴포저블은 특정 임계값을 넘어서야만 반응하도록 합니다. derivedStateOf는 필요한 경우에만 업데이트되는 새로운 Compose 상태 객체를 생성하며, 이는 distinctUntilChanged()처럼 동작합니다.

@Composable
// messages 매개변수가 변경되면 MessageList 컴포저블이 리컴포지션됩니다.
// derivedStateOf는 이 리컴포지션에 영향을 주지 않습니다.
fun MessageList(messages: List<Message>) {
    Box {
        val listState = rememberLazyListState()

        LazyColumn(state = listState) {
            // ...
        }

        // 첫 번째 항목이 지나면 버튼을 표시합니다. 불필요한 리컴포지션을 최소화하기 위해
        // 기억된 derived state를 사용합니다.
        val showButton by remember {
            derivedStateOf {
                listState.firstVisibleItemIndex > 0
            }
        }

        AnimatedVisibility(visible = showButton) {
            ScrollToTopButton()
        }
    }
}

 

 

 

snapshotFlow: Compose 상태를 Flow로 변환


snapshotFlow는 State<T> 객체를 콜드 Flow로 변환합니다. 이 함수는 수집될 때 블록을 실행하고, 읽은 State 객체의 값을 Flow로 내보냅니다. 블록 내에서 State 객체가 변경되면, 이전 값과 다를 경우 새로운 값이 Flow에서 수집기로 내보내집니다(Flow.distinctUntilChanged와 유사한 동작).

다음 예시는 사용자가 목록에서 첫 번째 항목을 지나 분석까지 스크롤할 때 기록되는 부작용을 보여줍니다.

val listState = rememberLazyListState()

LazyColumn(state = listState) {
    // ...
}

LaunchedEffect(listState) {
    snapshotFlow { listState.firstVisibleItemIndex }
        .map { index -> index > 0 }
        .distinctUntilChanged()
        .filter { it == true }
        .collect {
            MyAnalyticsService.sendScrolledPastFirstItemEvent()
        }
}

 

 

 

effect 재시작

 

Compose의 effect(LaunchedEffect, produceState, DisposableEffect 등)는 실행 중인 effect를 취소하고 새 키로 새 effect를 시작하기 위해 여러 인수를 사용할 수 있으며, 일반적인 형식은 다음과 같습니다.

EffectName(restartIfThisKeyChanges, orThisKey, ...) { block }

 

effect를 다시 시작하는 매개변수가 잘못되면 문제가 발생할 수 있습니다.

  • 적은 수의 매개변수로 effect를 다시 시작하면 버그가 생길 수 있습니다.
  • 너무 많은 매개변수로 effect를 다시 시작하면 비효율적일 수 있습니다.

일반적으로 effect 코드 블록에 사용되는 변수는 해당 변수들이 매개변수로 전달되어야 하며, 변수가 변경되어도 effect가 다시 시작되지 않아야 한다면 rememberUpdatedState로 래핑해야 합니다. remember를 사용할 경우, 변경되지 않는 변수는 매개변수로 전달할 필요가 없습니다.

 

 

 

 

 

Side Effect 처리 정리

 

Compose는 이러한 부수 효과(Side Effect)를 처리하기 위한 다양한 Effect API를 제공합니다.

이를 통해 우리는 Compose에서 부수 효과를 처리하기 위해 여러 가지 작업을 실행할 수 있습니다.

  • LaunchedEffect : Composable Lifecycle Scope에서 suspend fun 실행하기 위해 사용
  • DisposableEffect : Composable이 Dispose될 때 정리되어야 할 Side Effect 정의하기 위해 사용
  • SideEffect : Compose의 State을 Compose에서 관리하지 않는 객체와 공유하기 위해 사용

추가적으로 Compose는 위 3가지와 함께 사용할 수 있는 여러 CoroutineScope과 State관련 함수를 제공합니다. 

  • rememberCoroutineScope : Composable의 CoroutineScope을 참조하여 외부에서 실행할 수 있도록 함
  • rememberUpdatedState  : Launched Effect는 컴포저블의 State가 변경되면 재실행되는데 재실행 되지 않아도 되는 State를 정의하기 위해 사용
  • produceState : Compose State가 아닌것을 Compose의 State로 변환
  • derivedStateOf : State를 다른 State로 변환하기 위해 사용 Composable은 변환된 State에만 영향 받음
  • snapshotFlow : Composable의 State를 Flow로 변환

 

 

 

 

 

 

 

https://developer.android.com/develop/ui/compose/side-effects?hl=ko

반응형

'Android' 카테고리의 다른 글

Compose의 단계  (0) 2025.02.09
Jetpack Compose 이해하기  (0) 2025.02.08
[Android] Android KTX 잘 알아보기  (0) 2023.01.22