아티클 목록으로 가기

Compose 사이드 이펙트의 내부: RememberObserver와 컴포지션 생명주기

skydovesJaewoong Eum (skydoves)||18분 소요

Compose 사이드 이펙트의 내부: RememberObserver와 컴포지션 생명주기

Compose 화면을 아무거나 하나 열어 봐도 이펙트 핸들러는 이미 곳곳에 들어가 있습니다. id가 바뀔 때 로딩을 시작하려고 LaunchedEffect(userId) { viewModel.load(userId) }를 씁니다. 리스너를 등록하고 정리하려고 DisposableEffect(owner) { owner.register(cb); onDispose { owner.unregister(cb) } }를 씁니다. 버튼의 onClick에서 코루틴을 시작할 수 있도록 rememberCoroutineScope()를 호출합니다. Compose 바깥에 있는 무언가를 계속 동기화하려고 SideEffect { analytics.setCurrentScreen(name) }를 툭 던져 넣습니다. 이 네 가지는 이미 몸에 밴 습관입니다. 각각이 어떤 비용을 치르는지 굳이 곱씹지 않고도 손이 먼저 나갑니다.

대부분의 개발자는 LaunchedEffect를 '키가 바뀌면 이 블록을 실행하는 것' 정도로 떠올립니다. 그 멘탈 모델이 틀린 것은 아니지만, 런타임이 실제로 하는 일을 가려 버립니다. 키가 바뀔 때마다 Compose는 돌고 있던 코루틴을 취소하고 완전히 새로운 코루틴을 시작합니다. 블록이 실제로 일시 중단되든 아니든, 모든 LaunchedEffect 뒤에는 진짜 CoroutineScope, 진짜 Job, 진짜 코루틴이 있습니다. 만약 여러분의 이펙트가 그저 키에 반응하기만 하면 되는 동기 호출이라면, 요청한 적도 없는 코루틴에 비용을 치르고 있는 셈입니다. 왜 그런지, 그리고 언제 더 가벼운 도구가 나은 선택인지 알려면, 이 함수들을 따라 내려가 이들 대부분이 공유하는 단 하나의 인터페이스 RememberObserver에 이르러야 합니다.

이 글에서는 Compose 이펙트 핸들러가 어떻게 동작하는지를 깊이 있게 파고듭니다. 이펙트가 왜 컴포지션 생명주기 안에 잘 정의된 자리를 필요로 하는지, remember가 키 변경을 어떻게 새로운 객체로 바꿔 내는지, 그 객체가 컴포지션에 들어오고 떠날 때 LaunchedEffectImpl이 어떻게 코루틴을 시작하고 취소하는지, 이펙트 코루틴의 Job과 프레임 클록은 어디에서 오는지, DisposableEffectSideEffect, rememberCoroutineScope는 각각 어떻게 다른지, RememberEventDispatcherapplyChanges 동안 onRemembered, onForgotten, onAbandoned를 어떻게 호출하는지, 그리고 compose-effects 라이브러리의 RememberedEffect처럼 코루틴을 쓰지 않는 이펙트가 언제 그 비용을 온전히 피하는지까지 차례대로 살펴봅니다.

근본적인 문제: 선언적 트리 안에서 명령형 코드 실행하기

컴포지션은 선언적이고 반복 가능합니다. 컴포저블 함수는 한 번 실행되었다가 다음 리컴포지션에서 다시 실행되고, 또다시 실행될 수 있으며, 정해진 일정 같은 것은 없습니다. UI를 기술하는 데는 이것으로 충분하지만, 정해진 횟수만큼만 일어나야 하는 명령형 작업과는 부딪칩니다. 로딩을 시작하는 가장 직접적인 방법을 살펴보겠습니다.

@Composable
fun UserScreen(userId: String, viewModel: UserViewModel) {
    viewModel.load(userId)   // 리컴포지션이 일어날 때마다 매번 실행됨
    val user by viewModel.user.collectAsStateWithLifecycle()
    UserContent(user)
}

viewModel.load(userId)UserScreen이 리컴포즈될 때마다 실행되는데, 다른 어딘가에서 애니메이션이 도는 동안에는 초당 수십 번에 이를 수도 있습니다. 여러분이 원한 것은 userId마다 한 번이지, 리컴포지션마다 한 번이 아닙니다. 게다가 정리 코드를 둘 자리도 없습니다. 화면이 트리에서 빠져나가더라도 진행 중인 요청을 취소할 훅이 어디에도 없습니다.

이펙트 핸들러는 명령형 코드에 컴포지션 생명주기 안에서의 정해진 위치를 부여하려고 존재합니다. 앞서 본 순진한 호출은 답하지 못하는 세 가지 질문에 답을 줍니다. 얼마나 자주 실행할지, 언제 정리할지, 그리고 작업을 예약한 컴포지션이 커밋되기 전에 버려지면 어떻게 되는지입니다. 이 모든 답이 같은 곳에서 나오므로, 그 자리를 먼저 만나 보는 것이 좋습니다.

네 가지 핸들러, 그리고 그 밑에 깔린 하나의 인터페이스

매일같이 손이 가는 이펙트 핸들러가 네 가지 있습니다. LaunchedEffect는 키를 받아 suspend 블록을 코루틴에서 실행하고, 키가 바뀌면 다시 시작합니다. DisposableEffect는 키를 받아 설정 코드를 실행하고, 키가 바뀌거나 이펙트가 떠날 때 그에 대응하는 정리 코드를 실행합니다. SideEffect는 성공적인 컴포지션이 끝날 때마다 평범한 블록을 실행합니다. rememberCoroutineScope는 클릭 핸들러처럼 컴포지션 바깥에서 코루틴을 시작할 수 있는 CoroutineScope를 건네줍니다.

네 가지 중 셋은 속을 들여다보면 같은 모양입니다. 이들은 RememberObserver를 구현하는 객체를 만들어 내는 remember 호출이며, 생명주기가 실제로 사는 곳이 바로 그 인터페이스입니다.

public interface RememberObserver {
    public fun onRemembered()
    public fun onForgotten()
    public fun onAbandoned()
}

계약은 작고 정확합니다. onRemembered는 객체가 컴포지션을 위해 슬롯 테이블에 커밋되었을 때, 컴포지션의 적용(apply) 스레드에서 호출됩니다. onForgotten은 객체가 속한 그룹이 트리를 떠났거나 컴포지션이 해제되어 그 객체가 더 이상 어디에서도 기억되지 않을 때, 마찬가지로 적용 스레드에서 불립니다. onAbandoned는 한 번도 적용되지 않은 컴포지션에서 remember 계산으로 객체가 만들어졌을 때 onRemembered 대신 호출됩니다. KDoc은 뒤에서 중요해지는 두 가지를 보장합니다. 한 자리에서 기억된 객체는 onRememberedonAbandoned 중 하나만 받을 뿐 둘 다 받는 일은 결코 없다는 것, 그리고 여러 객체가 함께 기억되었다면 그 onForgotten 호출은 onRemembered 호출의 역순으로 실행된다는 것입니다.

SideEffect는 오히려 규칙을 더 또렷하게 보여 줄 예외라서 뒤에서 다룹니다. 나머지 셋은 하나의 주제를 변주한 것입니다. remember로 슬롯 테이블에 RememberObserver를 넣어 두고, 그 세 콜백이 일하게 하는 것이지요.

remember야말로 진짜 기본 요소다

모든 것을 다시 보게 만드는 관찰이 하나 있습니다. 이펙트 함수들은 자체 로직을 거의 담고 있지 않습니다. LaunchedEffect(key1)의 전체 모습은 다음과 같습니다.

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

이것이 함수의 전부입니다. 코루틴 컨텍스트를 붙잡아 둔 뒤, LaunchedEffectImpl을 만드는 팩토리와 함께 remember(key1)을 호출합니다. '키가 바뀌면 다시 시작한다'를 포함해 여러분이 LaunchedEffect와 결부 짓는 모든 동작은, 실은 remember(key1)의 동작입니다. 그러니 remember가 키를 두고 무슨 일을 하는지 알아야 합니다.

내부적으로 rememberComposer.cache이며, 이 함수는 지금 이 슬롯에 저장된 값을 읽어 그대로 둘지 다시 계산할지 결정합니다.

public inline fun <T> Composer.cache(invalid: Boolean, block: () -> T): T {
    return rememberedValue().let {
        if (invalid || it === Composer.Empty) {
            val value = block()
            updateRememberedValue(value)
            value
        } else it
    } as T
}

remember(key1) { ... }은 이 함수를 invalid = currentComposer.changed(key1)과 함께 호출하는데, 이 값은 키가 지난번에 저장된 값과 다를 때 true가 됩니다. 그래서 로직은 이렇습니다. 키가 바뀌었거나 아직 저장된 것이 없으면 팩토리를 실행해 새 값을 저장하고, 그렇지 않으면 캐시된 값을 손대지 않고 그대로 돌려줍니다. 키가 바뀌면 완전히 새로운 객체가 만들어지고, 이전에 저장돼 있던 객체는 컴포지션을 떠나도록 예약됩니다. 바로 이 하나의 사실이 이 글의 경첩입니다. 키 변경은 '여러분의 이펙트를 다시 실행'하는 것이 아닙니다. 새 RememberObserver를 만들고 옛것을 잊는 것이며, 이펙트의 동작은 그 두 객체가 onRememberedonForgotten에서 하는 일로부터 자연스럽게 흘러나옵니다.

LaunchedEffect 해부: 커튼 뒤의 코루틴

이제 remember가 저장하고 있는 객체를 봅시다. LaunchedEffectImplRememberObserver이며, 코루틴이 모습을 드러내는 곳이 바로 이 객체의 콜백들입니다.

internal class LaunchedEffectImpl(
    private val parentCoroutineContext: CoroutineContext,
    private val task: suspend CoroutineScope.() -> Unit,
) : RememberObserver, CoroutineExceptionHandler {
    private val scope = CoroutineScope(parentCoroutineContext + this)
    private var job: Job? = null

    override fun onRemembered() {
        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
    }
}

이 클래스는 생성자에서 CoroutineScope를 만들고 널이 될 수 있는 Job을 들고 있습니다. 컴포지션이 이 객체를 기억하면, onRemembered가 그 스코프에 task를 시작하고 그 결과로 나온 Job을 저장합니다. 컴포지션이 이 객체를 잊거나 버리면, JobLeftCompositionCancellationException과 함께 취소되고 비워집니다. onRemembered 안의 job?.cancel("Old job was still running!") 줄은 방어적인 코드로, 갓 만들어진 impl에는 아직 Job이 없으므로 보통은 아무 일도 하지 않습니다. 이 클래스는 CoroutineExceptionHandler도 구현하며 자기 자신을 자신의 스코프 컨텍스트에 더하는데, 소스 코드는 별도의 핸들러 객체를 만드는 대신 할당을 아끼려고 이렇게 했다고 밝히고 있습니다.

여기에 앞 절에서 본 remember의 동작을 더하면 키 변경 이야기가 완성됩니다. LaunchedEffect(userId) { load(userId) }가 있고 userIdA에서 B로 바뀐다고 해봅시다.

  1. 리컴포지션이 일어나면, remember(B)가 저장된 키 AB와 비교해 서로 다름을 확인하고 팩토리를 실행합니다. 그러면 자기만의 새 스코프를 가진 새 LaunchedEffectImpl이 만들어집니다. 옛 impl은 잊히도록 예약됩니다.
  2. 컴포지션이 변경을 적용할 때, 런타임은 옛 impl의 onForgotten을 호출해 아직 load(A)를 돌리던 코루틴을 취소하고, 새 impl의 onRemembered를 호출해 load(B)를 돌리는 새 코루틴을 시작합니다.

그래서 '키가 바뀌면 이펙트를 다시 시작한다'는, 기계적으로 보면 '옛 코루틴을 취소하고 새 코루틴을 시작한다'이며, 이 모든 것은 remember가 서로 다른 객체 정체성을 만들어 내는 데서 비롯됩니다. LaunchedEffect 함수 자체는 키를 들여다보는 일이 결코 없습니다. 화면이 트리에서 통째로 빠져나가면 onForgotten만 실행되어 코루틴이 취소되고, 다시 시작되는 것은 아무것도 없습니다. 진행 중이던 LaunchedEffect가 그 컴포저블이 사라질 때 자동으로 해체되는 이유가 바로 이것입니다.

Job과 프레임 클록은 어디에서 오는가

스코프는 parentCoroutineContext + this로 만들어지며, impl은 자기만의 Job()을 결코 더하지 않습니다. 여기서 의문이 하나 생깁니다. 시작된 코루틴의 부모는 무엇이고, 별도 설정도 없이 LaunchedEffect 안에서 withFrameNanosanimate*를 호출할 수 있는 이유는 무엇일까요? 답은 이펙트가 currentComposer.applyCoroutineContext로 붙잡아 둔 컨텍스트에 있습니다.

그 컨텍스트는 Recomposer가 조립합니다. Recomposer의 이펙트 컨텍스트는 사용자가 제공한 컨텍스트에, Recomposer가 더하는 두 요소를 합친 것입니다.

override val effectCoroutineContext: CoroutineContext =
    effectCoroutineContext + broadcastFrameClock + effectJob

broadcastFrameClockMonotonicFrameClock이고, effectJob은 모든 이펙트 작업의 부모가 되는 Job입니다. applyCoroutineContext가 이미 이 Job을 담고 있으므로, 표준 CoroutineScope(context) 팩토리는 또 다른 Job을 덧붙이지 않습니다. 그 덕분에 이펙트 코루틴의 Job은 recomposer의 effectJob의 자식이 됩니다. 여기서 두 가지 결과가 따라옵니다. 컴포지션이 해체될 때 일어나는 recomposer 전체의 취소는, 이 부모-자식 연결을 통해 모든 이펙트 코루틴을 취소합니다. 그리고 프레임 클록이 컨텍스트의 한 요소이기 때문에, withFrameNanos 같은 suspend 호출은 그것을 자동으로 찾아냅니다.

public suspend fun <R> withFrameNanos(onFrame: (frameTimeNanos: Long) -> R): R =
    coroutineContext.monotonicFrameClock.withFrameNanos(onFrame)

LaunchedEffect 안에 작성한 애니메이션이 여러분 쪽에서 아무것도 배선하지 않아도 화면과 딱 맞아떨어지는 이유가 바로 이것입니다. 프레임 클록은 취소용 Job을 실어 나르는 바로 그 컨텍스트에 함께 올라탑니다.

숨은 비용, 그리고 그 비용이 필요 없을 때

지금까지 이야기한 모든 것은 이펙트가 비동기일 때 정확히 여러분이 원하는 바입니다. flow를 수집하거나, suspend 네트워크 함수를 호출하거나, delay를 실행하거나, 애니메이션을 돌리는 일은 모두 코루틴과 취소용 Job, 그리고 프레임 클록을 필요로 하며, LaunchedEffect는 이들을 공짜로 제공합니다. 이 비용이 낭비가 되는 것은 오직 블록이 한 번도 일시 중단되지 않을 때뿐입니다. 아주 흔한 형태를 하나 살펴보겠습니다.

LaunchedEffect(count) {
    analytics.log("count changed to $count")   // 동기 코드, 절대 일시 중단되지 않음
}

여러분이 원한 것은 'count가 바뀔 때마다 이 한 줄을 실행하라'였습니다. 그런데 실제로 얻은 것은 LaunchedEffectImpl 할당 하나, CoroutineScope 하나, 그리고 키가 바뀔 때마다 코루틴 취소에 이은 코루틴 시작이며, 이 모든 것이 고작 동기 구문 한 줄을 실행하기 위한 것입니다. 코루틴이 시작되고, 블록은 한 번도 일시 중단되지 않고 끝까지 실행되며, 코루틴이 끝납니다. 그 어떤 기계 장치도 제 밥값을 하지 못했습니다.

더 가벼운 도구는, 스코프도 Job도 없이 그저 onRemembered에서 여러분의 블록을 실행하는 RememberObserver입니다. compose-effects 라이브러리가 RememberedEffect로 제공하는 것이 바로 이것입니다. 이 라이브러리의 README도 같은 트레이드오프를 짚습니다. LaunchedEffect는 새 코루틴 스코프를 만들고 키가 바뀔 때마다 작업을 다시 시작하므로 코루틴 작업에 어울리는 반면, RememberedEffect는 키 변경마다 스코프를 만들거나 시작하지 않으므로 사이드 이펙트의 실행을 기억하는 데 더 효율적인 선택지가 됩니다. 사용법은 LaunchedEffect와 판박이입니다.

var count by remember { mutableIntStateOf(0) }
RememberedEffect(key1 = count) {
    Log.d(tag, "$count")
}

이 설계는 지금까지 본 모든 것에서 자연스럽게 따라 나옵니다. onRemembered가 여러분의 블록을 실행하는 RememberObserver를 저장하는 remember(key1)이며, 앞서 본 키 변경 메커니즘을 코루틴만 뺀 채 그대로 재사용합니다. 판단 기준은 간단합니다. 블록이 일시 중단된다면 LaunchedEffect를 택하세요. 코루틴과 그 취소, 그리고 프레임 클록이 정말로 필요하기 때문입니다. 블록이 동기적이고 그저 키에 반응하기만 하면 된다면, 코루틴을 쓰지 않는 이펙트가 할당과, 취소하고 다시 시작하기를 반복하는 소모를 피해 줍니다. 그리고 다음 절에서 보듯이, 런타임에는 이미 대부분의 코드베이스가 제대로 활용하지 못하는 코루틴 없는 이펙트가 하나 딸려 있습니다.

DisposableEffect: 코루틴 없는 설정과 정리

DisposableEffect는 처음부터 줄곧 런타임에 있어 온, 코루틴을 쓰지 않는 이펙트입니다. 그 impl 역시 또 하나의 RememberObserver이지만, 콜백이 코루틴을 건드리는 대신 평범한 코드를 실행합니다.

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() {
        // [onRemembered]가 호출되지 않았으므로 할 일이 없음
    }
}

기억될 때는 여러분의 effect를 실행하고 그것이 돌려주는 DisposableEffectResult를 간직합니다. 잊힐 때는 그 결과에 대고 dispose()를 호출합니다. DisposableEffectScope는 오직 onDispose { } 블록이 결과 객체를 돌려주도록 만들기 위해서만 존재합니다.

public class DisposableEffectScope {
    public inline fun onDispose(
        crossinline onDisposeEffect: () -> Unit
    ): DisposableEffectResult =
        object : DisposableEffectResult {
            override fun dispose() { onDisposeEffect() }
        }
}

잠시 짚고 넘어갈 만한 두 가지가 있습니다. 첫째, 이 경로에는 코루틴이 어디에도 없으며, 그래서 DisposableEffect는 값에 키를 건 리스너나 옵저버, 콜백을 등록하고 해제하는 데 딱 맞는 도구입니다. 둘째, onAbandoned가 아무 일도 하지 않는다는 점에 주목하세요. onAbandoned에서도 여전히 Job을 취소하는 LaunchedEffectImpl과는 다릅니다. 그 이유는 onRemembered와의 대칭성에 있습니다. DisposableEffectImpl은 오직 onRemembered에서만 리소스를 획득하므로, 이펙트가 한 번도 기억되지 못한 채 버려졌다면 해제할 것이 없습니다. LaunchedEffectImplJob이 보통 널인데도 onAbandoned에서 방어적으로 취소하는데, 널인 Job을 취소하는 것은 아무 해가 없고, 이 대칭성이 콜백을 안전하게 지켜 주기 때문입니다.

짚어 둘 만한 사용성 문제가 하나 있습니다. DisposableEffect는 정리할 것이 아무것도 없을 때조차 항상 onDispose { } 결과를 돌려주기를 요구하므로, 순전히 키에 반응하려고 이것을 쓰면 빈 onDispose { }를 적어야 합니다. 바로 그 빈틈을 전용 RememberedEffect가 메웁니다. 키에 대한 똑같은 코루틴 없는 반응을, 정리라는 형식적 절차 없이 제공하는 것입니다.

SideEffect: 혼자 결이 다르다

SideEffect는 패턴을 깨뜨리는데, 어떻게 깨뜨리는지를 보면 오히려 패턴이 더 또렷해집니다. 이는 RememberObserver가 아니며, 슬롯 테이블에 아무것도 저장하지 않습니다.

@Composable
@NonRestartableComposable
public fun SideEffect(effect: () -> Unit) {
    currentComposer.recordSideEffect(effect)
}

SideEffectImpl 같은 클래스는 없습니다. recordSideEffect는 블록을 컴포지션의 변경 목록(change list)에 덧붙이고, 이 블록은 remember 콜백들 뒤, 적용 단계에서 실행됩니다. 아무것도 기억되지 않기 때문에 키도 없고 정리 훅도 없으며, 키 없는 SideEffect는 성공적인 컴포지션과 리컴포지션이 끝날 때마다 실행됩니다. 이것이 맡은 일은, 매번 동기화된 상태로 유지되어야 하는 Compose 바깥의 객체에 Compose 값을 내보내는 것입니다. 뷰나 시스템 서비스의 프로퍼티를 설정하는 경우가 그 예입니다. currentComposer.changed(keyN) 뒤에서 기록을 통제하는, 키를 받는 SideEffect 오버로드도 있지만, 흔히 쓰는 키 없는 형태는 딱 한 번이 아니라 매 적용마다 실행되도록 일부러 그렇게 되어 있습니다.

쓸모 있는 대목은 이 대비입니다. LaunchedEffectDisposableEffect는 기억되므로 정체성과 키, 그리고 떠날 때의 콜백을 가집니다. SideEffect는 기록될 뿐이라 그 어느 것도 없이 그저 매 적용의 끝에서 실행됩니다. 다른 것들은 다 있는데 SideEffect만 왜 정리도 키도 없는지 궁금했던 적이 있다면, 그 답은 이것입니다. SideEffect는 슬롯 테이블에 결코 들어가지 않습니다.

rememberCoroutineScope: 여러분이 직접 모는 스코프

마지막 핸들러는 아무것도 시작하지 않은 채 코루틴 스코프를 건네줍니다. 그 정의는 키가 없는 remember입니다.

@Composable
public inline fun rememberCoroutineScope(
    crossinline getContext: () -> CoroutineContext = { EmptyCoroutineContext }
): CoroutineScope {
    val composer = currentComposer
    return remember { createCompositionCoroutineScope(getContext(), composer) }
}

키가 없으므로 스코프는 첫 컴포지션 때 한 번 만들어져, 호출 지점이 트리를 떠날 때까지 유지됩니다. 이 함수가 돌려주는 객체는 RememberedCoroutineScope로, CoroutineScope이면서 동시에 RememberObserver입니다. 그 코루틴 컨텍스트는 처음 접근할 때 지연 생성되므로, 실제로는 한 번도 코루틴을 시작하지 않는 스코프라면 비용이 거의 들지 않으며, 그 onForgottenonAbandoned가 만들어진 것을 무엇이든 취소합니다. 그 Job은 앞서 본 바로 그 적용 컨텍스트 Job의 자식이므로, 이 스코프 역시 컴포지션과 함께 해체됩니다.

rememberCoroutineScope의 핵심은 이것이 하지 않는 일에 있습니다. 스스로는 아무것도 시작하지 않습니다. 여러분이 직접 onClick 같은 이벤트 콜백에서 scope.launch { }를 호출하는데, 그곳은 작업을 붙일 컴포지션이 없는 자리입니다. 반면 LaunchedEffect는 컴포지션의 사이드 이펙트로서 코루틴을 시작합니다. 소스 코드는 문서에서 그 규칙을 곧바로 밝힙니다. 컴포지션 자체의 사이드 이펙트로 스코프에서 코루틴을 시작해서는 결코 안 되며, 컴포지션이 시작하는 지속적인 작업에는 LaunchedEffect를 써야 한다는 것입니다. 그러니 둘 사이의 선택은 취향의 문제가 아닙니다. LaunchedEffect는 수명이 컴포지션에 키가 존재하는지에 묶인 코루틴을 위한 것이고, rememberCoroutineScope는 수명이 오직 호출 지점이 컴포지션에 남아 있는지에만 묶인, 사용자 이벤트로 시작되는 코루틴을 위한 것입니다.

컴포지션 생명주기: 콜백은 실제로 어떻게 호출되는가

이제 각 이펙트가 자신의 RememberObserver 콜백에서 무슨 일을 하는지 알게 되었습니다. 남은 질문은, 그 콜백들이 어떻게 제때 호출되는가입니다. 이것이 컴포지션 생명주기를 움직이는 장치이며, 세 악장으로 진행됩니다. 컴포지션 동안의 기록, 적용 동안의 디스패치, 그리고 정리입니다. 정리는 다시, 결코 커밋되지 않는 컴포지션을 버리는 일과, 허물어지는 컴포지션을 해제하는 일로 나뉩니다.

기록: remember는 홀더와 버려질 후보를 큐에 넣는다

컴포저블이 remember를 호출하고 팩토리가 값을 만들어 내면, composer는 updateCachedValue를 통해 그 값을 저장합니다. 그 값이 RememberObserver라면, 세 가지 일이 한꺼번에 일어납니다.

internal fun updateCachedValue(value: Any?) {
    val toStore =
        if (value is RememberObserver) {
            val holder = GapRememberObserverHolder(value, rememberObserverGroupIndex())
            if (inserting) {
                changeListWriter.remember(holder)
            }
            abandonSet.add(value)
            holder
        } else value
    updateValue(toStore)
}

옵저버는 RememberObserverHolder로 감싸이고, remember 연산이 변경 목록에 덧붙여지며, 무엇보다 원래 옵저버가 곧바로 컴포지션의 abandonSet에 더해집니다. 이 마지막 단계는, 이 컴포지션이 실제로 적용될지 런타임이 알기도 전에 갓 만들어진 모든 이펙트를 버려질 후보로 등록해 둡니다. 반대로 그룹이 제거될 때는, 슬롯을 훑는 과정에서 forgetting(holder)을 호출해 떠나는 옵저버를 기록합니다. 사이드 이펙트는 recordSideEffect로 따로 기록되며, 이는 사이드 이펙트 연산을 같은 변경 목록에 큐로 넣습니다.

디스패치: 잊힘은 역순, 기억됨은 정순, 그다음 사이드 이펙트

적용 시점에 CompositionImpl은 기록된 변경 사항을 RememberEventDispatcher, 곧 아래 코드의 rememberManager에게 넘기고, 이 객체는 변경을 적용한 뒤 생명주기 콜백을 디스패치합니다. 그 순서는 의도된 것입니다.

applier.onBeginChanges()
changes.execute(slotStorage, applier, rememberManager, errorContext)
applier.onEndChanges()

rememberManager.dispatchRememberObservers()
rememberManager.dispatchSideEffects()

changes.execute는 노드와 슬롯 변경을 적용하면서, 그 과정에서 각 옵저버를 디스패처에 기록합니다. 기억되는 옵저버는 remembering 목록에, 잊히는 옵저버는 하나로 통합된 leaving 목록에, 사이드 이펙트는 sideEffects 목록에 담깁니다. 그런 다음 dispatchRememberObservers가 콜백을 호출합니다. 잊힘이 먼저 역순으로 실행되고, 그다음 기억됨이 정순으로 실행됩니다.

fun dispatchRememberObservers() {
    // 잊힘을 역순으로 전송
    for (i in leaving.size - 1 downTo 0) {
        val instance = leaving[i]
        if (instance is RememberObserverHolder) {
            abandoning.remove(instance.wrapped)
            instance.wrapped.onForgotten()
        }
    }
    // 기억됨을 정순으로 전송
    remembering.forEach { instance ->
        abandoning.remove(instance.wrapped)
        instance.wrapped.onRemembered()
    }
}

여기서 두 가지 순서 선택이 드러납니다. 잊힘이 기억됨의 역순으로 실행되는 것은, 정리가 설정을 거울처럼 되비추게 하기 위해서입니다. 자식 이펙트가, 그 그룹을 감싸는 부모보다 먼저 잊히는 식입니다. 그리고 두 루프 모두 콜백을 부르기 전에 abandoning.remove(...)를 호출하는데, 진짜로 기억되거나 잊히는 옵저버는 이렇게 버려질 목록에서 빠집니다. 이다음 dispatchSideEffects가 기록된 사이드 이펙트를 실행합니다. 소스 코드의 주석은 사이드 이펙트가 왜 맨 마지막에 오는지 설명합니다. 기억된 객체를 붙잡아 둔 사이드 이펙트는 그 객체가 onRemembered를 받은 다음에 실행되어야 하며, 그래야 사이드 이펙트가 객체를 건드리기 전에 그 객체가 완전히 초기화되기 때문입니다.

버려짐: 존재한 적 없는 컴포지션

remember와 forget 디스패치가 끝난 뒤에도 abandonSet에 남아 있는 것은 무엇이든, 결코 커밋되지 않은 컴포지션에서 만들어진 것입니다. 그 옵저버들은 onAbandoned를 받습니다.

fun dispatchAbandons() {
    val iterator = abandoning.iterator()
    while (iterator.hasNext()) {
        val instance = iterator.next()
        iterator.remove()
        instance.onAbandoned()
    }
}

이는 중간에 실패한 컴포지션, 또는 계산되었다가 적용되기 전에 버려진 하위 트리가 지나는 경로입니다. 그런 컴포지션에서 만들어진 LaunchedEffectImplonRemembered가 한 번도 호출되지 않았으므로 코루틴을 시작한 적도 없고, onAbandoned는 거의 항상 널인 Job을 취소합니다. 버려짐 디스패치는 컴포지션이 폐기될 때 적용 경로에서 실행되며, composeContentrecompose 주위의 가드에서도 실행되어, 예외를 던진 컴포지션도 자신이 만든 옵저버를 여전히 정리하게 합니다. 버려진 옵저버는 onRememberedonForgotten도 받지 않고 오직 onAbandoned만 받는데, 이것이 바로 RememberObserver KDoc이 약속하는 그 보장입니다.

해제: 모든 것이 잊힌다

컴포지션이 해제되면, 런타임은 슬롯 테이블 전체를 훑으며 기억된 모든 옵저버를 잊습니다.

if (nonEmptySlotTable || abandonSet.isNotEmpty()) {
    rememberManager.use(abandonSet, errorContext) {
        if (nonEmptySlotTable) {
            applier.onBeginChanges()
            slotStorage.clear(rememberManager)
            applier.clear()
            applier.onEndChanges()
            dispatchRememberObservers()
        }
        dispatchAbandons()
    }
}

slotStorage.clear(rememberManager)는 모든 슬롯을 방문하며 각 RememberObserver를 잊히는 것으로 기록하고, 그런 다음 dispatchRememberObservers가 균형을 맞출 기억됨 하나 없이 그 모든 onForgotten 콜백을 역순으로 호출합니다. 화면이 백 스택에서 빠져나갈 때 자신이 시작한 모든 LaunchedEffect 코루틴을 취소하고 자신이 획득한 모든 DisposableEffect 리소스를 해제하도록 보장하는 것이 바로 이 메커니즘입니다. 돌아가는 채로 남는 것은 아무것도 없습니다.

전체 추적: 키 변경 하나를 처음부터 끝까지

앞에서 이것의 짧은 버전을 보았습니다. 이제 디스패처까지 이야기가 나왔으니, 같은 키 변경을 적용 단계의 모든 단계를 채워 넣어 살펴보겠습니다. 리컴포지션 도중 userIdA에서 B로 바뀌는 LaunchedEffect(userId) { load(userId) }를 예로 듭니다.

컴포지션 동안 LaunchedEffect(B, block)이 실행됩니다. 이 함수는 remember(B)를 호출하고, remember(B)는 저장된 키 AB와 비교해 서로 다름을 확인한 뒤 팩토리를 불러, 자기만의 스코프를 가진 새 LaunchedEffectImplimpl_B를 만들어 냅니다. 옛 impl_A는 자신의 슬롯을 떠나도록 예약되고, impl_BabandonSet에 더해진 뒤 홀더로 감싸이며, remember 연산이 큐에 들어갑니다.

적용 동안 changes.execute가 슬롯 변경을 기록하고, impl_B를 디스패처의 remembering 목록에, impl_Aleaving 목록에 넣습니다. 그런 다음 dispatchRememberObservers가 실행됩니다. forget 루프가 impl_A.onForgotten\(\)을 호출해 아직 load\(A\)를 돌리던 코루틴을 취소합니다. remember 루프가 impl_B.onRemembered()를 호출해 load(B)를 돌리는 새 코루틴을 시작하고 impl_Babandoning에서 제거합니다. dispatchSideEffects는 실행할 것을 찾지 못하고, dispatchAbandonsabandoning이 비어 있음을 확인합니다. 최종 결과는, 옛 로딩이 취소되고 새 로딩이 시작되었다는 것입니다. 순전히 remember가 새 키에 대해 다른 객체를 만들어 냈고, 디스패처가 한 옵저버를 잊고 다른 옵저버를 기억했기 때문입니다.

모든 이펙트 핸들러는 이 추적의 변주입니다. DisposableEffect는 똑같은 경로를 따르되, onForgotten이 코루틴을 취소하는 대신 리소스를 해제합니다. rememberCoroutineScope는 키 없이 이 경로를 따르므로, 스코프는 한 번 기억되고 오직 잊히기만 합니다. SideEffect는 이 경로 바깥에 앉아, 기억되는 대신 기록되며 매 적용의 끝에서 실행됩니다.

실전 패턴과 함정

이 내부 구조를 알면, 흔한 버그와 선택 몇 가지가 당연한 귀결로 바뀝니다.

  • 불안정한 키는 리컴포지션마다 이펙트를 다시 시작시킵니다. 갓 할당된 객체나 람다를 키로 넘기면 rememberchanged 검사가 매번 true가 되므로, 런타임은 리컴포지션마다 새 impl을 잊고 다시 기억하며 여러분의 코루틴을 끊임없이 취소하고 다시 시작합니다. 키는 의미 있는 동등성을 갖춘 안정적인 값이어야 합니다.
  • LaunchedEffect\(Unit\)은 한 번만 실행되고 다시 시작되지 않습니다. 키 없는 오버로드는 컴파일에 실패하는 deprecated 에러 그림자로만 존재하므로, '한 번만 실행'의 관용구는 Unit입니다. 새 키를 결코 보지 못하기 때문에, 블록은 자신이 읽는 상태를 통하지 않고서는 갱신된 값도 결코 보지 못합니다. 그러니 캡처된 값이 낡아 가는 것에 주의하세요.
  • 동기 반응에는 코루틴 없는 이펙트를 택하세요. 블록이 일시 중단 없이 그저 키에 반응하기만 한다면, DisposableEffectRememberedEffect 스타일의 이펙트가 코루틴을 통째로 건너뜁니다. LaunchedEffect는 진짜로 일시 중단되는 블록을 위해 아껴 두세요.
  • 컴포지션에서 rememberCoroutineScope에 코루틴을 시작하지 마세요. 컴포지션의 사이드 이펙트로 그 스코프에 코루틴을 시작하면, 어떤 키에도 묶이지 않아 리컴포지션에서 중복되기 쉬운 작업이 생깁니다. 컴포지션이 주도하는 코루틴에는 LaunchedEffect를, 이벤트가 주도하는 코루틴에는 rememberCoroutineScope를 쓰세요.
  • 이펙트는 변경이 끝난 뒤, 적용 스레드에서 실행됩니다. onRemembered, onForgotten, 그리고 사이드 이펙트는 모두 여러분의 컴포저블이 실행되는 동안이 아니라, 그 프레임을 위해 트리가 갱신된 뒤에 호출됩니다. 이펙트가 일관되고 커밋된 트리를 보게 되는 이유가 바로 이것입니다.

결론

이 글에서는 Compose 이펙트 핸들러가 익숙한 겉모습 아래에서 어떻게 동작하는지를 살펴보았습니다. LaunchedEffect, DisposableEffect, rememberCoroutineScope는 저마다 RememberObserver를 저장하는 remember이며, '키 변경 시 다시 시작'이라는 동작은 실은 런타임이 옛 객체를 잊는 사이에 remember가 새 객체를 만들어 내는 것입니다. LaunchedEffectImplonRemembered에서 코루틴을 시작하고 onForgotten에서 취소하며, 그 Job은 recomposer를 부모로 두고 프레임 클록은 같은 컨텍스트에 함께 올라탑니다. DisposableEffect는 코루틴 없이 설정과 정리를 실행하고, SideEffect는 기억되는 대신 기록되어 매 적용 뒤에 실행되며, RememberEventDispatcher는 잊힘을 역순으로, 기억됨을 정순으로, 그다음 사이드 이펙트를 호출하되, 결코 커밋되지 않는 컴포지션을 위한 버려짐 경로도 갖추고 있습니다.

이러한 내부 구조를 이해하면 알맞은 도구를 고르고 눈에 잘 띄지 않는 낭비를 피할 수 있습니다. 불안정한 키가 왜 매 프레임 코루틴을 다시 시작시키는지, LaunchedEffect(Unit)이 왜 다시 시작되지 않는지, 그리고 LaunchedEffect로 감싼 동기 이펙트가 왜 한 번도 쓰지 않는 코루틴에 비용을 치르는지가 보입니다. 블록이 일시 중단되지 않을 때는, DisposableEffectcompose-effects 라이브러리의 RememberedEffect처럼 코루틴을 쓰지 않는 이펙트가 스코프도 Job도 시작(launch)도 없이 똑같은 키 변경 의미론을 안겨 줍니다.

flow를 수집하든, 리스너를 등록하든, 값을 뷰에 동기화하든, 클릭에서 코루틴을 시작하든, 그 모든 것 아래에는 똑같은 작은 기계 장치가 있습니다. 이펙트 핸들러는 rememberRememberObserver 위에 얹힌 얇은 함수일 뿐이고, 나머지는 컴포지션 생명주기가 알아서 합니다. 여러분의 LaunchedEffect 안에 숨은 코루틴이 눈에 들어오는 순간, 그것이 정말로 필요한 때가 언제인지를 의식적으로 판단할 수 있게 됩니다.

아티클 목록으로 가기