본문 바로가기
안드로이드/코틀린

ComposeUI의 구성 순서와 SideEffect

by 나이아카 2025. 5. 22.

 ComposeUI를 구성하는데 있어 DisposableEffect와 LaunchedEffect는 굉장히 중요한 요소인데요. 회사에서 ComposeUI를 다루다 잘 안되던 문제를 잡다 문득 정리가 필요할 것 같아서 UI의 구성 순서와 Effect의 발생 시기를 정리해보려고 합니다.


SideEffect란

 ComposeUI에는 대표적으로 Composable로 표현되는 UI 구성요소와 SideEffect라는 UI 이외의 데이터를 가공하고 처리하는 구성요소로 이루어져 있습니다. 그 중 SideEffect는 UI를 직접적으로 구성하는(View가 아님!) 것과 별개로 이루어지는 작업들을 일컫습니다. 이 SideEffect에는 LaunchedEffect와 DisposableEffect, SideEffect(이름이 동일한 클래스입니다)가 있습니다. 참고로 모든 이펙트들은 @Composable로 구성되어, ComposeUI 블럭 내에서는 어느 위치든 호출이 가능합니다(당연히 람다 함수 내에서 호출은 불가능합니다).

 

LaunchedEffect

@Composable
@NonRestartableComposable
@OptIn(InternalComposeApi::class)
fun LaunchedEffect(
    key1: Any?,
    block: suspend CoroutineScope.() -> Unit
) {
    val applyContext = currentComposer.applyCoroutineContext
    remember(key1) { LaunchedEffectImpl(applyContext, block) }
}
internal class LaunchedEffectImpl(
    parentCoroutineContext: CoroutineContext,
    private val task: suspend CoroutineScope.() -> Unit
) : RememberObserver {
    private val scope = CoroutineScope(parentCoroutineContext)
    private var job: Job? = null

    override fun onRemembered() {
        // This should never happen but is left here for safety
        job?.cancel("Old job was still running!")
        job = scope.launch(block = task)
    }

    override fun onForgotten() {
        job?.cancel(LeftCompositionCancellationException())
        job = null
    }

    override fun onAbandoned() {
        job?.cancel(LeftCompositionCancellationException())
        job = null
    }
}

 LaunchedEffect는 ComposeUI의 SideEffect의 종류 중 하나입니다. key와 block 파라미터를 가지고 있으며, 비동기 작업을 지원합니다. block의 코드를 현재 Composer의 코루틴을 가지고 새로운 스코프를 생성한 후 launch 하는 기능을 가지고 있습니다. 

 이때 key값은 remember로 감싸져 키의 변동을 감지하게 됩니다. key값이 변경되면 다시 launchedEffect의 내부 블럭이 실행되는 방식으로 사용됩니다. 하지만 이 key에 State가 아닌 일반 String처럼 선언되어 있는 변수를 넣는다면(당연하게도) 변경이 확인되지 않아 블럭은 한 번만 실행되게 됩니다(하지만 문법적인 에러는 발생하지 않습니다). 이를 이용하여 key값에 Unit을 사용하게 되면 composition이 구성될 때만 호출되는 1회성 effect 블럭을 만들 수 있습니다.

 

DisposableEffect

@Composable
@NonRestartableComposable
fun DisposableEffect(
    key1: Any?,
    effect: DisposableEffectScope.() -> DisposableEffectResult
) {
    remember(key1) { DisposableEffectImpl(effect) }
}
class DisposableEffectScope {
    /**
     * Provide [onDisposeEffect] to the [DisposableEffect] to run when it leaves the composition
     * or its key changes.
     */
    inline fun onDispose(
        crossinline onDisposeEffect: () -> Unit
    ): DisposableEffectResult = object : DisposableEffectResult {
        override fun dispose() {
            onDisposeEffect()
        }
    }
}
private class DisposableEffectImpl(
    private val effect: DisposableEffectScope.() -> DisposableEffectResult
) : RememberObserver {
    private var onDispose: DisposableEffectResult? = null

    override fun onRemembered() {
        onDispose = InternalDisposableEffectScope.effect()
    }

    override fun onForgotten() {
        onDispose?.dispose()
        onDispose = null
    }

    override fun onAbandoned() {
        // Nothing to do as [onRemembered] was not called.
    }
}

 LaunchedEffect랑 크게 다른 부분은 Ondispose를 제외하면 없는 것 같습니다. 기본적으로 동작과 사용 방식은 동일하지만, 만약 화면이 사라질 때 종료해야 할 추가적인 이벤트가 있다면 이를 onDispose에 구현해둬야 합니다. 이 부분 외에는 동일한 방식으로 동작한다고 생각하면 되겠습니다. 그러나 LuanchedEffect는 항상 새로운 CoroutineScope을 생성해서 블럭을 실행시키는 반면, DisposableEffect는 새로운 CoroutineScope을 생성하는 것이 아니라 Composition 흐름 내에서 코드가 실행되는 형상으로 되어 있습니다. 

 

ComposeUI 구성 순서

 같은 @Composable 내에서 LaunchedEffect나 DisposableEffect가 최상단에 있다고 하더라도 ComposeUI는 UI 트리를 그리는 것을 최우선으로 합니다. 이를 통해 단일 화면 구성 순서를 살펴보면

UI트리 구성 -> UI 컴포지션 -> DisposableEffect 등록 -> LaunchedEffect 등록 -> UI 컴포지션 완료 -> DisposableEffect 시작 -> LaunchedEffect 시작

으로 구성됩니다. UI를 구성하면서 SideEffect를 등록하고, UI 구성이 완료되면 지정해두었던 SideEffect가 실행되는 순서입니다. 여기서 Disposable의 경우는 동기적으로, LaunchedEffect는 비동기적으로 발생하며 설계상으로 DisposableEffect가 LaunchedEffect보다 먼저 발생하게 됩니다. 

 

화면 A에서 B 이동할 때는 B UI 구성 완료된(UI 계층에 등록됨) 이후에 A 종료하는 방식으로 동작합니다. 이는 내비게이션을 통해 A -> B 이동할 때나 뒤로가기를 통해 B -> A 돌아갈 동일하게 동작합니다. 이를 살펴보면

A 화면 구성 -> A에서 B 이동 -> B화면 구성 -> A 화면의 Disposable onDispose 호출 -> A 화면 종료

순서로 구성이 됩니다. 새로 보여줄 UI(ComposeUI의 경우 Fragment나 Activity와는 다르게 backStack에 쌓여 존재하거나 하지 않고 recomposition되기 때문에 화면 이동이 논리적으로 앞으로 향하는지 뒤로 향하는지는 상관 없습니다.)가 먼저 위의 단일 화면 구성 순서에 따라 생성된 후, 제거해야 할 UI의 onDispose가 호출되게 됩니다. 이때 당연하게도 DisposableEffect가 없으면 생성 후 바로 종료됩니다.

 정리하자면, Composable은 항상 UI의 구성이 우선이고 이후 SideEffect를 발생시킵니다. 이때 동기적으로 처리하는 Disposable부터 작동하며 마지막으로 비동기적으로 LaunchedEffect가 발생하여 구성을 완료하게 됩니다.

 


 ComposeUI는 참 재밌습니다. 처음에 flutter를 접할 때도 이러한 형태의 UI 코딩 방식을 재밌다고 생각했었는데요. 비슷한 형태라 그런지 xml로 작성할 때 보다 만드는 재미가 좀 더 있습니다. 물론 EditText에는 있는데 TextField에는 없는 함수들처럼 아직까지 ComposeUI가 기존 xml처럼 기본으로 가지고 있지 않은 기능들 때문에 구현에 어려움을 겪는 경우가 많기는 한데, 점차 발전해나가는 모습을 기대할 수 있어서 배우는 재미가 있는 것 같습니다(아직 걸음마도 못뗀 것 같기는 하지만...).

댓글