Jetpack Compose의 세계에서 컴포저블(Composable) 함수는 '순수함수(Pure Function)' 와 같습니다. 동일한 상태(State)가 주어지면 항상 동일한 UI를 그려내는 '설계도' 역할을 하죠. 이 설계도는 상태가 변경될 때마다, 때로는 초당 수십 번씩 다시 그려질 수 있습니다. 이 과정을 리컴포지션(Recomposition) 이라고 부릅니다.
여기서 한 가지 중요한 규칙이 있습니다. "설계도를 그리는 과정에서 설계도 밖의 세상에 영향을 주어서는 안 된다."
만약 설계도를 그릴 때마다 스낵바를 띄우거나, 네트워크 요청을 보내거나, 데이터베이스에 데이터를 저장한다면 어떻게 될까요? 🤔 앱은 순식간에 통제 불능 상태에 빠지고, 수많은 버그와 성능 저하를 일으킬 겁니다.
하지만 현실의 앱은 '바깥 세상'과 소통해야만 합니다. 이처럼 컴포저블의 범위를 벗어나 앱의 상태를 변경하거나 외부와 통신하는 모든 작업을 사이드 이펙트(Side Effect, 부수 효과) 라고 합니다. Compose는 이 위험하고도 필수적인 작업을 안전하게 처리할 수 있도록, 마치 '특별 허가를 받은 통로'와 같은 강력한 API들을 제공합니다.
오늘은 이 '안전한 탈출구', 사이드 이펙트 핸들러들을 하나하나 깊이 있게 파헤쳐 보겠습니다.
LaunchedEffect: 컴포지션 시점의 비동기 작업 실행기
LaunchedEffect는 컴포저블이 처음 화면에 나타나거나(Composition), 특정 key 값이 변경되었을 때 코루틴을 실행하는 가장 기본적이고 널리 사용되는 사이드 이펙트 핸들러입니다.
언제 사용할까요? 🚀
- 화면이 처음 보일 때 초기 데이터를 로딩해야 할 때
- 특정 상태가 true가 되었을 때 스낵바를 표시하거나 다른 화면으로 이동하고 싶을 때
- ViewModel의 StateFlow나 Channel 같은 이벤트를 구독하고 특정 UI 액션을 수행해야 할 때
key의 비밀: 재시작의 열쇠
- key가 변경되면: 진행 중이던 기존 코루틴은 **취소(Cancel)**되고, 새로운 key와 함께 내부 코드가 처음부터 재시작됩니다.
- key가 변경되지 않으면: 리컴포지션이 일어나도 코루틴은 재시작되지 않고 계속 실행됩니다.
- key1 = Unit 또는 key1 = true: key 값이 절대 변하지 않으므로, 컴포저블이 처음 생성될 때 딱 한 번만 실행되고 화면에서 사라질 때까지 유지됩니다.
// 에러 상태에 따라 스낵바 보여주기
@Composable
fun MyScreen(
viewModel: MyViewModel = hiltViewModel()
) {
val uiState by viewModel.uiState.collectAsState()
val scaffoldState = rememberScaffoldState()
LaunchedEffect(key1 = uiState.errorMessage) {
uiState.errorMessage?.let { message ->
scaffoldState.snackbarHostState.showSnackbar(message)
viewModel.consumeErrorMessage()
}
}
Scaffold(scaffoldState = scaffoldState) { /* ... */ }
}
rememberCoroutineScope: 사용자 액션에 응답하는 비동기 작업
사용자가 버튼을 클릭하는 등, 리컴포지션과 상관없는 이벤트 콜백 내에서 코루틴을 실행하고 싶을 때 사용합니다. remember를 통해 컴포저블의 생명주기에 연결된 CoroutineScope를 얻어올 수 있습니다.
언제 사용할까요? 👆
- 버튼 클릭 시 네트워크 요청을 보내고 로딩 인디케이터를 표시해야 할 때
- 사용자가 목록을 아래로 당겨 새로고침할 때
// 버튼 클릭 시 프로필 업데이트
@Composable
fun ProfileEditScreen(viewModel: ProfileViewModel) {
val scope = rememberCoroutineScope()
var isLoading by remember { mutableStateOf(false) }
Button(
onClick = {
scope.launch {
isLoading = true
try {
viewModel.updateProfile()
} finally {
isLoading = false
}
}
},
enabled = !isLoading
) { /* ... */ }
}
DisposableEffect: 리소스 생성과 정리가 쌍으로 필요할 때
컴포저블이 화면에 보이는 동안 리스너를 등록하고, 화면에서 사라질 때 해제해야 하는 경우처럼 진입(Setup)과 퇴장(Clean-up) 시점의 작업이 명확하게 쌍을 이룰 때 사용합니다.
언제 사용할까요? 🧹
- LifecycleObserver를 등록하고 해제할 때
- BroadcastReceiver를 등록하고 해제할 때
// 화면의 생명주기 이벤트 감지
@Composable
fun AnalyticsLogger(lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current) {
DisposableEffect(key1 = lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) logScreenView("enter")
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
}
}
}
rememberUpdatedState: 최신 값을 참조해야 하는 장기 실행 작업
LaunchedEffect(Unit)처럼 단 한 번만 실행되는 코루틴 내부에서, 재시작 없이 컴포저블의 최신 상태나 람다를 참조해야 할 때 사용합니다.
언제 사용할까요? ⏱️
- 일정 시간 후 사라지는 화면에서, 사라지기 직전에 최신 상태를 기반으로 액션을 취해야 할 때
- 계속 실행되는 이펙트 안에서 부모로부터 받은 람다(onClick, onEvent)를 호출해야 할 때
// 최신 람다를 호출하는 타임아웃
@Composable
fun LandingScreen(onTimeout: () -> Unit) {
val updatedOnTimeout by rememberUpdatedState(onTimeout)
LaunchedEffect(Unit) {
delay(3000L)
updatedOnTimeout()
}
}
SideEffect: Compose 상태를 외부와 공유하기
매 리컴포지션이 성공적으로 완료될 때마다 코드를 실행하고 싶을 때 사용합니다. Compose가 관리하지 않는 외부 객체나 라이브러리와 Compose의 상태를 공유하는 용도로 주로 사용됩니다.
언제 사용할까요? 📢
- 현재 상태를 외부 분석(Analytics) 라이브러리에 전달할 때
- 외부 콜백을 통해 UI 상태를 외부에 알릴 때
// 분석 라이브러리에 현재 상태 전달
@Composable
fun UserProfileScreen(user: User) {
// user 정보가 바뀔 때마다 리컴포지션이 성공적으로 끝나면 호출됩니다.
SideEffect {
Analytics.setUserProperty("userName", user.name)
Analytics.setUserProperty("userTier", user.tier)
}
Text("사용자: ${user.name}")
}
produceState: 외부 스트림을 Compose State로 변환
Flow, LiveData, RxJava 등 외부의 비동기적인 데이터 스트림을 Compose가 즉시 사용할 수 있는 State로 변환해줍니다.
언제 사용할까요? 🔗
- Flow나 다른 데이터 스트림을 구독하여 UI에 표시해야 할 때
- 네트워크나 데이터베이스로부터 오는 데이터 스트림을 State로 만들고 싶을 때
// Flow를 State로 변환하기
@Composable
fun <T> Flow<T>.collectAsStateWithLifecycle(
initialValue: T
): State<T> {
// 위치 정보 Flow를 구독하여 State<Location>으로 변환합니다.
return produceState(initialValue = initialValue) {
// 이 블록은 코루틴 스코프입니다.
// Flow가 새로운 값을 방출(emit)하면 `value`가 업데이트됩니다.
this@collectAsStateWithLifecycle.collect {
value = it
}
}
}
snapshotFlow: Compose State를 외부 Flow로 변환
produceState의 정반대 역할을 합니다. Compose의 State 객체를 Kotlin Flow 로 변환하여, debounce, map, filter 등 풍부한 Flow 연산자를 활용할 수 있게 해줍니다.
언제 사용할까요? 🔍
- 사용자가 텍스트 입력을 멈췄을 때만 검색 API를 호출하고 싶을 때 (debounce)
- 스크롤 위치(LazyListState) 같은 Compose 상태의 변화를 ViewModel에서 감지하고 싶을 때
// 입력이 멈추면 자동 검색
@Composable
fun SearchScreen(
viewModel: SearchViewModel
) {
var text by remember { mutableStateOf("") }
TextField(
value = text,
onValueChange = { text = it }
)
// text State가 변경될 때마다 Flow가 새로운 값을 방출합니다.
LaunchedEffect(
key1 = Unit
) {
snapshotFlow { text }
.debounce(500) // 500ms 동안 입력이 없으면
.filter { it.isNotEmpty() } // 비어있지 않으면
.collect { query ->
viewModel.search(query) // 검색 실행
}
}
}
derivedStateOf: 불필요한 리컴포지션을 막는 계산된 State
derivedStateOf는 하나 이상의 State 객체를 기반으로 새로운 State를 계산하여 만듭니다. 핵심은, 내부 State가 바뀔 때마다 리컴포지션을 유발하는 것이 아니라, 계산된 최종 결과가 바뀔 때만 리컴포지션을 유발한다는 것입니다.
언제 사용할까요? ✨
- 여러 상태에 따라 UI의 가시성이 결정되는데, 이 계산이 자주 일어날 때
- LazyListState의 스크롤 위치처럼 매우 빈번하게 변경되는 상태를 기반으로 다른 UI를 제어할 때
// 스크롤 위치에 따라 '맨 위로' 버튼 표시
@Composable
fun MyList() {
val listState = rememberLazyListState()
// firstVisibleItemIndex는 스크롤할 때마다 계속 바뀝니다.
// 하지만 "맨 위로 가기" 버튼의 가시성은 (index > 0)의 결과, 즉 true/false가
// 바뀔 때만 변경되면 됩니다. derivedStateOf가 바로 이 역할을 합니다.
val showButton by remember {
derivedStateOf {
listState.firstVisibleItemIndex > 0
}
}
Box {
LazyColumn(
state = listState
) {
// ... list content ...
}
AnimatedVisibility(
visible = showButton,
modifier = Modifier.align(Alignment.BottomCenter)
) {
Button(
onClick = { /* ... */ }
) {
Text(text = "맨 위로")
}
}
}
}
마치며
오늘 우리는 Compose가 제공하는 거의 모든 종류의 사이드 이펙트 핸들러를 깊이 있게 탐험했습니다. 각 핸들러는 저마다의 명확한 역할과 목적을 가지고 있으며, 이들을 올바르게 사용하는 것이 안정적이고 성능 좋은 Compose 앱을 만드는 핵심입니다.
블로그 글에 대해 궁금한 점이 있다면 아래 카카오톡 오픈채팅에 들어와서 질문해주세요
Android Kotlin Compose QnA
open.kakao.com
'개발 > Compose' 카테고리의 다른 글
(Compose) Compose phase - 어떻게 화면을 그릴까? (1) | 2025.08.18 |
---|---|
(Compose) Lifecycle - Composition의 탄생부터 소멸까지 (2) | 2025.08.08 |
(Compose) Stateful vs Stateless (4) | 2025.08.07 |
(Compose) Compose UI Test (4) | 2025.08.05 |
(Compose) LazyColumn (3) | 2025.08.05 |