Introducing side effects
사이드 이펙트란?
Side Effect
- 호출자가 예상하지 못한 방식으로 함수 외부에서 발생하는 부수적인 행동이다.
- 이러한 행동은 함수의 동작을 변경시킬 수 있다.
- 코드 추론을 어렵게 하고, 테스트하기 어렵게 만들며, flaky한 코드를 초래할 수 있다.
본질적으로, 사이드 이펙트는 함수의 제어와 범위를 벗어나는 모든 것을 의미한다. 예를 들어 두 수를 더하는 함수는 다음과 같이 작성될 수 있다:
fun add(a: Int, b: Int): Int = a + b
이 함수는 입력값만을 사용해 결과를 도출하므로 순수 함수(pure function)이다. 같은 입력값에 대해 항상 같은 결과를 반환하며, 이 함수는 결정적(deterministic)이라고 할 수 있다. 따라서 개발자가 코드를 쉽게 추론할 수 있다.
사이드 이펙트의 예
- 전역 변수에 읽기/쓰기
- 메모리 캐시에 접근
- 데이터베이스 접근
- 네트워크 쿼리 수행
- 화면에 무언가 표시
- 파일에서 읽기
이제 동일한 함수에 캐시 기능을 추가한다고 가정해보자:
fun add(a: Int, b: Int) =
calculationsCache.get(a, b) ?:
(a + b).also { calculationsCache.store(a, b, it) }
이제 calculationsCache라는 캐시를 사용해 이전 결과를 재활용한다. 이 캐시는 함수의 제어 범위를 벗어난 외부 리소스로, 값이 마지막 실행 이후 변경되지 않았는지 확인할 방법이 없다. 만약 다른 스레드에서 캐시 값을 바꾼다면 어떻게 될까?
fun main() {
add(1, 2) // 3
// 다른 스레드가 cache.store(1, 2, 4) 호출
add(1, 2) // 4
}
동일한 입력값에 대해 다른 결과를 반환하게 되며, 함수는 더 이상 결정적이지 않다. 만약 이 캐시가 데이터베이스에 저장되어 있다면, 네트워크 오류로 인해 get 또는 store에서 예외가 발생할 수 있고, 이로 인해 함수가 실패할 수도 있다.
Side effects in Compose
Composable 함수 내부에서의 사이드 이펙트 문제
- Composable 함수 내부에서 사이드 이펙트를 실행하면, 이펙트는 Composable의 생명 주기(lifecycle) 제약을 벗어나게 된다.
- 이는 일반 함수에서 발생하는 사이드 이펙트 문제와 동일한 문제를 발생시킨다.
재구성(Recomposition) 문제
- Composable은 여러 번 재구성될 수 있으며, 이는 동일한 사이드 이펙트가 여러 번 실행되는 결과를 초래할 수 있다.
- 이런 이유로 Composable 함수 안에서 직접 사이드 이펙트를 실행하는 것은 좋지 않은 방법이다.
- Composable 함수는 재시작 가능하므로(restartable), 이를 염두에 두고 설계해야 한다.
예시: 네트워크 상태를 로드하는 Composable
@Composable
fun EventsFeed(networkService: EventsNetworkService) {
val events = networkService.loadAllEvents() // side effect
LazyColumn {
items(events) { event ->
Text(text = event.name)
}
}
}
- 위 코드는 매번 recomposition 시마다 loadAllEvents()가 실행된다.
- 이는 네트워크 요청이 중복 실행되는 문제를 유발할 수 있다.
- 우리가 원하는 것은 “최초 composition 시에만” 이펙트를 실행하고, 그 상태를 유지하는 것이다.
예시 2: 외부 상태와 동기화하는 코드
@Composable
fun MyScreen(drawerTouchHandler: TouchHandler) {
val drawerState = rememberDrawerState(DrawerValue.Closed)
drawerTouchHandler.enabled = drawerState.isOpen
// ...
}
- drawerTouchHandler.enabled 값을 외부 상태인 drawerState에 따라 업데이트하는 것은 하나의 사이드 이펙트이다.
Composable의 생명 주기
Composable은
- UI에 나타나면 composition에 들어가고,
- UI 트리에서 제거되면 composition에서 빠져나간다.
이 두 이벤트 사이에 이펙트가 실행될 수 있으며, 어떤 이펙트는 Composable 생명 주기를 넘어 지속될 수 있다.
이펙트 핸들러의 두 가지 분류
- Non-suspended effects
- 예: Composable이 composition에 진입할 때 콜백을 등록하고, 빠져나갈 때 해제하는 경우
- Suspended effects
- 예: 네트워크로부터 데이터를 가져와 상태를 갱신하는 경우
What we need
Jetpack Compose의 composition은 다양한 스레드에서 실행될 수 있고, 병렬로 실행되거나 순서가 달라질 수 있다. 이러한 유연성은 Compose 팀이 성능 최적화를 위해 열어두고자 하는 가능성이며, 따라서 composition 중에 아무 제어 없이 바로 사이드 이펙트를 실행하는 것은 지양해야 한다.
우리가 필요로 하는 메커니즘
- 이펙트는 Composable 생명 주기의 적절한 시점에 실행되어야 한다. 너무 이르거나 늦지 않게, Composable이 준비되었을 때 실행되어야 한다.
- suspend 함수 기반의 이펙트는 적절히 설정된 런타임에서 실행되어야 한다. 예: Coroutine 및 CoroutineContext.
- 참조를 캡처하는 이펙트는 composition에서 벗어날 때 이를 해제할 수 있는 기회를 가져야 한다.
- composition에서 벗어날 때, 진행 중인 suspend 이펙트는 취소되어야 한다.
- 입력값의 변화에 의존하는 이펙트는 그 값이 바뀔 때마다 자동으로 해제(dispose), 취소(cancel), 재시작(relaunch)되어야 한다.
이러한 메커니즘은 Jetpack Compose가 제공하며, 이를 effect handlers라고 부른다.
버전 정보
이 문서에서 공유되는 모든 effect handler는 1.0.0-beta02 버전 기준으로 사용할 수 있다. Jetpack Compose는 public API가 beta에 진입하면서 고정되었기 때문에, 1.0.0 정식 릴리스 전까지는 변경되지 않는다.
Effect Handlers
Composable의 생명주기
- Composable은 화면에 나타나면서 composition에 진입하고, UI 트리에서 제거되면 composition에서 벗어난다.
- 이 두 시점 사이에 이펙트가 실행될 수 있으며, 일부 이펙트는 Composable 생명주기를 넘어 실행될 수도 있다.
- 즉, 하나의 이펙트가 여러 composition에 걸쳐 존재할 수 있다.
이펙트 핸들러 분류
Jetpack Compose에서는 이펙트 핸들러를 두 가지로 분류할 수 있다.
- Non suspended effects
예: Composable이 composition에 들어갈 때 콜백을 초기화하고, 빠져나갈 때 해제하는 이펙트를 실행
- 콜백 등록 및 해제에 사용됨
- Suspended effects
예: 네트워크에서 데이터를 불러와서 UI 상태를 업데이트할 때 사용
- suspend 함수와 coroutine을 활용
Non suspended effects - DisposableEffect
개요
DisposableEffect는 composition 생명주기에 반응하는 사이드 이펙트를 나타낸다. 일반적으로 정리(dispose)가 필요한 non-suspend 타입의 이펙트에 사용된다.
주요 특징
- 정리가 필요한 non-suspended 이펙트를 다룰 때 사용된다.
- Composable이 composition에 진입할 때 처음 실행되며, 키(key)가 변경될 때마다 다시 실행된다.
- Composable이 composition에서 벗어나거나 키가 바뀌면 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.addCallback(backCallback)
onDispose {
backCallback.remove() // 메모리 누수 방지
}
}
}
코드 설명
- dispatcher는 CompositionLocal을 통해 얻은 값이다.
- DisposableEffect(dispatcher)는 dispatcher가 변경되면 이펙트를 재실행하고 이전 이펙트를 정리한다.
- Composable이 composition에서 사라질 때도 onDispose가 호출되어 콜백을 제거한다.
효과를 한 번만 실행하고 싶은 경우
효과를 오직 첫 composition 진입 시에만 실행하고, 나갈 때 정리하고 싶다면 다음처럼 상수를 키로 사용할 수 있다.
DisposableEffect(true)
DisposableEffect(Unit)
요약
- DisposableEffect는 composition 생명주기에 맞춰 이펙트를 실행하고 정리할 수 있게 해준다.
- 키가 변경되면 자동으로 이전 이펙트를 정리하고 새로운 이펙트를 실행한다.
- 항상 최소 한 개의 키가 필요하다.
SideEffect
개요
SideEffect는 composition의 사이드 이펙트를 처리하기 위한 핸들러 중 하나이다. 다음과 같은 특징이 있다:
- composition이 성공적으로 완료되었을 때만 실행된다.
- composition이 실패하면 이펙트는 무시된다.
- 슬롯 테이블(slot table)에 저장되지 않기 때문에 composition의 생명주기를 넘어서 지속되지 않는다.
- 다시 실행되거나 재시도되지 않는다.
- 일회성으로 실행되는 “이번 composition에서만 실행하고 잊는다” 식의 이펙트이다.
용도
- 정리(dispose)가 필요 없는 이펙트에 사용한다.
- 모든 composition 및 recomposition 이후에 실행된다.
- 외부 상태를 갱신(publish update)하는 데 유용하다.
사용 예시
@Composable
fun MyScreen(drawerTouchHandler: TouchHandler) {
val drawerState = rememberDrawerState(DrawerValue.Closed)
SideEffect {
drawerTouchHandler.enabled = drawerState.isOpen
}
// ...
}
코드 설명
- drawerTouchHandler.enabled를 drawerState.isOpen 값에 따라 업데이트함으로써 외부 상태를 반영한다.
- TouchHandler가 싱글톤이고, 앱 전체에서 항상 존재하는 컴포넌트라면 굳이 정리할 필요가 없기 때문에 SideEffect가 적합하다.
SideEffect는 외부 상태를 최신 상태로 유지해야 할 때 유용하다. Compose 내부의 상태 시스템으로 관리되지 않는 외부 값들을 지속적으로 업데이트하는 데에 적합한 도구이다.
정리 로직이 필요하지 않으며, composition 마다 한 번씩 실행된다는 점이 특징이다.
currentRecomposeScope
개요
currentRecomposeScope는 전통적인 View.invalidate()와 유사한 역할을 하는 도구이다. 이는 컴포지션을 명시적으로 무효화하여 재구성을 강제할 수 있다. 자체적으로 effect handler는 아니지만, 컴포지션과 관련된 중요한 도구이므로 다루게 된다.
작동 방식
- invalidate()를 호출하면 해당 scope에 한해 컴포지션을 무효화하고 다시 수행하도록 한다.
- 이는 Compose의 상태 시스템(State snapshot)을 사용하지 않을 때 유용하다.
인터페이스
interface RecomposeScope {
fun invalidate()
}
예시
interface Presenter {
fun loadUser(after: @Composable () -> Unit): User
}
@Composable
fun MyComposable(presenter: Presenter) {
val user = presenter.loadUser {
currentRecomposeScope.invalidate() // 재구성 강제
}
Text("The loaded user: ${user.name}")
}
사용 시 주의점
- 이 방식은 매우 특수한 상황에서만 사용해야 하며, 일반적으로는 Compose의 State를 사용하는 것이 더 바람직하다.
- State를 사용하면 Compose가 변경 사항을 자동으로 감지하고 스마트하게 재구성을 수행할 수 있다.
- 프레임 기반 애니메이션 등에서 필요한 경우에는 공식 문서를 참고하여 적절한 API를 사용하는 것이 좋다.
요약
- currentRecomposeScope는 외부 상태 변화에 따라 명시적으로 recomposition을 트리거할 수 있게 해주는 도구이다.
- 일반적인 상황에서는 Compose의 상태 시스템(State)을 사용하는 것이 더 안전하고 효율적이다.
Suspended effects
rememberCoroutineScope
설명
- composition 생명주기에 바인딩된 CoroutineScope를 생성한다.
- 이 scope를 사용해 suspend 함수 기반 이펙트를 실행할 수 있다.
- composition에서 벗어나면 scope도 자동으로 취소된다.
- 동일한 composition 안에서는 동일한 scope가 재사용된다.
사용 용도
- 유저 인터랙션에 반응해 작업을 실행할 때 사용
- dispatcher는 일반적으로 AndroidUiDispatcher.Main
예시
@Composable
fun SearchScreen() {
val scope = rememberCoroutineScope()
var currentJob by remember { mutableStateOf<Job?>(null) }
var items by remember { mutableStateOf<List<Item>>(emptyList()) }
Column {
Row {
TextField("Start typing to search", onValueChange = { text ->
currentJob?.cancel()
currentJob = scope.async {
delay(threshold)
items = viewModel.search(query = text)
}
})
}
Row {
ItemsVerticalList(items)
}
}
}
LaunchedEffect
설명
- composition 진입 시 실행되는 suspend 이펙트
- composition에서 벗어날 때 자동으로 취소됨
- key가 바뀔 때마다 취소되고 재실행됨
- recomposition을 넘어 job을 유지할 수 있음
특징
- 최소 하나의 key가 필요함
- dispatcher는 보통 AndroidUiDispatcher.Main
예시
@Composable
fun SpeakerList(eventId: String) {
var speakers by remember { mutableStateOf<List<Speaker>>(emptyList()) }
LaunchedEffect(eventId) {
speakers = viewModel.loadSpeakers(eventId)
}
ItemsVerticalList(speakers)
}
produceState
설명
- LaunchedEffect 위에 얹힌 문법적 설탕(syntax sugar)
- 상태(state)를 생성하고 유지하며, 그 상태는 외부 UI에서 관찰 가능
- 대부분의 경우 LaunchedEffect로 상태를 업데이트하는 상황에서 사용
특징
- 기본값과 key를 지정할 수 있음
- key가 없으면 내부적으로 LaunchedEffect(Unit)으로 동작하며, composition을 넘어 job이 유지됨
예시
@Composable
fun SearchScreen(eventId: String) {
val uiState = produceState(initialValue = emptyList<Speaker>(), eventId) {
value = viewModel.loadSpeakers(eventId)
}
ItemsVerticalList(uiState.value)
}
Third party library adapters
Jetpack Compose에서는 Observable, Flow, LiveData 등 외부 라이브러리에서 제공하는 데이터 스트림을 사용해야 할 경우가 많다. 이를 위해 Compose는 각각에 맞는 adapter들을 제공한다.
주요 의존성
implementation "androidx.compose.runtime:runtime:$compose_version" // Flow 포함
implementation "androidx.compose.runtime:runtime-livedata:$compose_version"
implementation "androidx.compose.runtime:runtime-rxjava2:$compose_version"
작동 방식
- 이 adapter들은 모두 내부적으로 effect handler에 위임된다.
- 각 adapter는 외부 데이터 스트림에 옵저버를 등록하고, 전달받은 값을 MutableState로 매핑한다.
- 이 MutableState는 외부에서 State로 노출된다.
예시 - LiveData
class MyComposableVM : ViewModel() {
private val _user = MutableLiveData(User("John"))
val user: LiveData<User> = _user
}
@Composable
fun MyComposable() {
val viewModel = viewModel<MyComposableVM>()
val user by viewModel.user.observeAsState()
Text("Username: ${user?.name}")
}
- observeAsState()는 내부적으로 DisposableEffect를 사용해 구현됨
예시 - RxJava2
class MyComposableVM : ViewModel() {
val user: Observable<ViewState> = Observable.just(ViewState.Loading)
}
@Composable
fun MyComposable() {
val viewModel = viewModel<MyComposableVM>()
val state by viewModel.user.subscribeAsState(ViewState.Loading)
when (state) {
ViewState.Loading -> TODO("Show loading")
ViewState.Error -> TODO("Show error")
is ViewState.Content -> TODO("Show content")
}
}
- subscribeAsState()는 RxJava2 외에도 Flowable 등에 확장되어 있음
예시 - KotlinX Coroutines Flow
class MyComposableVM : ViewModel() {
val user: Flow<ViewState> = flowOf(ViewState.Loading)
}
@Composable
fun MyComposable() {
val viewModel = viewModel<MyComposableVM>()
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")
}
}
- collectAsState()는 Flow를 suspend context에서 처리해야 하므로 produceState를 내부적으로 사용한다.
- 결국 이 또한 LaunchedEffect에 위임된다.
요약
- Compose는 다양한 외부 데이터 스트림을 위한 어댑터를 제공한다.
- 이 어댑터들은 대부분 DisposableEffect, LaunchedEffect, produceState 등 Compose 내부 이펙트 핸들러에 의존한다.
- 동일한 패턴을 따르며 커스텀 라이브러리도 쉽게 확장 가능하다.