본문 바로가기

Android/UI

[UI] Compose SideEffect?

Compose에서의 SideEffect

- SideEffect?

SideEffect란 Composable 범위 밖에서 발생하는 앱 상태에 대한 변경을 의미한다. 보통 Compose를 사용하게 된다면 여러 Composable을 겹쳐서 사용을 하게 되는데, 이로 인해 안드로이드 내부에서 각 Composable에 대한 LifeCycle을 만들게 된다. 또한 Composable은 단방향으로 바깥쪽에서 안쪽으로 상태를 내려주게 된다. 



하지만 이때 만약 안쪽에 있는 Composable이 바깥쪽에 있는 Composable의 상태에 대한 변경을 주거나 Composable에서 Composable이 아닌 앱 상태에 대해 변화를 준다면 SideEffect가 발생한다.

 

위 두 경우를 간단히 예제코드와 함께 짚어보고 넘어가겠다.

 

예제 1 : 바깥쪽에 있는 Composable의 상태에 변경을 줄 때

@Composable
fun OuterComposable() {
    // 바깥쪽 Composable의 상태를 정의한다.
    var count by remember { mutableStateOf(0) }

    Column {
        Text(text = "Outer Count: $count")

        // InnerComposable에서 상태를 직접 변경하도록 한다.
        InnerComposable { newCount ->
            // 상위 상태를 직접 변경한다.
            count = newCount
        }
    }
}

@Composable
fun InnerComposable(onUpdateCount: (Int) -> Unit) {
    Button(onClick = {
        // 버튼 클릭 시 상위 상태를 직접 변경한다.
        onUpdateCount(1)
    }) {
        Text(text = "Increment Outer Count")
    }
}

 

예제 2 : Composable이 Composable이 아닌 앱 상태에 변화를 줄 때

@Composable
fun LogComposable() {
    // 카운터 상태를 정의한다.
    var count by remember { mutableStateOf(0) }

    // Compose 함수 내에서 직접 로그를 출력한다.
    Log.d("LogComposable", "Current count: $count")


    // 버튼을 클릭하면 카운터를 증가시킨다.
    Button(onClick = { count++ }) {
        Text("Increment Count")
    }
}

 

- Effect API

Jetpack Compose에서는 우린 이러한 SideEffect를 해결하기 위해 Effect API라는 것을 제공한다. Effect API란 앞서 설명한 대로 앱 상태를 변경하는 경우 예측 가능한 방식으로 실행되도록 도와주는 함수들이다. 대표적으로 SideEffect, LaunchedEffect, DisposableEffect 등이 있다. 하나씩 차근차근 알아보자.

 

 

- SideEffect

SideEffect는 Composable의 상위 Composable을 재구성할 때 SideEffect를 실행할 수 있는 함수이다. 이름이 같아 헷갈릴 수 있지만 쉽게 말해 SideEffect가 발생하는 코드를 실행시켜 주는 함수라고 생각하면 된다.  보통은 로깅, 분석, 외부 상태 업데이트 등 UI에 직접적인 영향을 주지 않는 작업을 수행한다. 이 함수는 Composable의 상태나 속성에 의존하지 않는 작업을 실행하는데 유용하다.

 

Composable이 Recomposition 때, Composable 함수 내부에 있는 모든 코드가 다시 실행이 된다. 이때, SideEffect에 명시된 코드도 함께 실행이 된다. 하지만 UI는 앞서 설명한 것처럼 Composable의 상태 또는 속성에 변경된 내용으로만 업데이트된다.

 

사용예제

SideEffect를 사용하려면 Composable 함수 안에서 Log 등 원하는 동작을 실행시키기 원하는 지점에서 호출하면 된다.

@Composable
fun LogComposableWithSideEffect() {
    // 카운터 상태를 정의한다.
    var count by remember { mutableStateOf(0) }

    // SideEffect를 사용하여 상태가 변경될 때만 로그를 출력한다.
    SideEffect {
        Log.d("LogComposableWithSideEffect", "Current count: $count")
    }

    // 버튼을 클릭하면 카운터를 증가시킨다.
    Button(onClick = { count++ }) {
        Text("Increment Count")
    }
    
    // count의 state가 업데이트 됨에 따라 text가 변경되고 Recomposition이 트리거 된다.
    Text("카운트 ${count}")  
}

 

출력 결과

Current count: 0
Current count: 1
Current count: 2
Current count: 3
Current count: 4

위의 예제에서 SideEffect함수는 LogComposableWithSideEffect Composable 함수를 Recomposition 할 때마다 카운트 상태 변수의 현재 값을 기록한다. 이는 Composable의 동작을 디버깅하고 모니터링하는 데에 유용하다.

 

하지만 한 가지 주목할 점은, 현재 Composable 함수가 Recomposition 된 경우에만 SideEffect내부 동작이 트리거 되면서 중첩된 Composable함수에 대해서는 트리거 되지 않는다. 즉, 다른 Composable함수를 호출하는 Composable함수가 있는 경우, 내부 Composable함수가 재구성될 때 외부 Composable함수의 SideEffect는 트리거 되지 않는다라는 것이다.

 

이를 이해하기 위해 코드를 예제코드를 아래와 같이 변경해 보겠다.

@Composable
fun LogComposableWithSideEffect() {
    // 카운터 상태를 정의한다.
    var count by remember { mutableStateOf(0) }

    // SideEffect를 사용하여 상태가 변경될 때만 로그를 출력한다.
    SideEffect {
        Log.d("LogComposableWithSideEffect", "Current count: $count")
    }

    // 버튼을 클릭하면 카운터를 증가시킨다.
    Button(onClick = { count++ }) {
        // 이 Recomposition은 버튼이 클릭 될때마다 바깥쪽의 SideEffect를 트리거하지 않는다. 
        Text("count ${count}")  
     }
}
출력 결과

Current count: 0

위처럼 코드가 변경된다면 버튼을 여러 번 클릭하더라도 아래처럼만 출력이 될 것이다.

"아니 필자야 Recomposition 됐을 때 실행 된다며?"

라는 생각이 있을 수도 있다.

 

설명해 보자면 SideEffect 함수는 처음 Composable이 생성됐을 때와Recomposition 됐을 때만 실행이 된다. 하지만 위의 코드의 Text("count ${count}")가 내부 Composable인 Button Composable내에 위치해 있어 외부 Composable인 LogComposableWithSideEffect의 Recomposition에 영향을 주지 않아 초기값만 출력이 되는 것이다.

 

하지만 만약 아래의 코드처럼 Button 내부에 SideEffect 함수를 추가하게 된다면 Button의 Recomposition으로 인해 바깥쪽 카운트가 아닌 Button내부의 카운트 로그만 영향을 주기 때문에 내부 로그만 출력될 것이다.

@Composable
fun LogComposableWithSideEffect() {
    // 카운터 상태를 정의한다.
    var count by remember { mutableStateOf(0) }

    // SideEffect를 사용하여 상태가 변경될 때만 로그를 출력한다.
    SideEffect {
        Log.d("LogComposableWithSideEffect", "Current count: $count")
    }
    
    
	Column {
    	// 버튼을 클릭하면 카운터를 증가시킨다.
    	Button(onClick = { count++ }) {
        	SideEffect {
        		Log.d("LogComposableWithSideEffectButton", "Inner count: $count")
            }
            
            Text("count ${count}")
     	}
    }
}
출력 결과

Curren count: 0
Inner count: 0
Inner count: 1
Inner count: 2

 

 

- LaunchedEffect

LaunchedEffect는 별도의 코루틴 스코프에서 SideEffect를 실행하는 함수이다. LaunchedEffect는 UI 스레드를 차단하지 않고 네트워크 호출이나 애니메이션 등 시간이 오래 걸릴 수 있는 작업을 실행하는데 유용하다.

 

사용예제

아래는 LaunchedEffect를 사용한 예제이다.

@Composable
fun LaunchedEffectExample() {
    // 카운터와 LaunchedEffect의 Key값인 isLoading 상태를 정의한다.
    var count by remember { mutableStateOf(0) }
    var isShow by remember { mutableStateOf(false) }
    
    // LaunchedEffect를 사용하여 비동기 작업을 실행한다.
    LaunchedEffect(key1 = isShow) {
        Log.d("LaunchedEffectExample", "LaunchedEffect 시작")

        // 예시로 딜레이를 추가
        delay(2000L)
        count = 5
        Log.d("LaunchedEffectExample", "카운트 변경: $count")
    }

    // 버튼을 클릭하면 카운터를 증가시킨다.
    Button(onClick = { count++ }) {
        Text("Increment Count")
        isShow = true
    }
    
    // 현재 카운트 값을 표시한다.
    Text("카운트 $count")
}

 

출력 결과

LaunchedEffect 시작
(2초 후)
카운트 변경: 5

LaunchedEffect는 Key값을 받고 Key값이 변경될 때마다 실행이 된다. Key값이란 LaunchedEffect의 인스턴스를 식별하고 불필요하게 Recomposition 하지 않도록 하는데 사용한다.

 

Composable이 재구성될 때, Jetpack Compose에서 다시 그릴 필요가 있는지 여부를 결정한다. Composable의 상태나 속성이 변경되었거나 Composable이 invalidate라고 호출한 경우, Jetpack Compose는 Composable을 다시 그리게 된다.

특히 Composable이 Recomposition 될 때마다 다시 실행할 필요가 없는 시간이 많이 소요되는 작업이나 부수효과가 있는 경우 Composable을 다시 그리는 작업은 비용이 많이 드는 작업이 될 수 있다.

 

하지만 LaunchedEffect에 Key값을 제공함으로써 LaunchedEffect 인스턴스를 고유하게 식별하는 값을 지정할 수 있다. 이것이 바로 Key값이고 바로 이 Key값이 변경되면 Jetpack Compose는 LaunchedEffect 인스턴스를 새로운 인스턴스로 간주하고 SideEffect를 다시 실행한다.

Key값이 그대로 유지되면 Jetpack Compose는 SideEffect의 실행을 건너뛰고 이전 결과를 재사용하여 불필요한 Recomposition을 방지할 수 있다.

 

또한 Key값을 한 개가 아닌 여러 개가 들어올 수 있다. 그 경우 아래와 같이 사용하면 된다.

@Composable
fun LaunchedEffectExample() {
    var string by remember { mutableStateOf("Hello World") }
    var isShow by remember { mutableStateOf(false) }
    
    LaunchedEffect(key1 = string, key2 = isShow) {
        . . .
    }
}

 

 

- DisposableEffect

DisposableEffect는 상위 Composable이 처음 렌더링될 때 SideEffect를 실행하고 Composable이 UI계층에서 제거될 때 효과를 처분하는 함수이다. DisposableEffect함수는 이벤트 리스너나 애니메이션과 같이 Composable이 더 이상 사용되지 않을 때 정리가 필요한 리소스를 관리하는 데 유용하다.

 

사용예제

아래는 DisposableEffect를 사용한 예제이다.

@Composable
fun TimerExample() {
    // 타이머의 현재 값과 타이머가 활성화 상태인지를 관리하는 상태 변수를 정의합니다.
    var timer by remember { mutableStateOf(0) }
    var isTimerActive by remember { mutableStateOf(false) }

    // DisposableEffect를 사용하여 타이머를 관리한다.
    DisposableEffect(isTimerActive) {
        // 이 블록은 Composable이 처음 구성될 때 실행된다.
        println("타이머가 시작되었습니다.")
        val timerJob = GlobalScope.launch {
            while (isTimerActive) {
                delay(1000L) // 1초마다 타이머 값을 증가시킨다.
                timer++
            }
        }

        // 이 블록은 Composable이 사라질 때 실행된다.
        onDispose {
            println("타이머가 중지되었습니다.")
            timerJob.cancel() // 타이머 작업을 취소한다.
        }
    }

    // 타이머 시작 및 중지 버튼을 추가한다.
    Button(
        onClick = { isTimerActive = !isTimerActive },
        modifier = Modifier.padding(16.dp)
    ) {
        Text(if (isTimerActive) "타이머 중지" else "타이머 시작")
    }

    // 현재 타이머 값을 표시한다.
    Text(
        text = "타이머 값: $timer",
        modifier = Modifier.padding(16.dp)
    )
}

이 코드에서 우리는 DisposableEffect를 사용하여 매초 경과된 시간 상태 값을 증가시키는 코루틴을 사용한다.  이와 같이 onDispose를 이용하여 DisposableEffect를 코루틴 스코프와 함께 사용하여 Composable이 더 이상 사용되지 않을 때 코루틴이 취소되고 코루틴에서 사용되는 자원이 정리되도록 한다. 이를 통해 리소스 낭비 및 기타 성능 저하 문제를 방지하고 앱의 성능과 안정성을 향상할 수 있다.

 

- 정리 -

- LaunchedEffect 사용 케이스

LaunchedEffect는 별도의 코루틴 스코프에서 SideEffect를 실행하며 UI 스레드를 차단하지 않고 시간이 오래 걸리는 작업을 실행하는 데 유용합니다. 첫 번째 Composition이나 Key값이 변경 시 트리거 됩니다.

- 네트워크로부터 데이터를 가져올 때
- 이미지 프로세싱을 수행할 때
- DB를 업데이트할 때

 

- DisposableEffect 사용 케이스

DisposableEffect는 상위 Composable이 처음 렌더링될 때 실행되며 Composable이 더 이상 사용되지 않을 때 정리가 필요한 리소스를 관리하는 데 유용하다. 첫 번째 Composition 또는 Key값이 변경될 때 트리거되며 종료 시 onDispose() 메서드를 호출한다.

- 이벤트 리스너를 등록하고 제거할 때

- 애니메이션을 시작하고 정지할 때

- 카메라와 LocationManager와 같이 센서 리소스를 바인딩 또는 넌 바인딩 할 때

- DB연결을 관리할 때

 

- SideEffect 사용 케이스

SideEffect는 상위 Composable이 재구성될 때 실행되며 Composable의 상태나 속성에 의존하지 않는 작업을 실행하는 데 유용하다.

- 로깅 및 분석을 위한 코드를 사용할 때
- 블루투스 장치에 연결을 하기 위하여 초기화를 진행할 때
- 파일로부터 데이터를 최초 한번 로딩할 때
- 라이브러리를 초기화할 때

 

 

참고한 자료

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