-
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는 항상 일관된 커밋 트리를 봅니다.
- 한 객체는
onRememberedXORonAbandoned중 하나만 받습니다. 그리고onForgotten은onRemembered의 역순으로 호출됩니다.
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()는 정확히 이 순서를 지킵니다.- forget (역순) — teardown이 setup을 미러링하도록, 자식이 부모보다 먼저 정리됩니다.
- remember (정순)
- 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를 쓰면 안 되는지가 자연스럽게 이해됩니다.이 글은 개인 개발 학습 위키의 정리 노트를 기반으로 작성되었습니다.
'Android📱' 카테고리의 다른 글
Android Direct Share 제대로 이해하기 — Sharing Shortcuts API 정리 (0) 2026.08.04 a2ui: AI 에이전트가 처음 보는 Android UI를 그리는 법 (1) 2026.08.03 ComposeView가 언제 죽는지 아세요? — ViewCompositionStrategy 파헤치기 (0) 2026.07.10 Navigation 3 설명회 (0) 2026.07.06 [Android] pendingIntent putExtra 및 주의 사항 (0) 2024.09.12