관리 메뉴

나만을 위한 블로그

[Android Compose] 컴포저블 함수의 사이드 이펙트 처리 방법 - 2 - 본문

Android/Compose

[Android Compose] 컴포저블 함수의 사이드 이펙트 처리 방법 - 2 -

참깨빵위에참깨빵_ 2025. 11. 14. 18:25
728x90
반응형
rememberCoroutineScope

 

https://developer.android.com/develop/ui/compose/side-effects?hl=ko#remembercoroutinescope

 

Compose의 부수 효과  |  Jetpack Compose  |  Android Developers

이 페이지는 Cloud Translation API를 통해 번역되었습니다. Compose의 부수 효과 컬렉션을 사용해 정리하기 내 환경설정을 기준으로 콘텐츠를 저장하고 분류하세요. 부수 효과는 구성 가능한 함수의 범

developer.android.com

LaunchedEffect는 컴포저블 함수라 다른 컴포저블 함수 안에서만 쓸 수 있다. 컴포저블 밖에 있는데 컴포지션 종료 후 자동 취소되게 범위가 지정된 코루틴을 쓰려면 rememberCoroutineScope를 사용하라
또한 코루틴 하나 이상의 생명주기를 수동 관리해야 할 때마다(유저 이벤트 발생 시 애니메이션 취소 등) rememberCoroutineScope를 써라
rememberCoroutineScope는 호출되는 컴포지션의 지점에 바인딩된 CoroutineScope를 리턴하는 컴포저블 함수다. 호출이 컴포지션을 종료하면 범위가 취소된다
유저가 버튼 탭 시 아래 코드를 써서 스낵바를 띄울 수 있다
@Composable
fun MoviesScreen(snackbarHostState: SnackbarHostState) {

    // Creates a CoroutineScope bound to the MoviesScreen's lifecycle
    val scope = rememberCoroutineScope()

    Scaffold(
        snackbarHost = {
            SnackbarHost(hostState = snackbarHostState)
        }
    ) { contentPadding ->
        Column(Modifier.padding(contentPadding)) {
            Button(
                onClick = {
                    // Create a new coroutine in the event handler to show a snackbar
                    scope.launch {
                        snackbarHostState.showSnackbar("Something happened!")
                    }
                }
            ) {
                Text("Press me")
            }
        }
    }
}

 

다른 글에선 어떻게 설명하는지 확인한다.

 

https://moony211.medium.com/remembercoroutinescope-%EA%B0%9C%EB%85%90-%EB%B0%8F-%EC%82%AC%EC%9A%A9%EB%B2%95-%EC%A0%95%EB%A6%AC-660cb637979b

 

rememberCoroutineScope 개념 및 사용법 정리

Jetpack Compose에서 UI와 함께 coroutine을 안전하고 생명주기에 맞게 실행하기 위해 rememberCoroutineScope 를 사용한다. 이 글에서는 rememberCoroutineScope의 개념, 필요한 이유, 그리고 사용…

moony211.medium.com

rememberCoroutineScope는 컴포저블 함수 안에서 쓸 수 있는 CoroutineScope를 제공한다. 이 스코프는 해당 컴포저블의 컴포지션 생명주기에 맞춰 작동한다. 즉 컴포저블이 재구성돼도 스코프는 재사용되고 컴포저블이 컴포지션에서 사라지면 스코프는 정리된다

< 필요한 이유 >

컴포즈에선 상태, UI 업데이트에 따라 컴포저블 함수가 자주 재실행된다. 이때마다 코루틴 스코프를 새로 만들면

- 불필요한 코루틴이 계속 생성돼 리소스 낭비
- 메모리 누수, 중복 작업 발생 가능성
- 컴포저블 생명주기와 맞지 않아 예측 불가능한 동작 발생

rememberCoroutineScope를 쓰면 아래 장점이 있다

- 컴포지션 범위에 종속된 CoroutineScope 제공
- 불필요한 스코프 재생성 방지
- 생명주기 고려한 코루틴 안전 실행 가능

< 주의사항 >

- 뷰모델, Application 수준 스코프처럼 오래 지속되지 않음
- 컴포저블이 컴포지션에서 제거되면 스코프도 정리됨 -> 오래 실행되는 작업엔 부적합
- UI 동작(클릭, 스크롤 등)에 반응하는 일시적 작업에 적합

 

https://www.linkedin.com/pulse/senior-android-developer-interview-series-question-4-frolov-vmxvf/

 

Senior Android Developer Interview Series - Question 4: Differences Between rememberCoroutineScope and LaunchedEffect in Jetpack

Real Interview Question: How does differ from in Jetpack Compose? Options: is automatically restarted during recompositions provides a lifecycle-tied coroutine scope Both manage coroutines but differ in cancellation behavior cancels the coroutine when the

www.linkedin.com

rememberCoroutineScope는 리컴포지션 중에도 취소되지 않는 CoroutineScope를 제공한다. 대신 이 범위는 컴포지션이 존재할 동안 or 상위 스코프가 취소될 때까지 지속된다. 이는 버튼 클릭 같은 이벤트에 반응해 코루틴을 시작할 때 유용하고 리컴포지션에 재시작되면 안 될 때 적합하다. 때문에 이벤트 핸들러나 리컴포지션 후에도 유지돼야 하는 다른 로직에서 코루틴 실행 시에 유용하다
rememberCoroutineScope는 리컴포지션 중에 자동 재시작되지 않고 리컴포지션 전반에 걸쳐 같은 CoroutineScope를 유지한다...(중략)

 

rememberCoroutineScope는 컴포저블 생명주기에 바인딩된 CoroutineScope를 만드는 함수다.

내부적으로 컴포저블 함수의 생명주기를 알고 있어서 개발자가 직접 생명주기 관련 보일러 플레이트 코드를 짤 필요 없이 컴포저블에서 코루틴을 안전하게 실행하고 취소할 수 있다.

또한 remember가 값을 메모리에 저장하고 리컴포지션 발생 시 저장된 값을 복원하듯 같은 단어가 붙은 이 함수는 내부적으로도 비슷하게 동작하는데 remember와 같이 쓰면 비동기 작업을 잘 관리할 수 있다.

아래는 rememberCoroutineScope 사용 예시다.

 

import android.util.Log
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.example.composepractice.ui.theme.ComposePracticeTheme
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.joinAll
import kotlinx.coroutines.launch

private const val TAG = "rememberCoroutineScope"

@Composable
fun SideEffectTestScreen(paddingValues: PaddingValues) {
    var selectedExample by remember { mutableIntStateOf(1) }

    Column(
        modifier = Modifier
            .fillMaxSize()
            .padding(paddingValues)
            .padding(16.dp),
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.Top
    ) {
        Button(onClick = { selectedExample = if (selectedExample == 1) 2 else 1 }) {
            Text("예시 $selectedExample")
        }

        Spacer(modifier = Modifier.height(32.dp))

        when (selectedExample) {
            1 -> MultipleApiCallExample()
            2 -> CancellationExample()
        }
    }
}

@Composable
fun MultipleApiCallExample() {
    var userData by remember { mutableStateOf("") }
    var orderData by remember { mutableStateOf("") }
    var productData by remember { mutableStateOf("") }
    var status by remember { mutableStateOf("대기 중") }

    val scope = rememberCoroutineScope()

    Column(
        modifier = Modifier.padding(16.dp),
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        Text("순차 vs 병렬 API 호출")

        Spacer(modifier = Modifier.height(16.dp))

        Text("상태 : $status")

        Spacer(modifier = Modifier.height(8.dp))

        if (userData.isNotEmpty()) {
            Text("사용자 : $userData")
        }
        if (orderData.isNotEmpty()) {
            Text("주문 : $orderData")
        }
        if (productData.isNotEmpty()) {
            Text("상품 : $productData")
        }

        Spacer(modifier = Modifier.height(16.dp))

        Button(
            onClick = {
                scope.launch {
                    status = "순차 호출 중..."
                    userData = ""
                    orderData = ""
                    productData = ""

                    try {
                        userData = fetchUserData()
                        Log.d(TAG, "사용자 데이터 로드 완료")

                        orderData = fetchOrderData()
                        Log.d(TAG, "주문 데이터 로드 완료")

                        productData = fetchProductData()
                        Log.d(TAG, "상품 데이터 로드 완료")

                        status = "모두 완료 (약 3.3초)"
                    } catch (e: Exception) {
                        status = "에러 : ${e.message}"
                        Log.e(TAG, "API 호출 실패", e)
                    }
                }
            }
        ) {
            Text("순차 API 호출")
        }

        Spacer(modifier = Modifier.height(8.dp))

        Button(
            onClick = {
                scope.launch {
                    status = "병렬 호출 중..."
                    userData = ""
                    orderData = ""
                    productData = ""

                    try {
                        val userJob = launch { userData = fetchUserData() }
                        val orderJob = launch { orderData = fetchOrderData() }
                        val productJob = launch { productData = fetchProductData() }

                        joinAll(userJob, orderJob, productJob)

                        status = "모두 완료 (약 1.5초)"
                        Log.d(TAG, "병렬 호출 모두 완료")
                    } catch (e: Exception) {
                        status = "에러: ${e.message}"
                        Log.e(TAG, "API 호출 실패", e)
                    }
                }
            }
        ) {
            Text("병렬 API 호출")
        }
    }
}

suspend fun fetchUserData(): String {
    delay(1000)
    return "홍길동"
}

suspend fun fetchOrderData(): String {
    delay(1500)
    return "주문 #1234"
}

suspend fun fetchProductData(): String {
    delay(800)
    return "상품 3개"
}

@Composable
fun CancellationExample() {
    var status by remember { mutableStateOf("대기 중") }
    var count by remember { mutableIntStateOf(0) }
    val currentJob = remember { mutableStateOf<Job?>(null) }

    val scope = rememberCoroutineScope()

    Column(
        modifier = Modifier.padding(16.dp),
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        Text("코루틴 취소와 생명주기")

        Spacer(modifier = Modifier.height(16.dp))

        Text("상태 : $status")
        Text("카운트 : $count")

        Spacer(modifier = Modifier.height(16.dp))

        Button(
            onClick = {
                currentJob.value = scope.launch {
                    status = "실행 중..."
                    count = 0
                    try {
                        repeat(10) {
                            delay(1000)
                            count++
                            Log.d(TAG, "카운트 : $count")
                        }
                        status = "완료"
                        Log.d(TAG, "10초 카운트 완료")
                    } catch (e: CancellationException) {
                        status = "${count}초에 취소"
                        Log.d(TAG, "코루틴 취소됨")
                        throw e
                    }
                }
            },
            enabled = currentJob.value?.isActive != true
        ) {
            Text("10초 카운트 시작")
        }

        Spacer(modifier = Modifier.height(8.dp))

        Button(
            onClick = {
                currentJob.value?.cancel()
                Log.d(TAG, "수동 취소 호출")
            },
            enabled = currentJob.value?.isActive == true
        ) {
            Text("취소")
        }

        Spacer(modifier = Modifier.height(16.dp))

        Text("화면 벗어나면 자동 취소")
    }
}

 

1번 예시는 API 호출을 순차, 병렬으로 나눠 호출하는 예시다. 각각 1초, 1.5초, 0.8초 걸리기 때문에 순차 호출 시 약 3.3초, 병렬 호출 시 가장 긴 호출 시간인 약 1.5초가 소요된다.

2개의 버튼을 각각 클릭 시 컴포저블 함수 안에 rememberCoroutineScope()를 담은 scope 변수를 통해 데이터를 fetch한다고 가정한 함수 3개를 모두 호출한다.

LaunchedEffect를 안 쓴 이유는 버튼이 클릭될 때만 작동해야 하기 때문이다. 애초에 버튼의 onClick 람다 안에선 LaunchedEffect 컴포저블 함수를 쓸 수 없기도 하고, LaunchedEffect를 쓰면 화면이 나타났을 때 버튼을 안 눌러도 자동 실행된다.

그러나 rememberCoroutineScope()를 사용해서 버튼을 눌렀을 때만 실행되고 여러 번 클릭하면 여러 번 실행된다. 이걸 막으려면 별도 로직을 짜야 하는데 이건 생략한다.

 

이런 특징이 있어서 rememberCoroutineScope는 컴포지션 생명주기에 연결된 간단한 UI 작업에서 사용하고, 컴포지션 범위를 넘어서 더 길게 실행돼야 하는 작업은 viewModelScope나 lifecycleScope 같이 더 오래 지속되는 코루틴 스코프를 쓰는 게 좋다.

또한 rememberCoroutineScope를 쓰더라도 컴포저블 안에서 해당 스코프를 통한 비동기 비즈니스 로직을 직접 호출하기보다 뷰모델이나 다른 계층으로 로직을 옮기는 게 낫다.

그리고 rememberCoroutineScope는 메인 쓰레드를 사용하기 때문에 비즈니스 로직을 이 스코프에서 쓰는 건 좋지 않다. 뭘 근거로 메인 쓰레드를 사용하냐면 로그로 Thread.currentThread().name을 찍으면 main이라고 나오기 때문이다.

 

rememberUpdatedState

 

https://developer.android.com/develop/ui/compose/side-effects?hl=ko#rememberupdatedstate

 

Compose의 부수 효과  |  Jetpack Compose  |  Android Developers

이 페이지는 Cloud Translation API를 통해 번역되었습니다. Compose의 부수 효과 컬렉션을 사용해 정리하기 내 환경설정을 기준으로 콘텐츠를 저장하고 분류하세요. 부수 효과는 구성 가능한 함수의 범

developer.android.com

주요 매개변수 중 하나가 바뀌면 LaunchedEffect가 재시작된다. 하지만 경우에 따라 Effect에서 값이 바귀면 효과를 재시작하지 않을 값을 캡쳐할 수 있다. 이렇게 하려면 rememberUpdatedState를 써서 캡쳐하고 업데이트할 수 있는 이 값의 참조를 만들어야 한다. 이 접근 방식은 비용이 많이 들거나 다시 만들고 재시작할 수 없게 금지된 오래 지속되는 작업이 포함된 효과에 유용하다
예를 들어 시간이 지나면 사라지는 LandingScreen이 있다고 가정한다. 이 화면이 리컴포지션되는 경우에도 일정 시간 동안 대기하고 시간이 지났음을 알리는 Effect는 재시작되면 안 된다
@Composable
fun LandingScreen(onTimeout: () -> Unit) {

    // This will always refer to the latest onTimeout function that
    // LandingScreen was recomposed with
    val currentOnTimeout by rememberUpdatedState(onTimeout)

    // Create an effect that matches the lifecycle of LandingScreen.
    // If LandingScreen recomposes, the delay shouldn't start again.
    LaunchedEffect(true) {
        delay(SplashWaitTimeMillis)
        currentOnTimeout()
    }

    /* Landing screen content */
}
(중략)...onTimeout 람다에 LandingScreen이 리컴포지션된 최신 값이 항상 포함되게 하려면 rememberUpdatedState로 onTimeout을 래핑해야 한다. 코드에서 리턴된 State, currentOnTimeout은 Effect에서 써야 한다

 

https://proandroiddev.com/jetpack-compose-side-effects-iii-rememberupdatedstate-c8df7b90a01d

 

Jetpack Compose Side-Effects III— rememberUpdatedState

rememberUpdatedState helps keep an updated reference to variables in our compose side-effect. Read to see a use case where this could help

proandroiddev.com

rememberUpdatedState는 장기 실행되는 사이드 이펙트 안에서 변수에 대한 업데이트된 참조를 유지하면서도 리컴포지션 시 사이드 이펙트가 재시작되지 않게 할 때 유용하다...(중략)

 

rememberUpdatedState는 값이 바뀌어도 사이드 이펙트를 재시작하지 않으면서 항상 최신 값을 참조할 수 있게 해주는 API다. 최초의 컴포지션으로 생성됐어도 람다, 콜백의 상태가 항상 최신 상태를 유지할 것을 보장한다.

컴포저블에서 콜백, 람다를 만들 때 그 콜백, 람다 안에서 참조된 상태값은 함수가 이미 생성됐다면 자동 업데이트되지 않을 수 있는데 이때 rememberUpdatedState를 써서 내부 상태가 업데이트되지 않는 문제가 생기지 않게 해 준다.

아래는 rememberUpdatedState 사용 예시다.

 

import android.util.Log
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.delay

private const val TAG = "SideEffectTest"

@Composable
fun SideEffectTestScreen(paddingValues: PaddingValues) {
    Column(
        modifier = Modifier
            .fillMaxSize()
            .padding(paddingValues)
            .padding(16.dp),
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.Top
    ) {
        var message by remember { mutableStateOf("초기 메시지") }
        var counter by remember { mutableIntStateOf(0) }

        Column(
            modifier = Modifier.padding(16.dp),
            horizontalAlignment = Alignment.CenterHorizontally
        ) {
            Text("rememberUpdatedState 예시")

            Spacer(modifier = Modifier.height(16.dp))

            Text("현재 메시지 : $message")
            Text("변경 횟수 : $counter")

            Spacer(modifier = Modifier.height(16.dp))

            Button(
                onClick = {
                    counter++
                    message = "메시지 #$counter"
                }
            ) {
                Text("메시지 변경")
            }

            Spacer(modifier = Modifier.height(16.dp))

            TimerWithCallback(
                message = message,
                onTimeout = {
                    Log.d(TAG, "타이머 완료 - 최신 메시지 : $it")
                }
            )
        }
    }
}

@Composable
fun TimerWithCallback(
    message: String,
    onTimeout: (String) -> Unit
) {
    val currentMessage by rememberUpdatedState(message)
    val currentOnTimeout by rememberUpdatedState(onTimeout)

    var timeLeft by remember { mutableIntStateOf(5) }
    var isRunning by remember { mutableStateOf(false) }

    Column(horizontalAlignment = Alignment.CenterHorizontally) {
        if (isRunning) {
            Text("타이머 : ${timeLeft}초")
        }

        Spacer(modifier = Modifier.height(8.dp))

        Button(
            onClick = { isRunning = true },
            enabled = !isRunning
        ) {
            Text("5초 타이머 시작")
        }

        Spacer(modifier = Modifier.height(8.dp))

        Text(
            text = "타이머 실행 중에 메시지를 변경하면\n완료 시에 최신 메시지가 로그에 출력됨"
        )
    }

    // isRunning이 true가 될 때만 LaunchedEffect 실행
    // message가 바뀌어도 재시작되지 않는다
    LaunchedEffect(isRunning) {
        if (isRunning) {
            timeLeft = 5
            repeat(5) {
                delay(1000)
                timeLeft--
                Log.d(TAG, "남은 시간 : ${timeLeft}초, 현재 메시지 : $currentMessage")
            }

            // 타이머가 끝나면 최신 메시지를 콜백으로 전달
            currentOnTimeout(currentMessage)
            isRunning = false
        }
    }
}

 

코드 작동 방식을 확인하면, 먼저 SideEffectTestScreen이 실행되며 초기 컴포지션이 발생한다. 이 때 message는 초기 메시지, counter는 0으로 초기화된다.

그리고 TimerWithCallback 컴포저블이 컴포지션되면서 currentMessage, currentOnTimeout과 timeLeft, isRunning이 각각 초기화된다.

currentMessage, currentOnTimeout은 rememberUpdatedState로 초기화되는데 rememberUpdatedState의 내부 구현을 잠깐 보면 아래와 같다.

 

@Composable
public fun <T> rememberUpdatedState(newValue: T): State<T> =
    remember { mutableStateOf(newValue) }.apply { value = newValue }

 

첫 컴포지션에서 remember를 통해 State를 만들어 메모리에 저장한다.

이 때 메모리에 올라간 값들은 아래와 같을 것이다.

 

변수 할당된 값
currentMessage State("초기 메시지")
currentOnTimeout State(람다1)
timeLeft State(5)
isRunning State(false)

 

이후 5초 타이머 시작 버튼을 클릭하면 isRunning이 true로 바뀐다.

 

변수 할당된 값
currentMessage State("초기 메시지")
currentOnTimeout State(람다1)
timeLeft State(5)
isRunning State(true)

 

isRunning이 바뀌었으니 isRunning을 key로 사용하는 LaunchedEffect가 작동한다. 1초씩 줄어들며 currentMessage를 읽어서 currentOnTimeout의 매개변수로 넘긴다. currentOnTimeout엔 로그를 찍는 코드만 있어서 결과적으로 5초 타이머 시작을 누르면 남은 시간, 현재 메시지를 로그캣에 표시한다.

이때 메시지 변경 버튼을 누르면 counter가 1 증가하고 이 값이 "메시지 #1" 형태가 되어 Text에 반영된다. 즉 리컴포지션이 발생한다.

1초가 지났을 때 메시지 변경 버튼을 눌렀다면 아래와 같이 State 객체는 유지되면서 내부의 value 필드만 바뀔 것이다.

 

변수 할당된 값
currentMessage State("메시지 #1")
currentOnTimeout State(람다2)
timeLeft State(4)
isRunning State(true)

 

message를 매개변수로 받는 TimerWithCallback도 리컴포지션이 발생하는데, rememberUpdatedState의 영향으로 currentMessage, currentOnTimeout의 State 객체는 그대로 유지되고 값만 바뀐다.

디버깅으로 확인하면 더 알기 쉽다. 5초 타이머 시작을 눌렀을 때 디버깅 화면의 Recomposition State는 아래와 같다.

 

 

메시지 변경을 클릭하면 아래처럼 바뀐다.

 

 

람다가 static으로 표시되어 불변인 것으로 보이지만 내부 해시코드가 변경되는 걸로 봐서 람다는 새 객체로 생성되고 이것이 State의 value에 저장돼 업데이트된다고 볼 수 있다.

결과적으로 rememberUpdatedState를 사용하면 같은 State 객체(currentMessage, currentOnTimeout)를 유지하면서 리컴포지션이 발생한 후 항상 최신값을 읽어올 수 있다.

rememberUpdatedState를 사용하지 않고 LaunchedEffect만 사용할 경우 메시지 변경을 아무리 눌러도 로그캣에 "초기 메시지"만 표시되는 걸 볼 수 있다.

 

derivedStateOf

 

https://developer.android.com/develop/ui/compose/side-effects?hl=ko#derivedstateof

 

Compose의 부수 효과  |  Jetpack Compose  |  Android Developers

이 페이지는 Cloud Translation API를 통해 번역되었습니다. Compose의 부수 효과 컬렉션을 사용해 정리하기 내 환경설정을 기준으로 콘텐츠를 저장하고 분류하세요. 부수 효과는 구성 가능한 함수의 범

developer.android.com

컴포즈에선 관찰된 State 객체 or 컴포저블 입력이 바뀔 때마다 리컴포지션이 발생한다. State 객체나 입력이 UI가 실제로 업데이트해야 하는 것보다 더 자주 변경돼 불필요한 리컴포지션이 발생할 수 있다. 컴포저블의 입력이 리컴포즈해야 하는 것보다 더 자주 바뀔 경우 derivedStateOf를 써야 한다. 이 문제는 스크롤 위치 같이 자주 바뀌는 항목이 있지만 컴포저블이 특정 임계값을 넘을 때만 반응해야 하는 경우 자주 발생한다. derivedStateOf는 필요한 만큼만 업데이트되는 관찰 가능한 새 컴포즈 State 객체를 만든다. 이런 방식으로 Flow의 distinctUntilChanged와 비슷하게 작동한다
derivedStateOf는 비용이 많이 들기 때문에 결과가 바뀌지 않은 경우 불필요한 리컴포지션을 막는 데만 써야 한다
아래 코드는 derivedStateOf의 적절한 사용 사례를 보여준다
@Composable
// When the messages parameter changes, the MessageList
// composable recomposes. derivedStateOf does not
// affect this recomposition.
fun MessageList(messages: List<Message>) {
    Box {
        val listState = rememberLazyListState()

        LazyColumn(state = listState) {
            // ...
        }

        // Show the button if the first visible item is past
        // the first item. We use a remembered derived state to
        // minimize unnecessary compositions
        val showButton by remember {
            derivedStateOf {
                listState.firstVisibleItemIndex > 0
            }
        }

        AnimatedVisibility(visible = showButton) {
            ScrollToTopButton()
        }
    }
}
firstVisibleItemIndex는 첫 번째로 표시되는 항목이 바뀔 때마다 변경된다. 스크롤하면 값이 0~5 등으로 바뀐다. 하지만 값이 0보다 큰 경우에만 리컴포지션이 발생해야 한다. 업데이트 빈도가 일치하지 않으므로 derivedStateOf에 적합한 사용 사례다

< 잘못된 사용 >

두 컴포즈 State 객체를 합칠 때 State를 파생하므로 derivedStateOf를 써야 한다고 가정하는 게 일반적 실수다. 하지만 이는 순전히 오버헤드고 불필요하다
아래 코드에서 fullName은 firstName, lastName만큼 자주 업데이트해야 한다. 따라서 과도한 리컴포지션이 발생하지 않고 derivedStateOf를 쓸 필요가 없다
// DO NOT USE. Incorrect usage of derivedStateOf.
var firstName by remember { mutableStateOf("") }
var lastName by remember { mutableStateOf("") }

val fullNameBad by remember { derivedStateOf { "$firstName $lastName" } } // This is bad!!!
val fullNameCorrect = "$firstName $lastName" // This is correct

 

https://stackoverflow.com/a/73132540

 

Why do I need use derivedStateOf in Compose?

I think the running result of either Code A or Code B will be the same, why do I need to use derivedStateOf in Code A? Code A var age by remember { mutableStateOf(1) } val p...

stackoverflow.com

파생된 상태(derivedStateOf)는 특정 조건이 충족될 때 하나 이상의 상태를 같이 관찰하기 위한 것으로, 읽는 상태가 바뀔 때마다 리컴포지션을 읽거나 트리거하지 않게 사용한다. 상태 객체가 아닌 객체의 변경사항도 감지할 수 있지만 해당 객체를 업데이트해도 리컴포지션되지 않아서 상태 객체가 아닌 객체에 derivedStateOf를 쓰는 건 무의미하다고 생각한다. 이것의 장점은 매 프레임마다 바뀔 수 있는 상태를 읽으면서도 성능에 지장을 안 준다는 점이다...(중략)

 

derivedStateOf는 "파생된 상태"라는 이름이 붙은 만큼 하나 이상의 State 객체에서 파생된 값을 계산하는 API다.

종속된 상태가 자주 업데이트되더라도 계산된 값 자체가 바뀔 때만 리컴포지션을 트리거해서 리컴포지션을 최적화하기 때문에, 상태 업데이트가 빈번할 때 성능을 개선하거나 리컴포지션되는 걸 막을 때 유용하다.

그러나 디벨로퍼에서 말하는 것처럼 비용이 많이 드는 API기 때문에 장점이 확실하다고 판단될 경우에만 쓰는 게 낫다.

아래는 derivedStateOf 사용 예시다.

 

import android.util.Log
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp

private const val TAG = "SideEffectTest"

@Composable
fun SideEffectTestScreen(paddingValues: PaddingValues) {
    Column(
        modifier = Modifier
            .fillMaxSize()
            .padding(paddingValues)
            .padding(16.dp),
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.Top
    ) {
        DerivedStateOfExample()
    }
}

@Composable
fun DerivedStateOfExample() {
    var firstName by remember { mutableStateOf("") }
    var lastName by remember { mutableStateOf("") }
    var counter by remember { mutableIntStateOf(0) }

    // firstName이나 lastName이 변경될 때만 재계산됨
    // 다른 상태(counter)가 변경되어도 재계산 x
    val fullName by remember {
        derivedStateOf {
            Log.d(TAG, "fullName 재계산됨")
            if (firstName.isEmpty() && lastName.isEmpty()) {
                "이름 없음"
            } else {
                "$lastName$firstName".trim()
            }
        }
    }

    // derivedStateOf 없이 직접 계산 (비교용)
    val fullNameDirect = if (firstName.isEmpty() && lastName.isEmpty()) {
        Log.d(TAG, "fullNameDirect 재계산됨 (리컴포지션마다 실행)")
        "이름 없음"
    } else {
        Log.d(TAG, "fullNameDirect 재계산됨 (리컴포지션마다 실행)")
        "$lastName$firstName".trim()
    }

    Column(
        modifier = Modifier.padding(16.dp),
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        Text("derivedStateOf 예시")

        Spacer(modifier = Modifier.height(16.dp))

        Text("derivedStateOf 사용 : $fullName")
        Text("직접 계산 : $fullNameDirect")
        Text("카운터 : $counter")

        Spacer(modifier = Modifier.height(16.dp))

        // 이름 입력 버튼들
        Button(onClick = { firstName = "철수" }) {
            Text("이름 = 철수")
        }

        Spacer(modifier = Modifier.height(8.dp))

        Button(onClick = { lastName = "김" }) {
            Text("성 = 김")
        }

        Spacer(modifier = Modifier.height(8.dp))

        Button(onClick = { firstName = ""; lastName = "" }) {
            Text("이름 초기화")
        }

        Spacer(modifier = Modifier.height(16.dp))

        // counter 증가 버튼
        Button(onClick = { counter++ }) {
            Text("카운터 증가 (이름과 상관없음)")
        }

        Spacer(modifier = Modifier.height(16.dp))

        Text(
            text = "derivedStateOf는 재계산 되지 않지만\n직접 계산은 매번 재계산됨"
        )
    }
}

 

derivedStateOf는 다른 State로부터 파생된 값을 계산할 때 쓰고 의존하는 State가 바뀌었을 때만 재계산한다.

앱을 실행하면 아래 로그가 표시된다.

 

fullNameDirect 재계산됨 (리컴포지션마다 실행)
fullName 재계산됨

 

카운터 증가 버튼을 누르면 카운터는 1 증가하지만 derivedStateOf는 작동하지 않는다. fullNameDirect의 로그만 표시된다.

성, 이름 버튼을 눌러도 리컴포지션이 발생한다. 이때 derivedStateOf가 의존하는 firstName, lastName이 바뀌었기 때문에 derivedStateOf도 재계산을 수행한다.

둘의 차이점은 derivedStateOf가 의존하는 State의 변경 여부를 확인해서 필요할 때만 재계산되기 때문에 카운터 증가 버튼과 성, 이름 버튼 클릭 시의 작동 양식이 다르다.

잘 사용하면 성능 최적화에 도움이 되지만 비용이 많이 드는 API인 만큼 사용에 주의가 필요하다.

 

snapshotFlow

 

https://developer.android.com/develop/ui/compose/side-effects?hl=ko#snapshotFlow

 

Compose의 부수 효과  |  Jetpack Compose  |  Android Developers

이 페이지는 Cloud Translation API를 통해 번역되었습니다. Compose의 부수 효과 컬렉션을 사용해 정리하기 내 환경설정을 기준으로 콘텐츠를 저장하고 분류하세요. 부수 효과는 구성 가능한 함수의 범

developer.android.com

State<T> 객체를 cold flow로 바꾼다. snapshotFlow는 수집될 때 블록을 실행하고 읽은 State 객체의 결과를 내보낸다. snapshotFlow 블록 안에서 읽은 State 객체 중 하나가 바뀌면 새 값이 이전에 내보낸 값과 다를 경우 Flow에서 새 값을 수집기로 내보낸다. 이 동작은 Flow.distinctUntilChanged의 동작과 비슷하다
아래는 유저가 리스트에서 첫 번째 아이템을 지나 분석까지 스크롤할 때 기록되는 사이드 이펙트를 보여준다. listState.firstVisibleItemIndex는 Flow 연산자의 이점을 활용할 수 있는 Flow로 바뀐다
val listState = rememberLazyListState()

LazyColumn(state = listState) {
    // ...
}

LaunchedEffect(listState) {
    snapshotFlow { listState.firstVisibleItemIndex }
        .map { index -> index > 0 }
        .distinctUntilChanged()
        .filter { it == true }
        .collect {
            MyAnalyticsService.sendScrolledPastFirstItemEvent()
        }
}

 

https://blog.stackademic.com/how-compose-manages-state-a-deep-dive-into-snapshot-system-and-recomposition-triggers-21bd677ebe28

 

How Compose Manages State: A Deep Dive into Snapshot System and Recomposition Triggers

Introduction: A Late‑Night Debugging Tale

blog.stackademic.com

(중략)...snapshotFlow는 컴포즈 State를 Flow로 바꾼다. 기본 상태가 바뀔 때마다 값을 배출하고 컴포즈와 반응형 스트림을 연결할 때 유용하다. 그러나 Flow는 쓰레드 세이프하지만 원자적이지 않다. 상태를 스트림으로 바꿔야 하지만 동기화를 수동 처리해야 할 때 snapshotFlow를 써야 한다

 

snapshotFlow는 컴포즈 State를 cold flow로 바꾸는 함수다. 관찰된 상태가 바뀔 때마다 Flow는 업데이트된 값을 내보내는데 반응형 기반인 컴포즈의 State를 다른 반응형 기반인 Flow로 바꿔서 업데이트된 값을 관찰할 수 있게 해 주는 API다.

또한 상태 읽고 쓰기가 스냅샷의 스코프 안에서 이뤄지기 때문에 쓰레드 세이프하고, Flow를 수집하는 코루틴이 취소될 때 구독도 자동 취소해준다.

아래는 snapshotFlow 사용 예시다.

 

import android.util.Log
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.map

private const val TAG = "SideEffectTest"

@Composable
fun SideEffectTestScreen(paddingValues: PaddingValues) {
    Column(
        modifier = Modifier
            .fillMaxSize()
            .padding(paddingValues)
            .padding(16.dp),
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.Top
    ) {
        SnapshotFlowExample()
    }
}

@Composable
fun SnapshotFlowExample() {
    var searchText by remember { mutableStateOf("") }
    var counter by remember { mutableIntStateOf(0) }
    var lastSearched by remember { mutableStateOf("검색 전") }

    LaunchedEffect(Unit) {
        snapshotFlow { searchText }
            .filter { it.length >= 2 }  // 2글자 이상일 때만
            .distinctUntilChanged()      // 중복을 제거하고
            .map { it.uppercase() }      // 대문자로 바꾼다
            .collect { transformed ->
                Log.d(TAG, "검색어 변환됨: $transformed")
                lastSearched = transformed
                // API 호출 등 필요한 로직 추가
            }
    }

    // counter 변경 감지
    LaunchedEffect(Unit) {
        snapshotFlow { counter }
            .filter { it > 0 && it % 5 == 0 }  // 5의 배수일 때만
            .collect { count ->
                Log.d(TAG, "카운터가 5의 배수 도달 : $count")
            }
    }

    Column(
        modifier = Modifier.padding(16.dp),
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        Text("snapshotFlow 예시")

        Spacer(modifier = Modifier.height(16.dp))

        Text("현재 입력 : $searchText")
        Text("마지막 검색 : $lastSearched")
        Text("카운터 : $counter")

        Spacer(modifier = Modifier.height(16.dp))

        Button(onClick = { searchText = "a" }) {
            Text("검색어 = a (1글자)")
        }

        Spacer(modifier = Modifier.height(8.dp))

        Button(onClick = { searchText = "ab" }) {
            Text("검색어 = ab (2글자)")
        }

        Spacer(modifier = Modifier.height(8.dp))

        Button(onClick = { searchText = "abc" }) {
            Text("검색어 = abc")
        }

        Spacer(modifier = Modifier.height(8.dp))

        Button(onClick = { searchText = "abc" }) {
            Text("검색어 = abc (중복)")
        }

        Spacer(modifier = Modifier.height(8.dp))

        Button(onClick = { searchText = "" }) {
            Text("검색어 초기화")
        }

        Spacer(modifier = Modifier.height(16.dp))

        Button(onClick = { counter++ }) {
            Text("카운터 증가")
        }
    }
}

 

filter에서 2글자 이상으로 조건을 걸었기 때문에 a 버튼을 누르면 화면엔 현재 입력 값으로 a가 표시되지만 로그캣엔 아무 로그도 표시되지 않는다. ab, abc는 2글자를 넘기 때문에 로그에도 표시되는 걸 볼 수 있다.

Flow 연산자를 쓸 수 있어서 메서드 체이닝 형태로 여러 연산자들을 조합해 쓸 수 있고, 잘 사용하면 선언적이고 읽기 쉬운 코드를 짤 수 있다는 장점이 있다.

반응형
Comments