Notice
Recent Posts
Recent Comments
«   2026/07   »
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 29 30 31
Tags
more
Archives
Today
Total
관리 메뉴

훌륭한 개발자가 되기 위하여

[JetpackCompose] Compose Side Effect 본문

안드로이드

[JetpackCompose] Compose Side Effect

jay20033 2025. 8. 14. 21:57

부수 효과(Side Effect)란?

Compose에서 부수 효과란 "함수의 범위 밖에서 발생하는 앱 상태에 관한 변경사항"을 말한다.

컴포저블의 수명 주기 및 속성으로 인해 컴포저블에는 부수 효과가 없는 것이 좋지만 필요할 때가 있다.

예를 들어, 스낵바를 표시하거나 특정 상태 조건에 따라 다른 화면으로 이동하는 등 일회성 이벤트를 트리거할 때이다.

이러한 작업은 컴포저블의 수명 주기를 인식하는 관리된 환경에서 호출해야 한다.

LaunchedEffect : 컴포저블 범위 내에서 suspend 함수 실행

LaunchedEffect가 컴포지션을 시작하면 매개변수로 전달된 코드 블록으로 코루틴이 실행된다.

LaunchedEffect가 컴포지션을 종료하면 코루틴이 취소된다.

키가 변경되면 기존 코루틴이 취소되고 새 코루틴에서 새 Suspend 함수가 실행된다.

컴포저블이 컴포지션에 진입할 때 수행해야 하는 데이터 로딩, 애니메이션 시작, 이벤트 수신과 같은 작업에 유용하다.

// Allow the pulse rate to be configured, so it can be sped up if the user is running
// out of time
var pulseRateMs by remember { mutableLongStateOf(3000L) }
val alpha = remember { Animatable(1f) }
LaunchedEffect(pulseRateMs) { // Restart the effect when the pulse rate changes
    while (isActive) {
        delay(pulseRateMs) // Pulse the alpha every pulseRateMs to alert the user
        alpha.animateTo(0f)
        alpha.animateTo(1f)
    }
}
// 키가 상수인 경우 한 번만 실행됨
LaunchedEffect(Unit) { or LaunchedEffect(true)
    viewModel.loadData()
}

DisposableEffect : 메모리 해지 등이 필요한 Side Effect 실행에 적합

DisposableEffect 컴포저블의 Lifecycle에 따라 리소스 해지 및 실행 중인 테스트를 정리하기 위해 사용된다.

DisposableEffect는 리소스를 해제하기 위해 DisposableEffectScope를 제공하고 해당 스코프 안에서 onDispose 람다를 통해 해지 작업을 수행한다.

@Composable
fun HomeScreen(
    lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current,
    onStart: () -> Unit, // Send the 'started' analytics event
    onStop: () -> Unit // Send the 'stopped' analytics event
) {
    // Safely update the current lambdas when a new one is provided
    val currentOnStart by rememberUpdatedState(onStart)
    val currentOnStop by rememberUpdatedState(onStop)

    // If `lifecycleOwner` changes, dispose and reset the effect
    DisposableEffect(lifecycleOwner) {
        // Create an observer that triggers our remembered callbacks
        // for sending analytics events
        val observer = LifecycleEventObserver { _, event ->
            if (event == Lifecycle.Event.ON_START) {
                currentOnStart()
            } else if (event == Lifecycle.Event.ON_STOP) {
                currentOnStop()
            }
        }

        // Add the observer to the lifecycle
        lifecycleOwner.lifecycle.addObserver(observer)

        // When the effect leaves the Composition, remove the observer
        onDispose {
            lifecycleOwner.lifecycle.removeObserver(observer)
        }
    }

    /* Home screen content */
}

예를 들어, 생명 주기 이벤트를 기반으로 애널리틱스 관련 이벤트를 전송해야 하는 경우 LifecycleObserver를 사용하여 구현할 수 있다. 이 때 DisposableEffect를 사용하여 컴포저블이 컴포지션에 진입할 때 관찰자를 등록하고 떠날 때 자동으로 등록 취소할 수 있다. 이로 인해 리소스의 적절한 정리를 보장하고 메모리 누수를 방지할 수 있다.


SideEffect : Compose의 상태를 non-Compose 코드로 실행하기

SideEffect는 매 recomposition 직후에 적용해야 하는 작업을 실행하는 데 사용된다.

ViewModel이나 외부 라이브러리의 UI 상태 업데이트와 같이 컴포지션의 일부가 아닌 외부 시스템과 Compose 상태를 동기화하는데 적합하다.

  • 매 recomposition 후 실행이 보장된다.
  • non-compose 컴포넌트와의 상태 동기화에 유용하다.

💥주의사항 : 비동기 X, 즉시 실행만 보장

@Composable
fun rememberFirebaseAnalytics(user: User): FirebaseAnalytics {
    val analytics: FirebaseAnalytics = remember {
        FirebaseAnalytics()
    }

    // 매 recomposition 후 실행 → 항상 최신 userType 반영
    // On every successful composition, update FirebaseAnalytics with
    // the userType from the current User, ensuring that future analytics
    // events have this metadata attached
    SideEffect {
        analytics.setUserProperty("userType", user.userType)
    }
    return analytics
}

애널리틱스 라이브러리를 사용하면 커스텀 메타데이터를 이벤트에 연결하여 인구를 분류할 수 있는데 현재 사용자의 사용자 유형을 애널리틱스 서버로 전달되도록 하여 애플리케이션의 데이터와 동기화 상태를 유지할 수 있도록 할 수 있다.


rememberCoroutineScope : 컴포지션 함수 내에서 코루틴 실행

rememberCoroutineScope는 컴포저블이 컴포지션을 벗어날 때 활성중인 코루틴 스코프를 자동으로 취소한다.

내부적으로 컴포저블 함수의 내부 생명주기를 인지하게 작동하고 있으므로 컴포저블에서 코루틴을 알어서 런칭 및 취소한다.

@Composable
fun MoviesScreen(snackbarHostState: SnackbarHostState) {

    // Creates a CoroutineScope bound to the MoviesScreen's lifecycle
    val scope = rememberCoroutineScope()

    Scaffold(
        snackbarHost = {
            SnackbarHost(hostState = snackbarHostState)
        }
    ) { contentPadding ->
        Column(Modifier.padding(contentPadding)) {
            Button(
                onClick = {
                    // Create a new coroutine in the event handler to show a snackbar
                    scope.launch {
                        snackbarHostState.showSnackbar("Something happened!")
                    }
                }
            ) {
                Text("Press me")
            }
        }
    }
}

 

작동 방식

  • 컴포지션 인식 : rememberCoroutineScope에 의해 생성된 코루틴 스코프는 컴포지션으로 범위가 지정된다. (컴포지션에서 제거될 때 코루틴도 취소된다.)
  • 상태 관리 : remember Api가 값을 메모리에 저장하고 recomposition이 발생했을때 저장된 메모리에서 값을 꺼내와 복원한다.
  • 메모리 누수 방지 : GlobalScope를 사용하거나 코루틴 스코프를 사용하여 수동으로 관리하지 않고, 더 이상 사용되지 않을 때 알아서 리소스가 정리된다.

💥 컴포지션 생명주기에 연결된 가벼운 UI 작업은 rememberCoroutineScope를 사용하고 장기적으로 실행되어야 하는 경우 viewModelScope나 lifecycleScope과 같이 더 넓은 코루틴 스코프를 사용해야 한다.

또, rememberCoroutineScope는 기본적으로 메인 스레드를 사용하기 때문에 네트워크 요청이나 DB 쿼리와 같은 비즈니스 로직을 하는 경우에는 사용을 피해야한다.


rememberUpdatedState : 값이 변경되는 경우 최신 상태 유지하기

최초 컴포지션을 통해서 생성되었더라도 람다나 콜백의 상태 값에 대해 항상 최신 상태를 유지하도록 보장한다.(최신 콜백 유지)

rememberUpdatedState는 상태의 가장 최근 값을 기억하고 상태가 변경될 때마다 업데이트하고, 컴포저블 또는 람다 내에서 값을 읽고 가장 최신 상태를 반영하는 State<T> 객체를 반환한다.

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

    // 최신 콜백 저장
    val currentOnTimeout by rememberUpdatedState(onTimeout)

    // Create an effect that matches the lifecycle of LandingScreen.
    // If LandingScreen recomposes, the delay shouldn't start again.
    LaunchedEffect(true) {
        delay(SplashWaitTimeMillis)
        currentOnTimeout() // 변경된 콜백도 정확히 호출
    }

    /* Landing screen content */
}

위 코드는 앱에 시간이 지나면 사라지는 LandingScreen이다.

LandingScreen이 recomposition되어도 일정 시간 동안 대기하고 시간이 경과되었음을 알리는 효과는 다시 시작해서는 안된다.

onTimeout 람다에 LandingScreen이 재구성된 최신 값이 항상 포함하도록 하려면 rememberUpdatedState로 onTimeout을 래핑해야 한다.

만약 rememberUpdatedState를 사용하지 않는다면 delay가 실행되는 동안 컴포지션이 실행될 당시의 구현체 값은 캡쳐되었기 때문에, onTimeout 콜백의 구현체 자체가 변경되면 초기에 딜레이가 발생하기 이전의 콜백이 호출될 수 있다.

즉, rememberUpdatedState는 onTimeout 콜백을 항상 최신 상태로 업데이트하고, 딜레이가 완료되었을 때 가장 최신 함수가 호출되도록 보장한다.


produceState : non Compose 상태를 Compose 상태로 변환하기

suspend 함수 결과를 State로 반환

@Composable
fun <T : Any?> produceState(
    initialValue: T, // 상태 초기값
    vararg keys: Any?, // 코루틴 재시작을 위한 키 값
    producer: suspend ProduceStateScope<T>.() -> Unit // 상태 값 업데이트하는 suspend 람다
): State<T> // State 객체 반환
@Composable
fun loadNetworkImage(
    url: String,
    imageRepository: ImageRepository = ImageRepository()
): State<Result<Image>> {
    // Creates a State<T> with Result.Loading as initial value
    // If either `url` or `imageRepository` changes, the running producer
    // will cancel and will be re-launched with the new inputs.
    return produceState<Result<Image>>(initialValue = Result.Loading, url, imageRepository) {
        // In a coroutine, can make suspend calls
        val image = imageRepository.load(url)

        // Update State with either an Error or Success result.
        // This will trigger a recomposition where this State is read
        value = if (image == null) {
            Result.Error
        } else {
            Result.Success(image)
        }
    }
}

위 코드로 설명하면 초기 값은 Result.loading이고 url, imageRepository 값 중 하나라도 변경되면 기존에 돌아가던 생산자 코루틴은 취소되고 다시 시작된다.

생산자 코루틴은 아래 람다 함수로 imageRepository로 부터 image를 로딩하고 null 이 아닌 경우 Success로 넘기는 구조이다.

 

produceState 장점

  • 선언적(Declarative): 비동기 작업을 실행하고 그 결과를 Compose의 상태로 만들기 위해 깔끔하고 Compose의 선언적 방식을 있는 그대로 사용한다.
  • 컴포지션 생명주기 인지(Composition‑aware): 컴포저블이 컴포지션을 떠날 때 코루틴
    을 자동으로 취소하여 리소스 누수 위험을 방지한다.
  • 유연성(Flexible): 외부 suspend 함수와도 잘 작동하고 의존성(키)이 변경될 때
    코루틴 작업을 다시 시작할 수 있다.

derivedStateOf : 하나 이상의 상태 객체에서 파생된 값을 계산하여 다른 상태로 변환

Flow의 distinctUntilChanged() 연산자와 유사하게 작동한다. (계산 값 변경 시만 recompose)

상태가 자주 업데이트되더라도 계산된 값 자체가 변경될 때만 recomposition을 트리거하여 recomposition을 최적화한다.

이로 인해 빈번한 상태 업데이트가 있는 시나리오에서 성능을 개선하고 불필요한 recomposition을 방지하는데 유용하다.

약간의 오버헤드를 동반하기 때문에 recomposition 방지가 중요한 상황에서만 신중하게 사용해야 한다.

 

derivedStateOf 작동 방식

  1. derivedStateOf의 람다 매개변수 내에서 사용된 모든 상태들의 변화를 관찰
  2. 관찰된 상태 중 하나가 변경되면 새 값을 계산
  3. 새로 계산된 값이 이전 값과 다른 경우에만 recomposition을 트리거

올바른 예시

@Composable
// When the messages parameter changes, the MessageList
// composable recomposes. derivedStateOf does not
// affect this recomposition.
fun MessageList(messages: List<Message>) {
    Box {
        val listState = rememberLazyListState()

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

        // Show the button if the first visible item is past
        // the first item. We use a remembered derived state to
        // minimize unnecessary compositions
        val showButton by remember {
            derivedStateOf {
                listState.firstVisibleItemIndex > 0
            }
        }

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

스크롤 하면 firestVisibleItemIndex 값이 0,1,2,3,4 등으로 바뀌는데 0보다 큰경우에만 recomposition이 발생한다.

이 때 showButton 상태가 변경될 때만 AnimatedVisibility가 reocmpose된다.

 

잘못된 예시

// DO NOT USE. Incorrect usage of derivedStateOf.
var firstName by remember { mutableStateOf("") }
var lastName by remember { mutableStateOf("") }

// recomposition을 자주 유발하지 않기 때문에 derivedStateOf로 래핑하면 오히려 오버헤드가 추가됨.
val fullNameBad by remember { derivedStateOf { "$firstName $lastName" } } // This is bad!!!
val fullNameCorrect = "$firstName $lastName" // This is correct

snapshotFlow : Compose의 상태를 Flow로 변환

SnapshotFlow는 Compose의 태 변경을 수신하고 이 값을 Flow로 방출하여 상태 업데이트를 관리한다.

 

snapshotFlow의 주요 특징

  •  상태 관찰(State Observation): Snapshot 시스템을 사용하여 Compose의 상태 변화 관찰
  • 스레드 안전성(Thread Safety): 상태 읽기 및 쓰기가 Compose의 스냅샷 스코프 내에서
    발생하도록 보장하여 경쟁 조건(race condition) 방지
  •  유휴 건너뛰기(Idle Skipping): 상태 값이 변경될 때만 Flow 값으로의 방출이 발생하고
    recomposition이 발생하지 않는 중에는 업데이트를 건너뛰도록 보장
  • 취소 인지(Cancelation‑Aware): Flow를 수집하는 코루틴이 취소될 때 구독 또한 자동
    으로 취소하여, 컴포지션의 생명주기를 인지하는(composition‑aware) 형태로 동작하여
    부적절한 메모리 누수 방지
val listState = rememberLazyListState()

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

LaunchedEffect(listState) { // listState가 변경될 때마다 LaunchedEffect 실행
    snapshotFlow { listState.firstVisibleItemIndex } // 첫번째 아이템 인덱스 관찰
        .map { index -> index > 0 } // 스크롤 했는지 확인
        .distinctUntilChanged() // 값이 실제로 변경될 때만 방출
        .filter { it == true }
        .collect {
            MyAnalyticsService.sendScrolledPastFirstItemEvent()
        }
}

 

snapshotFlow 내부 작동 방식

  1. 람다 매개변수로 제공된 Compose의 상태를 Snapshot 관찰자 내에서 지속적으로 관찰
  2. 람다 매개변수에 상태 변수가 들어오는 순간 Compose는 해당 상태에 대해서 종속성 등록
  3. 상태가 변경되면 Compose는 snapshotFlow에 업데이트 사항을 알리고 새 값을 방출하도록
    트리거

 

 

참고자료

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

 

Compose의 부수 효과  |  Jetpack Compose  |  Android Developers

이 문서에서는 다양한 효과 API를 사용하여 Jetpack Compose 애플리케이션에서 부수 효과를 안전하게 관리하고 실행하여 예측 가능한 동작과 적절한 수명 주기 관리를 보장하는 방법을 설명합니다.

developer.android.com

https://developer.android.com/reference/kotlin/androidx/compose/runtime/package-summary?_gl=1*14sw6sn*_up*MQ..*_ga*MTI4ODg3MjM3Ni4xNzczMjIyMTg2*_ga_6HH9YJMN9M*czE3NzMyMjIxODYkbzEkZzAkdDE3NzMyMjIxODYkajYwJGwwJGgxNjMzMjc0MTU.#rememberCoroutineScope(kotlin.Function0)

 

androidx.compose.runtime  |  API reference  |  Android Developers

androidx.appsearch.builtintypes.properties

developer.android.com

https://proandroiddev.com/understanding-the-internals-of-side-effect-handlers-in-jetpack-compose-d55fbf914fde

 

Understanding the Internals of Side-Effect Handlers in Jetpack Compose

The Jetpack Compose ecosystem has grown exponentially in recent years, and it’s now widely adopted for building production-level UIs in…

proandroiddev.com