이번 포스팅은 컴포즈를 사용하다 보면 LaunchedEffect, rememberCoroutineScope 등 다양한 Effect API들을 사용하게 된다.
이러한 Effect API들이 무엇인지, 각각의 사용법과 특성에 대해 알아보겠다.
# 부수 효과란?
Effect API들을 알아보기 전에 부수 효과가 무엇인지 먼저 알 필요가 있다.
아래는 공식문서에 나온 부수 효과 설명이다.
정리를 하자면 아래와 같다
- 부수 효과는 구성 가능한 함수(Composable)의 범위 밖에서 발생하는 앱 상태에 관한 변경사항이다.
- Composable에는 부수 효과가 없는 것이 좋다.
하지만 아래와 같은 경우는 부수효과가 필요한 경우가 있다.
- 스낵바 표시
- 특정 상태 조건에 따라 다른 화면으로 이동하는 등 일회성 이벤트를 트리거할 때
## 부수 효과 자세히 알아보기
Composable은 각각의 생명주기와 상태에 대해 단방향 흐름을 가진다.
개발할 때 여러 Composable을 겹쳐서 사용하는데 이때 외부 Composable은 내부 Composable로 상태가 흐를 수 있게 구성되어 있다.
하지만 내부 Composable이 외부 Composable의 상태에 대한 변경을 준다면 단방향이 아닌 양방향 흐름이 된다.
이와 같이 양방향 의존성으로 인해 예측할 수 없는 효과가 생기는 것을 부수 효과(Side Effect)라고 한다.
# Effect API
구글에서는 부수 효과를 처리하기 위한 다양한 Effect API를 제공한다.
하나씩 알아보자!
## LaunchedEffect
✅ Composable 스코프에서 suspend 함수 실행하는 API
- Composable 내에서 안전하게 suspend 함수를 호출하고 싶을 때 사용한다.
- LaunchedEffect의 매개변수인 key의 상태가 변할 때마다 스코프가 실행된다.
- LaunchedEffect가 컴포지션을 종료하면 코루틴도 함께 취소
### 예제 코드
@Composable
fun LaunchedEffectEx(){
var counter by remember { mutableIntStateOf(0) }
var isLoading by remember { mutableStateOf(false) }
// isLoading을 key으로 선언
LaunchedEffect(key1 = isLoading) {
// 0.5초 딜레이 후 isLoading 상태 변경
delay(500)
isLoading = false
}
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
if (isLoading) {
CircularProgressIndicator()
} else {
Text(text = "Counter: $counter")
Spacer(modifier = Modifier.height(16.dp))
Button(onClick = {
counter++
isLoading = true
}) {
Text(text = "카운터 증가")
}
}
}
}
LaunchedEffect(isLoading) {
delay(500)
isLoading = false
}
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
if (isLoading) {
CircularProgressIndicator()
} else {
Text(text = "Counter: $counter")
Spacer(modifier = Modifier.height(16.dp))
Button(onClick = {
counter++
isLoading = true
}) {
Text(text = "카운터 증가")
}
}
}
}
위 코드는 카운터 증가 버튼을 누르면 0.5초 동안 로딩 화면을 보여주고 카운터를 증가시키는 예제이다.
더 자세히 살펴보면 isLoading이라는 변수를 LaunchedEffect에 key값으로 넣어줌으로써 isLoading의 상태가 변할 때마다 LaunchedEffect 스코프가 실행된다.
## rememberCoroutineScope
✅ 이벤트가 발생할때 suspend 함수를 사용할 수 있게 해주는 API
- 코루틴 하나 이상의 수명 주기를 수동으로 관리해야 할 때마다 사용한다.
- 예를들어 사용자 이벤트가 발생할 때 애니메이션을 취소해야 하는 경우 사용한다.
### 예제 코드
@Composable
fun RememberCoroutineScopeEx() {
var counter by remember { mutableIntStateOf(0) }
var isLoading by remember { mutableStateOf(false) }
// rememberCoroutineScope 선언
val coroutineScope = rememberCoroutineScope()
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
if (isLoading) {
CircularProgressIndicator()
} else {
Text(text = "Counter: $counter")
Spacer(modifier = Modifier.height(16.dp))
Button(onClick = {
// 위에서 선언한 코루틴 스코프 .launch 스코프안에 로직 넣어주기
coroutineScope.launch {
isLoading = true
counter++
delay(500)
isLoading = false
}
}) {
Text(text = "카운터 증가")
}
}
}
}
위 코드도 LaunchedEffect와 동일한 예제이다.
하지만 이번에는 LaunchedEffect 대신 rememberCoroutineScope을 사용했다.
rememberCoroutineScope를 변수로 선언해. launch 스코프 안에 실행하고 싶은 로직을 넣어준다.
갑자기 나는 여기서 아래와 같은 궁금한 점이 생겼다..!
LaunchedEffect와 rememberCoroutineScope 둘 다 suspend를 함수를 사용하는데 차이점이 뭐지??
## LaunchedEffect / rememberCoroutineScope 차이점
차이점이 궁금해 검색해서 알아보다가 정리해서 표로 만들어봤다.
LaunchedEffect | rememberCoroutineScope |
특정 값(key)이 바뀌면 자동으로 실행됨 | 이밴트가 발생할 때 수동으로 실행됨 |
주로 컴포넌트가 처음 나타날 때나 값이 변경될 때 사용 | 주로 사용자가 어떤 동작(버튼 클릭)을 할때 사용 |
자동으로 실행되는 코루틴 | 수동으로 실행되는 코루틴 |
실무로 대입해서 정리 하자면
LaunchedEffect는 "어떤 값이 바뀌면 자동으로 이 코드를 실행해 줘."
rememberCoroutineScope는 "이 버튼을 누르면 이 코드를 실행해 줘."
이 두문장의 느낌이 맞지 않을까 싶다.
## rememberUpdatedState
✅ 최신 값을 항상 참조할 수 있게 해주는 API
- Composable 내에서 안전하게 최신 상태 값을 참조하여 suspend 함수를 호출하고 싶을 때 사용한다.
- 주로 LaunchedEffect와 함께 사용된다.
- 콜백이나 외부 Composable의 값을 최신 값을 참조할 수 있게 해 준다.
rememberUpdatedState 내부 코드는 아래와 같다.
@Composable
fun <T> rememberUpdatedState(newValue: T): State<T> = remember {
mutableStateOf(newValue)
}.apply { value = newValue }
내부코드를 보면 remember로 감싼 mtableStateOf를 apply 해서 value를 바꿔주는 로직을 가지고 있는데 그 이유는 remember 내부의 mutableStateOf값을 수정하기 위해서는 value값을 직접 수정해줘야 하기 때문이다.
글만으로는 이해가 안 될 수도 있어서 예제 코드를 보면서 다시 설명하겠다.
### 예제 코드
이번 예제는 버튼을 누르면 이름이 바뀌는 예제인데 rememberUpdatedState를 사용한 경우와 사용하지 않은 경우를 비교해서 작성해 보겠다.
<최상위 컴포저블>
@Composable
fun TestScreen() {
var name by remember { mutableStateOf("김지게") }
var useUpdatedState by remember { mutableStateOf(true) }
Column {
Button(onClick = { name = if (name == "김지게") "이지게" else "김지게" }) {
Text("이름 변경")
}
Spacer(modifier = Modifier.height(16.dp))
Text("현재 이름: $name")
Spacer(modifier = Modifier.height(16.dp))
Button(onClick = { useUpdatedState = !useUpdatedState }) {
Text(if (useUpdatedState) "UpdatedState 사용 중" else "UpdatedState 미사용")
}
Spacer(modifier = Modifier.height(16.dp))
if (useUpdatedState) {
CounterWithUpdatedState(name)
} else {
CounterWithoutUpdatedState(name)
}
}
}
최상위에는 name이라는 remember로 감싸진 mutableStateOf가 있다.
<rememberUpdatedState 미사용>
@Composable
fun CounterWithoutUpdatedState(name: String) {
var count by remember { mutableStateOf(0) }
// 상위에서 넘어온 name을 다시 remember로 감싸준 mutableStateOf를 선언해준다.
val updateName by remember { mutableStateOf(name) }
LaunchedEffect(Unit) {
while (true) {
delay(1000)
count++
}
}
Text("UpdatedState 미사용: ${count}초 동안 ${updateName}님 안녕하세요!")
}
rememberUpdatedState를 사용하지 않고 상위에서 전달받은 name을 일반 remember로 감싼 mutableStateOf로 선언할 때 발생하는 문제점은 다음과 같다.
- 컴포저블이 처음 생성될 때, remember { mutableStateOf(name) }은 초기 name 값으로 새로운 State 객체를 생성한다.
- 이후 상위 컴포넌트에서 name 값이 변경되어도, 이 remember로 생성된 State 객체는 value을 바꾸지 못하므로 자동으로 업데이트되지 않는다.
- 컴포저블이 리컴포지션될 때마다 remember 블록은 이전에 생성된 동일한 State 객체를 반환하므로, 새로운 name 값이 반영되지 않는다.
결과적으로, 컴포저블 내부에서는 항상 초기에 받았던 name 값만 표시되고, 상위 컴포넌트에서의 아래 영상처럼 변경사항이 반영되지 않는다.
이 문제를 해결하기 위해 rememberUpdatedState를 사용하는 예제를 알아보자!
<rememberUpdatedState 사용>
@Composable
fun CounterWithUpdatedState(name: String) {
// rememberUpdatedState로 상위에서 넘어온 name을 새로 생성해준다.
val updatedName = rememberUpdatedState(name)
var count by remember { mutableStateOf(0) }
LaunchedEffect(Unit) {
while (true) {
delay(1000)
count++
}
}
Text("UpdatedState 사용: ${count}초 동안 ${updatedName.value}님 안녕하세요!")
}
이번 코드에서는 rememberUpdatedState로 상위에서 넘어온 name을 새로 생성하니 정상적으로 name의 값이 업데이트되는 것을 볼 수 있다.
💬 "도대체 왜 되는 건데??"라는 의문이 있는 사람은 여기서 다시 한번 rememberUpdateState 내부 코드를 봐보자!
@Composable
fun <T> rememberUpdatedState(newValue: T): State<T> = remember {
mutableStateOf(newValue)
}.apply { value = newValue }
내부 코드를 보면 remember의 apply를 통해 value 값을 넘어온 value로 바꿔주는 동작을 한다.
이렇게 함으로써 컴포저블은 항상 최신 상태를 유지하며, 상위 컴포넌트의 상태 변화에 즉각적으로 반응할 수 있다.
또한 불필요한 리컴포지션을 방지하여 성능을 최적화할 수 있다.
남은 EffectAPI(DisposableEffect, SideEffect, produceState, derivedStateOf, snapshotFlow)들은 2편에 다시 올리도록 하겠다!!
참조
https://developer.android.com/develop/ui/compose/side-effects?hl=ko
https://medium.com/@ans188/compose-side-effects-in-compose-d7729e9f38a2
https://kotlinworld.com/245