Shunz Android Dev Note

컴포즈 사이드 이펙트 - SideEffect, LaunchedEffect, DisposableEffect 본문

Compose

컴포즈 사이드 이펙트 - SideEffect, LaunchedEffect, DisposableEffect

_Shun_ 2023. 12. 24. 21:29

Jetpack Compose는 안드로이드에서 UI 개발을 훨씬 더 용이하게 만들어주었지만, 더 나은 성능을 위해 UI 효과를 제대로 관리하는 방법을 이해하는 것은 여전히 중요합니다. 이 포스트에서는, UI 효과를 효과적으로 관리하는 데 도움이 되는 세 가지 중요한 컴포저블 함수인 SideEffect, LaunchedEffect, DisposableEffect에 대해 알아보겠습니다.

 


왜 Side-effects(이하 부수효과) 인가?

Jetpack Compose에서 부수효과의 목적은 제어되고 예측 가능한 방식으로 컴포저블 함수 밖에서 앱의 상태를 변경하는 non-UI 관련 작업을 실행할 수 있도록 하는 것입니다.

 

데이터베이스를 업데이트하거나 네트워크 요청을 하는 등의 부수효과는 UI 렌더링 로직과 별도로 유지하여 코드의 성능과 유지보수성을 향상시켜야 합니다.

 

Jetpack Compose는 UI 렌더링 로직에서 부수효과를 분리하고 별도의 코루틴 스코프에서 실행함으로써 개발자가 효과적으로 부수효과를 관리 할 수 있는 SideEffect, LaunchedEffect, DisposableEffect와 같은 여러 컴포저블 함수를 제공합니다.

 

Jetpack Compose에서 부수효과 사용의 주요 장점은 다음과 같습니다.

  • 향상된 성능 : 컴포저블 함수 이외의 non-UI 관련 작업을 실행함으로써 UI 렌더링 로직은 응답과 성능을 유지할 수 있습니다.
  • 코드 가독성 향상 : UI 렌더링 로직에서 non-UI 관련 작업을 분리함으로써 코드베이스를 이해하고 유지하기 쉬워집니다.
  • 더 나은 디버깅 : 로깅 및 분석 작업에 부수효과를 사용할 수 있으므로 개발자가 앱의 동작을 더 잘 이해하고 문제를 식별하는 데 도움이 될 수 있습니다.

요약하자면, Jetpack Compose에서 부수효과의 목적은 UI 렌더링 로직에서 non-UI 관련 작업을 분리하여 코드베이스의 성능, 유지보수성 및 디버깅을 향상시키는 것입니다.

 

SideEffect

SideEffect은 컴포저블의 상위 컴포저블을 재구성할 때 부수효과를 실행할 수 있는 컴포저블 함수입니다. 보통 로깅, 분석, 외부 상태 업데이트 등 UI에 직접적인 영향을 주지 않는 작업을 수행합니다. 이 함수는 컴포저블의 상태나 속성에 의존하지 않는 작업을 실행할 때 유용합니다.

 

컴포저블이 재구성 될 때, 컴포저블 함수 내부에 있는 모든 코드가 다시 실행이 됩니다. 이 때, 부수효과에 명시된 코드도 함께 실행이 됩니다. 그러나 UI는 컴포저블의 상태 또는 속성에 변경된 내용으로만 업데이트됩니다.

 

SideEffect은 어떻게 사용하는걸까?

SideEffect을 사용하려면 컴포저블 함수 안에서 부수 효과를 실행시키기 원하는 지점에서 호출하면 됩니다.

예제 코드를 살펴보겠습니다.

@Preview
@Composable
fun SideEffectDemo() {
    val count = remember { mutableIntStateOf(0) }

    // 현재의 count를 로깅하기 위한 SideEffect
    SideEffect {
        // 재구성 될 때마다 호출됨
        Log.d("DEMO", "카운트 : ${count.value}")
    }

    Column {
        Button(onClick = { count.value++ }) {
            Text("Count 증가")
        }

        // state가 업데이트 됨에 따라 text가 변경되고 재구성이 트리거 됨.
        Text("카운트 ${count.value}")
    }
}

 

출력

카운트 : 0
카운트 : 1
카운트 : 2
카운트 : 3

 

이 예제에서 SideEffect 함수는 SideEffectDemo 함수를 재구성 할 때마다 카운트 상태 변수의 현재 값을 기록합니다. 이는 컴포저블의 동작을 디버깅하고 모니터링하는 유용합니다.

 

한 가지 주목할점은, 현재 컴포저블 함수가 재구성된 경우에만 부수효과가 트리거되며 중첩된 컴포저블 함수에 대해서는 트리거되지 않습니다. 즉, 다른 컴포저블 함수를 호출하는 컴포저블 함수가 있는 경우, 내부 컴포저블 함수가 재구성 될 때 외부 컴포저블 함수의 SideEffect이 트리거되지 않습니다. 이를 이해하기 위해 코드를 다음과 같이 변경해보겠습니다.

@Preview
@Composable
fun SideEffectDemo() {
    val count = remember { mutableIntStateOf(0) }

    // 현재의 count를 로깅하기 위한 SideEffect
    SideEffect {
        // 재구성 될 때마다 호출됨
        Log.d("DEMO", "카운트 : ${count.value}")
    }

    Column {
        Button(onClick = { count.value++ }) {
            // 이 재구성은 버튼이 클릭 될때마다 바깥쪽의 SideEffect는 트리거하지 않음.
            Text("카운트 ${count.value}")
        }
    }
}

 

버튼을 여러번 클릭하더라도 아래처럼만 출력이 됩니다.

출력

카운트 : 0

 

위의 코드에서 앱을 처음 시작할 때 SideEffectDemo 컴포저블 함수가 구성되고 SideEffect이 카운트의 초기값을 콘솔에 로깅합니다. Button을 클릭하면 Text Composable이 카운트의 새로운 값으로 다시 구성되지만 이로 인해 SideEffect가 다시 트리거되지는 않습니다.

 

이제 내부 Side Effect을 추가하여 어떻게 동작하는지 알아보겠습니다.

@Preview
@Composable
fun SideEffectDemo() {
    val count = remember { mutableIntStateOf(0) }

    // 현재의 count를 로깅하기 위한 SideEffect
    SideEffect {
        // Called on every recomposition
        Log.d("DEMO", "바깥쪽 카운트 : ${count.value}")
    }

    Column {
        Button(onClick = { count.value++ }) {
            SideEffect {
                // 재구성 될 때마다 호출됨
                Log.d("DEMO", "내부 카운트 : ${count.value}")
            }

            // 이 재구성은 버튼이 클릭 될 때마다 바깥쪽의 Side Effect은 트리거하지 않음.
            Text("카운트 ${count.value}")
        }
    }
}

 

버튼을 클릭하면 내부 카운트만 로깅이 되는것을 확인 할 수 있습니다.

출력

바깥쪽 카운트 : 0
내부 카운트 : 0
내부 카운트 : 1
내부 카운트 : 2

 

이제 그 이유를 알 것입니다. :-)

 

LaunchedEffect

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

 

LaunchedEffect를 어떻게 사용하는지에 대한 예제를 살펴 보겠습니다.

// 네트워크 호출. 2초의 딜레이가 있다고 가정.
private suspend fun fetchFruits(): List<String> {
    delay(2000)
    return arrayListOf("귤", "딸기", "바나나")
}

@Preview
@Composable
fun FavouritesFruits() {
    var isLoading by remember { mutableStateOf(false) }
    var fruits by remember { mutableStateOf(listOf<String>()) }

    LaunchedEffect(key1 = isLoading) {
        if (isLoading) {
            fruits = fetchFruits()
            isLoading = false
        }
    }

    Column {
        Button(onClick = { isLoading = true }) {
            Text("과일 불러오기")
        }

        if (isLoading) {
            // 로딩 중임을 나타내는 프로그레스 뷰
            CircularProgressIndicator()
        } else {
            // 가져온 과일을 리스트로 표시
            LazyColumn {
                items(fruits.size) { index ->
                    Text(text = fruits[index])
                }
            }
        }
    }
}

로딩 완료 후 표시되는 화면

 

위 예제에서, LaunchedEffect 함수는 isLoading 상태 변수가 true로 설정되어 있을 때 API 에서 데이터를 가져오기 위해 네트워크 호출을 실행합니다. 함수는 별도의 코루틴 스코프에서 실행되므로 작업이 수행되는 동안 UI가 응답 상태를 유지할 수 있습니다.

 

LaunchedEffect 함수는 isLoading.value로 설정된 key와 실행할 부수 효과를 정의하는 람다인 block으로 두개의 파라메터를 취합니다. 이 경우 블록 람다는 fetchFruits() 함수를 호출하는데, fetchFruits() 함수는 코루틴을 2초간 정지시켜 네트워크 호출을 시뮬레이션합니다. 데이터가 조회되면 데이터 상태 변수를 업데이트하고 isLoading을 fale로 설정하여 로딩 표시기를 숨기고 조회된 데이터를 표시합니다.

 

key 파라메터는 무엇일까요?

LaunchedEffect의 키 매개 변수는 LaunchedEffect 인스턴스를 식별하고 불필요하게 재구성되지 않도록 하는 데 사용됩니다.

 

컴포저블이 재구성될 때, Jetpack Compose에서 다시 그릴 필요가 있는지 여부를 결정합니다. 컴포저블의 상태나 속성이 변경되었거나 컴포저블이 invalidate라고 호출한 경우, Jetpack Compose는 컴포저블을 다시 그리게 됩니다. 특히 컴포저블이 재구성 될 때마다 다시 실행할 필요가 없는 시간이 많이 소요되는 작업이나 부수효과가 있는 경우 컴포저블을 다시 그리는 작업은 비용이 많이 드는 작업이 될 수 있습니다.

 

LaunchedEffect에 키 파라메터를 제공함으로써 LaunchedEffect 인스턴스를 고유하게 식별하는 값을 지정 할 수 있습니다. 키 파라메터의 값이 변경되면 Jetpack Compose는 LaunchedEffect 인스턴스를 새로운 인스턴스로 간주하고 부수효과를 다시 실행합니다. 키 파라메터의 값이 그대로 유지되면 Jetpack Compose는 부수효과의 실행을 건너뛰고 이전 결과를 재사용하여 불필요한 재구성을 방지할 수 있습니다.

 

LaunchedEffect에 대한 여러 키를 사용할 수도 있습니다.

// 랜덤 UUID를 LaunchedEffect의 키로 사용
val key = remember { UUID.randomUUID().toString() }

LaunchedEffect(key, isLoading.value) {
  ....
}

 

 

DisposableEffect

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

 

DisposableEffect를 어떻게 사용하는지에 대한 예제를 살펴 보겠습니다.

@Preview
@Composable
fun TimerDemo() {
    val elapsedTime = remember { mutableIntStateOf(0) }

    DisposableEffect(Unit) {
        val scope = CoroutineScope(Dispatchers.Default)
        val job = scope.launch {
            while (true) {
                delay(1000)
                elapsedTime.value += 1
                Log.d("DEMO", "타이머는 여전히 동작하는 중 ${elapsedTime.value}")
            }
        }

        onDispose {
            job.cancel()
        }
    }

    Text(
        text = "동작한 시간 : ${elapsedTime.value}",
        modifier = Modifier.padding(16.dp),
        fontSize = 24.sp
    )
}

 

이 코드에서 우리는 DisposableEffect를 사용하여 매초 경과된 시간 상태 값을 증가시키는 코루틴을 사용합니다. 또한 우리는 DisposableEffect를 사용하여 컴포저블이 더 이상 사용되지 않을 때 코루틴이 취소되고 코루틴에서 사용되는 자원이 정리되도록 합니다.

 

DisposableEffect의 cleanup 함수에서 job에 저장된 Job 인스턴스의 cancel() 메서드를 사용하여 코루틴을 취소합니다.

 

onDispose 함수는 컴포저블이 UI 계층에서 제거되면 호출되며 컴포저블에서 사용하는 리소스를 정리할 수 있는 방법을 제공합니다. 이 경우 onDispose를 사용하여 코루틴을 취소하고 코루틴에서 사용하는 리소스를 정리합니다.

 

이 DisposableEffect가 어떻게 동작하는지 확인하려면 다음 코드를 실행하여 결과를 확인해 보겠습니다.

@Preview
@Composable
fun TimerRunTimerDemo() {
    var visibleTimer by remember { mutableStateOf(true) }

    Column(
        modifier = Modifier.fillMaxSize(),
        horizontalAlignment = Alignment.CenterHorizontally,
    ) {
        Button(onClick = { visibleTimer = false }) {
            Text("타이머 숨기기")
        }
        Button(onClick = { visibleTimer = true }) {
            Text("타이머 표시 하기")
        }

        if (visibleTimer) {
            TimerDemo()
        }
    }
}

 

사용자가 버튼을 클릭하여 TimerDemo 컴포저블의 가시성을 전환할 수 있는 새로운 TimerRunTimerDemo 컴포저블을 추가했습니다. 사용자가 타이머를 숨기는 버튼을 클릭하면 TimerDemo 컴포저블이 UI 계층에서 제거되고 코루틴이 취소 및 정리가 됩니다.

 

onDispose 함수에서 job.cancel() 호출하는것을 제거하면 TimerDemo 컴포저블이 더 이상 사용되지 않는 경우에도 코루틴이 계속 실행되므로 리소스 릭이나 기타 성능 문제가 발생할 수 있습니다.

 

이와 같이 DisposableEffect와 코루틴스코프를 함께 사용함으로써 TimerDemo 컴포저블이 사용되지 않을 때 코루틴스코프에서 시작한 코루틴을 취소하고 리소스를 정리할 수 있도록 합니다. 이를 통해 리소스 릭 및 기타 성능 문제를 방지하고 앱의 성능과 안정성을 향상시킬수 있습니다.

 


 

각각 언제 사용해야 할까요?

DisposableEffect 의 사용 케이스

  • 이벤트 리스너를 등록하고 제거할 때
  • 애니메이션을 시작하고 정지 할 때
  • 카메라와 LocationManager와 같이 센서 리소스를 바인딩 또는 언바인딩 할 때
  • DB 연결을 관리 할 때

LaunchedEffect의 사용 케이스

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

SideEffect의 사용 케이스

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

1회 초기화에 SideEffect를 사용하는 방법의 예는 다음과 같습니다.

@Composable
fun SideEffectInitDemo() {
    var isInitialized by remember { mutableStateOf(false) }

    SideEffect {
        if (!isInitialized) {
            // Execute one-time initialization tasks here
            initializeBluetooth()
            loadDataFromFile()
            initializeLibrary()

            isInitialized = true
        }
    }

    // UI 코드
}

 


결론

SideEffect, DisposableEffect, LaunchedEffect의 주요 차이점을 요약해보았습니다.

  • SideEffect은 상위 컴포저블이 재구성될 때 실행되며 컴포저블의 상태나 속성에 의존하지 않는 작업을 실행하는 데 유용합니다.
  • DisposableEffect는 상위 컴포저블이 처음 렌더링될 때 실행되며 컴포저블이 더 이상 사용되지 않을 때 정리가 필요한 리소스를 관리하는 데 유용합니다. 첫 번째 컴포지션 또는 키가 변경 될 때 트리거되며 종료 시 onDispose() 메서드를 호출합니다.
  • LaunchedEffect는 별도의 코루틴 스코프에서 부수효과를 실행하며 UI 쓰레드를 차단하지 않고 시간이 오래 걸리는 작업을 실행하는 데 유용합니다. 첫 번째 컴포지션이나 키가 변경 시 트리거 됩니다.
주요 참고 링크 : 안드로이드 공식 문서