
공식 문서에 따르면 Side-effect의 정의는 다음과 같습니다.
"컴포저블 함수의 범위(Scope) 밖에서 발생하는 앱 상태의 변경"
컴포저블은 다음과 같은 생명주기와 특성을 가지기 때문입니다.
따라서 컴포저블 내부에서 직접적으로 앱의 상태를 변경하는 코드를 작성하면, 의도치 않게 여러 번 실행되거나 아예 실행되지 않는 등 버그를 유발할 수 있습니다.
이상적으로는 Side-effect가 없는 것이 좋지만, 실제 앱 개발에서는 반드시 필요한 경우가 있습니다.
이러한 작업은 컴포저블의 생명주기를 인지하고 있는 "통제된 환경(Controlled environment)"에서 호출되어야 안전합니다.
Compose에서는 이러한 Side-effect를 안전하게 처리하기 위해 Effect API를 제공합니다. 공식 문서에서 정의하는 Effect의 개념은 다음과 같습니다.
Key Term:"An effect is a composable function that doesn't emit UI and causes side effects to run when a composition completes."
(해석: 이펙트는 UI를 방출하지 않으면서, 구성(Composition)이 완료되었을 때 부수 효과를 실행하게 하는 컴포저블 함수입니다.)
즉, UI를 그리는 것이 목적이 아니라, 앱의 상태 변경이나 비동기 작업을 안전하게 수행하기 위한 특수한 컴포저블이라고 이해할 수 있습니다.
앱의 상태를 변경해야 할 때는 반드시 Effect API를 사용하여 예측 가능한 방식으로 실행되도록 해야 합니다. 하지만 Effect API는 다양한 가능성을 열어주는 만큼 오용하기 쉽기 때문에 다음 사항을 주의해야 합니다.
"LaunchedEffect restarts when one of the key parameters changes. However, in some situations you might want to capture a value in your effect that, if it changes, you do not want the effect to restart." "In order to do this, it is required to use rememberUpdatedState to create a reference to this value which can be captured and updated." "This approach is helpful for effects that contain long-lived operations that may be expensive or prohibitive to recreate and restart."
🧾 요약:
👨💻예제 코드 >
@Composable
fun BadSplashScreen(onTimeout: () -> Unit) {
// ❌ 문제의 코드: rememberUpdatedState 없이 그냥 사용
// LaunchedEffect(Unit)은 처음에 생성될 때의 onTimeout을 '캡처(Capture)'해서 안 놓아줍니다.
LaunchedEffect(Unit) {
delay(3000) // 3초 대기
// 💣 3초 뒤 실행되는 이 함수는,
// 3초 전(최초 실행 시)에 들어왔던 '구버전 onTimeout'입니다.
onTimeout()
}
}
// 위 함수를 호출하는 부모 코드 (사고 유발자)
@Composable
fun ParentScreen() {
// 처음 목적지는 "Home"
var destination by remember { mutableStateOf("Home") }
// [시나리오] 1초 뒤에 목적지가 "Login"으로 바뀜! (예: 토큰 만료 감지)
LaunchedEffect(Unit) {
delay(1000)
destination = "Login"
}
// destination이 바뀌면 onTimeout 람다도 새로 만들어져서 전달됨
BadSplashScreen(
onTimeout = {
Log.d("Navigation", "이동합니다: $destination")
}
)
}
기대하는 결과:
1초 뒤에 목적지가 "Login"으로 바뀌었으니, 3초 뒤에는 당연히 "Login"으로 이동해야 합니다.
실제 결과 (버그 발생):
[0초] BadSplashScreen 시작. (당시 destination="Home") -> 이펙트가 "Home으로 가라"는 명령을 꽉 쥠.
[1초] 부모에서 destination="Login"으로 변경. -> BadSplashScreen 재구성(Recomposition).
새로운 onTimeout("Login으로 가라")이 들어왔지만,
LaunchedEffect(Unit)은 재시작되지 않으므로 *새 명령을 무시함*.
[3초] 이펙트 종료. 꽉 쥐고 있던 *옛날 명령* 실행.
LOG: 이동합니다: Home <-- 😱 망함! (로그인 안 했는데 홈으로 감)
이유:
LaunchedEffect(Unit) 안의 람다(Lambda)는 최초 실행 시점의 변수들을 캡처(Capture)합니다 .
rememberUpdatedState가 없으면, 이 캡처된 람다는 죽을 때까지 업데이트되지 않는 '고인물'이 됩니다.
✅해결방법>
rememberUpdatedState 사용하기!
@Composable
fun GoodSplashScreen(onTimeout: () -> Unit) {
// ✅ 안전 장치: "함수가 새로 들어오면, 이 변수가 가리키는 곳을 최신으로 바꿔줘!"
val currentOnTimeout by rememberUpdatedState(onTimeout)
LaunchedEffect(Unit) {
delay(3000)
// 3초 뒤 실행될 때, currentOnTimeout은
// 방금 업데이트된 "Login으로 가라"는 최신 함수를 가리키고 있음.
currentOnTimeout()
}
}
🧐파헤쳐 보기>
val currentOnTimeout by rememberUpdatedState(onTimeout)
// [실제 동작 원리]
// 1. 뮤터블 스테이트(상자)를 하나 만듭니다.
val stateBox = remember { mutableStateOf(onTimeout) }
// 2. [가장 중요!] 함수가 다시 실행될 때마다, 상자 안의 값을 '새 함수'로 덮어씁니다.
stateBox.value = onTimeout
// 3. 'by' 키워드 때문에 currentOnTimeout을 부르면 자동으로 stateBox.value를 꺼내줍니다.
val currentOnTimeout: () -> Unit
get() = stateBox.value
"derivedStateOf creates a new Compose state object you can observe that only updates as much as you need."
"You should use the derivedStateOf function when your inputs to a composable are changing more often than you need to recompose."
"This often occurs when something is frequently changing, such as a scroll position, but the composable only needs to react to it once it crosses a certain threshold."
🧾 요약:
Top 버튼 등장.@Composable
fun AdvancedScrollExample() {
val listState = rememberLazyListState()
val items = List(100) { "Item #$it" }
// ---------------------------------------------------------
// 1. [Boolean] 버튼 표시 여부 (True <-> False)
// -> 10번 아이템 지날 때 딱 한 번 변함
val showButton by remember {
derivedStateOf { listState.firstVisibleItemIndex > 10 }
}
// 2. [Int] 현재 챕터 번호 (값 변경)
// -> 10, 20, 30번 아이템 지날 때마다 변함 (10개 단위 필터링)
val currentChapter by remember {
derivedStateOf { (listState.firstVisibleItemIndex / 10) + 1 }
}
// 3. [String] 구간 상태 메시지 (로직 분기)
// -> 0~20(초반), 21~80(중반), 81~(종반) 구간 진입 시 변함
val statusMessage by remember {
derivedStateOf {
when (listState.firstVisibleItemIndex) {
in 0..20 -> "🌱 초반부 탐색 중"
in 21..80 -> "🔥 한창 달리는 중"
else -> "🏁 거의 다 왔습니다!"
}
}
}
// ---------------------------------------------------------
Scaffold(
floatingActionButton = {
// [반응 1] showButton이 바뀔 때만 FAB가 생기거나 사라짐
if (showButton) {
FloatingActionButton(onClick = { /*...*/ }) { Text("Top") }
}
},
topBar = {
// [반응 2, 3] 챕터나 메시지가 '바뀔 때만' 상단바 다시 그림
TopAppBar(
title = {
Column {
Text("현재 챕터: $currentChapter 장")
Text(text = statusMessage, style = MaterialTheme.typography.bodySmall)
}
}
)
}
) { padding ->
LazyColumn(
state = listState,
contentPadding = padding
) {
items(items) { item ->
Text(
text = item,
modifier = Modifier.fillMaxWidth().padding(16.dp)
)
}
}
}
}
스크롤을 0번부터 100번까지 쭉 내린다고 가정했을 때, derivedStateOf가 언제 UI를 깨우는지(리컴포지션) 보세요.
| 스크롤 위치 (Index) | 1. 버튼 (Boolean) | 2. 챕터 (Int) | 3. 메시지 (String) | UI 갱신 여부 |
|---|---|---|---|---|
| 0 | False | 1장 | 🌱 초반부 | (초기화) |
| 1 ~ 9 | False (유지) | 1장 (유지) | 🌱 초반부 (유지) | 😴 조용함 (최적화) |
| 10 | False (유지) | 2장 (변경!) | 🌱 초반부 (유지) | ✅ 챕터 텍스트 갱신 |
| 11 | True (변경!) | 2장 (유지) | 🌱 초반부 (유지) | ✅ 버튼 등장 |
| 12 ~ 19 | True (유지) | 2장 (유지) | 🌱 초반부 (유지) | 😴 조용함 |
| 20 | True (유지) | 3장 (변경!) | 🌱 초반부 (유지) | ✅ 챕터 텍스트 갱신 |
| 21 | True (유지) | 3장 (유지) | 🔥 달리는 중 (변경!) | ✅ 메시지 텍스트 갱신 |
listState 자체는 1, 2, 3... 계속 바뀌고 있습니다. 하지만 우리가 만든 3가지 derivedStateOf의 결과값은 변하지 않았습니다. 그래서 UI는 단 한 번도 다시 그려지지 않습니다. (엄청난 성능 이득) 1111.currentChapter의 계산 결과가 1에서 2로 바뀌었습니다. 이때 derivedStateOf는 즉시 알림을 보내 상단바의 텍스트를 2장으로 바꿉니다.showButton의 계산 결과가 False에서 True로 바뀌었습니다. 즉시 알림을 보내 버튼을 그립니다.결론:
derivedStateOf는 값을 무시하는 게 아니라, "변화의 민감도"를 조절하는 것입니다. 값이 변해야 하는 순간에는 확실하게 변하고 UI를 갱신합니다.
정리가 잘되어 있고 이해가 너무 잘되는 글인거 같아요
감사합니다
혹시 compose internals 관련 글도 있을까요??