ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • Jetpack Compose Side Effects 파헤치기 — RememberObserver로 이해하는 effect 핸들러
    Android📱 2026. 8. 1. 12:16

    Jetpack Compose에서 LaunchedEffect, DisposableEffect, SideEffect를 그냥 "레시피"처럼 외워서 쓰고 계신가요? 사실 이 네 가지 effect 핸들러의 동작은 대부분 단 하나의 인터페이스, RememberObserver 위에 얹혀 있습니다. 이 글에서는 Compose 런타임 내부를 열어 effect가 "실제로" 어떻게 동작하는지 정리합니다.

    왜 effect가 필요한가

    Composition은 선언적이고 반복 실행됩니다. recomposition이 일어날 때마다 composable 본문이 다시 실행될 수 있죠. 그래서 아래 코드는 문제가 됩니다.

    @Composable
    fun UserScreen(userId: String, vm: UserViewModel) {
        vm.load(userId)   // recomposition마다 실행 (초당 수십 번 가능)
        val user by vm.user.collectAsStateWithLifecycle()
        UserContent(user)
    }

    effect 핸들러는 이런 명령형 코드(네트워크 로드, 리스너 등록, 외부 상태 동기화)에 "정의된 위치"를 부여해 세 가지 질문에 답합니다.

    • 이 코드는 얼마나 자주 실행되어야 하는가?
    • 언제 정리되어야 하는가?
    • 커밋 전에 버려지면 어떻게 처리하는가?

    RememberObserver — 모든 것의 기반

    핵심은 effect 핸들러 대부분이 RememberObserver라는 하나의 인터페이스 위에 세워져 있다는 점입니다.

    public interface RememberObserver {
        public fun onRemembered()   // slot table에 커밋될 때
        public fun onForgotten()    // 더 이상 remember 안 될 때 (그룹 이탈/dispose)
        public fun onAbandoned()    // apply 안 된 composition에서 만들어졌을 때
    }
    • 세 콜백은 모두 apply thread에서, 트리가 갱신된 뒤 발화합니다. 덕분에 effect는 항상 일관된 커밋 트리를 봅니다.
    • 한 객체는 onRemembered XOR onAbandoned 중 하나만 받습니다. 그리고 onForgottenonRemembered역순으로 호출됩니다.

    remember가 진짜 primitive다

    LaunchedEffect의 전체 구현을 보면 놀랍도록 짧습니다.

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

    remember(key1)은 내부적으로 Composer.cache(invalid = changed(key1)) { ... }입니다. 즉 키가 바뀌면 factory를 재실행해 새 객체를 만들고, 옛 객체는 forget을 예약합니다.

    핵심 통찰: "키가 바뀌면 effect가 재실행된다"가 아닙니다. 정확히는 RememberObserver를 만들고 옛 것을 forget하는 것이고, effect의 동작은 두 객체의 onRemembered/onForgotten에서 나옵니다.

    4대 핸들러

    1. LaunchedEffect — 커튼 뒤의 코루틴

    internal class LaunchedEffectImpl(...) : RememberObserver, CoroutineExceptionHandler {
        private val scope = CoroutineScope(parentCoroutineContext + this)
        private var job: Job? = null
        override fun onRemembered() { job?.cancel(...); job = scope.launch(block = task) }
        override fun onForgotten()  { job?.cancel(LeftCompositionCancellationException()); job = null }
        override fun onAbandoned()  { job?.cancel(LeftCompositionCancellationException()); job = null }
    }
    • 키가 A에서 B로 바뀌면: 옛 impl의 onForgotten(load(A) 취소) → 새 impl의 onRemembered(load(B) launch) 순으로 동작합니다.
    • 이때 생성되는 Job은 Recomposer의 effectJob의 자식이라, composition이 파괴되면 일괄 취소됩니다.
    • context에 broadcastFrameClock이 실려 있어 withFrameNanos/animate*가 자동 동작합니다.
    • 즉, 블록이 suspend할 때만 이 코루틴 기계장치가 제값을 합니다.

    2. DisposableEffect — 코루틴 없는 setup/teardown

    private class DisposableEffectImpl(...) : RememberObserver {
        private var onDispose: DisposableEffectResult? = null
        override fun onRemembered() { onDispose = InternalDisposableEffectScope.effect() }
        override fun onForgotten()  { onDispose?.dispose(); onDispose = null }
        override fun onAbandoned()  { /* onRemembered가 없었으니 할 일 없음 */ }
    }
    • 리스너·옵저버·콜백 등록/해제에 적합합니다. 코루틴이 없습니다.
    • onAbandoned가 비어 있는 이유: 자원을 onRemembered에서만 획득하므로, remember 전에 버려지면 해제할 것이 없기 때문입니다. (여기가 LaunchedEffect와의 결정적 차이입니다.)

    3. SideEffect — 예외 (slot table에 저장 안 됨)

    @Composable
    @NonRestartableComposable
    public fun SideEffect(effect: () -> Unit) { currentComposer.recordSideEffect(effect) }
    • RememberObserver아닙니다. 그래서 키도, cleanup도 없이 매 성공 apply 후 실행됩니다.
    • 용도: Compose 값을 외부 객체(View 속성, 시스템 서비스)에 매 pass마다 동기화할 때.

    4. rememberCoroutineScope — 내가 직접 모는 스코프

    @Composable
    public inline fun rememberCoroutineScope(
        crossinline getContext: () -> CoroutineContext = { EmptyCoroutineContext }
    ): CoroutineScope = remember { createCompositionCoroutineScope(getContext(), currentComposer) }
    • 키가 없으므로 최초 1회만 생성되고, 호출부가 트리를 떠날 때까지 유지됩니다.
    • 스스로 launch하지 않습니다. onClick 같은 이벤트에서 scope.launch { }를 직접 호출하기 위한 것입니다.
    • 규칙: composition 주도 코루틴은 LaunchedEffect, 이벤트 주도 코루틴은 rememberCoroutineScope.

    Composition 생명주기 — 콜백 발화 순서

    apply 단계의 dispatchRememberObservers()는 정확히 이 순서를 지킵니다.

    1. forget (역순) — teardown이 setup을 미러링하도록, 자식이 부모보다 먼저 정리됩니다.
    2. remember (정순)
    3. side effect (마지막) — remember된 객체가 초기화된 뒤 접근하도록.
    • abandon: apply되지 않은/실패한 composition의 객체는 abandonSet에 남아 onAbandoned를 받습니다.
    • dispose: slot table 전체를 forget → 모든 effect 정리. 백스택에서 화면이 빠지면 모든 코루틴이 취소되고 자원이 해제됩니다.

    언제 무엇을 쓸까

    상황 선택
    Flow 수집·suspend 함수·delay·애니메이션 LaunchedEffect (코루틴 필요)
    리스너 등록/해제, 키에 따른 동기 setup/teardown DisposableEffect
    외부 객체에 값 동기화 (매 pass) SideEffect
    클릭 등 이벤트에서 코루틴 시작 rememberCoroutineScope

    흔한 함정

    • 불안정 키 (매번 새 객체/람다) → 매 recomposition마다 코루틴을 취소·재실행합니다. 키는 안정 값 + 의미 있는 equality를 가져야 합니다.
    • LaunchedEffect(Unit) = 1회 실행·재시작 없음. 다만 캡처한 값이 stale해질 수 있으니 주의하세요.
    • 동기 반응에 LaunchedEffect를 쓰면 → LaunchedEffectImpl 할당 + CoroutineScope 생성 + 매 키 변경마다 취소·launch를 단 한 줄 실행에 낭비하게 됩니다. 이럴 땐 코루틴 없는 effect를 쓰세요.

    정리

    Compose의 effect 핸들러는 마법이 아닙니다. remember + RememberObserver라는 단순한 조합 위에 세워진 구조이고, "키가 바뀌면 새 observer를 만들고 옛 것을 잊는다"는 한 문장이 모든 동작의 근원입니다. 이 관점을 가지면 언제 어떤 핸들러를 써야 할지, 왜 동기 코드에 LaunchedEffect를 쓰면 안 되는지가 자연스럽게 이해됩니다.

    이 글은 개인 개발 학습 위키의 정리 노트를 기반으로 작성되었습니다.

Designed by Tistory.