Composable은 상태가 바뀔 때마다 몇 번이고 다시 실행될 수 있고(Recomposition), 실행 순서나 횟수를 개발자가 정할 수 없다.
그래서 Composable 본문에 네트워크 요청, 리스너 등록, 외부 객체 변경 같은 코드를 바로 쓰면 의도치 않게 여러 번 실행되거나 정리되지 않는 문제가 생긴다.
이렇게 Composable 함수 밖의 세계에 영향을 주는 작업을 부수 효과(Side Effect)라고 하고, Compose는 이를 안전하게 처리할 수 있도록 Effect API를 제공한다.
이 글에서는 가장 많이 쓰는 세 가지를 비교한다.
LaunchedEffectDisposableEffectSideEffectsuspend 함수를 바로 호출할 수 있다.delay를 이용한 타이머, 자동 넘김@Composable
fun UserScreen(userId: Long, viewModel: UserViewModel) {
// userId가 바뀔 때마다 이전 요청은 취소되고 새로 요청한다
LaunchedEffect(userId) {
viewModel.loadUser(userId)
}
}
| key | 동작 |
|---|---|
Unit, true 같은 고정값 | 컴포지션에 들어갈 때 한 번만 실행 |
userId 같은 변하는 값 | 값이 바뀔 때마다 취소 후 재시작 |
여러 개 (key1, key2) | 하나라도 바뀌면 재시작 |
LaunchedEffect(Unit)도 "앱 전체에서 한 번"이 아니다.
화면 회전 등으로 컴포지션이 새로 만들어지면 다시 실행된다.
정말 한 번만 해야 하는 작업은 ViewModel의init이나 Application에서 처리하는 게 맞다.
onDispose { }를 반환해야 한다. (없으면 컴파일 에러)onDispose가 실행된다.onDispose가 먼저 실행되고, 그다음 블록이 다시 실행된다.suspend 함수는 호출할 수 없다.등록하면 반드시 해제해야 하는 것들에 쓴다.
LifecycleObserver 등록/해제@Composable
fun LocationTracker(
lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current,
onStart: () -> Unit,
onStop: () -> Unit
) {
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
when (event) {
Lifecycle.Event.ON_START -> onStart()
Lifecycle.Event.ON_STOP -> onStop()
else -> Unit
}
}
lifecycleOwner.lifecycle.addObserver(observer)
// 컴포지션을 떠나거나 lifecycleOwner가 바뀌면 해제된다
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
}
}
}
onStart,onStop처럼 바뀔 수 있는 람다를 오래 쓰는 경우에는rememberUpdatedState로 감싸서 항상 최신 람다를 호출하게 하는 것이 안전하다.
Compose 상태를 Compose가 관리하지 않는 외부 객체에 반영(동기화)할 때 쓴다.
@Composable
fun StatusBarColor(color: Color) {
val view = LocalView.current
val darkTheme = isSystemInDarkTheme()
if (!view.isInEditMode) {
// color나 darkTheme이 바뀌어 리컴포지션될 때마다 최신 값으로 반영된다
SideEffect {
val window = (view.context as Activity).window
window.statusBarColor = color.toArgb()
WindowCompat.getInsetsController(window, view)
.isAppearanceLightStatusBars = !darkTheme
}
}
}
@Composable
fun rememberAnalytics(user: User): FirebaseAnalytics {
val analytics = remember { FirebaseAnalytics.getInstance(context) }
// 리컴포지션마다 현재 사용자 정보를 외부 객체에 맞춰준다
SideEffect {
analytics.setUserProperty("userType", user.userType)
}
return analytics
}
| 하고 싶은 작업 | SideEffect가 안 맞는 이유 | 대신 쓸 것 |
|---|---|---|
| 한 번만 하는 초기화 (라이브러리, 블루투스 연결 등) | 리컴포지션마다 반복 실행된다 | LaunchedEffect(Unit), ViewModel init, Application |
| 파일, DB, 네트워크에서 데이터 로딩 | 메인 스레드에서 매번 실행되어 UI가 버벅인다 | LaunchedEffect + ViewModel |
| 리스너 등록 | 해제할 방법이 없다 | DisposableEffect |
Composable 본문은 리컴포지션 도중에 취소되거나 결과가 버려질 수 있다.
본문에서 바로 외부 객체를 바꾸면, 실제로 화면에 반영되지 않은 값이 밖으로 새어나갈 수 있다.
SideEffect는 컴포지션이 성공적으로 적용된 뒤에만 실행되기 때문에 화면과 외부 상태가 어긋나지 않는다.
| 구분 | 실행 시점 | 정리(취소) 시점 | 코루틴 | 주 용도 |
|---|---|---|---|---|
| LaunchedEffect | 컴포지션 진입 시, key 변경 시 | 컴포지션 이탈 시, key 변경 시 자동 취소 | O | suspend 작업 (데이터 요청, delay, Flow 수집) |
| DisposableEffect | 컴포지션 진입 시, key 변경 시 | onDispose (이탈 시, key 변경 시) | X | 등록/해제가 짝을 이루는 작업 (리스너, Observer) |
| SideEffect | 리컴포지션 성공 시 매번 | 없음 | X | Compose 상태를 외부 객체에 동기화 |
LaunchedEffectDisposableEffectSideEffectinit이나 ApplicationrememberCoroutineScope : 버튼 클릭처럼 Composable 밖 콜백에서 코루틴을 시작할 때rememberUpdatedState : 오래 도는 Effect 안에서 재시작 없이 최신 값을 쓰고 싶을 때derivedStateOf : 자주 바뀌는 상태에서 덜 자주 바뀌는 값을 계산해 리컴포지션을 줄일 때
아 이건 좀 ...