이펙트 핸들러를 본격적으로 배우기 전에, 먼저 사이드 이펙트(Side Effect)가 무엇인지, 그리고 왜 Compose에서 이를 주의 깊게 다뤄야 하는지 알아보겠습니다.
사이드 이펙트란 함수의 제어 범위를 벗어난 모든 작업을 의미합니다. 이를 이해하기 위해 먼저 순수 함수(Pure Function)를 살펴보겠습니다.
fun add(a: Int, b: Int) = a + b
이 함수는 오직 입력값 a와 b만 사용하여 결과를 만듭니다. 언제, 어디서, 몇 번을 호출하든 1과 2를 넣으면 항상 3이 나옵니다. 이런 함수는 동작을 예측하기 쉽고 안전합니다.
반면, 아래 코드를 보겠습니다.
fun add(a: Int, b: Int) =
calculationsCache.get(a, b) ?: (a + b).also {
calculationsCache.store(a, b, it)
}
이 함수는 외부의 캐시(Cache)에 의존합니다. 만약 다른 스레드가 캐시를 수정하거나 데이터베이스 연결이 끊긴다면, 같은 입력값을 넣어도 결과가 달라지거나 에러가 날 수 있습니다. 즉, 함수의 동작이 외부 상태에 따라 변하는 비결정적(Non-deterministic) 상태가 됩니다.
이처럼 전역 변수 수정, DB 접근, 네트워크 통신, 파일 입출력 등 함수 외부의 상태를 읽거나 변경하는 모든 행위를 사이드 이펙트라고 합니다.
Compose의 핵심 특징 중 하나는 리컴포지션(Recomposition)입니다. Composable 함수는 상태가 변할 때마다 수시로 다시 실행될 수 있습니다.
만약 Composable 함수 본문에 네트워크 요청 같은 사이드 이펙트를 그대로 작성하면 어떻게 될까요?
@Composable
fun EventsFeed(networkService: EventsNetworkService) {
// 나쁜 예: 리컴포지션 될 때마다 네트워크 요청을 보냄
val events = networkService.loadAllEvents()
LazyColumn {
items(events) { event ->
Text(text = event.name)
}
}
}
위 코드는 화면이 갱신될 때마다 loadAllEvents()를 계속 호출합니다. 아주 짧은 시간에 수십 번 호출될 수도 있습니다. 우리가 원하는 건 화면이 처음 뜰 때 딱 한 번만 데이터를 불러오는 것입니다.
외부 객체의 상태를 업데이트하는 경우도 마찬가지입니다.
@Composable
fun MyScreen(drawerTouchHandler: TouchHandler) {
val drawerState = rememberDrawerState(DrawerValue.Closed)
// 나쁜 예: 매번 실행되며, 화면이 사라져도 해제되지 않음. onDispose에서 뒷정리를 안 함
drawerTouchHandler.enabled = drawerState.isOpen
// ...
}
이 코드는 매 리컴포지션마다 핸들러 설정을 덮어씁니다. 더 큰 문제는 이 화면(MyScreen)이 사라질 때 설정을 원래대로 되돌리거나 연결을 끊는 코드가 없다는 점입니다. 이는 메모리 누수나 버그의 원인이 됩니다.
사이드 이펙트는 상태가 있는 Stateful 프로그램을 작성하는데 필요합니다. 때문에, Jetpack Compose는 생명주기를 인식하는 방식으로 사이드 이펙트를 실행할 수 있는 메커니즘인 이펙트 핸들러 제공합니다.
이펙트 핸들러는 사이드 이펙트를 실행할 때 다음 2가지를 만족하면서 실행할 수 있도록 합니다.
이를 사용하면 복잡한 비동기 작업이나 리소스 관리를 안전하게 처리할 수 있습니다.
Compose는 성능 최적화를 위해 아주 똑똑하게, 때로는 예측하기 어렵게 동작합니다. 컴포저블 함수들을 다른 스레드로 옮겨서 실행하거나, 병렬로 동시에 실행하거나, 심지어 실행 순서를 바꿔버리기도 합니다.
이러한 특성 때문에, 아무런 안전장치 없이 컴포저블 함수 본문에 사이드 이펙트 코드를 덩그러니 놓아두는 것은 매우 위험합니다. 언제, 어떻게 실행될지 보장할 수 없기 때문입니다.
따라서 우리에게는 다음과 같은 기능을 보장해 줄 안전한 장치(메커니즘)가 필요합니다.
이 모든 기능을 제공하는 것이 바로 Jetpack Compose의 이펙트 핸들러입니다.
종류를 나누기 전에 컴포저블의 생명주기를 아주 간단히 짚고 넘어가겠습니다.
이펙트 핸들러는 이 진입과 이탈 사이에서 동작합니다. 어떤 이펙트는 여러 번의 리컴포지션(화면 갱신)이 일어나는 동안에도 죽지 않고 살아남아 작업을 계속 수행하기도 합니다.
이펙트 핸들러는 크게 두 가지 유형으로 나뉩니다.
코루틴을 사용하지 않는(비동기가 아닌) 작업을 처리하는 이펙트 핸들러 3가지를 알아보겠습니다. 이들은 주로 콜백 연결/해제나 외부 상태와의 동기화를 위해 사용됩니다.
가장 자주 사용되는 핸들러 중 하나입니다. 컴포저블이 시작될 때 무언가를 연결하고, 끝날 때 반드시 연결을 끊어야 하는 작업(Cleanup)에 사용합니다.
key로 전달한 값이 변경되면 기존 이펙트를 정리(Dispose)하고 다시 실행합니다.onDispose 블록을 실행하여 뒷정리를 합니다.사용 예시: 뒤로 가기 버튼 핸들러
뒤로 가기 동작을 가로채는 콜백을 등록하고, 화면이 사라지면 콜백을 제거해야 메모리 누수가 생기지 않습니다.
@Composable
fun BackPressHandler(onBackPressed: () -> Unit, enabled: Boolean = true) {
// 안드로이드 시스템의 뒤로 가기 처리기 가져오기
val dispatcher = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher
// 콜백 객체 생성 (이전 장의 remember 활용)
val backCallback = remember(enabled) {
object : OnBackPressedCallback(enabled) {
override fun handleOnBackPressed() {
onBackPressed()
}
}
}
// dispatcher가 변경되거나 컴포저블이 시작될 때 실행
DisposableEffect(dispatcher) {
// 1. 콜백 등록 (연결)
dispatcher?.addCallback(backCallback)
// 2. 뒷정리 (해제)
// dispatcher가 바뀌거나, 이 컴포저블이 화면에서 사라질 때 실행됨
onDispose {
backCallback.remove()
}
}
}
💡
만약 재실행 없이 처음과 끝에 딱 한 번만 실행하고 싶다면, 키 값으로 상수(Unit 또는 true)를 전달하면 됩니다.
Compose의 상태를 Compose가 관리하지 않는 외부의 일반 객체와 동기화할 때 사용합니다.
슬롯 테이블에 저장되지 않는 이펙트이며, composition을 너머 지속되지 않습니다.
DisposableEffect와 달리 뒷정리(onDispose) 기능이 없습니다. 즉, 일회성 실행이나 단순 값 전달에 적합합니다.사용 예시: 외부 객체에 상태 전달
앞서 살펴본 "나쁜 예시"를 SideEffect를 사용해 올바르게 수정한 코드입니다.
@Composable
fun MyScreen(drawerTouchHandler: TouchHandler) {
val drawerState = rememberDrawerState(DrawerValue.Closed)
// 리컴포지션이 일어날 때마다 최신 drawerState를 외부 핸들러에 전달
SideEffect {
drawerTouchHandler.enabled = drawerState.isOpen
}
}
이 코드는 MyScreen이 다시 그려질 때마다 drawerTouchHandler의 상태를 최신 drawerState와 일치시킵니다. 만약 여기서 DisposableEffect를 쓰지 않은 이유는, TouchHandler가 앱 전역에서 쓰이는 싱글턴 객체라 굳이 연결을 끊을 필요가 없다고 가정했기 때문입니다.
이것은 핸들러라기보다는 Compose의 동작을 강제로 제어하는 도구입니다.
invalidate()를 호출하여 강제로 리컴포지션을 수행하게 합니다.State를 쓰지 않는 일반적인 데이터가 변경되었을 때, 화면을 갱신하고 싶을 때 사용합니다.사용 예시: State가 아닌 데이터의 변경 감지
interface Presenter {
fun loadUser(onLoaded: () -> Unit): User
}
@Composable
fun MyComposable(presenter: Presenter) {
// presenter.loadUser는 State가 아닌 일반 객체를 반환한다고 가정
val user = presenter.loadUser {
// 데이터 로드가 끝나면 강제로 화면 갱신 요청
currentRecomposeScope.invalidate()
}
Text("The loaded user: ${user.name}")
}
💡
주의: 이은 매우 특수한 경우에만 사용해야 합니다. 대부분의 경우 mutableStateOf 같은 Compose의 상태(State) 시스템을 사용하는 것이 훨씬 효율적이고 버그가 적습니다. "이런 기능도 있다" 정도로만 알아두고, 가능하면 State를 활용하여 스마트 리컴포지션의 이점을 누리세요.
코루틴을 사용하여 비동기 작업을 처리하는 일시 중단 이펙트(Suspended Effects) 3가지를 알아보겠습니다. 이들은 네트워크 요청이나 DB 작업처럼 시간이 걸리는 일을 처리할 때 필수적입니다.
컴포저블의 생명주기를 따르는 코루틴 스코프(CoroutineScope)를 생성합니다.
사용 예시: 검색어 입력 스로틀링(Debounce) 사용자가 타자를 칠 때마다 매번 검색 요청을 보내지 않고, 입력이 잠시 멈췄을 때 요청을 보내는 예제입니다.
@Composable
fun SearchScreen(viewModel: SearchViewModel) {
// 1. 생명주기를 인식하는 코루틴 스코프 생성
val scope = rememberCoroutineScope()
var currentJob by remember { mutableStateOf<Job?>(null) }
var items by remember { mutableStateOf<List<Item>>(emptyList()) }
Column {
TextField(
value = "Start typing to search",
onValueChange = { text ->
// 2. 사용자 입력(이벤트) 발생 시 코루틴 실행
// 이전 작업 취소 (타자를 빨리 치면 이전 요청은 무시)
currentJob?.cancel()
// 새 작업 시작
currentJob = scope.launch {
delay(300L) // 0.3초 대기 (스로틀링)
items = viewModel.search(query = text)
}
}
)
ItemsVerticalList(items)
}
}
💡
LaunchedEffect와의 차이점은, rememberCoroutineScope는 사용자 상호작용에 의해 시작된 작업의 범위를 정하는 데 사용되는 반면, LaunchedEffect는 composition에 의해 시작된 작업의 범위를 정하는 데 사용된다는 것입니다.
컴포저블이 화면에 처음 나타나거나, 특정 조건(키)이 바뀌었을 때 코루틴을 실행합니다.
사용 예시: 데이터 로딩 화면이 뜰 때 데이터를 불러오거나, 이벤트 ID가 바뀔 때마다 스피커 목록을 새로고침하는 경우입니다.
@Composable
fun SpeakerList(eventId: String, viewModel: SpeakerViewModel) {
var speakers by remember { mutableStateOf<List<Speaker>>(emptyList()) }
// eventId가 바뀌면 기존 로딩을 취소하고 새로 로드함
LaunchedEffect(eventId) {
speakers = viewModel.loadSpeakers(eventId)
}
ItemsVerticalList(speakers)
}
LaunchedEffect를 조금 더 편하게 쓰기 위해 만든 문법적 설탕(Syntactic Sugar)입니다.
사용 예시: 코드 간소화
위의 LaunchedEffect 예시와 똑같은 동작을 더 짧게 작성할 수 있습니다.
@Composable
fun SearchScreen(eventId: String, viewModel: SpeakerViewModel) {
// 초기값은 빈 리스트, eventId가 바뀌면 블록 내부 재실행
val uiState by produceState(initialValue = emptyList<Speaker>(), eventId) {
// 이 블록은 코루틴 스코프입니다.
// 작업 결과를 value에 할당하면 uiState가 업데이트됩니다.
value = viewModel.loadSpeakers(eventId)
}
ItemsVerticalList(uiState)
}
기존 안드로이드 앱은 LiveData, RxJava, Kotlin Flow 같은 다양한 라이브러리를 사용하여 데이터를 관리합니다. Compose는 이러한 외부 라이브러리의 데이터를 Compose의 State로 쉽게 변환해 주는 어댑터를 제공합니다.
모든 어댑터는 결국 이펙트 핸들러에 위임하게 됩니다. 데이터가 변경되면 이펙트 핸들러가 이를 감지하여 Compose의 State를 업데이트해 줍니다. 덕분에 우리는 복잡한 연결 과정 없이 State처럼 편하게 값을 읽을 수 있습니다.
LiveData를 사용 중이라면 observeAsState()를 사용하여 State로 변환할 수 있습니다. 내부적으로 DisposableEffect를 사용하여 관찰자(Observer)를 등록하고 해제합니다.
class MyComposableVM : ViewModel() {
private val _user = MutableLiveData(User("John"))
val user: LiveData<User> = _user
}
@Composable
fun MyComposable() {
val viewModel = viewModel<MyComposableVM>()
// LiveData를 State로 변환 (null일 수 있으므로 주의)
val user by viewModel.user.observeAsState()
Text("Username: ${user?.name}")
}
@Composable
public fun <R, T : R> LiveData<T>.observeAsState(initial: R): State<R> {
val lifecycleOwner = LocalLifecycleOwner.current
val state = remember {
@Suppress("UNCHECKED_CAST") /* Initialized values of a LiveData<T> must be a T */
mutableStateOf(if (isInitialized) value as T else initial)
}
DisposableEffect(this, lifecycleOwner) {
val observer = Observer<T> { state.value = it }
observe(lifecycleOwner, observer)
onDispose { removeObserver(observer) }
}
return state
}RxJava의 Observable이나 Flowable을 사용한다면 subscribeAsState()를 사용합니다. 초기값(Initial Value)을 지정해 주어야 합니다.
class MyComposableVM : ViewModel() {
// 로딩 상태로 시작하는 스트림
val user: Observable<ViewState> = Observable.just(ViewState.Loading)
}
@Composable
fun MyComposable() {
val viewModel = viewModel<MyComposableVM>()
// Observable을 State로 변환 (초기값 필수)
val uiState by viewModel.user.subscribeAsState(ViewState.Loading)
when (uiState) {
ViewState.Loading -> { /* 로딩 표시 */ }
ViewState.Error -> { /* 에러 표시 */ }
is ViewState.Content -> { /* 콘텐츠 표시 */ }
}
}
Kotlin Coroutines의 Flow는 collectAsState()를 사용합니다.
차이점 LiveData나 RxJava 어댑터가 콜백 기반인 DisposableEffect를 쓰는 것과 달리, Flow는 코루틴 환경에서 동작해야 하므로 내부적으로 produceState(결국 LaunchedEffect)를 사용합니다.
class MyComposableVM : ViewModel() {
val user: Flow<ViewState> = flowOf(ViewState.Loading)
}
@Composable
fun MyComposable() {
val viewModel = viewModel<MyComposableVM>()
// Flow를 State로 변환
val uiState by viewModel.user.collectAsState(ViewState.Loading)
when (uiState) {
ViewState.Loading -> { /* 로딩 표시 */ }
ViewState.Error -> { /* 에러 표시 */ }
is ViewState.Content -> { /* 콘텐츠 표시 */ }
}
}
이 모든 어댑터 함수들은 특별한 마법이 아닙니다. 모두 우리가 배운 이펙트 핸들러(DisposableEffect, LaunchedEffect)를 활용하여 구현된 것입니다.
따라서 만약 Compose가 기본적으로 지원하지 않는 커스텀 라이브러리나 데이터 소스를 연결해야 한다면, 여러분도 동일한 패턴을 사용하여 직접 어댑터를 만들 수 있습니다.
@Composable
public fun <T> Flow<T>.collectAsStateWithLifecycle(
initialValue: T,
lifecycle: Lifecycle,
minActiveState: Lifecycle.State = Lifecycle.State.STARTED,
context: CoroutineContext = EmptyCoroutineContext,
): State<T> {
return produceState(initialValue, this, lifecycle, minActiveState, context) {
lifecycle.repeatOnLifecycle(minActiveState) {
if (context == EmptyCoroutineContext) {
this@collectAsStateWithLifecycle.collect { this@produceState.value = it }
} else
withContext(context) {
this@collectAsStateWithLifecycle.collect { this@produceState.value = it }
}
}
}
}