Compose 부수효과 정리 - LaunchedEffect, SideEffect, DisposableEffect, rememberCoroutineScope, derivedStateOf, snapshotFlow

SSY·2025년 7월 15일
0

Compose

목록 보기
15/15
post-thumbnail

1. 부수효과란

Composable 함수에서의 부수효과란, UI 범위 외부에서 발생한 이벤트가 UI 상태를 변경시키는 작업을 의미한다. 여기서 중요한건 '외부 범위'를 어디로 지정할까인데, 첫 번째로 ViewModel이 있다. 이는 Composable함수 범위 밖에서 UI 상태를 관리하는 객체로써 이 곳에서 발생하는 이벤트는 UI의 상태를 변경하기에 충분하다. 여기서 UI의 상태란 UIState뿐만 아니라, 1회성으로 UI 변경을 트리거하는 것을 의미하며, 가령, 화면이동, 스낵바 로딩 등이 있다.

두 번째로는 Composable함수 내부이지만 UI로직이 아닌 곳으로부터 발생한 이벤트가 UI를 변경시키는 경우가 있다. 가령, Composable함수의 .clickable {...}와 같이 Composable Scope가 아닌 곳으로부터 발생한 이벤트로 UI의 상태를 변경시키는 것이며, 단순한 예로 스낵바를 띄울때가 있다. 또는 Composable함수 범위이지만 LaunchedEffect { ... }의 경우도 Composable Scope가 아닌 곳으로부터 발생한 이벤트가 UI의 변경을 트리거하는 경우도 있다. 즉, 부수효과란 UI 범위가 아닌 곳, 좀 구체적으로 말하자면 Composable함수의 Scope가 아닌 곳에서 발생한 이벤트가 UI 변경을 트리거하는걸 의미한다.

Compose는 함수형, 선언형 UI이다. 즉, 입력이 동일하면 출력도 동일해야한단 뜻이다. 하지만 내부에 어쩔 수 없이 부수효과가 들어가야만 할 때가 있다. 이를 위해 Compose API에는 이러한 부수효과를 적재적소 잘 관리할 수 있는 6가지 API를 제공한다.

2. LaunchedEffect

  • @Composable 함수 내부 범위에서 CoroutineScope를 람다로 제공받아 비동기 작업 실행이 가능하다.
  • key파라미터가 존재하는데, 해당 값 변경 시, Coroutine 블록이 재실행된다.
  • LaunchedEffect(Unit) 또는 LaunchedEffect(true)와 같이 고정된 key를 설정할 경우, Composition 시점에 단 한 번만 실행시킬 수 있다.

위 특성을 활용하여 활용이 여럿 가능하다. 나의 경우, Paging3라이브러리를 안쓰고 LauchedEffect를 사용하여 이를 직접 구현하는 부수효과를 만들었다. Lazy한 스크롤에서 item의 버퍼 갯수에 해당하는 item에 도착할 경우, onScrollToEnd()호출한다.

@Composable
fun PaginationLoadEffect(
  listState: LazyListState,
  onScrollToEnd: () -> Unit,
  bufferItemCount: Int = 5
) {
  val shouldLoadMore by remember {
    derivedStateOf {
      val layoutInfo = listState.layoutInfo
      val lastVisibleItemIndex = layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: return@derivedStateOf false
      val totalItemCount = layoutInfo.totalItemsCount

      totalItemCount != 0 &&
        lastVisibleItemIndex >= totalItemCount - bufferItemCount
    }
  }
  LaunchedEffect(shouldLoadMore) {
    if (shouldLoadMore) onScrollToEnd()
  }
}

또한 MVI패턴에서 UiSideEffect를 옵저빙받는 코드를 정의할때도 사용한다. 해당 옵저빙 코드는 리컴포지션에따라 여러번 옵저빙될 경우, 핫 스트림 특성에 따라 복수개의 옵저빙 코드가 생성된다. 따라서 key파라미터를 true or Unit과 같이 고정시켜놓음으로써 첫 컴포지션 때, 1번만 호출되도록 설정한다.

LaunchedEffect(Unit) {
  viewModel.uiSideEffect.collect {
    is MainUiSideEffect.OnNavigatTo_A_Activity -> {}
    is MainUiSideEffect.OnNavigatTo_B_Activity -> {}
    is MainUiSideEffect.OnFinish -> {}
    is MainUiSideEffect.OnShowToastMessage -> {}
  }
}

하지만, 곧 다루게 될 rememberCoroutineScope와 비교했을 때, LaunchedEffect는 해당 함수의 수명주기가 @Composable함수에 묶여있다.

3. rememberCoroutineScope

  • 코루틴을 시작시키는 위치가 '일반 람다'에서도 가능하다.
  • LaunchedEffect와는 달리, 첫 컴포지션 때 Coroutine이 자동으로 호출되지 않으며, 개발자가 launch를 직접 호출해줘야 한다.
  • 따라서 사용자 이벤트 시점(eg., clickable {...})때, Coroutine을 시작하기에 좋다.

rememberCoroutineScope또한 정의하는 첫 위치가 composable함수 내부라는 점은 동일하다. 하지만 Coroutine builder를 사용해 코루틴 작업을 시작할 수 있는 위치가 일반 람다(eg., .clickable { ... })에서도 가능하다는 점이 특징이다. 이로인해, rememberCoroutineScope로 시작된 코루틴은 composable범위가 아니라 하더라도, Coroutine의 범위가 Composable함수에 묶여 있기에, 해당 Composable함수가 사라질 경우, 해당 Coroutine은 제거된다는 특징이 있다. rememberCoroutineScoope를 사용하는 대표적인 예시로 clickable { ... } 또는 Button() Composable 함수의 버튼을 클릭해 코루틴 작업을 트리거하는 것이다.

@Composable
fun SaveButton(viewModel: MyViewModel) {
    val coroutineScope = rememberCoroutineScope()

    Button(onClick = {
        coroutineScope.launch {
            viewModel.saveData()
        }
    }) {
        Text("저장")
    }
}

LauchedEffectrememberCoroutineScope를 비교해보면 아래와 같다.

항목rememberCoroutineScope()LaunchedEffect
실행 시기원할 때 수동으로 실행첫 Composition, 상태값 변경에 따른 Recomposition
용도사용자 이벤트 처리첫 초기화, 변경사항 처리
예시버튼 클릭 및 비동기 작업 처리화면 첫 진입에 따른 비동기 작업 처리, key 변경에 따른 재동작 처리

4. SideEffect

  • key파라미터가 없다. 이는 곧 UI가 그려지는 첫 시점인 Composition과 업데이트되는 시점인 Recomposition때마다, 람다가 매번 호출된다는 의미다.
  • 내부 블럭은 중단함수 람다가 아닌 일반 람다이다.
  • Composable 함수의 내부 실시간 상태를 ViewModel 등, 외부에 전달해야 할 때 사용한다.
  • 대표적인 예로, 로그 출력, Firebase Analytics 등 사용자의 행동을 분석할 때 사용함으로써 실시간 UI 상태를 외부에 알릴 수 있다.
  • 그 외, 주의사항으로 SideEffect내에서 composable함수의 상태 변수를 업데이트할 경우 무한 Recomposition에 빠지니 주의한다.

이전 LaunchedEffect는 내부 람다가 중단함수를 실행시킬 수 있었다. 하지만 SideEffect와 이후 소개할 DisposableEffect는 내부 블럭이 일반 람다이다. 즉 Coroutine실행이 불가능하다는 의미이다.

또한 SideEffectLauchedEffect, DisposableEffect와 달리 key파라미터가 없다. 이는 곧, 내부 람다 실행 시점이 UI가 처음 그려지는 Composition과 업데이트되는 Recomposition에 모두 발생한다는 것이다.

다만, Composable함수의 실시간 상태를 외부에서 알아야할 때 SideEffect를 사용하면 좋다. 가령, 사용자가 특정 이벤트를 발생시켜 UI가 변경되고, 이로 인해 SideEffect람다가 호출된다 가정하자. 이때, 로그를 찍는다든지, FirebaseAnalytics와같은 사용자 트래킹툴로 실시간 로그를 전송해야할 때 특히 유용할 수 있다.

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

    // 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
}

5. DisposableEffect

  • key파라미터가 존재하므로, LaunchedEffect와 갱신 시점이 같다.
  • 일반 람다의 작업 완료 후, onDispose를 통한 정리작업이 있을 경우 유용하다.
  • 대표적인 예로, BroadcastReceiver, LifecycleObserver, 외부 SDK의 종료 작업 등이 있다.

DisposableEffect를 활용하기 좋을 때는 특정 작업을 해제해야 할때이다. 대표적으로 LifecycleEventObserver를 사용하여 Composable함수 내에서 Activity의 생명주기를 가져오는 코드를 사용하곤 하는데, 이는 추후, lifecycleOwner.lifecycle.removeObserver(observer)를 해야하는 만큼 적절한 쓰임새라 볼 수 있다.

@Composable
fun LifecycleEffect(
  onCreate: () -> Unit = {},
  onStart: () -> Unit = {},
  onRestart: () -> Unit = {},
  onResume: () -> Unit = {},
  onPause: () -> Unit = {},
  onStop: () -> Unit = {},
  onDestroy: () -> Unit = {},
) {
  val lifecycleOwner = LocalLifecycleOwner.current
  var wasStopped by remember { mutableStateOf(false) }

  DisposableEffect(lifecycleOwner) {
    val observer = LifecycleEventObserver { _, event ->
      when (event) {
        Lifecycle.Event.ON_CREATE -> onCreate()
        Lifecycle.Event.ON_START -> {
          if (wasStopped) {
            onRestart()
            wasStopped = false
          }
          onStart()
        }
        Lifecycle.Event.ON_RESUME -> onResume()
        Lifecycle.Event.ON_PAUSE -> onPause()
        Lifecycle.Event.ON_STOP -> {
          wasStopped = true
          onStop()
        }
        Lifecycle.Event.ON_DESTROY -> onDestroy()
        else -> {}
      }
    }

    lifecycleOwner.lifecycle.addObserver(observer)
    onDispose {
      lifecycleOwner.lifecycle.removeObserver(observer)
    }
  }
}

아래는 LaunchedEffectDisposableEffect를 비교한 표이다.

항목LaunchedEffectDisposableEffect
목적Coroutine 기반 작업 실행리소스 등록/해제
실행 시점Composition 진입 시Composition 진입 시 & 제거 시
종료 처리자동 취소 (Job.cancel)onDispose로 명시적 정리
예시API 요청, 초기화리시버 등록, 옵저버 등록/해제

6. derivedStateOf

  • UI에서 필요한 상태값 경우보다, 상위 Composable 함수의 상태 변경이 빈번할 경우 무분별한 Recomposition 방지를 위해 사용하며, 이는 Flow의 distinctUntilChange()처럼 값의 연속 수신을 제한하는 역할을 한다.
  • 대표적인 예로, LazyListState로부터 스크롤 위치를 받아오는데, 이 값이 단순 경계값 초과에 따른 true/false로만 반환될 경우, derivedStateOf사용을 고려할 수 있다.
  • Flow의 distinctUntilChange()와 동일한 역할

Compose는 선언형 UI므로, 상위 상태 변수의 변경에 따라 UI가 선택적으로 변화한다. 하지만 상위 상태 변수가 너무 많이 바뀐다면 문제가 생긴다. 바로, 해당 변수가 주입하는 Composable함수 또한 그만큼 빈번하게 Recomposition이 된다는 점이다.

즉, UI에서 필요한 상태값 변경보다 상위 Composable 함수의 상태 변수의 변경이 더 많다면, derivedStateof를 사용할 수 있다.

아래의 코드는 LazyListState로부터 받아온 하위 item의 인덱스가 0보다 클 경우, 특정 애니메이션의 visibility를 결정해주는 코드이다. 이때, derivedStateOf를 사용하지 않는다면 불필요하게 많이 Recomposition되는 스크롤 상태값을 고스란히 받아 UI에 전달해주게 되고, 이로인해 UI가 버벅거릴 수 있다.

@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()
        }
    }
}

또 다른 예시로, 입력하는 텍스트의 길이에 따라, '제목이 깁니다.' 또는 '제목이 짧습니다.'라는 말을 표현해준다 하자. 이때 입력받는 텍스트의 길이는 계속해서 변화하게 되는데, 이에따라 무분별한 Recomposition이 발생할 수 있다. 방지를 위해 derivedStateOf를 사용함으로써 경계값이 지날때만 Recomposition을 발생시킨다.

@Composable
fun TitleLengthChecker(title: String) {
    val isLongTitle by remember(title) {
        derivedStateOf { title.length > 10 }
    }

    Text(text = if (isLongTitle) "제목이 깁니다" else "제목이 짧습니다")
}

7. snapshotFlow

  • '사진을 찍다'라는 의미에 맞게, 특정 상황의 Compsable 상태 변수의 값을 얻어올 수 있다. 상태 변수를 첫 번째로 얻어오는 시점은 collect()등을 사용하여 Flow 구독을 시작하는 시점이다.
  • Flow 구독 이후, 해당 상태값이 변경됐을때만 값을 다운스트림한다. 이 또한 Flow의 distinctUntilChange()derivedStateOf()와 유사하게 동작한다.

서버 가상머신을 띄워보면 '스냅샷'이라는 기능을 볼 수 있는데, 이는 해당 서버가 가지고 있는 상태를 말 그대로 사진을 찍는다는 의미이다. 이로써, 작업중인 서버가 장애가 났다 하더라도, 스냅샷을 찍어놓은 시점으로 원복하면 그때의 상태 그대로 롤백이 가능하다.

snapshotFlow도 이와 같다. Composable함수의 상태값의 특정 시점에 사진을 찍는다는 뜻이며, 첫 번째로 값을 캡쳐하는 시기는 collect()등을 사용한 종단 연산자를 통한 스트림의 구독 시점이다.

그 이후, snapshotFlow()가 캡쳐한 상태값이 변경됐을 경우, 그때에만 값을 다운스트림하여 구독자에서 이를 수신받을 수 있다.

아래 코드는 @Stable 마커로 표시된 LazyListState이다. 해당 객체는 public으로 선언된 프로퍼티들이 변경될 때, Compose Runtime에게 Recomposition을 지시할 수 있으며 이를 LaunchedEffect()key파라미터로써 내부 중단함수 블럭을 트리거하게된다.

그로써 snapshotFlow는 해당 상태변수가 변경됐을 때, snapshotFlow()의 스트림을 제거한 후, 다시 만들게되며 이로써 collect()의 재호출 및 listState.firstVisibleItemIndex를 새롭게 캡쳐한 값으로 다운스트림받게된다.

val listState = rememberLazyListState()

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

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

참고

profile
불가능보다 가능함에 몰입할 수 있는 개발자가 되기 위해 노력합니다.

0개의 댓글