Jetpack Compose는 재미있고, 쉽고, 효과적이며 직관적인 UI 도구입니다.
특히 사용자 지정 컴포넌트를 선언적으로 쉽게 빌드할 수 있는 기능 덕분에 많은 개발자들이 선호합니다.
하지만 Jetpack Compose의 기능을 최대한 활용하려면 부작용(Side Effect) 과 효과 핸들러에 대한 이해가 필수입니다.
📌 정의: 구성 가능한 함수의 범위 밖에서 발생하는 앱 상태 변화
안드로이드에서 UI를 개발할 때, 사이드 이펙트 관리는 개발자가 직면하는 주요 과제 중 하나입니다.
이러한 부작용은 Compose의 선언적 UI 모델과 충돌할 수 있기 때문에 적절한 패턴을 이용한 통제가 중요합니다.
Jetpack Compose에서의 Side Effect는 예를 들어:
등과 같이 컴포저블 함수 외부에서 관리되는 상태를 예측 가능하게 제어하기 위해 사용됩니다.
💡 Side Effect는 UI 렌더링 로직과 반드시 분리되어야 합니다.
이는 다음과 같은 이점을 제공합니다:
| 장점 | 설명 |
|---|---|
| ⚡ 성능 향상 | 컴포저블 함수 외부에서 UI와 무관한 연산을 실행해, 렌더링 성능을 보존 |
| 🧩 코드 구성 개선 | UI 로직과 비즈니스 로직을 분리하여 가독성과 유지보수성 향상 |
| 📈 프로덕트 품질 개선 | 이벤트 로깅이나 사용자 행동 분석을 통해 UX 개선 및 이슈 추적에 유용 |
Jetpack Compose는 이러한 사이드 이펙트를 개별 Coroutine Scope에서 안전하게 실행할 수 있도록 다음과 같은 기능을 제공합니다:
SideEffectLaunchedEffectDisposableEffectJetpack Compose에서는 UI와 분리된 부작용(Side Effect)을 안전하고 효율적으로 제어하기 위한 함수들을 제공합니다.
그중 가장 많이 사용되는 대표적인 함수는 LaunchedEffect입니다.
LaunchedEffect는 독립적인 Coroutine Scope에서 비동기 작업을 실행하기 위한 컴포저블 함수입니다.
UI 스레드를 블로킹하지 않기 때문에 네트워크 호출, 애니메이션, 데이터 로딩 등의
오래 걸리는 작업을 안전하게 실행할 수 있습니다.
@Composable
fun MyComposable() {
val isLoading = remember { mutableStateOf(false) }
val data = remember { mutableStateOf(listOf<String>()) }
LaunchedEffect(isLoading.value) {
if (isLoading.value) {
val newData = fetchData()
data.value = newData
isLoading.value = false
}
}
Column {
Button(onClick = { isLoading.value = true }) {
Text("Fetch Data")
}
if (isLoading.value) {
CircularProgressIndicator()
} else {
LazyColumn {
items(data.value.size) { index ->
Text(text = data.value[index])
}
}
}
}
}
private suspend fun fetchData(): List<String> {
delay(2000) // 네트워크 요청을 시뮬레이션
return listOf("Item 1", "Item 2", "Item 3", "Item 4", "Item 5")
}
LaunchedEffect 함수는 다음 두 개의 파라미터를 입력받습니다:
key: 해당 side effect 블록을 언제 재실행할지를 결정하는 식별자 block: 실제 실행될 side effect 로직 (람다식)key는 LaunchedEffect 블록의 identity 역할을 하며, 다음과 같은 목적을 가지고 있습니다:
key 값이 변경되면: 기존 coroutine이 자동으로 취소되고, 블록이 재실행됩니다.key 값이 동일하다면: side effect는 재실행되지 않고 유지됩니다.이렇게 하면 불필요한 side effect 재실행을 자동으로 방지할 수 있습니다.
LaunchedEffect(isLoading.value) {
if (isLoading.value) {
val newData = fetchData()
data.value = newData
isLoading.value = false
}
}
컴포저블 함수는 상태 변경, 프로퍼티 변경, 무효화 등
다양한 이유로 리컴포지션(Recomposition) 될 수 있습니다.
리컴포지션마다 무조건 side effect를 다시 실행하면
➡️ 퍼포먼스에 큰 영향을 줄 수 있습니다.
key는 이를 방지해주는 스마트한 조건 트리거 역할을 합니다.
val key = remember { UUID.randomUUID().toString() }
LaunchedEffect(key, isLoading.value) {
// 이 블록은 key 또는 isLoading.value 중 하나라도 변경되면 재실행됩니다.
}
key를 넘기면 anyOf(key1, key2, ...) 방식으로 작동됨 @Composable
fun OneTimeEffectSample() {
LaunchedEffect(Unit) {
// 이 블록은 Composition 시 단 한 번만 실행됩니다.
println("LaunchedEffect - 단 한 번 실행됨")
}
Text(text = "Hello, Jetpack Compose!")
}
Unit은 더 이상 바뀌지 않는 불변 값이기 때문에
→ 리렌더링(Recomposition)이 일어나더라도
→ LaunchedEffect는 다시 실행되지 않습니다.
흔히 초기화 작업, 최초 로딩 등에 유용하게 사용됩니다.
DisposableEffect는 컴포저블이 Composition에 진입하거나 제거될 때 실행할 작업을 정의할 수 있는 함수입니다.
주로 리소스 등록/해제, 리스너 바인딩/제거, 콜백 연결/해제 등에 사용됩니다.
BroadcastReceiver, SensorListener, Callback 등 Lifecycle에 연동된 작업이 필요할 때 @Composable
fun DisposableEffectSample(context: Context) {
DisposableEffect(Unit) {
val receiver = object : BroadcastReceiver() {
override fun onReceive(ctx: Context?, intent: Intent?) {
println("Broadcast 수신됨")
}
}
val filter = IntentFilter(Intent.ACTION_BATTERY_CHANGED)
context.registerReceiver(receiver, filter)
// 컴포저블이 Composition에서 제거될 때 호출됨
onDispose {
context.unregisterReceiver(receiver)
println("BroadcastReceiver 해제됨")
}
}
Text(text = "Battery 상태 감지 중...")
}
| 항목 | 설명 |
|---|---|
| 초기화 시점 | Composition에 진입할 때 block 실행 |
| 정리 시점 | Composition에서 제거될 때 onDispose {} 실행 |
| 의존성 key | key가 바뀌면 기존 onDispose → 정리 후 → 새 block 실행 |
| 코루틴 사용 여부 | 자체적으로 coroutine scope를 사용하지 않음 (필요 시 수동 처리 필요) |
DisposableEffect(someId) {
// someId가 바뀌면 이 블록 재실행 + 기존 리소스 정리
onDispose {
// 정리 작업
}
}
LaunchedEffect와 마찬가지로 key를 활용해 효과 범위를 좁힐 수 있습니다.
DisposableEffect는 Compose에서 명확한 라이프사이클 관리가 필요한 경우 유용합니다.
ViewModel이나 rememberCoroutineScope보다 정밀한 Composition 진입/이탈 시점을 잡을 수 있어
리소스 관리에 최적화되어 있습니다.
SideEffect는 Compose의 Recomposition 이후에 동기적으로 실행되는 부작용 로직을 정의할 때 사용합니다.
LaunchedEffect나 DisposableEffect는 coroutine이나 lifecycle 처리를 동반하지만,
SideEffect는 가볍고 빠르게 외부 상태를 갱신하는 목적에 적합합니다.
SideEffect 스코프 비교@Composable
fun Counter() {
// 카운터에서 사용하기 위해 정의한 state
val count = remember { mutableStateOf(0) }
// count state의 현재 값을 기록하기 위한 SideEffect
SideEffect {
// Recomposition마다 호출
log("Outer Count is ${count.value}")
}
Column {
Button(onClick = { count.value++ }) {
// count state의 현재 값을 기록하기 위한 SideEffect
SideEffect {
// Recomposition마다 호출
log("Inner Count is ${count.value}")
}
// 여기서 발생한 recomposition은 외부의 side effect를 실행시키지 않는다.
Text("Increase Count ${count.value}")
}
}
}
SideEffect는 자신이 선언된 컴포저블 블록의 Recomposition에만 반응한다.
Counter() 전체가 다시 컴포지션될 때 호출Button 블록이 다시 컴포지션될 때만 호출컴포저블 내부에서 발생한 상태 변경은 해당 블록만 Recompose한다.
count.value++ 호출 시:Text()와 Button 블록만 리컴포지션됨Counter() 자체는 리컴포지션되지 않음| 구분 | Outer SideEffect | Inner SideEffect |
|---|---|---|
| Scope | Counter() 전체 | Button { ... } 내부 |
| Recomposition 조건 | Counter() 전체 리렌더 시 | Button 컴포저블 리렌더 시 |
| 버튼 클릭 시 실행됨? | ❌ 실행 안 됨 | ✅ 실행됨 |
⚠️
SideEffect는 컴포저블 블록마다 독립적으로 작동하며,
상태 변경이 전파되는 범위에 따라 실행 여부가 달라집니다.
Jetpack Compose에서는 선언형 UI 구조 속에서 UI 외부와의 상호작용을 안전하게 처리하기 위해
세 가지 주요 Side Effect 함수를 제공합니다.
| 함수 | 대표 용도 | 주의할 점 |
|---|---|---|
| 🔄 SideEffect | - 로그 출력 - 외부 상태 변수 동기화 - Compose 외부 객체에 UI 값 반영 | - 동기 실행 - 상태 변경 금지 - Composition 직후 실행됨 |
| 🚀 LaunchedEffect | - 네트워크 요청 - 애니메이션 실행 - 딜레이 기반 작업 - 일회성 초기화 작업 (Unit) | - Coroutine Scope 사용 - 비동기 작업 가능 - key 변경 시 재실행 |
| ♻️ DisposableEffect | - 리스너 등록/해제 - BroadcastReceiver 관리 - Lifecycle 대응 (onDispose) | - 동기적 정리 작업 - onDispose 필수 작성 - Composition 해제 시 실행됨 |
지금까지 Jetpack Compose의 SideEffect, LaunchedEffect, DisposableEffect에 대해 알아봤습니다.
이 함수들을 상황에 맞게 적절히 활용하면 다음과 같은 이점을 얻을 수 있습니다: