와챠의 우당탕탕 코딩 일기장
[Android] 2-3. State, Effect, Coroutine 활용① / LaunchedEffect vs rememberCoroutineScope / collectAsStateWithLifecycle 본문
[Android] 2-3. State, Effect, Coroutine 활용① / LaunchedEffect vs rememberCoroutineScope / collectAsStateWithLifecycle
minWachya 2025. 5. 2. 01:01이번엔 다양한 Effect를 통해 State를 활용하는 방법을 배워보려고 한다.
<목차>
- 만들 앱 기능 소개
- 기존 프로젝트 패키지와 파일 소개
- MutableStateFlow, StateFlow: ViewModel에서 변경되는 데이터를 관리하는 데에 사용
- + collectAsStateWithLifecycle
- LaunchedEffect, rememberUpdatedState: 컴보저블에서 특정 작업을 실행하고 suspend 함수 호출이 가능한 함수
- rememberCoroutineScope: scope 안에서 suspend 함수를 사용할 수 있는 코루틴
- + LaunchedEffect vs rememberCoroutineScope
1. 만들 앱 기능 소개
실습하면서 최종 완성할 앱의 기능은 다음과 같다.
- 항공편을 탐색할 수 있는 앱
- 인원 수에 따른 여행지 추천
- 랜딩 화면에서 데이터 로드
- drawer
- 목적지 검색하면 해당 검색어 목적지만 list에 띄워줌
- Up 버튼을 통해 List의 맨 위 항목으로 이동
2. 프로젝트 패키지와 파일 소개
앱의 구조와 각 파일의 역할은 다음과 같다.
파란 색으로 되어 있는 파일만 건드릴 예정이다.
그래도 패키징 어떻게 해두셨는지 슬쩍 참고 겸 전체 동작 흐름을 파악할 겸 적어둔다!
- base
- BaseUserInput.kt: 위 UI의 Tab 화면에 있는 Input의 기본 뼈대
- CraneDrawer.kt: drawer UI
- CraneTabs.kt: 위 UI의 "Fly, Sleep, Eat" 탭 부분
- EditableUserInput.kt: 수정 가능한 Input 부분. state통해 관리.(목적지 검색 가능)
- ExploreSection.kt: 위 UI의 목적지 list 부분
- Result: 통신 상태(Success, Error)를 관리할 sealed class
- data
- Cities.kt: 여러 국가들 데이터(국가명, 수도명, 위도, 경도)
- DestinationsLocalDataSource: 레스토랑, 호텔, 목적지들 데이터(국가명, 위치, 이미지)
- DestinationsRepository: DestinationsLocalDataSource의 데이터를 관리함. (검색어와 일치하는 나라명을 가진 데이터 반환)
- ExploreModel.kt: Cities, DeatinationsLocalDataSource에 쓰이는 데이터를 관리할 data class
- details
- DetailsActivity.kt: 특정 국가를 클릭했을 때 그 나라의 자세한 정보(이름, 구글맵)를 보여주는 화면
- DetailsViewModel: DestinationsRepository를 통한 목적지 데이터 최종 관리
- MapViewUtils.kt: 현재 Lifrcycle에 따라 구글맵(지도)의 Lifecycle을 관리함
- di
- DispatchersModule.kt: Dispatchers.Default 반환
- home
- CraneHome.kt: 홈 UI(탭, 탭 별 화면, 목적지 리스트들, 슬라이딩 메뉴...)
- HomeFeatures.kt: Fly, Sleep, Eat 탭 별 화면
- LandingScreen.kt: 랜딩 화면
- MainActivity: 전체 UI(랜딩화면 보인 후 홈 화면으로 이동)
- MainViewModel.kt: Home에서 사용할 데이터 관리 뷰 모델
- SearchUserInput.kt: BaseUserInput를 활용한 Home의 Tab 화면에 쓰일 실제 Input
- ui
- CraneTheme.kt
- Typography.kt
- util
- NetworkUtil.kt: Request의 Log를 확인하기 위한 Interceptor 생성
- Shapes.kt
- CraneApplication: @HiltAndroidApp 포인트
3. MatableStateFlow, StateFlow
앱을 실행하면 랜딩 화면 후 홈 화면이 나온다.
이때 홈 화면에서 사용할 데이터를 즉시 로드해야 하고, Tab 선택에 따라 적절한 데이터를 반환해야 한다.
이러한 데이터들은 관리하기 편하게 한 곳에 모아 관리하는데 그게 MainViewModel이다!
여기서는 서버 연결을 따로 하지 않고 앱 내의 데이터를 가져온다.
// home/MainViewModel.kt
@HiltViewModel
class MainViewModel @Inject constructor(
private val destinationsRepository: DestinationsRepository,
@DefaultDispatcher private val defaultDispatcher: CoroutineDispatcher
) : ViewModel() {
// 호텔, 레스토랑 데이터들...(생략)
// 목적지 데이터들
private val _suggestedDestinations = MutableStateFlow<List<ExploreModel>>(emptyList())
val suggestedDestinations: StateFlow<List<ExploreModel>> = _suggestedDestinations.asStateFlow()
// ViewModel 실행 시 목적지 데이터 설정
init {
_suggestedDestinations.value = destinationsRepository.destinations
}
// 인원수에 따른 추천 목적지 반환
fun updatePeople(people: Int) {
viewModelScope.launch {
// 생략...
/* val newDestinations = withContext(defaultDispatcher) {~}
_suggestedDestinations.value = newDestinations */
}
}
// 목적지명에 newDestination가 포함된 목적지 반환
fun toDestinationChanged(newDestination: String) {
viewModelScope.launch {
// 생략...
/* val newDestinations = withContext(defaultDispatcher) {~}
_suggestedDestinations.value = newDestinations */
}
}
}
여기서 포인트는 수정 가능한(MutableStateFlow) 목적지 데이터를 ViewModel 밖에서 사용할 땐 수정 불가능한 형태(StateFlow)로 반환하는 부분이다.
목적지 데이터는 홈 화면에서 사용자가 설정하는 조건에 따라 다양하게 변경되는데, 홈 화면에서 이러한 목적지 데이터를 변경할 수 없도록 안전하게 설정해야한다.
private val _suggestedDestinations = MutableStateFlow<List<ExploreModel>>(emptyList())
val suggestedDestinations: StateFlow<List<ExploreModel>> = _suggestedDestinations.asStateFlow()
suggestedDestinations가 변경될 때마다 UI를 업데이트하기 위해서 collectAsStateWithLifecycle()를 사용한다.
이를 사용하려면 아래 종속성을 추가해야 한다.
dependencies {
implementation "androidx.lifecycle:lifecycle-runtime-compose:$lifecycle_version"
}
collectAsStateWithLifecycle: flow에서 값을 수집하고 최신 값을 Compose state로 나타내는 라이프사이클 인식 방식의 composable 함수입니다. 새로운 flow 방출이 발생할 때마다 이 State 객체의 값이 업데이트 됩니다. 이렇게 하면 Composition의 모든 State.value가 사용되는 부분에서 recomposition이 일어납니다.
출처: https://medium.com/hongbeomi-dev/jetpack-compose에서-flow를-안전하게-사용하기-a394a679909b
홈 화면에서 사용할 데이터를 MainViewModel에서 가져와서 이를 collectAsStateWithLifecycle를 통해 사용해주면 된다.
그러면 데이터가 변경될 때마다 해당 데이터를 사용하는 UI가 변경된다!!
// home/CraneHome.kt
@Composable
fun CraneHomeContent(
//...
viewModel: MainViewModel = viewModel(),
) {
val suggestedDestinations by viewModel.suggestedDestinations.collectAsStateWithLifecycle()
// ...
}
여기까지 설정하면 앱 실행 시 인원수에 따라 목적지가 변경되는 기능을 확인할 수 있다.
4. LaunchedEffect, rememberUpdatedState
홈에서 사용되는 데이터를 랜딩 화면에서 미리 로딩한 뒤, 로딩이 끝나면 홈 화면으로 이동하려고 한다.
(이 앱은 서버 연결이 없으니 이러한 대기를 delay로 대체한다.)
이렇게 컴포저블에서 suspend 함수를 사용하기 위해선 LaunchedEffect를 사용하면 된다.
LaunchedEffect: 컴포저블의 범위에서 suspend 함수를 실행합니다.
컴포저블의 전체 기간에 걸쳐 작업을 실행하고 suspend 함수를 호출할 수 있는 기능을 사용하려면 LaunchedEffect 컴포저블을 사용하세요. LaunchedEffect가 컴포지션을 시작하면 매개변수로 전달된 코드 블록으로 코루틴이 실행됩니다.
LaunchedEffect가 컴포지션을 종료하면 코루틴이 취소됩니다.
LaunchedEffect가 다른 키로 재구성되면 기존 코루틴이 취소되고 새 코루틴에서 새 suspend 함수가 실행됩니다.
출처: 공식문서 - Compose의 부수 효과
이 컴포저블 함수의 lifecycle 동안 delay가 한 번만 동작하게 하려면 Unit를 키로 사용하면 된다.(LaunchedEffect(Unit) { ... }).
또한 중간에 onTimeout 함수가 변경될 때, 마지막 onTimeout 함수를 기억할 수 있도록 rememberUpdatedState API를 사용한다.
이 API는 최신 값을 캡처하고 업데이트한다.
// home/LandingScreen.kt
@Composable
fun LandingScreen(onTimeout: () -> Unit, modifier: Modifier = Modifier) {
Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
// 제일 마지막에 불린 onTimeout 저장
val currentOnTimeout by rememberUpdatedState(onTimeout)
// LandingScreen가 리컴포즈 되거나 onTimeout이 변경될 때 delay가 재시작되지 않도록 키를 Unit로 설정
LaunchedEffect(Unit) {
delay(SplashWaitTime) // < 여기서 앱에 필요한 데이터 미리 로드하면 됨
currentOnTimeout()
}
// 앱 로고
Image(painterResource(id = R.drawable.ic_crane_drawer), contentDescription = null)
}
}
이제 이 랜딩 화면을 앱 시작 시 먼저 보여주고,
로딩이 끝나면(delay가 끝나면) 홈 화면이 보이도록 코딩하면 아래와 같다.
// home/MainActivity.kt file
@Composable
private fun MainScreen(onExploreItemClicked: OnExploreItemClicked) {
Surface(color = MaterialTheme.colors.primary) {
// 랜딩 화면 표시 여부: 기본값 true
var showLandingScreen by remember { mutableStateOf(true) }
// 랜딩 화면 표시값에 따라 랜딩화면 또는 홈 화면을 보여줌
if (showLandingScreen) {
LandingScreen(onTimeout = { showLandingScreen = false })
} else {
CraneHome(onExploreItemClicked = onExploreItemClicked)
}
}
}
5. rememberCoroutineScope
메뉴를 눌렀을 때 drawer가 나타나도록 코딩해보려고 한다.
ㄱㅅ하게도 Scaffold에서 이를 위한 기본 기능을 제공해준다.
drawerContent에 drawer UI을 넣어주면 된다.
drawer를 열고 닫는 함수는 open()인데, 얘는 suspend 함수이므로 코루틴 내에서 사용해야 한다.
scope 안에서 suspend 함수를 사용할 수 있는 코루틴은 rememberCoroutineScope이다.
rememberCoroutineScope
부수효과를 구현하는 또 다른 방법으로는 rememberCoroutineScope 함수로 호출된 컴포지션에 바인딩된 CoroutineScope를 반환하여 해당 scope 안에서 suspend 함수를 구현하는 방법이다.
rememberCoroutineScope는 호출된 컴포지션에 바인딩되어있는 scope를 반환하기 때문에 해당 컴포지션이 취소되면 coroutineScope도 같이 취소된다.
출처: https://velog.io/@moonliam_/AndroidCompose-rememberCoroutineScope-vs-LaunchedEffect
// home/CraneHome.kt file
@Composable
fun CraneHome(
onExploreItemClicked: OnExploreItemClicked,
modifier: Modifier = Modifier,
) {
val scaffoldState = rememberScaffoldState()
Scaffold(
scaffoldState = scaffoldState,
modifier = Modifier.statusBarsPadding(),
drawerContent = {
CraneDrawer()
}
) {
val scope = rememberCoroutineScope()
CraneHomeContent(
modifier = modifier,
onExploreItemClicked = onExploreItemClicked,
openDrawer = {
scope.launch {
scaffoldState.drawerState.open()
}
}
)
}
}
+ LaunchedEffect vs rememberCoroutineScope
간단하게는 이렇게 말할 수 있을듯
- 공통점: 둘 다 suspend 함수를 실행할 수 있다.
- 차이점:
- LaunchedEffect는 컴포저블 범위에서 suspend 함수를 실행하고,
- rememberCoroutineScope는 컴포지션 외부에서 함수를 실행한다.
근데 난 이것 만으로 아래 강의 문서가 바로 이해가 안 되었기에 더 주절거려봄...
LaunchedEffect는 컴포저블에 대한 호출이 컴포지션으로 향할 때 Effect가 실행되도록 합니다.
...
LandingScreen의 본문에 rememberCoroutineScope 및 scope.launch를 사용하는 경우,
코루틴은 호출이 컴포지션으로 향하는지 여부와 무관하게 Compose에서 LandingScreen을 호출할 때마다 실행됩니다.
따라서 리소스를 낭비하게 되며 제어된 환경에서 이 부작용을 실행하지 않게 됩니다.
LandingScreen에서는 LaunchedEffect를 사용하는 게 적절한 이유는...
(랜딩 스크린이 데이터를 로드한다는 목표를 가지고 있는 한...) 컴포저블 범위 내에서 한 번만 실행되는 게 적절하기 때문이다.
drawer 기능 구현 때 사용한 open 함수를 rememberCoroutineScope에서 사용했던 이유는...
open 함수가 컴포지션 외부에 있는 일반 콜백(openDrawer)에서 호출되기 때문이다.
음, open 함수가 컴포지션 외부에서 동작하니까 rememberCoroutineScope사용? 오키!!
근데 LandingScreen에서는 왜 꼭 컴포저블 범위 내에서 동작하게 해야됨? 컴포저블 외부에서 작동하게 하면 안됨? 긍까 LaunchedEffect 대신 rememberCoroutineScope 쓰면 안됨? 하는 생각이 들 수 있다.
물론 rememberCoroutineScope로 충분히 동작하게 할 순 있다...
하지만 문제가 있다.
"컴포저블 범위 내에서" 라는 말에 밑줄을 좍좍 그어둔 이유는
컴포저블 범위에서 suspend 함수를 실행하니까 컴포저블 수명 주기에 따라 Effect를 관리할 수 있다.<라는 의미도 갖고 있기 때문이다.
즉, LaunchedEffect는 리컴포지션 시에는 기존 코루틴이 취소되고 새 코루틴이 시작된다.
하지만 rememberCoroutineScope는 컴포지션 외부에서 함수를 실행하므로...
리컴포지션이 되어도 기존 코루틴이 취소되지 않고 계속 진행된다...
이게 어떤 문제가 될 수 잇냐면... 일단 원래 잘 쓰인 예를 보자.
LandingScreen은 데이터를 로드하기 위해 의미없는 화면을 잠깐 보여준 뒤 홈 화면으로 이동하는 것이 목적이기 때문에 리컴포지션이 되어도 다시 로딩 화면을 보여줄 이유가 없다. 그래서 로딩 화면을 보여주는 건 앱 첨 실행 후, 즉 LandingScreen의 첫 컴포지션에만 실행되면 되므로 컴포지션이 여러 번 되더라도 한 번만 실행되라고 key도 Unit으로 설정했다.
글고 LandingScreen의 컴포지션 수명 주기가 다하면 실행되던 코루틴도 알아서 사라진다.
하지만 만약 LandingScreen에서 rememberCoroutineScope를 썼다...?
그럼 리컴포지션이 되었을 때마다 로딩 화면이 다시 보인다ㅋ 새로운 코루틴이 실행되기 때문이다.
참고한 블로그 글 덕분에 이해가 많이 되어서 같이 첨부한다!
rememberCoroutineScope는 컴포저블 외부에 있지만 컴포지션을 종료한 후 자동으로 취소되도록 범위가 지정된 코루틴을 실행하거나코루틴 하나 이상의 수명 주기를 수동으로 관리해야 할 때(예: 사용자 이벤트가 발생할 때 애니메이션을 취소해야 하는 경우) 유용하게 사용할 수 있다.
(중략)...
재구현이 상당히 자주 일어나는 화면에서 rememberCoroutineScope를 설정했다면 지나치게 많은 코루틴이 생성되어 심할 경우 앱 크래시를 유발할 수도 있다.
출처: https://velog.io/@moonliam_/AndroidCompose-rememberCoroutineScope-vs-LaunchedEffect
참고
- [Kotlin] Flow, StateFlow, MutableStateFlow, SharedFlow
- Jetpack Compose에서 flow를 안전하게 사용하기
- Compose의 부수 효과
- [Compose] 다양한 방법으로 Drawer를 구현해보자 - Scaffold, ModalDrawer
- [Android/Compose] rememberCoroutineScope vs LaunchedEffect
헉헉헉 공식문서 영어로 된 거 파파고로 번역했다가 번역 이상해서 뭔소리고?? 이러면서 다시 영어로 보고... 배우는 데에 시간이 너무 오래 걸렷더요..
예를 들면 Effect를 부작용이나 부수효과로 번역한다던가... suspend fun을 정지 함수로 번역한다던가.
아니 뭔 말하는지 예측은 되는데 한글로 쭈욱 글을 읽자니 뇌에 잘 안 들어오는 기분...
차라리 전문을 영어로 읽거나, 전문 용어만이라도 영어로 읽는 게 더 이해가 잘 되는 거 같음...
글고 갑자기 레벨이 확 높아지는 기분!? 갑자기 실전인 느낌이라 더더 배우는 게 더뎠다.
그래도 기왕 처음 배우는 거 확실히 배우고 싶어서 이거저거 찾아보고 내 언어로 표현해보려고 열시미 노력한 거 같아서 뿌듯.
강의는 하나인데 배우는 게 넘 많고 설명도 길어져서 2편으로 나누려고 한닷ㅎㅎ
그나저나 이전에 LiveData와 비슷한 흐름인 거 같아서 그나마 이해가 빨랐다!!