본문 바로가기

Android/Tech

[Jetpack Compose] Jetpack Compose 의 다양한 Side-Effect

Unsplash, Matthieu Rochette.

State Management In Composables

Composable 은 기본적으로 Stateless 입니다.  '기본적으로 Stateless' 라는 문장의 의미는, 때로는 Stateless 하지 않을 수 있다는 것을 말합니다.

 

@Composable
fun StatelessText() {
    Text(text = "ADD")
}

 

위 Composable 은 Stateless Composable 입니다. 말 그대로 아무런 상태도 갖지 않기 때문에, 단독으로 있는 경우 별도의 Recomposition 이 발생하지 않습니다.

 

@Composable
fun StatefulText() {
    val text = remember { mutableStateOf("false") }
    Text(
        modifier = Modifier.clickable { text.value = if (text.value == "false") "true" else "false" },
        text = text.value
    )
}

 

위 Composable 은 Stateful Composable 입니다. 해당 Composable 을 클릭하면 변수 text 에 할당된 MutableState 의 값이 변하고, Composable 내부의 Text Composable 은 이를 참조하고 있기 때문에 Recomposition 이 발생합니다.

 


What is Recomposition?

Recomposition 은 이름으로 유추할 수 있듯, Composition 이 재가동된다는 의미입니다. UI 를 새롭게 그리는 것입니다. 위 예제의 StatefulText 는 함수 내부에 State 를 갖고 있고, 함수 내부의 Text 는 이를 참조하여 표시할 문자열을 결정합니다. 해당 Composable 특정한 이벤트를 통해 State 의 값을 변경할 수 있고, 이를 통해 Text 는 변경된 새로운 문자열을 표시해야 합니다. 그러므로 UI 를 새롭게 다시 그려야하는 것이죠.

 

Recomposition 은 생각보다 흔하게 발생하는 일이고, 트리거되는 일이 많습니다. 그래서 함부로 어떠한 환경이나 이벤트에 의해 어느 Composable 에서 Recomposition 이 일어날 것이다 라는 예상하며 개발을 해선 안 된다고 생각합니다.

 

Recomposition 이 야기하는 가장 큰 문제점은, 특별한 조치를 취하지 않으면 해당 Composable 내의 코드를 재시작한다는 점입니다. 

재시작의 문제점

Screen-Level Composable 에 네트워크 통신을 통해 데이터를 불러오는 코드가 있다고 가정해봅시다. Composable 은 여러 이유로 Recomposition 될 수 있고, 그 것이 다소 빈번할 수 있습니다. 그렇게 되면 데이터가 이미 불러와졌음에도 불구하고 수 회 추가적으로 데이터를 요청할 수 있습니다. 이에 소모되는 리소스는 상상만해도 끔찍합니다.

 

위와 같은 상황을 회피하기 위해 고안된 것이 Side-Effect 입니다.

 


What is Side-Effect in Jetpack Compose?

Composable 이 호출되면서 발생하는 부수 효과를 의미합니다. Side-Effect 를 잘 활용하면 UI 로직과 외부 리소스 처리 로직을 분리하여 코드를 간결하게 유지할 수 있습니다. 예를 들어 데이터베이스 접근, 네트워크 호출, 파일 시스템 액세스 등의 작업을 Recompostion 의 영향권에서 탈출시키거나 그 빈도를 줄이기 위해 사용됩니다.

 

Jetpack Compose 에서 제공하는 Side-Effect 의 종류는 다음과 같습니다.

 

  • LaunchedEffect
  • DisposableEffect
  • rememberCoroutineScope
  • produceState
  • derivedStateOf

하나씩 알아봅니다.

 


LaunchedEffect

fun LaunchedEffect(
    key1: Any?,
    block: suspend CoroutineScope.() -> Unit
) {
    val applyContext = currentComposer.applyCoroutineContext
    remember(key1) { LaunchedEffectImpl(applyContext, block) }
}

 

가장 대표적인 Side-Effect 함수입니다. Composable 의 Composition 은 해당 Composable 이 렌더링 되지 않을 때 비로소 끝이 나고, LaunchedEffect 는 Composition 이 끝날 때 해당 코루틴도 함께 취소됩니다.

 

주요 파라미터인 key 는 Effect 를 발생시키는 해당 함수가 의존하는 값입니다. 해당 값이 변하지 않으면 Recomposition 이 수행되어도 함수는 실행되지 않습니다.

 

저같은 경우에는, TextField 의 줄바꿈에 따라 화면이 자동으로 스크롤 되도록 할 때 자주 사용하는데, key 파라미터에 최대 스크롤 값을 할당해주면 됩니다.

 

수행 시간이 긴 작업에도 사용될 수 있는데, 작업에 따라 withContext() 메서드를 함께 사용해주면 좋겠습니다.

 


DisposableEffect

fun DisposableEffect(
    key1: Any?,
    effect: DisposableEffectScope.() -> DisposableEffectResult
) {
    remember(key1) { DisposableEffectImpl(effect) }
}

 

일회성 Side-Effect 를 만드는 데에 사용합니다. 메모리 릭을 확실하게 방지할 수 있는 로직을 제공합니다. 해당 함수의 블럭 내에서 onDispose 블럭을 추가로 구현할 수 있는데, onDispose 블럭 내에서 특정 리소스를 해제해주는 방식입니다.

 

onDispose 블럭은 해당 Composable 이 Compositon 을 떠날 때 실행됩니다. 파일 다운로드나 사운드 재생, 옵저버 등에 주로 사용됩니다. 저는 Composable 내에서 Lifecycle Observer 를 사용해야 할 때, Observer 리소스를 해제하는 용도로 사용하고 있습니다.

 

key 파라미터는 LaunchedEffect 처럼 해당 값이 변경되는 경우, 수행하던 Effect 는 취소되고 새로운 Effect 가 실행됩니다.

 


rememberCoroutineScope

inline fun rememberCoroutineScope(
    crossinline getContext: @DisallowComposableCalls () -> CoroutineContext =
        { EmptyCoroutineContext }
): CoroutineScope {
    val composer = currentComposer
    val wrapper = remember {
        CompositionScopedCoroutineScopeCanceller(
            createCompositionCoroutineScope(getContext(), composer)
        )
    }
    return wrapper.coroutineScope
}

 

Recomposition 에 의해 재실행되지 않는 CoroutineScope 를 반환합니다. 해당 CoroutineScope 는 getContext() 메서드를 통해 얻어 온 Context 에서 제공됩니다. 당연스럽게도 Composition 을 벗어나면 취소됩니다. 

 

Dispatcher 로는 기본적으로 Dispatchers.Main.immediate 가 적용됩니다. 

Dispatchers.Main.immediate 는 안드로이드의 메인 스레드에서 (UI 스레드) Coroutine 작업을 즉시 실행하기 위한 Dispatcher 이며, 큐잉 없이 즉시 실행됩니다. UI 업데이트에 주로 사용되므로, Composable 과 함께 사용하기 적합한 Dispatcher 입니다.

 

또한, 별도의 Dispatcher 를 명시적으로 전달하는 것도 가능합니다. 다음 스니펫을 참고하시면 되겠습니다.

 

val coroutineScope = rememberCoroutineScope()
LaunchedEffect(key1 = Unit) {
    coroutineScope.launch(Dispatchers.IO) {
    	// do something
    }
}

 


ProduceState

내부 계산 결과로 State<T> 를 반환하는 Side-Effect 입니다. 통신이나 DB 액세스 등을 통해 값을 가져오고 즉시에 처리하기 좋아 보이는데, 저는 MVI 패턴을 좋아해서 사용할 일은 크게 없을 것 같습니다. 있다면 UI 애니메이팅 관련 작업이나 많은 계산 과정 등이 필요할 때 사용할 수 있어 보입니다.

@Composable
fun CounterApp() {
    val countState = produceState(initialValue = 0, key1 = "count") { count ->
        // 증가한 카운트 값을 반환
        delay(1000)
        count + 1
    }

    Text(text = "Count: ${countState.value}")
}

 


DerivedStateOf

한 개, 또는 여러 개의 조건을 하나의 State 로 만들 수 있습니다.

 

val derivedState = remember { derivedStateOf { conditionA && conditionB } }

 

위 코드는 conditionA 와 conditionB 가 모두 true 일 때, derivedState 의 value 가 true 가 됩니다. 이렇게만 작성하면 이걸 어디다 쓰겠나 싶은데, 의외로 요긴하게 쓰입니다.

저는 보통 리스트의 인덱스에 따라 UI 의 변경을 주고 싶을 때 사용합니다. 다음과 같이 말입니다.

 

val textColorState = animateColorAsState(
    targetValue = if (index == remember { derivedStateOf { hoursLazyListState.firstVisibleItemIndex } }.value + 1) MainBlackColor 
    else GrayColor5)

 

ScrollPosition 에 따라 UI 를 업데이트하고 싶은 경우에도 사용되는데, ScrollPosition 의 경우 워낙 값의 변화 빈도가 잦기 때문에 매 ScrollPosition 마다 조건을 비교하고 트리거하는 경우에는 성능에 무리를 줄 수 있습니다. 그러므로 derivedStateOf 를 사용하여 ScrollPosition 이 일정 수치 이상으로 커지거나, 그 이하로 작아질 경우에만 조건이 트리거되도록 설정할 수 있습니다.

 


간단하게만 알아봤습니다. 개인적으로 Jetpack Compose 의 핵심은 Recomposition 이라고 생각합니다. Recomposition 을 제대로 아는 개발자가 성능 저하의 여지가 없는 좋은 UI를 구현할 수 있는 것 같습니다.