[Jetpack Compose Internals] 6장 이펙트 및 이펙트 핸들러

빙티·2026년 1월 11일

Jetpack Compose

목록 보기
7/7
post-thumbnail

이펙트와 이펙트 핸들러

이번 장에서는 사이드 이펙트 개념과 컴포즈에서 제공하는 이펙트 핸들러를 알아본다.
먼저, 컴포저블 트리에서 사이드 이펙트를 제어하는 것이 중요한 이유를 되짚어보자.


사이드 이펙트 소개

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 함수 호출이 예상치 못한 상황에서 실패할 가능성이 생기는 것이다.

이렇듯 사이드 이펙트는 기대를 벗어나는 동작을 유발하거나 함수의 동작 자체를 변경한다.
이는 코드를 추론하기 어렵게 만들고 테스트를 불가능하게 하며 버그 가능성을 높인다.

사이드 이펙트의 예시

  • 전역 변수 입출력
  • 메모리 캐시 접근
  • 데이터베이스 작업
  • 네트워크 요청
  • 화면 출력
  • 파일 읽기



Compose에서의 사이드 이펙트

컴포저블 함수 안에서 실행되는 사이드 이펙트는 컴포저블 생명 주기의 통제와 제약을 벗어난다.
이는 코드와 애플리케이션 상태의 무결성을 해칠 수 있어 매우 위험하다.
특히 모든 컴포저블 함수는 여러 번 리컴포지션될 수 있으므로, 해당 범위에 맞게 실행·취소되도록 해야 한다.

모든 안드로이드 앱에는 필연적으로 사이드 이펙트가 존재한다.
그 예시로 네트워크에서 데이터를 로드하는 사이드 이펙트를 살펴보자.

@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에서 벗어날 때 작업을 취소할 수 있다.
이러한 메커니즘을 이펙트 핸들러라고 한다.




우리가 필요로 하는 것 (What we need)

composition은 최적화를 위해 다양한 컴포즈 런타임 실행 전략을 따른다.
예를 들면 각기 다른 스레드에서 수행되거나, 소스코드와 다른 순서 혹은 병렬로 실행될 수 있다.
이렇게 다양한 composition 환경에서 안전하게 사이드 이펙트를 실행하기 위한 몇가지 매커니즘이 있다.


일반적인 이펙트의 공통 매커니즘

  • Composable 생명주기 안에서 이펙트를 실행한다.
  • 이펙트가 의존하는 상태가 바뀌면 자동으로 취소 및 재시작합니다.
  • 참조를 캡처하는 이펙트는 composition을 떠날 때 해당 참조를 정리한다.

suspend 이펙트의 매커니즘

  • 적절한 코루틴과 CoroutineContext 안에서 실행된다.
  • composition을 떠날 때 실행 중인 suspend 이펙트를 취소한다.

컴포즈는 이러한 메커니즘을 준수하는 이펙트 핸들러를 제공하여,
개발자가 컴포저블 안에서 안전하게 사이드 이펙트를 다룰 수 있게 한다.





이펙트 핸들러의 종류 (Effect Handlers)

모든 @Composable은 생명주기를 갖는다.

  1. 화면에 구체화될 때 : composition에 들어간다.
  2. UI 트리에서 제거될 때 : composition을 떠난다.

이펙트는 일반적으로 두 단계 사이에서 실행되며, 여러번의 리컴포지션에 걸쳐 지속되기도 한다.
이펙트 핸들러는 아래처럼 크게 두 종류로 분류할 수 있다.

  • 비일시 중단 이펙트 (Non suspended effects)
    e.g. 컴포저블이 composition에 들어갈 때 사이드 이펙트를 실행하고 떠날 때 폐기한다.
  • 일시 중단 이펙트 (Suspended effects)
    e.g. 네트워크에서 데이터를 로드하여 UI 상태로 제공한다.



비일시 중단 이펙트 (Non suspended effects)

DisposableEffect

리소스 해제처럼, 컴포저블이 종료될 때 반드시 수행해야 하는 정리 작업이 있는 경우 유용하다.

  • composition에 들어갈 때 : 사이드 이펙트를 수행한다.
  • 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가 변경되면 다시 실행
        dispatcher.addCallback(backCallback) // 시작(Enter): 콜백 등록
        onDispose {
            backCallback.remove() // 종료(Exit): 콜백 제거
        }
    }
}

위 코드는 CompositionLocal에서 얻은 dispatcher에 콜백을 연결하는 뒤로가기 버튼 핸들러다.

composition에 들어가거나 dispatcher가 변할 때 콜백 연결을 위해 dispatcher이펙트 핸들러 키로 전달한다. 또한 메모리 누수 방지를 위해 composition을 떠날 때 onDispose에서 콜백을 제거한다.

DisposableEffect는 적어도 하나의 키가 필요하다.
이때 키 값으로 trueUnit상수를 전달하면 composition 진입 시 최초 한 번만 실행된다.



SideEffect

컴포지션이나 리컴포지션이 성공해 변경 사항이 슬롯 테이블에 반영된 후에만 실행된다.
리컴포지션이 성공한 시점의 최신 상태를 컴포즈 밖의 외부 객체에 반영할 때 유용하다.

@Composable
fun MyScreen(drawerTouchHandler: TouchHandler) {
    val drawerState = rememberDrawerState(DrawerValue.Closed)

    SideEffect { // 컴포지션 성공 시 실행됨
        // 컴포즈 상태 객체(drawerState.isOpen)를
        // Compose의 외부 객체(drawerTouchHandler)에 동기화
        drawerTouchHandler.enabled = drawerState.isOpen
    }

    // ... UI 구현
}

SideEffect는 Key값이 없다.
매 리컴포지션 성공마다 실행하여, 외부 객체가 항상 최신 상태를 유지하도록 보장하기 위함이다.



currentRecomposeScope

recompose를 직접 요청할 수 있는 인터페이스로, 이펙트 핸들러보다는 이펙트 그 자체에 가깝다.

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와 스마트 리컴포지션을 활용한다.





일시 중단 이펙트 (Suspended effects)

rememberCoroutineScope

이 함수는 특정 composition 생명주기를 따르는 CoroutineScope를 생성해,
이를 활용하면 컴포즈 생명주기 안에서 일시 중단 이펙트를 안전하게 실행할 수 있다.

특히, 컴포지션이나 상태 변화가 일어나는 시점에 자동으로 실행되는 것이 아닌
개발자가 정의한 특정 시점(클릭 이벤트 등)에 수동으로 이펙트를 실행할 때 유용하다.

rememberCoroutineScope의 특징

  • composition간 동일한 스코프가 반환되므로 계속해서 작업을 수행할 수 있다.
  • composition에 들어올 때 : Applier dispatcher가 이펙트를 실행한다.
  • composition을 떠날 때 : 모든 진행 중인 작업이 취소된다.
@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) }
    }
}

위 코드는 스로틀링이다. (과거의 postDelayedHandler 방식과 유사함)
텍스트 입력이 변경될 때마다 진행 중이던 이전 작업을 취소하고, 새로운 작업을 delay와 함께 예약한다.
이를 통해 네트워크 요청 사이에 최소한의 지연 시간을 항상 강제한다.



LaunchedEffect

컴포저블이 처음 실행될 때 초기 상태를 로드하고 여러번의 리컴포지션에 걸쳐 작업을 지속하는 사용한다.
또한 LaunchedEffect는 적어도 하나의 키가 필요하다.

  • composition에 들어올 때 : 이펙트를 실행한다.
  • 키가 변경될 때 : 이펙트를 취소하고 재시작한다.
  • composition을 떠날 때 : 이펙트를 취소한다.
@Composable
fun SpeakerList(eventId: String) {
    var speakers by remember { mutableStateOf<List<Speaker>>(emptyList()) }

    LaunchedEffect(eventId) { // eventId가 변경되면 기존 작업은 취소되고 다시 실행됨
        speakers = viewModel.loadSpeakers(eventId) // 중단 가능한(suspended) 이펙트
    }

    ItemsVerticalList(speakers)
}


produceState

remember + mutableStateOf + LaunchedEffect 세 기능을 합친 문법 설탕이다.

비컴포즈 상태(Flow, LiveData, 일반 클래스)를 컴포즈 상태 State<T>로 변환할 때 유용하다.
또한 상태 초기값과 여러개의 키를 지정할 수 있다는 특징이 있다.
만약 key를 설정하지 않으면 내부적으로 LaunchedEffectUnit을 설정한 것처럼 동작한다.

@Composable
fun SearchScreen(eventId: String) {
    // 초기값, Key를 인자로 받아 State를 생성
    val uiState = produceState(initialValue = emptyList<Speaker>(), key1 = eventId) {
        value = viewModel.loadSpeakers(eventId) // 코루틴 스코프에서 실행
    }

    ItemsVerticalList(uiState.value)
}

핵심 동작 방식

  1. 초기값 설정: 기본값 initialValue로 시작한다.
  2. 코루틴 실행: LaunchedEffect와 마찬가지로, 컴포지션 진입 시 코루틴 블록이 시작된다.
  3. Key 감지: eventId가 바뀌면 진행 중인 작업을 취소하고 다시 데이터를 가져온다.
  4. 수명 주기: 컴포저블이 화면에서 사라지면 코루틴도 자동으로 종료됩니다.



비교

API실행 시점용도수명 주기특징 및 비고
DisposableEffect컴포지션 진입 시,
Key 변경 시리소스 정리Key가 유지되는 동안onDispose 블록 필수, 메모리 누수 방지용
SideEffect리컴포지션
성공 시컴포즈 상태외부 소스 동기화즉시 실행 및 완료컴포즈 상태를 외부 라이브러리/로그 등에 전달할 때 사용
currentRecomposeScope호출 시수동 리컴포지션 트리거해당 컴포지션 스코프Internals API, 일반적인 State 변화 없이 강제로 다시 그릴 때 사용
rememberCoroutineScope호출 시코루틴 스코프 제공컴포지션이 유지되는 동안코루틴을 시작할 수 있는 스코프 제공
LaunchedEffect컴포지션 진입 시,
Key 변경 시비동기 작업 수행Key가 유지되는 동안코루틴 스코프 제공, Key 변경 시 기존 작업 취소 후 재시작
produceState컴포지션 진입 시,
Key 변경 시외부 소스컴포즈 상태 변환해당 상태가 사용되는 동안remember + LaunchedEffect의 합성어(Syntactic Sugar)



서드 파티 라이브러리 어댑터 (Third party library adapters)

컴포즈는 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”

서드파티 어댑터의 일반적인 흐름

  1. 서드파티 API를 사용해 옵저버를 추가한다.
    e.g. Flow.collect, LiveData.observe, Observable.subscribe
  2. 새로운 데이터가 방출될 때마다 ad-hoc MutableState로 매핑한다.
  3. 어댑터 함수에 의해 불변 State로 노출된다.
    함수가 MutableState가 아닌 State 타입을 반환, 단방향 데이터 흐름(UDF)을 유지한다.


LiveData

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}")
}


RxJava2

RxJava의 Observable을 컴포즈의 State로 변환할 땐 susbcribeAsState를 사용한다.
susbcribeAsStateFlowable을 변환할 때도 활용할 수 있다.
내부적에서 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)을 요구한다.



Coroutines Flow

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")
    }
}
profile
할머니에게 설명할 수 없다면 제대로 이해한 게 아니다

0개의 댓글