Mastering Side Effects in Jetpack Compose

이진영·2025년 6월 12일
post-thumbnail

🎯 Jetpack Compose와 부작용(Side Effects)

Jetpack Compose는 재미있고, 쉽고, 효과적이며 직관적인 UI 도구입니다.
특히 사용자 지정 컴포넌트를 선언적으로 쉽게 빌드할 수 있는 기능 덕분에 많은 개발자들이 선호합니다.

하지만 Jetpack Compose의 기능을 최대한 활용하려면 부작용(Side Effect) 과 효과 핸들러에 대한 이해가 필수입니다.


❓ 부작용(Side Effect)이란?

📌 정의: 구성 가능한 함수의 범위 밖에서 발생하는 앱 상태 변화

안드로이드에서 UI를 개발할 때, 사이드 이펙트 관리는 개발자가 직면하는 주요 과제 중 하나입니다.
이러한 부작용은 Compose의 선언적 UI 모델과 충돌할 수 있기 때문에 적절한 패턴을 이용한 통제가 중요합니다.


🤔 왜 Side-effect를 사용해야 할까?

Jetpack Compose에서의 Side Effect는 예를 들어:

  • 데이터베이스 내 데이터 업데이트
  • 네트워크 통신
  • 로그 수집

등과 같이 컴포저블 함수 외부에서 관리되는 상태를 예측 가능하게 제어하기 위해 사용됩니다.


🔍 Side Effect가 필요한 이유

💡 Side Effect는 UI 렌더링 로직과 반드시 분리되어야 합니다.

이는 다음과 같은 이점을 제공합니다:


✅ Jetpack Compose에서 Side Effect의 장점

장점설명
⚡ 성능 향상컴포저블 함수 외부에서 UI와 무관한 연산을 실행해, 렌더링 성능을 보존
🧩 코드 구성 개선UI 로직과 비즈니스 로직을 분리하여 가독성과 유지보수성 향상
📈 프로덕트 품질 개선이벤트 로깅이나 사용자 행동 분석을 통해 UX 개선 및 이슈 추적에 유용

Jetpack Compose는 이러한 사이드 이펙트를 개별 Coroutine Scope에서 안전하게 실행할 수 있도록 다음과 같은 기능을 제공합니다:

  • SideEffect
  • LaunchedEffect
  • DisposableEffect

⚙️ Jetpack Compose의 Side Effect 함수 살펴보기

Jetpack Compose에서는 UI와 분리된 부작용(Side Effect)을 안전하고 효율적으로 제어하기 위한 함수들을 제공합니다.
그중 가장 많이 사용되는 대표적인 함수는 LaunchedEffect입니다.


🚀 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 파라미터 — 어떻게 동작할까?

LaunchedEffect 함수는 다음 두 개의 파라미터를 입력받습니다:

  • key: 해당 side effect 블록을 언제 재실행할지를 결정하는 식별자
  • block: 실제 실행될 side effect 로직 (람다식)

📌 key 파라미터의 역할

keyLaunchedEffect 블록의 identity 역할을 하며, 다음과 같은 목적을 가지고 있습니다:

  • key 값이 변경되면: 기존 coroutine이 자동으로 취소되고, 블록이 재실행됩니다.
  • key 값이 동일하다면: side effect는 재실행되지 않고 유지됩니다.

이렇게 하면 불필요한 side effect 재실행을 자동으로 방지할 수 있습니다.


🎯 실제 예제 속 key 활용

LaunchedEffect(isLoading.value) {
    if (isLoading.value) {
        val newData = fetchData()
        data.value = newData
        isLoading.value = false
    }
}

⚠️ 왜 중요한가?

컴포저블 함수는 상태 변경, 프로퍼티 변경, 무효화
다양한 이유로 리컴포지션(Recomposition) 될 수 있습니다.

리컴포지션마다 무조건 side effect를 다시 실행하면
➡️ 퍼포먼스에 큰 영향을 줄 수 있습니다.

key는 이를 방지해주는 스마트한 조건 트리거 역할을 합니다.

🔄 여러 개의 key 조합도 가능

val key = remember { UUID.randomUUID().toString() }

LaunchedEffect(key, isLoading.value) {
    // 이 블록은 key 또는 isLoading.value 중 하나라도 변경되면 재실행됩니다.
}
  • 여러 key를 넘기면 anyOf(key1, key2, ...) 방식으로 작동됨
  • 복합 조건 하에서 side effect를 유연하게 트리거할 수 있음

🔂 단 한 번만 실행되는 LaunchedEffect 예제

@Composable
fun OneTimeEffectSample() {
    LaunchedEffect(Unit) {
        // 이 블록은 Composition 시 단 한 번만 실행됩니다.
        println("LaunchedEffect - 단 한 번 실행됨")
    }

    Text(text = "Hello, Jetpack Compose!")
}

📝 설명

  • Unit은 더 이상 바뀌지 않는 불변 값이기 때문에
    → 리렌더링(Recomposition)이 일어나더라도
    LaunchedEffect는 다시 실행되지 않습니다.

  • 흔히 초기화 작업, 최초 로딩 등에 유용하게 사용됩니다.


♻️ DisposableEffect

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 {} 실행
의존성 keykey가 바뀌면 기존 onDispose → 정리 후 → 새 block 실행
코루틴 사용 여부자체적으로 coroutine scope를 사용하지 않음 (필요 시 수동 처리 필요)

🔁 key 사용 예시

DisposableEffect(someId) {
    // someId가 바뀌면 이 블록 재실행 + 기존 리소스 정리
    onDispose {
        // 정리 작업
    }
}

LaunchedEffect와 마찬가지로 key를 활용해 효과 범위를 좁힐 수 있습니다.


🧼 요약

DisposableEffect는 Compose에서 명확한 라이프사이클 관리가 필요한 경우 유용합니다.

ViewModel이나 rememberCoroutineScope보다 정밀한 Composition 진입/이탈 시점을 잡을 수 있어
리소스 관리에 최적화되어 있습니다.


🔄 SideEffect

SideEffect는 Compose의 Recomposition 이후에 동기적으로 실행되는 부작용 로직을 정의할 때 사용합니다.

LaunchedEffectDisposableEffect는 coroutine이나 lifecycle 처리를 동반하지만,
SideEffect가볍고 빠르게 외부 상태를 갱신하는 목적에 적합합니다.


✅ 언제 사용하나요?

  • 로그 출력
  • 외부 View 속성 업데이트
  • Compose 외부 객체와의 값 동기화
  • 렌더링 후 side-effect가 필요한 간단한 처리

🔨 예제: 컴포저블 블록별 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}")
        }
    }
}

🎯 이 예제가 보여주는 핵심 개념

  1. SideEffect자신이 선언된 컴포저블 블록의 Recomposition에만 반응한다.

    • Outer SideEffectCounter() 전체가 다시 컴포지션될 때 호출
    • Inner SideEffectButton 블록이 다시 컴포지션될 때만 호출
  2. 컴포저블 내부에서 발생한 상태 변경은 해당 블록만 Recompose한다.

    • count.value++ 호출 시:
      • Text()Button 블록만 리컴포지션됨
      • Counter() 자체는 리컴포지션되지 않음
        → 따라서 Outer SideEffect는 실행되지 않음

🧠 핵심 요약

구분Outer SideEffectInner SideEffect
ScopeCounter() 전체Button { ... } 내부
Recomposition 조건Counter() 전체 리렌더 시Button 컴포저블 리렌더 시
버튼 클릭 시 실행됨?❌ 실행 안 됨✅ 실행됨

⚠️ SideEffect는 컴포저블 블록마다 독립적으로 작동하며,
상태 변경이 전파되는 범위에 따라 실행 여부가 달라집니다.


🧩 정리하기!

Jetpack Compose에서는 선언형 UI 구조 속에서 UI 외부와의 상호작용을 안전하게 처리하기 위해
세 가지 주요 Side Effect 함수를 제공합니다.


🧾 각 Side Effect 함수의 대표적인 사용처 정리

함수대표 용도주의할 점
🔄 SideEffect- 로그 출력
- 외부 상태 변수 동기화
- Compose 외부 객체에 UI 값 반영
- 동기 실행
- 상태 변경 금지
- Composition 직후 실행됨
🚀 LaunchedEffect- 네트워크 요청
- 애니메이션 실행
- 딜레이 기반 작업
- 일회성 초기화 작업 (Unit)
- Coroutine Scope 사용
- 비동기 작업 가능
- key 변경 시 재실행
♻️ DisposableEffect- 리스너 등록/해제
- BroadcastReceiver 관리
- Lifecycle 대응 (onDispose)
- 동기적 정리 작업
- onDispose 필수 작성
- Composition 해제 시 실행됨

✅ 마무리하며

지금까지 Jetpack Compose의 SideEffect, LaunchedEffect, DisposableEffect에 대해 알아봤습니다.
이 함수들을 상황에 맞게 적절히 활용하면 다음과 같은 이점을 얻을 수 있습니다:

  • ✅ UI 렌더링과 부작용 로직의 명확한 분리
  • 예측 가능한 실행 흐름
  • 성능 최적화유지보수성 향상

profile
Android Developer

0개의 댓글