
이번 장에서는 사이드 이펙트 개념과 컴포즈에서 제공하는 이펙트 핸들러를 알아본다.
먼저, 컴포저블 트리에서 사이드 이펙트를 제어하는 것이 중요한 이유를 되짚어보자.
1장 컴포저블 함수의 속성에서 사이드 이펙트의 개념을 다룬 적 있다.
사이드 이펙트란 함수의 제어 범위를 벗어나는 모든 작업을 의미한다.
사이드 이펙트를 포함하는 함수는 같은 입력에 대해 같은 결과를 보장하지 않는다.
이를 비결정적이라고 하며, 이러한 함수는 실행 결과를 예측하기 어렵다.
두 정수를 더하는 아래 예시 함수를 살펴보자.
fun add(a: Int, b: Int) = a + b
위 함수는 오직 주어진 입력값만 사용해 결과를 계산하는 순수 함수다.
내부에서 입력값들을 더하는 단순한 작업만 수행하므로 같은 입력의 결과는 항상 동일하다.
따라서 이 함수는 결정적이며, 동작을 쉽게 예측할 수 있다.
이제 여기에 캐싱 작업을 추가해 보자.
fun add(a: Int, b: Int) =
calculationsCache.get(a, b) ?:
(a + b).also { calculationsCache.store(a, b, it) }
}
위 코드는 계산 전 캐시를 검색하고, 계산 후에는 결과를 캐시에 저장한다.
이때 캐시는 함수의 제어 범위 밖에 존재한다.
함수 입장에서는 캐싱된 값이 마지막 실행 이후 수정되었는지 알 방법이 없다.
만약 다른 스레드에서 이 캐시를 동시에 업데이트한다고 가정해 보자.
그러면 아래처럼 같은 입력값으로 함수를 두 번 호출해도 서로 다른 결과가 반환될 수 있다.
fun main() {
add(1, 2) // 3
// 다른 스레드가 호출함: cache.store(1, 2, res = 4)
add(1, 2) // 4
}
즉, add 함수는 더 이상 결정적이지 않다.
더 나아가 캐시가 메모리 대신 DB에 의존한다고 생각해보자.
조회나 저장 과정에서 DB 연결이 끊어지면 예외가 발생할 수 있다.
add 함수 호출이 예상치 못한 상황에서 실패할 가능성이 생기는 것이다.
이렇듯 사이드 이펙트는 기대를 벗어나는 동작을 유발하거나 함수의 동작 자체를 변경한다.
이는 코드를 추론하기 어렵게 만들고 테스트를 불가능하게 하며 버그 가능성을 높인다.
사이드 이펙트의 예시
컴포저블 함수 안에서 실행되는 사이드 이펙트는 컴포저블 생명 주기의 통제와 제약을 벗어난다.
이는 코드와 애플리케이션 상태의 무결성을 해칠 수 있어 매우 위험하다.
특히 모든 컴포저블 함수는 여러 번 리컴포지션될 수 있으므로, 해당 범위에 맞게 실행·취소되도록 해야 한다.
모든 안드로이드 앱에는 필연적으로 사이드 이펙트가 존재한다.
그 예시로 네트워크에서 데이터를 로드하는 사이드 이펙트를 살펴보자.
@Composable
fun EventsFeed(networkService: EventsNetworkService) {
val events = networkService.loadAllEvents() // 네트워크 로드 사이드 이펙트
LazyColumn {
items(events) { event ->
Text(text = event.name)
}
}
}
일반적으로는 UI를 처음 그릴 때만 네트워크 데이터를 가져오고, 화면이 보이는 동안 그 상태를 유지하는 것을 기대할 것이다.
하지만 모든 컴포저블 함수는 짧은 시간 내에 여러 번 리컴포지션될 수 있다.
따라서 매 리컴포지션마다 사이드 이펙트가 실행되며 찰나의 순간 많은 네트워크 요청이 발생할 위험이 있다.
또한 요청이 끝나기 전에 컴포저블이 composition을 벗어나도 네트워크 작업은 취소되지 않고 계속 이어질 것이다.
또 다른 예시로 외부 상태를 업데이트하는 사이드 이펙트를 다루는 코드를 살펴보자.
@Composable
fun MyScreen(drawerTouchHandler: TouchHandler) {
val drawerState = rememberDrawerState(DrawerValue.Closed)
drawerTouchHandler.enabled = drawerState.isOpen
// ...
}
drawer는 터치할 수 있는 UI 요소로, 초기 상태는 닫힘이지만 열림으로 바뀔 수 있다.
MyScreen 컴포저블은 모든 컴포지션/리컴포지션 시점에 drawer 상태를 확인하고
drawer가 열려 있을 때만 TouchHandler의 터치를 활성화한다.
이때 drawerTouchHandler.enabled = drawerState.isOpen는 외부 객체 상태를 바꾸는 사이드 이펙트다.
따라서 위 코드에서 해제되지 않아 잠재적인 리소스 누수 문제를 일으킬 수 있다.
이러한 문제를 해결하고자, 컴포즈는 생명주기를 인식하며 사이드 이펙트를 실행할 수 있는 메커니즘을 제공한다.
이를 통해 리컴포지션들이 일어나는 동안 작업을 지속하거나 composition에서 벗어날 때 작업을 취소할 수 있다.
이러한 메커니즘을 이펙트 핸들러라고 한다.
composition은 최적화를 위해 다양한 컴포즈 런타임 실행 전략을 따른다.
예를 들면 각기 다른 스레드에서 수행되거나, 소스코드와 다른 순서 혹은 병렬로 실행될 수 있다.
이렇게 다양한 composition 환경에서 안전하게 사이드 이펙트를 실행하기 위한 몇가지 매커니즘이 있다.
일반적인 이펙트의 공통 매커니즘
suspend 이펙트의 매커니즘
CoroutineContext 안에서 실행된다.suspend 이펙트를 취소한다.컴포즈는 이러한 메커니즘을 준수하는 이펙트 핸들러를 제공하여,
개발자가 컴포저블 안에서 안전하게 사이드 이펙트를 다룰 수 있게 한다.
모든 @Composable은 생명주기를 갖는다.
이펙트는 일반적으로 두 단계 사이에서 실행되며, 여러번의 리컴포지션에 걸쳐 지속되기도 한다.
이펙트 핸들러는 아래처럼 크게 두 종류로 분류할 수 있다.
DisposableEffect리소스 해제처럼, 컴포저블이 종료될 때 반드시 수행해야 하는 정리 작업이 있는 경우 유용하다.
onDispose 블록을 실행한다.@Composable
fun backPressHandler(onBackPressed: () -> Unit, enabled: Boolean = true) {
val dispatcher = LocalOnBackPressedDispatcherOwner.current.onBackPressedDispatcher
val backCallback = remember {
object : OnBackPressedCallback(enabled) {
override fun handleOnBackPressed() {
onBackPressed()
}
}
}
DisposableEffect(dispatcher) { // dispatcher가 변경되면 다시 실행
dispatcher.addCallback(backCallback) // 시작(Enter): 콜백 등록
onDispose {
backCallback.remove() // 종료(Exit): 콜백 제거
}
}
}
위 코드는 CompositionLocal에서 얻은 dispatcher에 콜백을 연결하는 뒤로가기 버튼 핸들러다.
composition에 들어가거나 dispatcher가 변할 때 콜백 연결을 위해 dispatcher를 이펙트 핸들러 키로 전달한다. 또한 메모리 누수 방지를 위해 composition을 떠날 때 onDispose에서 콜백을 제거한다.
DisposableEffect는 적어도 하나의 키가 필요하다.
이때 키 값으로 true나 Unit 등 상수를 전달하면 composition 진입 시 최초 한 번만 실행된다.
SideEffect컴포지션이나 리컴포지션이 성공해 변경 사항이 슬롯 테이블에 반영된 후에만 실행된다.
리컴포지션이 성공한 시점의 최신 상태를 컴포즈 밖의 외부 객체에 반영할 때 유용하다.
@Composable
fun MyScreen(drawerTouchHandler: TouchHandler) {
val drawerState = rememberDrawerState(DrawerValue.Closed)
SideEffect { // 컴포지션 성공 시 실행됨
// 컴포즈 상태 객체(drawerState.isOpen)를
// Compose의 외부 객체(drawerTouchHandler)에 동기화
drawerTouchHandler.enabled = drawerState.isOpen
}
// ... UI 구현
}
SideEffect는 Key값이 없다.
매 리컴포지션 성공마다 실행하여, 외부 객체가 항상 최신 상태를 유지하도록 보장하기 위함이다.
currentRecomposeScoperecompose를 직접 요청할 수 있는 인터페이스로, 이펙트 핸들러보다는 이펙트 그 자체에 가깝다.
interface RecomposeScope {
fun invalidate()
}
안드로이드 뷰에서 측정, 레이아웃, 그리기 단계를 강제로 재실행하는 invalidate와 비슷하다.
(e.g. Canvas 기반 애니메이션에서 매 프레임마다 뷰를 invalidate하고 다시 그린다.)
currentRecomposeScope.invalidate()를 호출하면 composition을 무효화해 강제 리컴포지션할 수 있다.
이는 컴포즈 상태 스냅샷이 아닌 진실의 원천(source of truth)을 사용할 때 유용하다.
interface Presenter {
fun loadUser(after: @Composable () -> Unit): User
}
@Composable
fun MyComposable(presenter: Presenter) {
// State 아님!
val user = presenter.loadUser { currentRecomposeScope.invalidate() }
Text("The loaded user: ${user.name}")
}
위 코드에서는 presenter의 loadUser를 호출하고 결과를 기다린다.
이 때 State를 사용하지 않기 때문에 수동으로 invalidate를 실행해 리컴포지션을 강제하고 있다.
이는 극단적인 예시로, 일반적으로는 컴포즈 런타임에서 제공하는 State와 스마트 리컴포지션을 활용한다.
rememberCoroutineScope이 함수는 특정 composition 생명주기를 따르는 CoroutineScope를 생성해,
이를 활용하면 컴포즈 생명주기 안에서 일시 중단 이펙트를 안전하게 실행할 수 있다.
특히, 컴포지션이나 상태 변화가 일어나는 시점에 자동으로 실행되는 것이 아닌
개발자가 정의한 특정 시점(클릭 이벤트 등)에 수동으로 이펙트를 실행할 때 유용하다.
rememberCoroutineScope의 특징
Applier dispatcher가 이펙트를 실행한다.@Composable
fun SearchScreen() {
val scope = rememberCoroutineScope()
var currentJob by remember { mutableStateOf<Job?>(null) }
var items by remember { mutableStateOf<List<Item>>(emptyList()) }
Column {
Row {
TextField(
value = "",
label = { Text("Start typing to search") },
onValueChange = { text ->
currentJob?.cancel() // 이전 검색 작업 취소 (디바운싱)
currentJob = scope.launch { // 새로운 검색 작업 시작
delay(threshold)
items = viewModel.search(query = text)
}
}
)
}
Row { ItemsVerticalList(items) }
}
}
위 코드는 스로틀링이다. (과거의 postDelayed나 Handler 방식과 유사함)
텍스트 입력이 변경될 때마다 진행 중이던 이전 작업을 취소하고, 새로운 작업을 delay와 함께 예약한다.
이를 통해 네트워크 요청 사이에 최소한의 지연 시간을 항상 강제한다.
LaunchedEffect컴포저블이 처음 실행될 때 초기 상태를 로드하고 여러번의 리컴포지션에 걸쳐 작업을 지속하는 사용한다.
또한 LaunchedEffect는 적어도 하나의 키가 필요하다.
@Composable
fun SpeakerList(eventId: String) {
var speakers by remember { mutableStateOf<List<Speaker>>(emptyList()) }
LaunchedEffect(eventId) { // eventId가 변경되면 기존 작업은 취소되고 다시 실행됨
speakers = viewModel.loadSpeakers(eventId) // 중단 가능한(suspended) 이펙트
}
ItemsVerticalList(speakers)
}
produceStateremember + mutableStateOf + LaunchedEffect 세 기능을 합친 문법 설탕이다.
비컴포즈 상태(Flow, LiveData, 일반 클래스)를 컴포즈 상태 State<T>로 변환할 때 유용하다.
또한 상태 초기값과 여러개의 키를 지정할 수 있다는 특징이 있다.
만약 key를 설정하지 않으면 내부적으로 LaunchedEffect에 Unit을 설정한 것처럼 동작한다.
@Composable
fun SearchScreen(eventId: String) {
// 초기값, Key를 인자로 받아 State를 생성
val uiState = produceState(initialValue = emptyList<Speaker>(), key1 = eventId) {
value = viewModel.loadSpeakers(eventId) // 코루틴 스코프에서 실행
}
ItemsVerticalList(uiState.value)
}
핵심 동작 방식
initialValue로 시작한다.LaunchedEffect와 마찬가지로, 컴포지션 진입 시 코루틴 블록이 시작된다.eventId가 바뀌면 진행 중인 작업을 취소하고 다시 데이터를 가져온다.| API | 실행 시점 | 용도 | 수명 주기 | 특징 및 비고 |
|---|---|---|---|---|
DisposableEffect | 컴포지션 진입 시, | |||
| Key 변경 시 | 리소스 정리 | Key가 유지되는 동안 | onDispose 블록 필수, 메모리 누수 방지용 | |
SideEffect | 리컴포지션 | |||
| 성공 시 | 컴포즈 상태 → 외부 소스 동기화 | 즉시 실행 및 완료 | 컴포즈 상태를 외부 라이브러리/로그 등에 전달할 때 사용 | |
currentRecomposeScope | 호출 시 | 수동 리컴포지션 트리거 | 해당 컴포지션 스코프 | Internals API, 일반적인 State 변화 없이 강제로 다시 그릴 때 사용 |
rememberCoroutineScope | 호출 시 | 코루틴 스코프 제공 | 컴포지션이 유지되는 동안 | 코루틴을 시작할 수 있는 스코프 제공 |
LaunchedEffect | 컴포지션 진입 시, | |||
| Key 변경 시 | 비동기 작업 수행 | Key가 유지되는 동안 | 코루틴 스코프 제공, Key 변경 시 기존 작업 취소 후 재시작 | |
produceState | 컴포지션 진입 시, | |||
| Key 변경 시 | 외부 소스 → 컴포즈 상태 변환 | 해당 상태가 사용되는 동안 | remember + LaunchedEffect의 합성어(Syntactic Sugar) |
컴포즈는 Observable, Flow, LiveData 등 서드 파티 라이브러리 타입을 위한 어댑터를 제공한다.
아래처럼 별도의 종속성이 필요하다.
// Flow 어댑터를 포함
implementation ”androidx.compose.runtime:runtime:$compose_version”
// LiveData를 위한 어댑터를 포함
implementation ”androidx.compose.runtime:runtime‑livedata:$compose_version”
// RxJava의 Observable과 Flowable을 위한 어댑터를 포함
implementation ”androidx.compose.runtime:runtime‑rxjava2:$compose_version”
서드파티 어댑터의 일반적인 흐름
Flow.collect, LiveData.observe, Observable.subscribeMutableState로 매핑한다.MutableState가 아닌 State 타입을 반환, 단방향 데이터 흐름(UDF)을 유지한다.LiveData를 컴포즈의 State로 변환하기 위해 observeAsState를 활용할 수 있다.
observeAsState는 내부에서 DisposableEffect 핸들러를 사용한다.
자세한 구현은 소스코드를 참고하자.
class MyComposableVM : ViewModel() {
private val _user = MutableLiveData(User("John"))
val user: LiveData<User> = _user
}
@Composable
fun MyComposable() {
val viewModel = viewModel<MyComposableVM>()
// LiveData 어댑터 : observeAsState
val user by viewModel.user.observeAsState() // LiveData -> Compose State
Text("Username: ${user?.name}")
}
RxJava의 Observable을 컴포즈의 State로 변환할 땐 susbcribeAsState를 사용한다.
susbcribeAsState는 Flowable을 변환할 때도 활용할 수 있다.
내부적에서 DisposableEffect를 사용하여, 자세한 구현은 소스코드에서 확인할 수 있다.
class MyComposableVM : ViewModel() {
val user: Observable<ViewState> = Observable.just(ViewState.Loading)
}
@Composable
fun MyComposable() {
val viewModel = viewModel<MyComposableVM>()
// RxJava 어댑터 : subscribeAsState
val uiState by viewModel.user.subscribeAsState(ViewState.Loading) // Observable -> Compose State
when (uiState) {
ViewState.Loading -> TODO("Show loading")
ViewState.Error -> TODO("Show Snackbar")
is ViewState.Content -> TODO("Show content")
}
}
Observable은 값을 방출하기 전까지 시간이 걸릴 수 있으므로,
컴포즈는 화면을 즉시 그리기 위해 초기값(ViewState.Loading)을 요구한다.
collectAsState의 구현은 앞서 소개한 어댑터들과 조금 다르다.
Flow는 일시 중단 컨텍스트 안에서 소비되어야 해서 LaunchedEffect 기반의 produceState를 사용한다.
자세한 구현은 소스코드에서 확인할 수 있다.
이렇듯 모든 어댑터는 앞서 다룬 이펙트 핸들러들에 의존하며,
통합하려는 라이브러리가 있을 경우 동일한 패턴을 따라 자신만의 어댑터를 작성할 수도 있다.
class MyComposableVM : ViewModel() {
val user: Flow<ViewState> = flowOf(ViewState.Loading)
// ...
}
@Composable
fun MyComposable() {
val viewModel = viewModel<MyComposableVM>()
// Flow 어댑터(collectAsState)를 사용하여
// Kotlin Flow를 Compose State로 변환합니다.
val uiState by viewModel.user.collectAsState(ViewState.Loading)
when (uiState) {
ViewState.Loading -> TODO("Show loading")
ViewState.Error -> TODO("Show Snackbar")
is ViewState.Content -> TODO("Show content")
}
}