사이드 이펙트(Side-Effect)란?
사이드 이펙트(Side-Effect) 란, 컴포저블 함수의 범위를 벗어나 외부 상태 변화나 처리를 하는 것을 의미합니다.
컴포저블은 리컴포지션(Recomposition)이 예측 불가능하고, 실행 순서가 변경될 수도 있으며,
심지어 특정 리컴포지션이 폐기될 수도 있기 때문에, 컴포저블에는 사이드 이펙트가 없는 것이 좋습니다.
하지만, 일부 경우에는 사이드 이펙트가 필요할 수도 있습니다. 예를들어 스낵바를 표시하거나 특정 상태 조건에 따라 다른 화면으로 이동하는 등 일회성 이벤트를 트리거할 때입니다.
이러한 작업은 컴포저블의 생명주기를 인식하는 환경에서 실행되어야 합니다.
이번 포스팅에서는 Jetpack Compose에서 사이드 이펙트를 처리하는 다양한 API에 대해 알아보겠습니다.
상태(State)와 Effect 사용 사례
Compose 이해 문서에 설명된 대로 컴포저블에는 사이드 이펙트가 없어야 합니다. 상태 관리 문서에 설명된 대로 앱 상태를 변경해야 하는 경우 이러한 사이드 이펙트가 예측 가능한 방식으로 실행되도록 Effect API를 사용해야 합니다.
핵심용어:
Effect는 UI를 직접 렌더링하지 않으며, 컴포지션이 완료될 때 실행되는 사이드 이펙트를 포함하는 컴포저블 함수입니다.
Compose에서는 다양한 Effect API를 제공하지만, 과도하게 사용될 수도 있습니다.
Effect API를 사용할 때는 UI와 관련된 작업만 실행해야 하며, 단방향 데이터 흐름을 깨뜨리지 않도록 주의해야합니다.
참고:
반응형 UI는 본질적으로 비동기이며, Compose는 콜백(Callback)대신 코루틴(Coroutine)을 적극 활용하는 방식으로 이를 해결합니다.
LaunchedEffect: 컴포저블의 범위에서 suspend 함수 실행하기
컴포저블의 생명주기 동안 작업을 수행하면서 suspend 함수를 호출하려면,
LaunchedEffect 컴포저블을 사용해야 합니다.
LaunchedEffect가 컴포지션(Composition)에 들어오면, 코루틴이 실행되며, 전달된 코드 블록이 실행됩니다.
그리고 LaunchedEffect가 컴포지션에서 사라지면 해당 코루틴은 자동으로 취소됩니다.
또한, LaunchedEffect가 새로운 키(key)로 리컴포지션되면, 기존 코루틴은 취소되고 새로운 코루틴이 실행됩니다.
아래 코드는 alpha 값을 서서히 변경하여 깜빡이는 애니메이션을 구현하는 예제입니다.
// 사용자가 시간이 부족할 때 속도를 조절할 수 있도록 pulse 속도 설정
var pulseRateMs by remember { mutableStateOf(3000L) }
val alpha = remember { Animatable(1f) }
LaunchedEffect(pulseRateMs) { // ✅ pulseRateMs가 변경되면 effect 재시작
while (isActive) {
delay(pulseRateMs) // ✅ 지정된 시간만큼 대기
alpha.animateTo(0f) // ✅ alpha 값을 0으로 변경 (점점 투명)
alpha.animateTo(1f) // ✅ alpha 값을 1로 변경 (다시 원래 상태로)
}
}
위의 코드에서 애니메이션은 일시중단 함수 delay(pulseRateMs)를 사용하여 지정된 시간만큼 기다린 후, alpha 값을 0 → 1로 변화시킵니다. 이는 컴포저블의 생명주기 동안 반복됩니다.
rememberCoroutineScope: 컴포저블 외부에서 코루틴 실행하기
LaunchedEffect는 컴포저블 함수 내부에서만 사용할 수 있는 컴포저블 함수입니다.
만약 컴포저블 외부(이벤트 핸들러 등)에서 코루틴을 실행해야 하지만, 해당 코루틴이 컴포지션(Composition)과 연결되어 자동으로 취소되도록 하려면, rememberCoroutineScope를 사용해야 합니다.
"컴포저블 외부에서 코루틴을 실행한다"는 의미가 컴포저블 밖에서 rememberScope를 사용할 수 있다는 뜻은 아닙니다. 컴포저블 내부에서 CoroutineScope를 얻어야 하지만, 코루틴 실행은 UI 이벤트(예: 버튼 클릭)에서 해야 하는 경우의 의미입니다.
또한, 사용자 이벤트 발생 시 애니메이션을 취소하는 등의 코루틴 수명 주기를 직접 제어해야 할 경우에도 rememberCoroutineScope를 사용할 수 있습니다.
rememberCoroutineScope는 컴포저블에서 호출된 위치를 기준으로 하는 CoroutineScope를 반환하는 컴포저블 함수입니다. 해당 CoroutineScope는 호출된 컴포지션의 수명주기와 함께 관리되며, 컴포지션에서 제거될 때 자동으로 취소됩니다.
아래 예제는 사용자가 버튼을 클릭하면 Snackbar를 표시하는 코드입니다.
@Composable
fun MoviesScreen(snackbarHostState: SnackbarHostState) {
// ✅ MoviesScreen의 생명주기에 연결된 CoroutineScope 생성
val scope = rememberCoroutineScope()
Scaffold(
snackbarHost = {
SnackbarHost(hostState = snackbarHostState)
}
) { contentPadding ->
Column(Modifier.padding(contentPadding)) {
Button(
onClick = {
// ✅ 이벤트 핸들러 내부에서 새로운 코루틴 실행
scope.launch {
snackbarHostState.showSnackbar("Something happened!")
}
}
) {
Text("Press me")
}
}
}
}
LaunchedEffect vs rememberCoroutineScope
LaunchedEffect와의 차이점은 rememberCoroutineScope가 "즉시 코루틴을 실행하지 않는다" 는 점입니다.
LunchedEffect는 Coposition에 진입하면 즉시 실행하지만, rememberCoroutineScope는 코루틴 스코프를 기억하고 제공할 뿐 실제 코루틴 실행은 사용자가 명시적으로 launch를 호출해야합니다. 또한 LaunchedEffect는 특정 키 값이 변경될 때마다 자동으로 실행되어 코루틴의 수명주기가 관리되지만, rememberCoroutineScope는 특정 이벤트(예: 버튼 클릭)에서 코루틴을 실행하고 싶을 때 사용됩니다.
rememberCoroutineScope | LaunchedEffect | |
사용 위치 | ✅ 컴포저블 내부에서만 사용 가능 | ✅ 컴포저블 내부에서만 사용 가능 |
코루틴 실행 시점 | 사용자가 특정 이벤트를 트리거할 때 실행 | LaunchedEffect가 Composition에 진입하면 즉시 실행 |
재실행 여부 | 수동으로 실행해야 하므로 자동 재실행 없음 | 키(Key)가 변경되면 기존 코루틴이 취소되고 새로운 코루틴 실행 |
rememberUpdatedState: 값이 변경되어도 Effet를 재시작하지 않도록 참조 유지하기
LaunchedEffect는 키(Key)로 전달된 값이 변경될 때마다 기존 코루틴을 취소하고 새로운 코루틴을 실행합니다.
하지만 어떤 값이 변경되더라도 기존의 Effect를 재시작하지 않고 유지해야 할 때가 있습니다.
이럴 때는 rememberUpdatedState를 사용하면 변경되는 값을 참조할 수 있도록 유지할 수 있습니다.
이는 비용이 많이 드는 장기 실행 작업(예: 네트워크 요청, 애니메이션, 타이머 등)의 재시작을 방지하는 데 유용합니다.
예를 들어, 앱의 LandingScreen이 일정 시간이 지나면 자동으로 사라지도록 만들려 한다고 가정해봅시다.
이때, LandingScreen이 리컴포지션되더라도 타이머가 다시 시작되지 않아야 합니다.
예제 1: LaunchedEffect만 사용했을 때 문제점
@Composable
fun LandingScreen(onTimeout: () -> Unit) {
LaunchedEffect(onTimeout) { // ❌ onTimeout이 변경될 때마다 이 블록이 다시 실행됨
delay(5000)
onTimeout()
}
}
문제점
- LandingScreen이 리컴포지션될 때 onTimeout이 변경될 가능성이 있음.
- 그러면 기존의 LaunchedEffect가 취소되고 새로운 LaunchedEffect가 실행됨.
- 즉, 타이머가 다시 시작되므로, LandingScreen이 의도보다 오래 표시될 수 있음.
✔️ 해결 방법: rememberUpdatedState를 사용하여 최신 값 유지
@Composable
fun LandingScreen(onTimeout: () -> Unit) {
// ✅ 최신 onTimeout 값을 유지하도록 함
val currentOnTimeout by rememberUpdatedState(onTimeout)
// ✅ true를 키(Key)로 사용하여 한 번만 실행됨
LaunchedEffect(true) {
delay(5000)
currentOnTimeout() // ✅ 항상 최신 onTimeout을 호출
}
}
왜 이렇게 하면 해결될까?
1️⃣ rememberUpdatedState(onTimeout)은 onTimeout 값이 변경되더라도 기존의 LaunchedEffect를 다시 실행하지 않음.
2️⃣ LaunchedEffect(true)는 처음 한 번만 실행되며, 이후 리컴포지션되어도 다시 실행되지 않음.
3️⃣ 하지만 rememberUpdatedState 덕분에 항상 최신 onTimeout 값을 참조할 수 있음.
DisposableEffect: 정리(Cleanup)가 필요한 Effect 관리
어떤 사이드 이펙트(Side-Effect)는 컴포저블이 사라질 때 정리(Cleanup) 작업이 필요할 수 있습니다.
이럴 때 DisposableEffect를 사용하면, 컴포저블이 컴포지션에서 제거될 때 정리 로직을 실행할 수 있습니다.
또한, DisposableEffect의 키(Key)가 변경되면 기존 Effect를 정리한 후 새로운 Effect를 실행합니다.
예를 들어, 화면(HomeScreen)의 Lifecycle이 변경될 때 분석(Analytics) 이벤트를 전송하려고 한다고 가정해 봅시다.
이럴 때 LifecycleObserver를 등록하고, 컴포저블이 사라지면 이를 정리해야 합니다.
@Composable
fun HomeScreen(
lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current,
onStart: () -> Unit, // ✅ '시작됨' 이벤트 전송
onStop: () -> Unit // ✅ '중지됨' 이벤트 전송
) {
// ✅ 최신 onStart, onStop 값을 유지
val currentOnStart by rememberUpdatedState(onStart)
val currentOnStop by rememberUpdatedState(onStop)
// ✅ lifecycleOwner가 변경되면 기존 Effect를 정리하고 다시 실행
DisposableEffect(lifecycleOwner) {
// ✅ LifecycleObserver 생성
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_START) {
currentOnStart()
} else if (event == Lifecycle.Event.ON_STOP) {
currentOnStop()
}
}
// ✅ Lifecycle에 옵저버 등록
lifecycleOwner.lifecycle.addObserver(observer)
// ✅ onDispose 블록에서 옵저버 제거 (Cleanup)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
}
}
/* Home screen content */
}
위 코드에서는 DisposableEffect(lifecycleOwner)를 사용하면, lifecycleOwner가 변경될 때 기존 Effect를 정리하고 새로운 Effect를 실행합니다.
또한, 컴포저블이 컴포지션에서 제거되면 onDispose가 실행되어, LifecycleObserver를 해제하 리소스를 정리하게 됩니다.
DisposableEffect는 반드시 onDispose 블록을 포함해야 합니다. 그렇지 않으면 IDE 빌드 타임에 오류가 발생합니다.
참고:
onDispose에 빈 블록을 포함하는 것은 좋은 방법이 아닙니다. 사용 목적에 맞지 않다면 다른 Effect API(LaunchedEffect 등)를 고려해야 합니다.
SideEffect: Compose 상태를 Compose 외부 코드와 공유하기
SideEffect는 Compose 상태를 Compose가 관리하지 않는 외부 코드와 공유할 때 사용됩니다.
SideEffect는 리컴포지션이 성공적으로 완료될 때마다 실행됩니다. 따라서, 리컴포지션이 보장되지 않은 상태에서 Effect를 실행하면 잘못된 동작이 발생할 수 있습니다.
보통 로깅, Analytics 등 UI에 직접적으로 영향을 주지 않는 작업을 할 때 사용됩니다.
아래 예제에서는 Firebase Analytics에서 사용자 정보를 업데이트하는 코드를 보여줍니다.
이 코드는 현재 사용자(User)의 userType을 Firebase Analytics에 설정하여, 이후의 모든 분석 이벤트에 메타데이터로 첨부합니다.
@Composable
fun rememberFirebaseAnalytics(user: User): FirebaseAnalytics {
val analytics: FirebaseAnalytics = remember {
FirebaseAnalytics()
}
// ✅ 리컴포지션이 완료될 때마다 Firebase Analytics에 userType 업데이트
SideEffect {
analytics.setUserProperty("userType", user.userType)
}
return analytics
}
Compose의 상태(user.userType)를 외부 코드(Firebase Analytics)에 전달함
produceState: Compose 외부 상태를 Compose 상태로 변환하기
produceState는 Compose 외부에서 관리되는 상태(예: Flow, LiveData, RxJava, 네트워크 데이)를 Compose 내부에서 사용할 수 있는 State로 변환하는 기능을 합니다.
- produceState는 Composition에 들어갈 때 자동으로 코루틴을 실행하고, 컴포지션에서 제거될 때 코루틴을 취소함.
- 외부 상태의 변경을 State로 감싸서 UI에서 관찰할 수 있도록 제공함.
- 동일한 값이 설정될 경우 리컴포지션이 발생하지 않음.
- 비동기 데이터 소스뿐만 아니라, 동기적인 데이터 소스도 관찰 가능함.
- 구독한 데이터 소스를 해제하려면 awaitDispose를 사용할 수 있음.
아래 예제는 네트워크에서 이미지를 가져와 State로 변환하는 loadNetworkImage 함수입니다.
이 함수는 State<Result<Image>>를 반환하여, Compose UI에서 비동기적으로 데이터를 사용할 수 있도록 합니다.
@Composable
fun loadNetworkImage(
url: String,
imageRepository: ImageRepository = ImageRepository()
): State<Result<Image>> {
// ✅ 초기 값: 로딩 상태
return produceState<Result<Image>>(initialValue = Result.Loading, url, imageRepository) {
// ✅ 비동기 네트워크 요청 (Suspending 함수 호출 가능)
val image = imageRepository.load(url)
// ✅ State 값 업데이트 (리컴포지션 발생)
value = if (image == null) {
Result.Error
} else {
Result.Success(image)
}
}
}
produceState 동작 방식
1️⃣ produceState는 initialValue로 State<T>를 생성함.
2️⃣ Composition이 시작될 때 코루틴을 실행하여 imageRepository.load(url)을 호출함.
3️⃣ 가져온 데이터를 value에 설정하면, State가 업데이트되고 UI가 리컴포지션됨.
4️⃣ 컴포저블이 컴포지션에서 제거되면, 실행 중인 코루틴도 자동으로 취소됨.
5️⃣ url이나 imageRepository가 변경되면 기존 작업을 취소하고 새롭게 실행됨.
핵심사항:
produceState는 내부적으로 다른 Effect API(remember, mutableStateOf, LaunchedEffect)를 활용하여 동작합니다.
- remember { mutableStateOf(initialValue) } → State를 생성하고 저장
- LaunchedEffect → 비동기 작업 실행 및 State 업데이트
즉, produceState는 remember + mutableStateOf + LaunchedEffect 조합을 간편하게 사용할 수 있도록 만든 API입니다.
derivedStateOf: 여러 개의 상태를 변환하여 새로운 상태 생성하기
Compose에서는 관찰된 상태 객체(State)나 입력 값이 변경될 때마다 리컴포지션(Recomposition)이 발생합니다.
그러나 어떤 상태(State)는 너무 자주 변경되지만, UI는 그만큼 자주 업데이트할 필요가 없는 경우가 있습니다.
이런 경우 불필요한 리컴포지션을 방지하기 위해 derivedStateOf를 사용할 수 있습니다.
- derivedStateOf는 특정 상태의 변경을 감지하고, 필요할 때만 새로운 상태로 업데이트됨.
- 이를 통해 불필요한 리컴포지션을 줄일 수 있음.
주의:
derivedStateOf는 비용이 높은 연산이므로, 리컴포지션이 불필요한 경우에만 사용해야 합니다.
derivedStateOf 올바른 사용 예제
아래 예제에서는 스크롤 위치(firstVisibleItemIndex)가 변경될 때, 특정 임계값을 넘었을 때만 UI를 업데이트하도록 설정합니다.
@Composable
fun MessageList(messages: List<Message>) {
Box {
val listState = rememberLazyListState()
LazyColumn(state = listState) {
// 메시지 리스트 UI 구성
}
//val showButton = listState.firstVisibleItemIndex > 0
// ✅ derivedStateOf를 사용하여 불필요한 리컴포지션 방지
val showButton by remember {
derivedStateOf {
listState.firstVisibleItemIndex > 0
}
}
// 스크롤 위치가 0보다 클 때만 버튼 표시
AnimatedVisibility(visible = showButton) {
ScrollToTopButton()
}
}
}
listState.firstVisibleItemIndex는 스크롤할 때마다 값이 변경됩니다. (0 → 1 → 2 → 3 → ...)
하지만, UI는 특정 조건(예: firstVisibleItemIndex > 0)을 만족할 때만 업데이트되면 됩니다.
derivedStateOf를 사용하면, 이전 값과 현재 값이 같으면 UI를 다시 그리지 않으며, 불필요한 리컴포지션을 방지하여 성능을 최적화할 수 있습니다.
❌ derivedStateOf를 잘못 사용하는 경우
// ❌ 잘못된 사용 예제
var firstName by remember { mutableStateOf("") }
var lastName by remember { mutableStateOf("") }
val fullNameBad by remember { derivedStateOf { "$firstName $lastName" } } // ❌ 불필요한 사용
val fullNameCorrect = "$firstName $lastName" // ✅ 이렇게 사용하는 것이 올바름
firstName 또는 lastName이 변경될 때 항상 fullName도 변경되어야 합니다.
즉, 어차피 리컴포지션이 발생해야 하는 경우이므로, derivedStateOf를 사용할 필요가 없습니다.
이 경우 그냥 val fullName = "$firstName $lastName"로 선언하면 충분합니다.
snapshotFlow: Compose의 State를 Flow로 변환하기
snapshotFlow를 사용하면 Compose의 State<T> 객체를 콜드(Cold) Flow로 변환할 수 있습니다.
snapshotFlow는 State 값을 읽고 Flow로 변환하여 제공합니다.
그리고 읽은 State가 변경될 때마다 새로운 값을 Flow에서 방출합니다. (단, 같은 값이 연속해서 나오면 무시됩니다.)
아래 코드는 사용자가 리스트에서 첫 번째 아이템을 넘어서 스크롤할 때만 분석(Analytics) 이벤트를 전송하는 예시입니다.
val listState = rememberLazyListState()
LazyColumn(state = listState) {
// 리스트 아이템 UI 구성
}
LaunchedEffect(listState) {
snapshotFlow { listState.firstVisibleItemIndex }
.map { index -> index > 0 } // ✅ 첫 번째 아이템을 넘어서면 true 반환
.distinctUntilChanged() // ✅ 같은 값이 연속으로 나오면 무시
.filter { it == true } // ✅ true 값만 필터링
.collect {
MyAnalyticsService.sendScrolledPastFirstItemEvent() // ✅ 이벤트 전송
}
}
listState.firstVisibleItemIndex 값이 변경될 때마다 Flow가 새로운 값을 방출됩니다.
Effect 재시작 (Restarting Effects)
Compose의 LaunchedEffect, produceState, DisposableEffect 등의 Effect API는
하나 이상의 키(Key) 값을 사용하여 기존 Effect를 취소하고 새로운 Effect를 실행할 수 있습니다.
Effect API의 일반적인 형태는 다음과 같습니다.
EffectName(restartIfThisKeyChanges, orThisKey, orThisKey, ...) { block }
- 키(Key)가 변경되면 기존 Effect가 취소되고 새로운 Effect가 실행됨.
- 키를 잘못 설정하면 Effect가 너무 자주 혹은 너무 적게 재시작될 수 있어 문제가 발생할 수 있음.
올바른 키 설정이 중요한 이유
1️⃣ 키가 너무 적게 변경되면 → 앱의 버그 발생 가능
- 예를 들어, lifecycleOwner가 변경되었는데도 DisposableEffect가 재시작되지 않으면,
잘못된 lifecycleOwner를 계속 사용하게 되어 문제가 발생함.
2️⃣ 키가 너무 자주 변경되면 → 불필요한 성능 저하 발생
- rememberUpdatedState를 사용하면 특정 변수 변경 시에도 기존 Effect를 유지할 수 있음.
rememberUpdatedState를 사용해야 하는 경우
- Effect 내부에서 참조하는 값이 변경될 수 있지만, Effect 자체는 다시 실행되지 않아야 하는 경우
- 예제 코드: onStart와 onStop이 변경되더라도, DisposableEffect가 다시 실행되지 않도록 해야 함.
@Composable
fun HomeScreen(
lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current,
onStart: () -> Unit, // '시작됨' 분석 이벤트 전송
onStop: () -> Unit // '중지됨' 분석 이벤트 전송
) {
// ✅ 최신 onStart, onStop 값을 유지하지만, DisposableEffect가 다시 실행되지는 않음
val currentOnStart by rememberUpdatedState(onStart)
val currentOnStop by rememberUpdatedState(onStop)
// ✅ lifecycleOwner가 변경될 때만 DisposableEffect를 재시작
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_START) {
currentOnStart()
} else if (event == Lifecycle.Event.ON_STOP) {
currentOnStop()
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
}
}
}
onStart와 onStop이 변경될 가능성이 있지만, DisposableEffect는 재시작될 필요가 없습니다.
rememberUpdatedState를 사용하여 항상 최신 onStart와 onStop을 참조할 수 있도록 유지합니다.
반면 lifecycleOwner는 변경되면 DisposableEffect가 재시작되어야 하므로, 키로 전달됩니다.
정리하자면, 아래와 같습니다.
- Effect의 키를 적절하게 설정하지 않으면, 의도치 않은 버그 또는 성능 저하가 발생할 수 있음.
- Effect 내부에서 참조하는 값이 변경되더라도, Effect 자체를 재시작하지 않으려면 rememberUpdatedState를 사용.
- Effect가 특정 상태 변경에만 반응하도록 키를 신중하게 선택해야 함.
끝.
참고자료
Compose의 부수 효과 | Jetpack Compose | Android Developers
이 페이지는 Cloud Translation API를 통해 번역되었습니다. Compose의 부수 효과 컬렉션을 사용해 정리하기 내 환경설정을 기준으로 콘텐츠를 저장하고 분류하세요. 부수 효과는 구성 가능한 함수의 범
developer.android.com
언제 derivedStateOf를 써야할까?
본 내용은 학습을 위해 Jetpack Compose — When should I use derivedStateOf? 을 보고 입맛대로 정리한 글입니다.
velog.io
'Android > Compose' 카테고리의 다른 글
[Android] Compose의 CompositionLocal 이란? (0) | 2025.05.14 |
---|---|
[Android] remember와 rememberUpdateState의 차이 (0) | 2025.04.18 |
[Android] Compose 상태 호이스팅 위치 정하기 (0) | 2025.03.07 |
[Android] Compose 상태관리 (0) | 2025.03.07 |
[Android] Compose의 UI 렌더링 동작 매커니즘 (0) | 2025.03.07 |