[Android Compose] Effect Handlers 딥다이브

2025. 6. 20. 23:52·Android/Compose

안녕하세요! 최근 개발 실력이 나름 늘어간다고 생각이 들면서, 이전까지 모호하게 알고 있다고 생각이 들었던 것들에 대해 다시 기초부터 다지는 중인데요. 내가 컴포즈로 개발을 하면서 사이드 이펙트에 대해 고민을 하고 코드를 작성하고 있었나?라는 생각이 들어 정리를 해봤습니다. 다른 분들에게도 도움이 됐으면 좋겠네요!

사이드 이펙트란?

사이드 이펙트는 컴포저블 함수의 범위를 벗어나 외부 상태가 변경되는 작업을 말해요. 네트워크 요청, 데이터베이스 접근, 파일 시스템 조작 등이 대표적인 예시예요.

왜 문제가 될까요?

컴포저블 함수는 언제든지, 몇 번이든 호출될 수 있어요. 리컴포지션은 상태 변화, 부모 컴포저블의 변화, 시스템 설정 변경 등 다양한 이유로 발생하죠. 만약 컴포저블 함수 내에서 직접 네트워크 요청을 한다면, 리컴포지션할 때마다 동일한 요청이 반복 실행되는 문제가 생길 수 있어요.

이런 점은 컴포저블의 핵심 원칙 위반이에요.

  • 멱등성: 같은 입력에 대해 같은 출력
  • 빠른 실행: 언제든 취소되고 재시작될 수 있음
  • 부작용 없음: UI 구성 외의 다른 작업 금지

그래서 Effect Handler를 활용하자.

Effect Handler들은 컴포지션 생명주기와 연동하여 사이드 이펙트를 안전하게 관리해줘요. 각각 다른 시나리오에 특화되어 있어서, 상황에 맞는 핸들러를 선택하는 것이 중요해요.

LaunchedEffect: 컴포지션 생명주기 기반 코루틴 관리

LaunchedEffect는 키값을 기반으로 코루틴의 생명주기를 관리해요. 내부적으로는 RememberSaveable과 비슷한 방식으로 키값을 추적하고, 키가 변경되거나 컴포저블이 제거될 때 기존 코루틴을 취소하고 새로운 코루틴을 시작해요.

핵심은 구조화된 동시성(Structured Concurrency)이에요. 컴포저블의 생명주기와 코루틴의 생명주기가 연동되어, 메모리 누수나 좀비 코루틴이 생기는 것을 방지해요.

@Composable
fun UserProfile(userId: String) {
    var userData by remember { mutableStateOf<User?>(null) }

    // userId가 바뀔 때만 새로운 요청 실행
    LaunchedEffect(userId) {
        userData = userRepository.getUser(userId)
    }
}

언제 사용할까요?

  • 데이터 로딩: 화면 진입 시 또는 특정 값 변경 시
  • 일회성 작업: 스낵바 표시, 화면 전환, 애니메이션 시작
  • ViewModel Flow 구독: UI에 데이터 스트림 연결

가장 많이 사용하는 패턴은 ViewModel의 Flow나 LiveData를 구독하는 거예요. LaunchedEffect가 컴포지션 생명주기에 맞춰 구독과 해제를 자동으로 처리해요.

rememberCoroutineScope: 사용자 이벤트 기반 코루틴 제어

rememberCoroutineScope는 컴포지션 생명주기는 알지만, 실행 시점은 개발자가 제어할 수 있는 코루틴 스코프를 제공해요. 내부적으로 컴포저블이 활성 상태일 때만 유효한 CoroutineScope를 생성하고, 컴포저블이 제거되면 해당 스코프의 모든 코루틴을 자동으로 취소해요.

LaunchedEffect와의 핵심 차이점은 실행 시점의 제어권인데요. LaunchedEffect는 컴포지션 시점에 자동 실행되지만, rememberCoroutineScope는 개발자가 원하는 시점(주로 사용자 이벤트)에 실행할 수 있어요.

@Composable
fun ScrollToTopButton() {
    val listState = rememberLazyListState()
    val coroutineScope = rememberCoroutineScope()

    Button(
        onClick = {
            // 사용자가 클릭했을 때만 실행
            coroutineScope.launch {
                listState.animateScrollToItem(0)
            }
        }
    ) { Text("맨 위로") }
}

주의사항은 절대로 컴포저블 함수의 최상위 레벨에서 직접 실행하면 안 돼요. 리컴포지션마다 새로운 코루틴이 생성되어 성능 문제와 예측 불가능한 동작을 일으킬 수 있어요. 반드시 콜백 함수 내부에서만 사용해야 해요.

rememberUpdatedState: 클로저 캡처 문제 해결

클로저 캡처(Closure Capture)??

장시간 실행되는 비동기 작업에서 외부 변수를 참조할 때, 작업 시작 시점의 값이 캡처되어 최신 값을 반영하지 못하는 문제가 발생해요. 이는 Kotlin의 클로저 특성 때문이에요.

 

rememberUpdatedState는 값의 참조(Reference)를 저장하는 게 아니라, 항상 최신 값을 가리키는 포인터 역할을 해요. 내부적으로 MutableState를 사용하여 값이 변경될 때마다 자동으로 업데이트되고, 실제 사용 시점에 최신 값을 반환해요.

@Composable
fun SplashScreen(onTimeout: () -> Unit) {
    val currentOnTimeout by rememberUpdatedState(onTimeout)

    LaunchedEffect(Unit) {
        delay(3000) // 3초 대기 중에 onTimeout이 변경될 수 있음
        currentOnTimeout() // 항상 최신 콜백 실행
    }
}

언제 사용할까요?

  • 타이머나 지연된 콜백: 실행 시점의 최신 콜백 보장
  • 장시간 실행되는 비동기 작업: 작업 완료 시 최신 상태 반영
  • 애니메이션 완료 콜백: 애니메이션 중 콜백이 변경되는 경우

DisposableEffect: 리소스 생명주기 관리

DisposableEffect는 리소스의 등록과 해제를 쌍으로 관리하는 Effect Handler예요. 내부적으로 onDispose 블록을 저장해 두고, 키값이 변경되거나 컴포저블이 제거될 때 반드시 해당 블록을 실행해요.

핵심은 보장된 정리(Guaranteed Cleanup)예요. 어떤 상황에서든 (앱 종료, 화면 회전, 네비게이션 등) 정리 코드가 실행되어 메모리 누수를 방지해요.

@Composable
fun LocationTracker() {
    DisposableEffect(Unit) {
        val listener = LocationListener { /* 위치 업데이트 */ }
        locationManager.requestLocationUpdates(listener)

        onDispose {
            locationManager.removeUpdates(listener) // 반드시 실행됨
        }
    }
}

언제 사용할까요?

  • 시스템 리스너 등록/해제: 센서, 위치, 네트워크 상태 등
  • 외부 라이브러리 초기화/정리: 광고 SDK, 분석 라이브러리 등
  • 구독 관리: 이벤트 버스, WebSocket 연결 등

Android의 생명주기 콜백과 비슷한 역할이지만, 컴포저블 단위로 더 세밀하게 관리할 수 있어요.

SideEffect: 외부 시스템과의 동기화

SideEffect는 리컴포지션이 성공적으로 완료된 직후에 실행되어요. 이는 매우 중요한 특징인데, UI가 완전히 업데이트된 후에 외부 시스템에 변경사항을 알리는 거예요.

 

컴포즈는 리컴포지션 과정에서 여러 단계를 거쳐요: Composition → Layout → Drawing. SideEffect는 이 모든 과정이 성공적으로 완료된 후에 실행되어, UI 상태와 외부 상태의 일관성을 보장해요.

@Composable
fun AnalyticsScreen(screenName: String) {
    // 화면이 완전히 그려진 후 분석 이벤트 전송
    SideEffect {
        Analytics.logScreenView(screenName)
    }
}

언제 사용할까요?

  • 분석 데이터 전송: 화면 조회, 사용자 행동 추적
  • 외부 라이브러리 동기화: 컴포즈 상태를 비-컴포즈 시스템에 전달
  • 시스템 UI 동기화: 상태바, 네비게이션 바 색상 변경

주의해야 하는건 SideEffect 내에서는 컴포즈 상태를 변경하면 안 돼요. 무한 리컴포지션을 일으킬 수 있어요.

produceState: 비동기 데이터의 상태화

collectAsState는 기존 Flow를 컴포즈 상태로 변환하지만, produceState는 비동기 로직 자체를 컴포즈 상태로 만들어줘요. 더 유연하고 컴포즈다운 방식이에요.

 

produceState는 내부적으로 LaunchedEffect와 mutableStateOf를 조합한 것과 비슷해요. 키값이 변경되면 기존 코루틴을 취소하고 새로운 코루틴을 시작하며, 비동기 작업의 결과를 상태로 발행해요.

@Composable
fun WeatherDisplay(city: String) {
    val weather by produceState<Weather?>(null, city) {
        value = weatherApi.getCurrentWeather(city)
    }

    weather?.let { WeatherCard(it) } ?: LoadingIndicator()
}

언제 사용할까요?

  • 네트워크 요청 결과를 상태로: API 응답을 직접 UI 상태로 변환
  • 실시간 데이터 스트림: 타이머, 센서 데이터, WebSocket 등
  • 복잡한 비동기 로직: 여러 단계의 비동기 작업을 하나의 상태로 통합

derivedStateOf: 파생 상태 관리

derivedStateOf의 핵심은 지연 계산(Lazy Evaluation)과 결과 캐싱이에요. 의존하는 상태들을 추적하고, 실제 값이 변경됐을 때만 재계산해요.

예를 들어, 스크롤 위치에 따라 FAB를 보여주는 로직에서:

  • 스크롤 위치: 0 → 1 → 2 → ... → 100 (계속 변화)
  • FAB 표시 여부: false → false → false → ... → true (5 이후에만 true)

derivedStateOf 없이는 스크롤할 때마다 리컴포지션이 발생하지만, 사용하면 실제 boolean 값이 변경될 때만 리컴포지션이 발생해요.

컴포즈는 스냅샷 시스템(Snapshot System)을 사용해 상태 변화를 추적해요. derivedStateOf는 이 시스템과 연동되어 의존 상태들을 자동으로 추적하고, 계산 결과를 캐싱해요. 의존 상태가 변경되면 즉시 재계산하는 게 아니라, 실제로 값이 읽힐 때 지연 계산해요.

@Composable
fun SmartScrollButton() {
    val listState = rememberLazyListState()

    // 스크롤 위치는 계속 바뀌지만, boolean 결과가 바뀔 때만 리컴포지션
    val showButton by remember {
        derivedStateOf { listState.firstVisibleItemIndex > 5 }
    }

    AnimatedVisibility(visible = showButton) {
        FloatingActionButton(onClick = { /* 스크롤 업 */ }) {
            Icon(Icons.Default.KeyboardArrowUp, null)
        }
    }
}

언제 사용할까요?

  • 복잡한 조건 계산: 여러 상태를 조합한 파생 상태
  • 성능이 중요한 계산: 스크롤, 애니메이션 등 자주 변경되는 상태 기반
  • 임계값 기반 로직: 특정 조건을 만족할 때만 UI 변경

주의할 점은 derivedStateOf자체의 리소스가 크기 때문에 불필요한 곳에 사용하는 건 낭비예요.

snapshotFlow: 상태를 Flow로 역변환

collectAsState가 Flow → State 변환이라면, snapshotFlow는 State → Flow 변환이에요. 컴포즈의 상태 변화를 Flow 형태로 만들어 반응형 프로그래밍을 가능하게 해요.

 

snapshotFlow는 컴포즈의 스냅샷 시스템을 Flow와 연결하는 브릿지 역할을 해요. 블록 내에서 읽은 모든 상태를 자동으로 추적하고, 그 상태들 중 하나라도 변경되면 새로운 값을 Flow로 발행해요.

특별한 점은 읽기 추적(Read Tracking)인데요, 블록 내에서 어떤 상태를 읽었는지 자동으로 파악하여, 해당 상태들만 감시해요.

@Composable
fun SearchWithDebounce() {
    var query by remember { mutableStateOf("") }

    LaunchedEffect(Unit) {
        snapshotFlow { query }
            .debounce(300) // Flow 연산자 활용 가능
            .distinctUntilChanged()
            .collect { searchQuery ->
                // 검색 실행
            }
    }

    TextField(value = query, onValueChange = { query = it })
}

언제 사용할까요?

  • 디바운싱: 검색어 입력, 필터링 등
  • 복잡한 반응형 로직: 여러 상태 조합으로 복잡한 Flow 파이프라인 구성
  • 상태 기반 사이드 이펙트: 특정 상태 변화에 따른 네트워크 요청 등

그럼 언제 뭘 써야 하는데요??

일반적으로 쓰일 수 있는 경우를 나눠봤어요.

실행 시점별 분류

  • 컴포지션 시점: LaunchedEffect, produceState
  • 리컴포지션 완료 후: SideEffect
  • 사용자 이벤트 시: rememberCoroutineScope
  • 생명주기 연동: DisposableEffect

목적별 분류

  • 데이터 로딩: LaunchedEffect, produceState
  • 성능 최적화: derivedStateOf
  • 리소스 관리: DisposableEffect
  • 상태 동기화: SideEffect, snapshotFlow
  • 최신 값 보장: rememberUpdatedState

마무리

사이드 이펙트를 올바르게 처리하려면 컴포지션 생명주기를 이해하는 것이 가장 중요해요. 각 Effect Handler는 컴포지션의 다른 시점과 연동되어 있으니, 상황에 맞는 핸들러를 선택하는 것이 핵심이에요.

무엇보다 컴포저블의 기본 원칙(멱등성, 빠른 실행, 부작용 없음)을 위반하지 않으면서도 필요한 사이드 이펙트를 안전하게 처리할 수 있도록 설계되어 있으니 잘 활용해서 좋은 코드를 작성해 봐요!

공식 문서/출처 링크

  • Compose의 부수 효과
  • 컴포지션 생명주기
  • 상태 및 Jetpack Compose

'Android > Compose' 카테고리의 다른 글

[Android Compose] @Composable 종속성을 StateFlow로 바꿔보자  (0) 2025.08.09
[Android Compose] flowWithLifecycle은 언제 쓰면 좋을까?  (0) 2025.08.08
[Android Compose] 상태 읽기 지연(Defer State Reads)으로 리컴포지션 최적화하기  (0) 2025.06.18
[Android Compose] Figma의 타원형 Radial Gradient를 구현해보자  (0) 2025.06.06
[Android Compose] Figma 그림자를 쉽게 만들어 보자  (0) 2025.05.14
'Android/Compose' 카테고리의 다른 글
  • [Android Compose] @Composable 종속성을 StateFlow로 바꿔보자
  • [Android Compose] flowWithLifecycle은 언제 쓰면 좋을까?
  • [Android Compose] 상태 읽기 지연(Defer State Reads)으로 리컴포지션 최적화하기
  • [Android Compose] Figma의 타원형 Radial Gradient를 구현해보자
한민돌
한민돌
Android 개발자가 되기까지.
  • 한민돌
    미래 가젯 연구소
    한민돌
  • 전체
    오늘
    어제
    • 분류 전체보기 (20) N
      • Android (4)
        • Compose (10)
        • Jetpack (2)
      • Kotlin (2)
        • Kotlin In Action (0)
      • 외부 활동 (2) N
        • 우아한테크코스 8기 (2) N
  • 블로그 메뉴

    • 홈
    • 태그
  • 링크

    • GitHub
  • 인기 글

  • 태그

    rememberupdatedstate
    Baseline Profile
    custom plugin
    jetpack
    runcatching
    coroutine
    Google Recommend Architecture
    AboutLibraries
    우테코
    backing property
    producestate
    LaunchedEffect
    Multi-module
    derivedstateof
    OssLicensePlugin
    sideeffect
    build-logic
    API35
    compose
    Android
  • 최근 글

  • hELLO· Designed By정상우.v4.10.3
한민돌
[Android Compose] Effect Handlers 딥다이브
상단으로

티스토리툴바