부수 효과(Side Effect)란 컴포저블 함수가 화면에 표시되는 동안 발생하는 외부 작업으로, 컴포저블 함수 자체의 범위를 벗어나 앱의 상태를 변경하는 작업을 의미합니다.
쉽게 말해, 컴포저블 함수가 화면에 보여주기 위해 UI를 그리는 것 외에 다른 작업을 해야 할 때 사용되는 것입니다. 예를 들어, 버튼을 눌렀을 때 데이터베이스에 저장하거나, 서버에서 데이터를 가져와 화면에 표시하는 등의 작업이 부수 효과에 해당합니다. 이와 같이 부수 효과는 컴포저블 함수가 단순히 화면에 UI를 그리는 것을 넘어, 외부 시스템과의 상호작용이나 상태 관리를 처리하는 데 사용됩니다.
이러한 작업은 컴포저블의 수명 주기와 특성(예: 예측할 수 없는 리컴포지션) 때문에 최소화해야 합니다.
그러나 스낵바 표시나 특정 상태 조건에 따른 화면 전환 같은 일회성 이벤트에는 필요할 수 있습니다. 이러한 작업은 컴포저블의 수명 주기를 인식하는 관리된 환경에서 수행되어야 하며, Jetpack Compose는 이를 위한 다양한 Effect API를 제공합니다.
컴포저블 내에서 suspend 함수를 호출하기 위해서 LaunchedEffect 컴포저블을 사용합니다.
LaunchedEffect 컴포저블이 호출되면 코루틴이 즉시 실행되고 비동기 코드가 실행됩니다. 부모 컴포저블이 종료되면 LaunchedEffect 인스턴스와 코루틴이 파기되기 때문에 안전하게 부모 컴포저블의 라이프사이클 동안만 비동기 코드를 실행할 수 있습니다.
LaunchedEffect는 키 값이 바뀌게 되면 기존 코루틴이 취소되고 새 코루틴이 생성되기 때문에 코루틴 내 suspend 함수가 재실행되게 됩니다. LaunchedEffect는 부모 컴포저블이 재구성되더라도 key값이 변하지 않는한 동일한 코루틴을 유지합니다.
@Composable
fun MainScreen() {
val snackbarHostState = remember { SnackbarHostState() }
var isCheck by remember { mutableStateOf(false) }
val onCheckedChange = { isOn: Boolean ->
isCheck = isOn
}
LaunchedEffect(key1 = isCheck) {
snackbarHostState.showSnackbar("isCheck: $isCheck")
}
Scaffold(
snackbarHost = {
SnackbarHost(hostState = snackbarHostState)
}
) { contentPadding ->
Row(
modifier = Modifier
.padding(contentPadding)
.fillMaxSize(),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
Checkbox(checked = isCheck, onCheckedChange = onCheckedChange)
Text(text = "Check or Uncheck CheckBox")
}
}
}

key 값을 isCheck로 설정했기 때문에 isCheck의 값이 바뀔 때마다 코루틴 스코프 안의 코드가 실행됩니다.
만약 key 값을 설정하지 않고 항상 동일한 코루틴 스코프를 유지하고자 할 때는 key 값으로 Unit을 설정하면 됩니다.
LaunchedEffect는 컴포저블 함수이기 때문에 컴포저블 내에서만 사용할 수 있습니다. 컴포저블 외부에 있지만 컴포지션이 종료되면 자동으로 취소되도록 코루틴을 실행하려면 rememberCoroutineScope를 사용합니다. 따라서 rememberCoroutineScope를 호출한 쪽의 컴포지션이 종료되게 된다면 코루틴이 취소되게 됩니다.
컴포지션 종료 후 자동으로 취소되는 경우 외에도 수동으로 코루틴의 수명 주기를 관리헤야할 때도 사용합니다.
(특정 사용자 이벤트가 발생했을 때 애니메이션을 취소해야하는 경우)
대표적인 예로는 버튼을 클릭하는 경우 스낵바를 띄우거나 화면을 전환하고자 할 때 사용합니다.
@Composable
fun MainScreen() {
val scope = rememberCoroutineScope()
val snackbarHostState = remember { SnackbarHostState() }
Scaffold(
snackbarHost = {
SnackbarHost(hostState = snackbarHostState)
}
) { contentPadding ->
Column(
modifier = Modifier.padding(contentPadding).fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Button(
onClick = {
scope.launch {
snackbarHostState.showSnackbar("Snackbar message")
}
}
) {
Text("Button")
}
}
}
}
SideEffect는 부모 컴포저블이 재구성이 재구성을 완료할 때마다 효과를 실행합니다. LaunchedEffect와 다르게 key 파라미터를 받지 않습니다.
@Composable
fun MainScreen() {
Scaffold { contentPadding ->
var count by remember { mutableIntStateOf(0) }
Column(
modifier = Modifier
.padding(contentPadding)
.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Button(onClick = { count++ }) {
Text(text = "Increment")
}
Text(text = "Count: $count")
SideEffect {
Log.d("MainScreen", "Current count is $count")
}
}
}
}


count 값이 변경될 때마다 SideEffect 블록이 호출되어 현재 count 값을 로그로 출력합니다. SideEffect는 UI 상태를 외부 시스템과 동기화하거나 로깅과 같은 작업을 처리하는 데 유용합니다.
DisposableEffect는 키가 변경되거나 컴포저블이 컴포지션을 종료한 후 정리해야 하는 부수 효과가 있는 경우 사용합니다. 이를 통해 필요할 때마다 효과를 재설정하고 이전 부수 효과를 정리할 수 있습니다. 주로 리소스 해제나 리스너 제거와 같은 정리 작업에 사용합니다.
@Composable
fun DisposableEffectExample() {
val lifecycleOwner = LocalLifecycleOwner.current
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
when (event) {
Lifecycle.Event.ON_START -> println("ON_START")
Lifecycle.Event.ON_STOP -> println("ON_STOP")
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
}
}
Text("Hello, World!")
}
DisposableEffect(key)는 키가 변경될 때마다 실행됩니다. 처음 컴포지션에 들어올 때와 key가 변경될 때마다 onDispose 블록이 호출되어 정리 작업이 수행됩니다. key 값이 변경될 때마다 이전 DisposableEffect가 정리되고, 새로운 DisposableEffect가 시작됩니다.