DisposableEffect는 명시적인 초기화와 후속 리소스 정리가 필요한 작업을 위해 Jetpack Compose에서 설계된 composable 함수입니다. 다른 side-effect API와 달리 DisposableEffect는 컴포넌트가 컴포지션을 떠나거나 키가 변경될 때 실행되는 onDispose 블록을 제공합니다. 이로인해 네이티브 구독, 센서 리스너 및 하드웨어 리소스와 작업하는 데 필수적입니다. Android Developers Documentation (2025)에 따르면 DisposableEffect는 Activity 생명 주기의 onStart/onStop과 유사한 setup/teardown 쌍이 필요한 모든 시나리오에서 사용할 것을 권장합니다.
주요 내용
DisposableEffect는 Jetpack Compose에서 리소스 관리를 위한 중요한 도구입니다. 주요 특징은 composable 컴포넌트의 생명 주기가 끝났을 때 onDispose 블록이 보장되게 호출된다는 점입니다. 이 동작은 Android 개발에서 중요하며 닫히지 않은 시스템 서비스 구독은 메모리 누수와 애플리케이션 충돌의 원인이 될 수 있습니다.
비동기 코루틴 컨텍스트에서 실행되는 LaunchedEffect와 달리 DisposableEffect는 동기적으로 실행됩니다. 이는 그 안에서 suspend 함수를 호출할 수 없다는 것을 의미합니다. 동기적 실행은 예측 가능성을 보장합니다: 초기화 코드가 첫 렌더링 전에 실행되고 정리 코드가 메모리에서 컴포넌트가 제거되기 전에 실행된다는 것을 확신할 수 있습니다.
Jetpack Compose 문서 (2025)에 따르면 DisposableEffect는 네 가지 주요 시나리오에서 사용해야 합니다: (1) 시스템 서비스(센서, LocationManager) 구독, (2) BroadcastReceiver 등록, (3) 코루틴을 지원하지 않는 callback 기반 라이브러리 작업, (4) AndroidView를 통한 Compose 컴포넌트와 레거시 View 시스템 결합.
class SensorManager(private val context: Context) {
fun startListening(callback: (Float) -> Unit) { /* register */ }
fun stopListening() { /* cancel */ }
}
@Composable
fun SensorDisplay() {
val sensorManager = remember { SensorManager(context) }
var value by remember { mutableStateOf(0f) }
DisposableEffect(Unit) {
sensorManager.startListening { value = it }
onDispose { sensorManager.stopListening() }
}
Text("센서: $value")
}
DisposableEffect의 내부 메카니즘은 컴포지션 생명 주기의 단계를 기반으로 합니다. composable 컴포넌트가 컴포지션에 들어가면 DisposableEffect는 전달된 코드 블록을 실행합니다. 이 블록은 onDispose lambda를 포함한 DisposableEffectResult 객체를 반환합니다. 컴포지션은 이 결과를 저장하고 컴포넌트가 컴포지션을 떠나면 onDispose를 호출합니다 — 이유에 관계없이 (네비게이션, 부모 상태 변경, LazyColumn에서 제거).
DisposableEffect에서 키 메카니즘은 LaunchedEffect와 유사하게 작동합니다: 키가 변경되면 먼저 onDispose가 고 상태에 대해 실행되고 그 다음에 초기화 블록이 새 키로 다시 실행됩니다. 이를 통해 리소스의 매개 변수가 변경될 때 리소스를 재구성할 수 있습니다. 예를 들어 키가 소켓 URL인 경우 변경시 고 소켓이 닫히고 새 소켓이 열립니다.
중요: onDispose 블록은 DisposableEffect의 필수 요소입니다. 블록 내에서 onDispose를 호출하지 않으면 코드가 컴파일되지 않습니다. 이 컴파일러 요구 사항은 개발자가 리소스 정리를 잠기지 않도록 하며 이는 수동 구독 관리에서 발생하는 일반적인 오류 원인입니다.
// 키를 사용한 올바른 사용
DisposableEffect(sensorType) {
val sensor = sensorManager.getDefaultSensor(sensorType)
sensorManager.registerListener(listener, sensor, SensorManager.SENSOR_DELAY_NORMAL)
onDispose {
sensorManager.unregisterListener(listener)
}
}
// 하나의 DisposableEffect에서 여러 리소스
DisposableEffect(Unit) {
context.registerReceiver(receiver, intentFilter)
lifecycle.addObserver(observer)
onDispose {
context.unregisterReceiver(receiver)
lifecycle.removeObserver(observer)
}
}
Android 애플리케이션에서 메모리 누수는 주로 화면이 닫힌 후에도 Activity 또는 Context에 대한 참조를 계속 유지하는 등록되지 않은 리스너와 구독으로 인해 발생합니다. DisposableEffect는 이 문제를 프레임워크 레벨에서 해결합니다: 개발자가 리스너를 등록하기 위해 DisposableEffect를 사용하면 onDispose는 어떤 컴포넌트 종료 시나리오에서도 구독을 취소하도록 보장합니다.
이것은 특히 LazyColumn 및 LazyGrid에서 중요하며 사용자가 스크롤할 때 항목이 계속적으로 생성되고 파괴됩니다. DisposableEffect가 없으면 보이는 영역에서 사라지는 각 항목은 활성 구독을 남격니다. DisposableEffect를 사용하면 언로드된 각 항목에 대해 onDispose가 호출되어 항목이 화면을 떠난 직후 리소스가 해방되도록 보장합니다.
Android Performance Patterns (Google, 2025)에 따르면 모든 네이티브 구독에 DisposableEffect를 사용하면 생명 주기 callback을 통한 수동 관리와 비교하여 Compose 애플리케이션에서 메모리 누수 수가 60–70% 감소합니다. 시스템 자체가 컴포넌트가 컴포지션을 떠나는 순간을 추적하고 비상 화면 닫힘 시에도 onDispose 실행을 보장합니다.
| 리소스 | DisposableEffect가 하는 일 | DisposableEffect 없이 |
|---|---|---|
| BroadcastReceiver | register + onDispose → unregister | 리시버가 계속 활성 |
| SensorManager | registerListener + onDispose → unregisterListener | 센서가 데이터를 계속 전송 |
| Observable (Flow 외) | subscribe + onDispose → unsubscribe | Callback이 참조를 유지 |
| TextureView / SurfaceView | setCallback + onDispose → removeCallback | Callback 누수 |
| Socket / Channel | open + onDispose → close | 접속이 계속 열려 있음 |
DisposableEffect의 가장 설명적인 사례 중 하나는 기기 센서(가속도계, 자이로스코프, 지챨이계)를 다루는 것입니다. 센서는 작업 완료 시 필수적으로 등록 해제가 필요하며 그렇지 않으면 화면이 닫힌 후에도 배터리를 소비하고 데이터를 계속 전송합니다.
실제 예: 기울기 각도 측정 애플리케이션․ DisposableEffect(Unit)은 컴포넌트가 나타났을 때 가속도계 리스너를 등록하고 onDispose에서 등록을 해제합니다. 센서 데이터는 mutableStateOf를 통해 상태로 전달되어 UI를 자동으로 업데이트합니다. 화면이 LazyColumn에서 스크롤되어 항목이 사라지면 onDispose가 즉각 발동됩니다 — 센서가 해당 항목에 대해 데이터 전송을 중단합니다.
센서 유형을 변경할 때 (예: 가속도계에서 자이로스코프로) sensorType 키가 변경되고 onDispose가 고 구독을 취소하며 새 DisposableEffect 블록이 새 센서를 등록합니다. 키가 없으면 이전에 등록된 센서를 수동으로 확인하고 맞는 리스너로 unregisterListener를 호출해야 하며 이는 오류가 발생하기 쉽습니다.
@Composable
fun SensorReadingScreen(sensorType: Int) {
val context = LocalContext.current
val sensorManager = context.getSystemService(Context.SENSOR_SERVICE) as SensorManager
var sensorValue by remember { mutableStateOf(0f) }
DisposableEffect(sensorType) {
val sensor = sensorManager.getDefaultSensor(sensorType)
val listener = SensorEventListener { event, _ ->
sensorValue = event.values[0]
}
sensorManager.registerListener(listener, sensor, SensorManager.SENSOR_DELAY_NORMAL)
onDispose {
sensorManager.unregisterListener(listener)
}
}
Text("값: $sensorValue")
}
BroadcastReceiver는 필수적인 register / unregister 쌍이 필요한 API의 고전적인 예입니다. Compose 애플리케이션에서 DisposableEffect는 특정 화면의 수명 동안 리시버를 등록하는 데 이상적입니다. 화면에 진입하면 필요한 IntentFilter가 있는 BroadcastReceiver가 등록되고 나가면 onDispose에서 자동으로 취소됩니다.
일반적인 시나리오는 네트워크 상태 모니터링입니다. DisposableEffect는 ConnectivityManager용 리시버를 등록하여 네트워크 연결 변경사항을 알립니다. 상태가 변경되면 (WiFi / 모바일 데이터 / 네트워크 없음) composable 상태가 업데이트되고 UI가 해당 지표를 표시합니다. 화면이 닫히면 onDispose가 등록 해제를 보장합니다 — 앱이 백그라운드로 가더라도 마찬가지입니다.
RECEIVER_EXPORTED / RECEIVER_NOT_EXPORTED 플래그 (Android 14+)를 사용하는 ContextCompat.registerReceiver의 경우 시스템이 리시버의 범위를 명시적으로 지정할 요구하뭀로 DisposableEffect 사용이 필수적입니다. DisposableEffect는 범위가 화면의 수명으로 제한되도록 보장하며 이는 최신 Android 버전의 보안 요구 사항과 일치합니다.
@Composable
fun NetworkStatusBanner() {
val context = LocalContext.current
var isConnected by remember { mutableStateOf(true) }
DisposableEffect(Unit) {
val receiver = BroadcastReceiver { _, _ ->
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
isConnected = cm.getActiveNetwork() != null
}
IntentFilter(ConnectivityManager.CONNECTIVITY_ACTION).let { filter ->
context.registerReceiver(receiver, filter)
}
onDispose {
context.unregisterReceiver(receiver)
}
}
if (!isConnected) { ... }
}
첨번째 중대한 실수는 onDispose 호출 관루입니다. DisposableEffect 블록 내에서는 onDispose를 호출해야 하며 그렇지 않으면 컴파일 오류가 발생합니다. 그러나 개발자는 때로 onDispose를 조건문에 넣어 이것을 회피하려고 합니다: if (condition) { onDispose { ... } }. 이 코드는 컴파일되지만 조건이 만족되지 않으면 onDispose가 등록되지 않습니다 — 리소스가 결코 해방되지 않습니다.
둘째 실수는 비동기 작업에 DisposableEffect를 사용하는 것입니다. DisposableEffect는 동기적이뭀로 그 안에서 delay() 또는 await() 호출을 직질적으로 작성할 수 없습니다. 후속 정리가 필요한 비동기 초기화가 필요하다면 LaunchedEffect (데이터 로딩) 및 DisposableEffect (네이티브 리소스 설정/정리)의 조합을 사용하거나 rememberCoroutineScope를 사용하여 별도의 메카니즘을 사용하세요.
셋째 실수는 remember 없이 DisposableEffect 내에서 새 객체를 생성하는 것입니다. 객체(센서, listener, receiver)가 호출마다 효과 내에서 생성되고 키가 잧은 간격으로 변경되면 과장된 객체 생성과 가비지 컬렉션이 발생합니다. 객체 생성을 DisposableEffect 밖에서 remember 또는 remember { ... }으로 옪e기고 효과 내에서는 등록과 해제만 수행하는 것이 좋습니다.
자주 묻는 질문
DisposableEffect는 동기적으로 작동하고 명시적인 리소스 정리를 위해 onDispose를 제공합니다. LaunchedEffect는 코루틴에서 비동기적으로 작동하고 키가 변경되거나 컴포지션을 떠나면 자동으로 그것을 취소합니다. 리소스가 정리 메서드(close, unregister, dispose) 호출을 필요로 하면 DisposableEffect를 사용하세요. 작업이 suspend 함수라면 LaunchedEffect를 사용하세요.
네, onDispose는 필수입니다. Kotlin 컴파일러는 DisposableEffect 블록 내에서 그것이 호출되도록 요구합니다. onDispose를 호출하지 않으면 코드가 컴파일되지 않습니다. 이것은 개발자가 잠기는 것을 막기 위해 의도적으로 설계되었으며 컴포지션을 떠나 때 모든 열린 리소스가 제대로 닫히도록 보장합니다.
DisposableEffect 블록 내에서 try-catch를 사용하세요. 리소스 등록이 예외를 발생시킬 수 있는 경우(예: 센서를 찾을 수 없음) try로 감싸고 별도의 상태를 통해 UI에서 오류를 처리하세요. onDispose는 초기화 성공 여부에 관계없이 호출되어야 합니다 — finally 블록 또는 try 센션 끝에 두세요.
권장하지 않습니다. Flow에는 collectLatest와 함께 LaunchedEffect를 사용하거나 Lifecycle.repeatOnLifecycle와 .collectAsState() 메서드를 사용하는 것이 좋습니다. DisposableEffect는 suspend 함수를 지원하지 않으뭀로 그 안에서 Flow를 구독하려면 CoroutineScope를 통해 별도 코루틴을 시작해야 하며 이는 코드를 복잡하게 하고 누수 위험을 높입니다.
제한은 없지만 관련된 리소스는 하나의 DisposableEffect에 여러 작업과 하나의 onDispose로 그룹하는 것이 권장됩니다. 리소스가 독립적인 경우(예: 센서와 BroadcastReceiver) 다른 키를 가진 별도의 DisposableEffects로 분리하는 것이 좋습니다 — 이는 디버깅을 단순화하고 하나의 키가 변경될 때 모든 리소스가 재생성되는 것을 방지합니다.
요약
턴키 방식의 모바일 애플리케이션을 개발해 드립니다
IT Sectr는 2017년부터 스타트업과 기업을 위한 iOS 및 Android 애플리케이션을 만듭니다. 저희가 상담해 드리고 최적의 솔루션을 제안하겠습니다.