Jetpack Compose

[Jetpack Compose] 공식문서 읽기 Side-effects in Compose Part 1

베르_최성훈 2025. 1. 26. 16:30

 

들어가며

 LaunchedEffect, DisposableEffect 를 많이 사용하는데 제대로 알고 사용하자는 의미로 공식문서를 읽어보며 SideEffect 에 대해 공부해보겠습니다. 제 생각과 의역이 들어가기 때문에 정확한 정보를 얻고 싶다면 본문을 읽는걸 추천드립니다.

https://developer.android.com/develop/ui/compose/side-effects

 

 side-effects 는 composable function 범위 밖에서 발생한 앱의 상태 변화입니다. side-Effects는 예상하지 못한 리컴포지션, 리컴포지션 간 순서 변경, 리컴포지션 취소로 이어질 수 있습니다. 때문에 side-effects 는 이상적으로 없어야합니다. 하지만 스낵바를 보여주거나 화면을 이동하는 이벤트 등 side-effects 가 필요한 경우도 있습니다. 이 경우 이벤트가 컴포저블의 lifecycle 에 맞춰서 실행되어야합니다. 그때 사용해야하는 것이 Effect Api 입니다. 이 글에서 Jetpack compose 의 side-effects API 들에 대해 공부해봅시다.

 

State and effect use cases

 An effect is a composable function that doesn't emit UI and causes side effects to run when a composition completes.

 

 문서에 따르면 "effect 는 Composable 함수이지만 UI 를 emit 하지않는다. 컴포지션이 완료될 때 side effects 를 실행한다." 라고 얘기합니다. 화면을 그리는 것이 아니라 Side effects 를 실행하기 위한 함수라고 이해하면 될 것 같습니다. Compose 에서 effect 는 남용되기 쉽습니다. 특히 effect 함수를 사용할 때는 Unidirectional data flow (단방향 데이터 흐름) 이 깨지지 않도록 주의해야합니다.

 

참고: 컴포즈에서 반응형 UI 는 비동기로 발생하며 콜백 대신 코루틴을 이용합니다. 

 

1. LaunchedEffect : run suspend functions in the scope of a composable

 먼저 가장 많이 보는 LaunchedEffect 입니다. Composable scope 내에서 전체 생명주기에 걸쳐 suspend 함수를 실행하려면 LaunchedEffect 를 사용해야합니다. LaunchedEffect 가 컴포지션을 시작하면 LaunchedEffect 의 매개변수로 전달된 coroutine block 이 실행되며 LaunchedEffect 의 composition 이 끝나면 coroutine block 이 cancel 됩니다. 만약 LaunchedEffect 가 전달받은 key 가 변경되면 이미 실행중인 coroutine 은 cancel 되고 새로운 coroutine 이 다시 실행됩니다.

 

var pulseRateMs by remember { mutableStateOf(3000L) }
val alpha = remember { Animatable(1f) }
LaunchedEffect(pulseRateMs) { // pulseRateMs 의 값이 변경되면 코루틴을 취소하고 변경된 값으로 다시 실행됨
    while (isActive) {
        delay(pulseRateMs) // pulseRateMs 시간마다 alpha 가 -> 0 -> 1
        alpha.animateTo(0f)
        alpha.animateTo(1f)
    }
}

 

 코드를 보면 pulseRateMs 가 있는데 이 값을 LaunchedEffect key 로 전달했기 때문에 이 값이 바뀌면 LaunchedEffect 코루틴이 취소되고 다시 실행됩니다. 초기에 3초(3000)에 한 번씩 alpha 가 변하다가 pulseRateMs 가 1000 이 된다면 1초에 한번씩 alpha 가 변경 되는 것이죠. 이는 Composable 생명주기가 끝날 때까지 반복됩니다.

 

2. rememberCoroutineScope : obtain a composition-aware scope to launch a coroutine outside a composable

 LaunchedEffect 는 Composable 함수라 다른 composable 함수 내에서만 실행할 수 있습니다. Composable 외부에 있지만 Composition 종료 후 자동으로 취소되게 하려면 rememberCoroutineScope 를 사용해야합니다. 또한 코루틴 생명주기를 수동으로 관리해야할 때 사용할 수 있습니다. 버튼 클릭 같은 이벤트 핸들링을 할 때 유용하게 사용할 수 있습니다.

 

@Composable
fun MoviesScreen(snackbarHostState: SnackbarHostState) {

    // MoviesScreen Composable 함수의 생명주기에 바인딩된 CoroutineScope 구성
    val scope = rememberCoroutineScope()

    Scaffold(
        snackbarHost = {
            SnackbarHost(hostState = snackbarHostState)
        }
    ) { contentPadding ->
        Column(Modifier.padding(contentPadding)) {
            Button(
                onClick = {
                    // showSnackbar 하기 위해 새로운 코루틴 생성
                    scope.launch {
                        snackbarHostState.showSnackbar("Something happened!")
                    }
                }
            ) {
                Text("Press me")
            }
        }
    }
}

 

3. rememberUpdatedState: reference a value in an effect that shouldn't restart if the value changes

 LaunchedEffect 는 key 매개변수 중 하나가 변경되면 다시 시작합니다. 그러나 경우에 따라서 값이 변경되더라도 다시 시작하지 않게 하고 싶을 수 있습니다. 이 경우 rememberUpdatedState 를 사용할 수 있습니다. 이 방법은 비용이 많이 들거나 다시 시작해서는 안되는 작업에 사용할 때 유용합니다.

 

 예를 들어 시간이 지나면 사라지는 LandingScreen 이 있을 때, 컴포지션 이후 onTimeout 값이 변경되어 리컴포지션 되더라도 "일정시간 후에 대기 시간 경과 알림" 이라는 효과는 다시 시작해서는 안됩니다. 그렇다고 onTimeout 이 최초의 onTimeout 으로 실행되면 문제가 생길 수 있겠죠. 최신의 onTimeout 을 가리키게 하려면 rememberUpdatedState 를 사용하면 됩니다!

 

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

    // LandingScreen 이 리컴포지션 되면 항상 최근의 onTimeout function 을 가리킵니다.
    val currentOnTimeout by rememberUpdatedState(onTimeout)

    // 재실행하지 않고 다시 delay 할 필요없이 최신의 onTimeout 을 실행할 수 있다는 의미입니다.
    LaunchedEffect(true) {
        delay(SplashWaitTimeMillis)
        currentOnTimeout()
    }

    /* Landing screen content */
}

 

 

effect 를 호출 시점 생명주기 와 동일하게 생성하려면 변하지 않는 상수(Unit or true) 를 매개변수로 전달하면 됩니다. 

 

4. DisposableEffect: effects that require cleanup

key 가 변경되거나 Composition 종료 후 정리해야하는 sideEffect 의 경우 DisposableEffect 를 사용해야합니다. DisposableEffect key가 변경되면 컴포저블이 현재 효과를 dispose (삭제, 정리) 하고 effect 를 다시 호출하여 reset 합니다.

 

예를 들어, LifecycleObserver 를 사용해 생명주기에 맞는 이벤트를 보내고 싶다면 DisposableEffect 를 사용해 관찰자를 등록, 등록 취소할 수 있습니다.

 

@Composable
fun HomeScreen(
    lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current,
    onStart: () -> Unit, // Started 이벤트 전송
    onStop: () -> Unit // stoppted 이벤트 전송
) {
    // 새로운 값이 전달되면 최신 값을 가리킨다.
    val currentOnStart by rememberUpdatedState(onStart)
    val currentOnStop by rememberUpdatedState(onStop)

    // lifecycleOwner 값이 변경되면 effect 를 정리하고 리셋한다.
    DisposableEffect(lifecycleOwner) {
        val observer = LifecycleEventObserver { _, event ->
        	// onStart 라면 onStart 이벤트 전송
            if (event == Lifecycle.Event.ON_START) {
        	// onStop 이라면 onStop 이벤트 전송
            } else if (event == Lifecycle.Event.ON_STOP) {
                currentOnStop()
            }
        }

        // lifecycle 옵저버 add
        lifecycleOwner.lifecycle.addObserver(observer)

        // Composition 이 종료될 때 observer 를 제거한다. (dispose)
        onDispose {
            lifecycleOwner.lifecycle.removeObserver(observer)
        }
    }

    /* Home screen content */
}

 

 

마무리하며

 오늘은 LaunchedEffect, rememberCoroutineScope, rememberUpdatedState, DisposableEffect 에 대해 학습해보았습니다. 

단순 공식문서 읽기, 생각 남기기 활동이지만 모르고 사용하던 것들을 제대로 이해할 수 있는 시간을 가질 수 있었습니다.

다음 글에서 남은 SideEffect, produceState, derivedStateOf, snapshotFlow 에 대해 알아보겠습니다.