SideEffect는 Jetpack Compose의 composable 함수로, 성공적인 재구성(recomposition)마다 전달된 코드 블록을 실행합니다. LaunchedEffect 및 DisposableEffect와 달리 SideEffect는 키에 연결되지 않으며 정리(cleanup) 블록이 없습니다 — 렌더링 후에 Compose 상태를 외부 시스템과 동기화할 뿐입니다. 따라서 콜백 함수 업데이트, ViewPager와의 동기화, Analytics SDK로 데이터 전송에 이상적입니다. Android Developers Documentation (2025)에 따르면 SideEffect는 Compose가 성공적인 재구성을 확인한 후에만 실행되며, 재구성이 생략된 경우 실행되지 않습니다.
핵심 사항
SideEffect는 Jetpack Compose에서 가장 간단한 사이드 이펙트 API입니다. composable 컴포넌트의 성공적인 재구성마다 코드 블록을 실행합니다. 여기서 "성공적인"이라는 단어가 핵심입니다. Compose가 재구성이 필요하지 않다고 판단하면 (예: 모든 입력 매개변수가 변경되지 않았고 결과가 동일한 경우) SideEffect는 실행되지 않습니다. 이를 통해 동기화 블록이 UI가 실제로 변경된 경우에만 호출됩니다.
SideEffect의 주요 사용 사례는 Compose 트리의 일부가 아닌 객체와 Compose 상태를 동기화하는 것입니다. 일반적인 예로는 Legacy View 시스템에서 콜백 함수 업데이트, ViewPager에 현재 상태 전달, 표시 데이터 변경 시 Analytics SDK로 이벤트 전송, 외부 형식으로 업데이트를 기대하는 매핑 SDK와의 동기화 등이 있습니다.
Android Developer Blog (2025)에 따르면 SideEffect는 종종 remember와 함께 사용됩니다. remember는 객체(예: 콜백)를 보존하고 SideEffect는 종속성이 변경될 때마다 이를 업데이트합니다. 이 패턴은 리스너 객체를 수락하고 업데이트 시 이를 다시 생성하지 않는 라이브러리에 특히 중요합니다. SideEffect가 없으면 리스너는 현재 상태에 대한 오래된 참조를 보유하게 됩니다.
@Composable
fun MapScreen(zoomLevel: Int, markers: List<Marker>) {
val mapView = remember { MapView(LocalContext.current) }
SideEffect {
mapView.setZoom(zoomLevel)
mapView.updateMarkers(markers)
}
AndroidView(factory = { mapView })
}
SideEffect를 이해하려면 Jetpack Compose의 실행 단계를 이해해야 합니다. Compose는 각 프레임에 대해 세 단계를 거칩니다: Composition (무엇을 표시할지), Layout (어디에 표시할지), Drawing (어떻게 표시할지)입니다. SideEffect는 Composition 단계의 끝에서 실행됩니다 — 모든 composable 함수가 실행된 후, Layout 단계 이전입니다. 이를 통해 SideEffect는 재구성 후 모든 변수의 최종 상태를 확인할 수 있습니다.
생명주기에서 이 위치는 중요한 이점을 제공합니다: SideEffect는 내부에서 상태가 변경되더라도 무한 재구성을 유발할 수 없습니다. 구성 후에 실행되므로 SideEffect 내에서 이루어진 변경 사항은 다음 프레임에만 적용됩니다. 이는 composable 함수 본문 내 상태 변경의 일반적인 사이클(구성 내 setState가 현재 구성이 완료되기 전에 새 구성을 트리거하는 경우)을 방지합니다.
또 다른 특징은 SideEffect가 키에 의해 최적화되지 않는다는 점입니다. 어떤 특정 상태가 변경되었는지에 관계없이 모든 재구성에서 실행됩니다. 더 정밀한 제어가 필요한 경우(특정 매개변수가 변경될 때만 실행) 키가 있는 LaunchedEffect를 사용하거나 remember를 통해 SideEffect를 변경 확인으로 래핑하세요.
// SideEffect optimized with remember
var currentZoom by remember { mutableIntStateOf(zoomLevel) }
SideEffect {
if (currentZoom != zoomLevel) {
map.animateToZoom(zoomLevel)
currentZoom = zoomLevel
}
}
// Without this check SideEffect would call animateToZoom
// on every recomposition, even if zoomLevel didn't change
SideEffect의 가장 일반적인 실제 시나리오는 현재 상태를 캡처하는 콜백 함수를 업데이트하는 것입니다. Jetpack Compose에서는 이를 "콜백 수명 주기 관리"라고 합니다. 문제는 Kotlin의 람다 표현식이 참조로 변수를 캡처한다는 것입니다. 콜백이 특정 값으로 생성되고 나중에 변수가 변경되면 콜백은 계속 이전 값을 사용합니다.
예를 들어보겠습니다: Android용 Google Maps SDK는 setOnCameraMoveListener()를 통해 OnCameraMoveListener 객체를 수락합니다. isTrackingEnabled를 캡처하는 람다를 전달하면 isTrackingEnabled가 변경될 때 람다가 업데이트되지 않습니다 — Maps SDK는 계속해서 오래된 데이터로 이전 콜백을 호출합니다. SideEffect는 이 문제를 해결합니다: 재구성마다 리스너를 다시 설정하여 SDK가 항상 최신 상태로 현재 람다를 사용하도록 보장합니다.
Maps SDK for Android Documentation (2025)에 따르면 Google은 Maps를 Jetpack Compose와 통합할 때 정확히 이 패턴을 권장합니다. 유사한 접근 방식이 WebView, VideoView, TextureView 및 set 메서드를 통해 콜백을 수락하는 기타 View 기반 컴포넌트에 사용됩니다. SideEffect는 상태가 변경될 때마다 콜백이 최신 상태를 유지하도록 보장합니다.
@Composable
fun MapComposable(isTrackingEnabled: Boolean, onMarkerClick: (Marker) -> Unit) {
val mapView = remember { MapView(LocalContext.current) }
SideEffect {
mapView.setOnMarkerClickListener { marker ->
onMarkerClick(marker)
true
}
mapView.isTrafficEnabled = isTrackingEnabled
}
AndroidView(factory = { mapView })
}
SideEffect의 또 다른 중요한 시나리오는 UI 상태가 변경될 때 분석 시스템에 이벤트를 전송하는 것입니다. 예를 들어 사용자가 Compose 화면 내 TabLayout에서 탭을 전환하면 SideEffect가 현재 선택된 탭 상태를 Firebase Analytics 또는 AppsFlyer에 전달할 수 있습니다. 선택된 탭이 변경될 때마다(재구성이 발생하면) SideEffect가 해당 이벤트를 전송합니다.
onClick 또는 onTabSelected에서 직접 이벤트를 전송하는 것과의 차이점은 SideEffect가 모든 소스의 상태 변경에서 트리거된다는 것입니다 — 사용자 작업뿐만 아니라 프로그래밍 방식 변경, 화면 회전 후 상태 복원, 딥 링크도 포함됩니다. 이는 SideEffect를 변경 소스와 독립적인 범용 동기화 메커니즘으로 만듭니다.
Firebase Best Practices (Google, 2025)에 따르면 SideEffect를 통한 분석 이벤트 전송은 사용자 여정의 더 완전한 그림을 제공합니다. 직접적인 사용자 작업 없이 발생하는 것을 포함한 모든 상태 변경을 캡처하기 때문입니다. 그러나 과용하지 않는 것이 중요합니다: 각 분석 이벤트는 네트워크 요청이므로 자주 변경되는 상태(스크롤 위치, 손가락 좌표)에는 SideEffect가 적합하지 않습니다 — 디바운스를 사용하거나 중요한 변경 시에만 이벤트를 전송하세요.
@Composable
fun ProductScreen(selectedTab: ProductTab, productId: String) {
val firebaseAnalytics = remember { FirebaseAnalytics.getInstance(LocalContext.current) }
SideEffect {
val params = Bundle().apply {
putString(FirebaseAnalytics.Param.CONTENT_TYPE, selectedTab.name)
putString(FirebaseAnalytics.Param.ITEM_ID, productId)
}
firebaseAnalytics.logEvent( FirebaseAnalytics.Event.VIEW_ITEM, params)
}
// UI with TabRow and selected tab
}
SideEffect와 LaunchedEffect 사이의 선택은 두 가지 요소에 달려 있습니다: 비동기 실행이 필요한지와 키 기반 제어가 필요한지입니다. SideEffect는 동기식이며 모든 재구성에서 실행됩니다. LaunchedEffect는 비동기식(코루틴)이며 키가 변경될 때만 실행되고 모든 재구성에서 실행되지는 않습니다.
| 특성 | SideEffect | LaunchedEffect |
|---|---|---|
| 실행 | 모든 재구성에서 | 키 변경 시 |
| 비동기 | 동기식 | 코루틴 |
| 키 | 없음 | 있음 (vararg) |
| 정리 | 없음 | 자동 코루틴 취소 |
| 일반적 사용 | 콜백, 분석, 뷰 동기화 | 로딩, Flow 구독, 타이머 |
실제로 사이드 이펙트 사용 사례의 70%는 LaunchedEffect(비동기 작업, 데이터 로딩)가, 20%는 DisposableEffect(정리가 있는 리소스)가, 그리고 단 10%만 SideEffect(콜백 동기화)가 담당합니다. SideEffect는 제한된 작업 범위를 위한 특수 도구이지만, 이러한 작업에서는 필수 불가결합니다.
주요 실수는 SideEffect 내에서 Compose 상태를 변경하는 것입니다. SideEffect가 직접적으로 무한 루프를 유발하지는 않지만(구성 단계 후에 실행되므로), 과도한 재구성을 트리거할 수 있습니다. SideEffect 내에서 상태가 변경되면(mutableStateOf) 다음 프레임에서 새 재구성이 트리거되어 다시 SideEffect가 실행됩니다. 이는 무한 루프는 아니지만 프레임워크에 불필요한 작업입니다.
두 번째 실수는 SideEffect 내에서 무거운 계산을 수행하는 것입니다. SideEffect는 모든 재구성에서 호출되며 재구성은 초당 수십 번 발생할 수 있으므로(애니메이션, 스크롤 중) SideEffect 내의 무거운 코드는 프레임 드롭을 유발합니다. 무거운 작업은 구성 외부로 이동하세요 — 코루틴(LaunchedEffect)으로 옮기거나 derivedStateOf / remember를 통해 계산하세요.
세 번째 실수는 SideEffect를 비동기 코드에 사용하려는 것입니다. SideEffect는 suspend 함수가 아니므로 delay(), await(), collect() 및 기타 코루틴 작업이 내부에서 컴파일되지 않습니다. 재구성 후 비동기 작업을 수행해야 하는 경우 LaunchedEffect와 함께 snapshotFlow { ... }를 사용하거나 rememberCoroutineScope를 통해 코루틴을 시작하세요.
자주 묻는 질문
네, SideEffect는 모든 성공적인 구성에서 실행됩니다. 첫 번째 구성도 포함됩니다 — 컴포넌트가 처음 화면에 나타날 때입니다. 이는 LaunchedEffect(Unit)와 다른 점입니다. LaunchedEffect(Unit)도 첫 번째 구성에서 한 번 실행되지만 후속 재구성에서는 실행되지 않습니다(키가 변경되지 않은 경우).
아니요, SideEffect는 구성 단계 후에 실행됩니다 — 내부에서 이루어진 변경 사항은 다음 프레임에만 적용되어 루프를 방지합니다. 그러나 SideEffect 내에서 자주 상태를 변경하면 재구성의 연쇄 효과가 발생하여 성능이 저하될 수 있습니다. SideEffect 내에서의 상태 변경은 정말 필요한 경우에만 수행하세요.
SideEffect는 모든 재구성에서 동기적으로 실행됩니다. snapshotFlow는 Compose 상태에서 Flow를 생성하며 반응형 변경 처리를 위해 LaunchedEffect에서 collectLatest와 함께 사용할 수 있습니다. snapshotFlow는 디바운스, 필터 또는 distinctUntilChanged로 변경에 반응해야 하는 경우에 적합합니다 — 이는 동기식 SideEffect에서는 불가능합니다.
Android Studio Compose Modifier Debugger를 사용하거나 컴포넌트 이름과 호출 빈도로 로깅을 추가하세요. SideEffect가 예상보다 더 자주 실행되는 경우 부모 컴포넌트의 상태가 불필요하게 변경되고 있는지 확인하세요. 최적화: UI의 안정적인 부분을 별도의 composable 함수로 분리하고 unstable 어노테이션을 추가하여 재구성 수를 줄이세요.
네, 다른 목적으로 동일한 컴포넌트에서 사용할 수 있습니다. DisposableEffect는 리소스 설정 및 정리(한 번)를 담당하고, SideEffect는 모든 재구성에서 해당 리소스와 현재 상태를 동기화합니다. 일반적인 예: DisposableEffect가 API를 통해 콜백을 등록하고 SideEffect가 변경 시마다 해당 콜백 내 캡처된 데이터를 업데이트합니다.
요약
턴키 방식의 모바일 애플리케이션을 개발해 드립니다
IT Sectr는 2017년부터 스타트업과 기업을 위한 iOS 및 Android 애플리케이션을 만듭니다. 저희가 상담해 드리고 최적의 솔루션을 제안하겠습니다.