SideEffect 관련 공식 문서 일부 정리

Philipy (윤상필)·2026년 1월 10일
post-thumbnail

SideEffect 관련 공식 문서

Side-effect(부수 효과) 간단정리

공식 문서에 따르면 Side-effect의 정의는 다음과 같습니다.

"컴포저블 함수의 범위(Scope) 밖에서 발생하는 앱 상태의 변경"

왜 Side-effect 없는(Side-effect free) 상태를 지향해야 할까?

컴포저블은 다음과 같은 생명주기와 특성을 가지기 때문입니다.

  • 예측 불가능한 리컴포지션: 언제 다시 그려질지 알 수 없습니다.
  • 순서 보장 없음: 컴포저블의 재구성이 다른 순서로 실행될 수 있습니다.
  • 폐기 가능성: 리컴포지션이 도중에 취소되거나 폐기될 수 있습니다.

따라서 컴포저블 내부에서 직접적으로 앱의 상태를 변경하는 코드를 작성하면, 의도치 않게 여러 번 실행되거나 아예 실행되지 않는 등 버그를 유발할 수 있습니다.

그럼에도 Side-effect가 필요한 순간

이상적으로는 Side-effect가 없는 것이 좋지만, 실제 앱 개발에서는 반드시 필요한 경우가 있습니다.

  • 일회성 이벤트(One-off event) 트리거: 예를 들어, 특정 상태 조건에 따라 스낵바(Snackbar)를 표시하거나 다른 화면으로 이동(Navigation)해야 하는 경우입니다.

이러한 작업은 컴포저블의 생명주기를 인지하고 있는 "통제된 환경(Controlled environment)"에서 호출되어야 안전합니다.

Effect란 무엇인가? (핵심 정의)

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는 다양한 가능성을 열어주는 만큼 오용하기 쉽기 때문에 다음 사항을 주의해야 합니다.

  • UI 관련 작업에 집중: Effect 내부에서 수행하는 작업은 UI와 관련된 것이어야 합니다.
  • 단방향 데이터 흐름(Unidirectional Data Flow) 준수: Effect를 사용하더라도 단방향 데이터 흐름 원칙을 깨뜨리지 않도록 주의해야 합니다.
  • 과도한 사용 지양: 꼭 필요한 경우에만 사용하여 코드 복잡도를 낮춰야 합니다.

rememberUpdatedState: reference a value in an effect

Step 1. 원본 인용 & 명사형 재가공 (Source & Rewrite)

"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."

🧾 요약:

  • LaunchedEffect는 키 매개변수 중 하나 변경 시 재시작됨.
  • 값이 변경되어도 이펙트가 재시작되지 않기를 원하면서 값은 캡처하고 싶은 상황 존재함.
  • 이를 위해 캡처 및 업데이트 가능한 값의 참조 생성하는 rememberUpdatedState 사용 필수.
  • 이 접근 방식은 재생성 및 재시작 비용이 비싸거나 금지된 장기 실행 작업 포함 이펙트에 유용함.

👨‍💻예제 코드 >

@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: convert one or multiple state objects into another state

Step 1. 원본 인용 & 명사형 재가공 (Source & Rewrite)

"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."

🧾 요약:

  • derivedStateOf는 필요한 만큼만 업데이트되는 관찰 가능한 새 Compose 상태 객체 생성함.
  • Composable 입력이 재구성 필요한 것보다 더 자주 변경될 때 derivedStateOf 사용해야 함.
  • 스크롤 위치 같이 자주 변경되지만 특정 임계값 넘을 때만 반응 필요한 경우 자주 발생함.

📱 시나리오: 100개의 아이템이 있는 리스트

  1. Boolean 패턴 (임계값): 10번 아이템을 지나면 Top 버튼 등장.
  2. Grouping 패턴 (페이지): 10개 아이템마다 "현재 페이지" 번호 변경 (1페이지 -> 2페이지...).
  3. Range 패턴 (상태 메시지): 스크롤 위치에 따라 "초반", "중반", "종반" 텍스트 변경.

💻 코드 예제

@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 갱신 여부
0False1장🌱 초반부(초기화)
1 ~ 9False (유지)1장 (유지)🌱 초반부 (유지)😴 조용함 (최적화)
10False (유지)2장 (변경!)🌱 초반부 (유지)✅ 챕터 텍스트 갱신
11True (변경!)2장 (유지)🌱 초반부 (유지)✅ 버튼 등장
12 ~ 19True (유지)2장 (유지)🌱 초반부 (유지)😴 조용함
20True (유지)3장 (변경!)🌱 초반부 (유지)✅ 챕터 텍스트 갱신
21True (유지)3장 (유지)🔥 달리는 중 (변경!)✅ 메시지 텍스트 갱신

해설

  1. Index 1~9 구간: listState 자체는 1, 2, 3... 계속 바뀌고 있습니다. 하지만 우리가 만든 3가지 derivedStateOf의 결과값은 변하지 않았습니다. 그래서 UI는 단 한 번도 다시 그려지지 않습니다. (엄청난 성능 이득) 1111.
  2. Index 10: currentChapter의 계산 결과가 1에서 2로 바뀌었습니다. 이때 derivedStateOf는 즉시 알림을 보내 상단바의 텍스트를 2장으로 바꿉니다.
  3. Index 11: showButton의 계산 결과가 False에서 True로 바뀌었습니다. 즉시 알림을 보내 버튼을 그립니다.

결론:

derivedStateOf는 값을 무시하는 게 아니라, "변화의 민감도"를 조절하는 것입니다. 값이 변해야 하는 순간에는 확실하게 변하고 UI를 갱신합니다.

profile
Tech Phase-smith, Karax wannabe

3개의 댓글

comment-user-thumbnail
2026년 2월 10일

정리가 잘되어 있고 이해가 너무 잘되는 글인거 같아요
감사합니다
혹시 compose internals 관련 글도 있을까요??

2개의 답글