|

DisposableEffect 정리

DisposableEffect 정리 대표 이미지
Compose effect는 실행보다 cleanup 필요 여부로 구분하는 편이 더 실전적이다

DisposableEffect는 Compose effect 중에서도 특히 자주 헷갈리는 API입니다. 결론부터 말하면 리스너나 observer처럼 나중에 반드시 정리해야 하는 연결에 가장 잘 맞습니다.

LaunchedEffect가 비동기 시작에 가깝고, SideEffect가 현재 Compose 상태를 바깥으로 반영하는 데 가깝다면, DisposableEffect는 등록과 해제를 한 블록에 묶어 두는 데 강합니다.

이번 글에서는 DisposableEffect를 단독 API로 외우지 않고, 언제 cleanup이 필요한지, key가 왜 중요한지, LaunchedEffect·SideEffect와 어디서 갈라지는지 실전 예시로 정리하겠습니다.

DisposableEffect 선택 기준 요약 카드
cleanup이 필요한 연결이면 DisposableEffect를 먼저 떠올리면 된다

왜 따로 있을까

Compose는 재구성이 자주 일어나기 때문에, 외부 객체에 붙인 리스너나 observer를 언제 떼어야 하는지가 분명하지 않으면 금방 중복 등록이나 누수가 생깁니다. 그래서 공식 문서도 DisposableEffect를 cleanup이 필요한 effect라고 설명합니다.

  • LifecycleObserver 등록 후 제거
  • SDK listener 연결 후 해제
  • callback bridge 연결 후 정리
  • key 변경 시 이전 연결을 끊고 새 연결을 붙여야 하는 경우

즉 이 API의 핵심은 “뭘 실행할까”보다 붙인 것을 언제 정리할까에 있습니다.


다른 effect와 차이

세 effect 모두 이름만 보면 비슷하지만 실제 책임은 다릅니다. LaunchedEffect는 suspend 작업 시작, SideEffect는 현재 상태를 바깥 객체에 반영, DisposableEffect는 등록과 해제를 같이 책임지는 구조에 더 가깝습니다.

@Composable
fun AnalyticsScreen(screenName: String) {
    SideEffect {
        analytics.setCurrentScreen(screenName)
    }
}

@Composable
fun UserScreen(userId: String, repository: UserRepository) {
    var user by remember { mutableStateOf<User?>(null) }

    LaunchedEffect(userId) {
        user = repository.loadUser(userId)
    }
}

둘 다 cleanup이 본질은 아닙니다. 반면 DisposableEffect는 onDispose가 반드시 따라붙어야 한다는 점에서 성격이 확연히 다릅니다.

LaunchedEffect SideEffect DisposableEffect 비교 카드
시작·반영·정리라는 세 기준으로 보면 effect 경계가 훨씬 선명해진다

key가 중요한 이유

DisposableEffect는 그냥 한 번 돌고 끝나는 API가 아닙니다. key가 바뀌면 현재 effect를 dispose하고 새 effect를 다시 시작합니다. 그래서 key는 “언제 이전 연결을 정리하고 새 연결로 갈아탈지”를 정하는 기준점입니다.

@Composable
fun UserPresenceObserver(
    userId: String,
    tracker: PresenceTracker,
) {
    DisposableEffect(userId, tracker) {
        val listener = tracker.observe(userId)

        onDispose {
            listener.dispose()
        }
    }
}

userId가 바뀌었는데 이전 userId 연결이 남아 있으면 바로 버그가 됩니다. 그래서 key를 정확히 고르는 일이 중요합니다.


예시 1: LifecycleObserver

@Composable
fun TrackScreenLifecycle(
    lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current,
    analytics: Analytics,
) {
    DisposableEffect(lifecycleOwner, analytics) {
        val observer = LifecycleEventObserver { _, event ->
            when (event) {
                Lifecycle.Event.ON_START -> analytics.log("screen_start")
                Lifecycle.Event.ON_STOP -> analytics.log("screen_stop")
                else -> Unit
            }
        }

        lifecycleOwner.lifecycle.addObserver(observer)

        onDispose {
            lifecycleOwner.lifecycle.removeObserver(observer)
        }
    }
}

공식 문서도 LifecycleEventObserver 예시로 DisposableEffect를 설명합니다. 이 패턴의 장점은 등록과 해제가 한눈에 보인다는 점입니다.

DisposableEffect observer 흐름도
observer를 붙이고 key 변경이나 화면 이탈 시 제거하는 흐름

예시 2: callback SDK

플레이어, 지도, 위치, 센서 같은 외부 SDK는 callback 기반인 경우가 많습니다. 이런 구조에서는 DisposableEffect가 특히 잘 맞습니다.

@Composable
fun PlayerStateObserver(
    player: VideoPlayer,
    onPlayingChanged: (Boolean) -> Unit,
) {
    val latestCallback by rememberUpdatedState(onPlayingChanged)

    DisposableEffect(player) {
        val listener = object : PlayerListener {
            override fun onIsPlayingChanged(isPlaying: Boolean) {
                latestCallback(isPlaying)
            }
        }

        player.addListener(listener)

        onDispose {
            player.removeListener(listener)
        }
    }
}

여기서는 listener 등록/해제는 DisposableEffect가 맡고, effect 안에서 최신 lambda를 잡는 문제는 rememberUpdatedState가 보완합니다. 둘의 역할을 분리해서 보면 덜 헷갈립니다.


자주 하는 실수

  1. listener 등록을 LaunchedEffect에 넣고 해제를 잊는 것
  2. 정말 필요하지 않은 값까지 key에 넣어 effect를 자주 재시작하는 것
  3. cleanup이 없는데도 습관적으로 DisposableEffect를 쓰는 것
  4. 오래 살아 있는 effect 안에서 최신 callback 참조 문제를 놓치는 것

공식 문서가 빈 onDispose를 좋은 실천이 아니라고 말하는 이유도 같습니다. 정말 정리할 것이 없다면, 지금 effect 자체를 잘못 고른 것일 수 있습니다.


빠르게 고르는 기준

  • suspend 작업 시작이 핵심이면 LaunchedEffect를 먼저 본다
  • 현재 상태를 바깥 객체에 밀어 넣는 일이라면 SideEffect를 먼저 본다
  • 리스너/observer를 붙이고 나중에 떼야 한다면 DisposableEffect를 먼저 본다
  • 클릭 이벤트 안에서 코루틴을 시작해야 하면 rememberCoroutineScope를 먼저 본다

관련해서 같이 보면 좋은 글은 LaunchedEffect와 SideEffect 차이, collectAsStateWithLifecycle 정리, remember와 rememberSaveable 차이입니다.


cleanup만 보고 판단하면 쉬워진다

DisposableEffect가 헷갈릴 때는 suspend냐 아니냐보다, 나중에 반드시 떼어야 하는 연결인가를 먼저 보시면 됩니다.

즉 observer, listener, callback bridge처럼 붙였다가 해제해야 하는 구조라면 DisposableEffect 쪽이 더 자연스럽습니다.

마무리

DisposableEffect는 시작보다 정리가 중요한 연결을 안전하게 묶는 도구입니다. Compose effect를 이름으로만 외우지 말고, cleanup이 필요한가라는 질문으로 나누면 훨씬 덜 헷갈립니다.

외부 기준으로는 Android Developers의 Side-effects in Compose 문서를 꼭 함께 확인해보세요.

함께보면 좋은 글