Android 완벽 마스터
Android의 핵심 개념과 실전 활용법
학습 항목
이미지 로딩 중...
Jetpack Compose 고급 패턴 완벽 가이드
Jetpack Compose의 고급 패턴들을 실무 중심으로 다룹니다. State Hoisting, Side Effects, Custom Layouts 등 실제 프로젝트에서 바로 활용할 수 있는 핵심 패턴들을 초급 개발자도 이해할 수 있도록 상세히 설명합니다.
목차
- State Hoisting
- remember와 rememberSaveable
- LaunchedEffect
- derivedStateOf
- DisposableEffect
- SideEffect
- Custom Layout
- Slot API 패턴
1. State Hoisting
시작하며
여러분이 Compose로 버튼과 텍스트가 있는 간단한 카운터를 만들었는데, 나중에 이 카운터의 값을 다른 화면에서도 사용해야 하는 상황을 겪어본 적 있나요? 아니면 같은 상태를 여러 컴포넌트가 공유해야 하는데 어떻게 구조화해야 할지 막막했던 경험이 있으신가요?
이런 문제는 실제 앱 개발에서 굉장히 자주 발생합니다. 컴포넌트마다 상태를 따로 관리하면 데이터가 중복되고, 동기화 문제가 생기며, 나중에 유지보수가 어려워집니다.
바로 이럴 때 필요한 것이 State Hoisting입니다. 상태를 컴포넌트 밖으로 끌어올려서 재사용 가능하고 테스트하기 쉬운 컴포넌트를 만들 수 있게 해줍니다.
개요
간단히 말해서, State Hoisting은 컴포저블 함수 내부의 상태를 상위 컴포저블로 이동시키는 패턴입니다. 왜 이 패턴이 필요할까요?
실무에서는 단일 컴포넌트가 독립적으로 동작하는 경우가 거의 없습니다. 대부분의 UI는 여러 컴포넌트가 협력해서 동작하고, 같은 데이터를 공유해야 합니다.
예를 들어, 검색 화면에서 검색어 입력 필드와 검색 결과 목록이 같은 검색어 상태를 공유해야 하는 경우에 매우 유용합니다. 기존에는 컴포넌트 내부에서 상태를 직접 관리했다면, 이제는 상태를 파라미터로 받고 변경 이벤트를 콜백으로 전달할 수 있습니다.
이 패턴의 핵심 특징은 첫째, 단일 진실 공급원(Single Source of Truth)을 만들어 상태를 한 곳에서 관리하고, 둘째, 컴포넌트를 stateless하게 만들어 재사용성을 높이며, 셋째, 테스트가 쉬워진다는 점입니다. 이러한 특징들이 중요한 이유는 프로젝트가 커질수록 상태 관리의 복잡도가 기하급수적으로 증가하기 때문입니다.
코드 예제
// State Hoisting 적용 전: 재사용 어려움
@Composable
fun CounterBad() {
var count by remember { mutableStateOf(0) }
Button(onClick = { count++ }) {
Text("Count: $count")
}
}
// State Hoisting 적용 후: 재사용 가능
@Composable
fun Counter(
count: Int, // 상태를 파라미터로 받음
onCountChange: (Int) -> Unit // 변경 이벤트를 콜백으로
) {
Button(onClick = { onCountChange(count + 1) }) {
Text("Count: $count")
}
}
// 사용하는 쪽에서 상태 관리
@Composable
fun CounterScreen() {
var count by remember { mutableStateOf(0) }
Counter(count = count, onCountChange = { count = it })
}
설명
이것이 하는 일: State Hoisting은 컴포넌트가 직접 상태를 소유하는 대신, 상태와 상태 변경 함수를 파라미터로 받아서 사용하도록 만듭니다. 이렇게 하면 컴포넌트는 "어떻게 보일지"에만 집중하고, "무엇을 보여줄지"는 상위 컴포넌트가 결정합니다.
첫 번째로, 컴포넌트 내부에 있던 remember { mutableStateOf() }를 제거하고 파라미터로 상태를 받습니다. 이렇게 하는 이유는 컴포넌트가 상태를 소유하지 않고 단순히 "전달받은 값을 표시"하는 역할만 하도록 만들기 위함입니다.
이는 React의 제어 컴포넌트(Controlled Component) 패턴과 동일한 개념입니다. 두 번째로, 상태를 변경해야 할 때는 직접 변경하지 않고 onCountChange 같은 콜백 함수를 호출합니다.
그러면 상위 컴포넌트가 이 이벤트를 받아서 실제로 상태를 업데이트합니다. 내부에서는 리컴포지션이 발생하고, 새로운 값이 다시 파라미터로 전달되어 UI가 업데이트됩니다.
마지막으로, 실제 상태 관리는 CounterScreen처럼 상위 컴포넌트에서 합니다. 여기서 remember로 상태를 만들고, 이를 여러 자식 컴포넌트에 전달할 수 있습니다.
최종적으로 "상태는 위에서, 이벤트는 아래에서 위로"라는 단방향 데이터 흐름이 완성됩니다. 여러분이 이 패턴을 사용하면 컴포넌트를 독립적으로 테스트할 수 있고, 같은 컴포넌트를 다른 상태와 함께 재사용할 수 있으며, 상태가 한 곳에서 관리되어 디버깅이 쉬워집니다.
또한 프리뷰를 만들 때도 원하는 상태를 직접 전달할 수 있어서 다양한 시나리오를 쉽게 확인할 수 있습니다.
실전 팁
💡 모든 컴포넌트에 State Hoisting을 적용할 필요는 없습니다. 재사용이 필요하거나 상태를 공유해야 할 때만 적용하세요. 간단한 내부 UI 상태(예: 드롭다운 열림/닫힘)는 컴포넌트 내부에 두는 게 더 간단합니다.
💡 파라미터 이름은 value와 onValueChange처럼 일관된 네이밍을 사용하세요. 이는 Compose의 표준 컴포넌트들(TextField, Slider 등)이 사용하는 컨벤션입니다.
💡 상태를 너무 높이 끌어올리면 불필요한 리컴포지션이 발생할 수 있습니다. 상태는 "공유가 필요한 가장 낮은 공통 조상"에 위치시키는 게 최적입니다.
💡 ViewModel과 함께 사용할 때는 Screen 레벨에서 ViewModel의 상태를 관찰하고, 하위 컴포넌트들에는 State Hoisting된 형태로 전달하세요. 이렇게 하면 컴포넌트는 순수하게 유지되면서도 ViewModel의 이점을 누릴 수 있습니다.
2. remember와 rememberSaveable
시작하며
여러분이 입력 폼을 만들었는데, 화면을 회전하거나 다른 앱으로 갔다가 돌아오면 입력한 내용이 모두 사라지는 경험을 해보셨나요? 아니면 리컴포지션이 발생할 때마다 값이 초기화되어서 UI가 깜빡이는 문제를 겪어보셨나요?
이런 문제는 Compose의 리컴포지션 특성을 이해하지 못해서 발생합니다. Compose는 상태가 변경될 때마다 컴포저블 함수를 다시 실행하는데, 이때 일반 변수는 매번 초기값으로 재설정됩니다.
사용자가 입력한 값이나 계산한 결과가 사라지면 앱의 사용성이 크게 떨어집니다. 바로 이럴 때 필요한 것이 remember와 rememberSaveable입니다.
리컴포지션을 거쳐도 값을 유지하고, 심지어 프로세스가 종료되었다가 복구될 때도 상태를 보존할 수 있게 해줍니다.
개요
간단히 말해서, remember는 리컴포지션 사이에 값을 기억하고, rememberSaveable은 프로세스 종료 후에도 값을 복구하는 함수입니다. 왜 이 함수들이 필요할까요?
Compose는 성능 최적화를 위해 UI를 매우 자주 리컴포지션합니다. 상태가 변경되거나, 부모가 리컴포지션되거나, 화면 크기가 바뀌는 등의 이유로 컴포저블 함수가 다시 실행됩니다.
예를 들어, 사용자가 텍스트를 입력하는 동안 매 글자마다 리컴포지션이 발생하는데, 이때마다 입력값이 초기화되면 텍스트 입력 자체가 불가능해집니다. 기존 View 시스템에서는 View 인스턴스가 상태를 자동으로 유지했다면, Compose에서는 명시적으로 remember를 사용해야 합니다.
이 함수들의 핵심 특징은 첫째, remember는 컴포지션에 값을 저장하여 리컴포지션 사이에 유지하고, 둘째, rememberSaveable은 Bundle에 저장하여 액티비티 재생성 시에도 복구하며, 셋째, key 파라미터로 조건부 재계산을 제어할 수 있다는 점입니다. 이러한 특징들이 중요한 이유는 사용자 경험의 연속성을 보장하고 불필요한 재계산을 방지하여 성능을 최적화하기 때문입니다.
코드 예제
@Composable
fun RememberExample() {
// 일반 변수: 리컴포지션마다 0으로 초기화됨
val badCount = 0
// remember: 리컴포지션 사이에 값 유지
var count by remember { mutableStateOf(0) }
// rememberSaveable: 프로세스 종료 후에도 복구
var savedCount by rememberSaveable { mutableStateOf(0) }
// 비용이 큰 계산은 remember로 캐싱
val expensiveResult = remember(count) {
// count가 변경될 때만 재계산
performExpensiveCalculation(count)
}
Column {
Text("Bad: $badCount") // 항상 0
Button(onClick = { count++ }) {
Text("Remember: $count") // 회전시 초기화됨
}
Button(onClick = { savedCount++ }) {
Text("Saveable: $savedCount") // 회전 후에도 유지
}
}
}
설명
이것이 하는 일: remember와 rememberSaveable은 Compose의 컴포지션 메모리에 값을 저장하여, 함수가 다시 실행되어도 이전 값을 유지하거나 복구할 수 있게 만듭니다. 첫 번째로, 일반 변수인 badCount는 리컴포지션이 발생할 때마다 함수가 처음부터 다시 실행되므로 항상 0으로 초기화됩니다.
반면 remember { mutableStateOf(0) }는 첫 컴포지션에서만 초기값으로 설정되고, 이후 리컴포지션에서는 이전 값을 불러옵니다. 이렇게 하는 이유는 UI 상태의 연속성을 보장하기 위함입니다.
두 번째로, rememberSaveable은 remember의 기능에 더해서 안드로이드의 onSaveInstanceState 메커니즘을 사용합니다. 화면 회전, 다크모드 전환, 백그라운드에서 프로세스 종료 등의 상황에서 액티비티가 재생성되면, remember만 사용한 상태는 사라지지만 rememberSaveable은 Bundle에 저장했다가 복구합니다.
내부적으로 Parcelable이나 Serializable로 직렬화 가능한 타입만 저장할 수 있습니다. 세 번째로, remember의 key 파라미터를 사용한 예제를 보면 remember(count)처럼 작성되어 있습니다.
이는 count 값이 변경될 때만 람다 블록을 다시 실행하라는 의미입니다. 비용이 큰 계산이나 객체 생성을 캐싱할 때 매우 유용하며, 의존성이 변경되지 않으면 이전 결과를 재사용합니다.
여러분이 이 함수들을 적절히 사용하면 불필요한 재계산을 방지하여 성능이 향상되고, 사용자가 입력한 데이터가 보존되어 UX가 개선되며, 화면 회전 등의 구성 변경에도 안정적으로 동작하는 앱을 만들 수 있습니다. 특히 폼 입력, 스크롤 위치, 선택된 탭 등의 UI 상태는 반드시 rememberSaveable로 관리해야 사용자 불만을 방지할 수 있습니다.
실전 팁
💡 사용자 입력이나 선택 같은 중요한 UI 상태는 rememberSaveable을 사용하세요. 화면 회전은 개발 중에는 잘 테스트하지 않지만 실제 사용자들은 자주 겪는 상황입니다.
💡 비용이 큰 객체(예: Bitmap, 대용량 리스트)는 remember로 캐싱하되, key를 명시하여 의존성이 변경될 때만 재생성하도록 하세요. 이는 성능 최적화의 핵심입니다.
💡 rememberSaveable은 기본적으로 Bundle에 저장 가능한 타입만 지원합니다. 커스텀 객체를 저장하려면 Parcelable을 구현하거나 mapSaver/listSaver를 사용하여 직렬화 로직을 제공해야 합니다.
💡 ViewModel의 상태를 관찰할 때는 remember가 아닌 collectAsState()나 observeAsState()를 사용하세요. ViewModel은 이미 구성 변경에서 살아남으므로 중복 보존이 불필요합니다.
💡 remember 블록 안에서 외부 변수를 캡처할 때 주의하세요. 람다는 한 번만 실행되므로 이후 변경된 값은 반영되지 않습니다. 의존성이 있다면 key 파라미터로 명시해야 합니다.
3. LaunchedEffect
시작하며
여러분이 화면이 표시될 때 API를 호출해야 하는데, 어디서 코루틴을 시작해야 할지 고민해본 적 있나요? 컴포저블 함수 본문에서 직접 launch를 호출했더니 리컴포지션마다 중복 호출되는 문제를 겪어보셨나요?
이런 문제는 Compose의 선언적 특성과 명령형 작업의 충돌에서 발생합니다. 컴포저블 함수는 여러 번 실행될 수 있고, 리컴포지션 시점을 예측할 수 없습니다.
그런데 API 호출이나 애니메이션 같은 작업은 정확히 한 번만, 또는 특정 조건이 변경될 때만 실행되어야 합니다. 잘못하면 같은 API를 수십 번 호출하거나 메모리 누수가 발생할 수 있습니다.
바로 이럴 때 필요한 것이 LaunchedEffect입니다. Compose의 생명주기와 연동되어 안전하게 코루틴을 실행하고, 컴포저블이 사라지면 자동으로 취소되는 Side Effect를 제공합니다.
개요
간단히 말해서, LaunchedEffect는 Compose에서 코루틴을 안전하게 실행하기 위한 컴포저블 함수입니다. 왜 이 함수가 필요할까요?
컴포저블 함수는 순수 함수여야 하고 Side Effect가 없어야 합니다. 하지만 실제 앱에서는 네트워크 호출, 데이터베이스 쿼리, 타이머 등의 Side Effect가 필수적입니다.
예를 들어, 사용자 프로필 화면이 표시될 때 서버에서 사용자 정보를 가져와야 하는 경우, 이 작업을 안전하게 실행할 방법이 필요합니다. 기존에는 onCreate나 onResume에서 작업을 시작했다면, Compose에서는 LaunchedEffect로 "이 컴포저블이 컴포지션에 들어올 때 한 번 실행"을 선언할 수 있습니다.
이 함수의 핵심 특징은 첫째, key가 변경되지 않는 한 리컴포지션되어도 재실행되지 않고, 둘째, 컴포저블이 컴포지션을 떠나면 자동으로 코루틴이 취소되며, 셋째, 여러 key를 지정하여 복잡한 의존성을 관리할 수 있다는 점입니다. 이러한 특징들이 중요한 이유는 메모리 누수를 방지하고 불필요한 작업 중복을 막아 안정적인 앱을 만들 수 있기 때문입니다.
코드 예제
@Composable
fun UserProfileScreen(userId: String) {
var user by remember { mutableStateOf<User?>(null) }
var isLoading by remember { mutableStateOf(true) }
// userId가 변경될 때마다 실행
LaunchedEffect(userId) {
isLoading = true
try {
// 안전한 코루틴 실행: 화면이 사라지면 자동 취소
user = fetchUserFromApi(userId)
} catch (e: Exception) {
// 에러 처리
} finally {
isLoading = false
}
}
// 화면 진입시 한 번만 실행
LaunchedEffect(Unit) {
logScreenView("UserProfile")
}
if (isLoading) {
CircularProgressIndicator()
} else {
user?.let { UserInfo(it) }
}
}
설명
이것이 하는 일: LaunchedEffect는 컴포저블이 컴포지션에 진입할 때 코루틴을 시작하고, key가 변경되면 이전 코루틴을 취소하고 새로 시작하며, 컴포저블이 컴포지션을 떠나면 자동으로 취소하는 생명주기 관리를 제공합니다. 첫 번째로, LaunchedEffect(userId)는 userId를 key로 사용합니다.
화면이 처음 표시될 때 한 번 실행되고, userId가 변경되면 이전 API 호출을 취소하고 새로운 userId로 다시 호출합니다. 이렇게 하는 이유는 사용자가 빠르게 프로필을 전환할 때 이전 요청이 완료되기를 기다리지 않고 즉시 새 요청을 시작하기 위함입니다.
리컴포지션이 발생해도 userId가 같으면 재실행되지 않습니다. 두 번째로, LaunchedEffect(Unit)은 상수를 key로 사용하여 화면 진입시 딱 한 번만 실행됩니다.
로깅, 애널리틱스, 일회성 초기화 작업에 적합합니다. 내부 코루틴은 CoroutineScope에서 실행되므로 suspend 함수를 자유롭게 호출할 수 있고, 컴포저블이 제거되면 자동으로 취소되어 메모리 누수가 방지됩니다.
세 번째로, try-catch-finally 블록으로 에러를 처리하고 로딩 상태를 관리합니다. API 호출이 실패하거나 취소되어도 finally 블록은 실행되어 isLoading을 false로 설정하므로 UI가 무한 로딩 상태에 빠지지 않습니다.
코루틴이 취소되면 CancellationException이 발생하는데, 이는 정상적인 취소이므로 별도 처리가 필요 없습니다. 여러분이 이 패턴을 사용하면 화면 전환시 이전 작업이 자동으로 취소되어 리소스가 절약되고, 컴포넌트와 작업의 생명주기가 자동으로 동기화되어 메모리 누수를 걱정할 필요가 없으며, 의존성 변경시 자동으로 재실행되어 항상 최신 상태를 유지할 수 있습니다.
ViewModel과 함께 사용하면 더욱 강력한 아키텍처를 구축할 수 있습니다.
실전 팁
💡 key로 Unit을 사용하면 한 번만 실행되지만, true 같은 상수를 사용해도 동일합니다. 가독성을 위해 Unit을 권장합니다.
💡 여러 개의 key를 사용할 수 있습니다: LaunchedEffect(userId, categoryId). 둘 중 하나라도 변경되면 재실행됩니다.
💡 LaunchedEffect 내부에서 상태를 변경하면 리컴포지션이 발생하지만, LaunchedEffect 자체는 재실행되지 않습니다. 이는 무한 루프를 방지하는 중요한 특성입니다.
💡 긴 작업이나 폴링 작업은 while(isActive) 루프로 구현하세요. 컴포저블이 제거되면 isActive가 false가 되어 루프가 종료됩니다.
💡 Flow를 수집할 때는 collectAsState()를 사용하는 게 더 간단하지만, 복잡한 로직이 필요하면 LaunchedEffect에서 collect를 호출할 수 있습니다.
4. derivedStateOf
시작하며
여러분이 검색 화면을 만드는데, 검색어를 입력할 때마다 전체 검색 결과 리스트가 리컴포지션되어서 성능이 느려지는 경험을 해보셨나요? 아니면 상태 A와 상태 B를 조합한 계산 결과를 사용하는데, A나 B 중 하나만 변경되어도 불필요한 재계산이 발생하는 문제를 겪어보셨나요?
이런 문제는 Compose의 리컴포지션 메커니즘을 잘못 이해해서 발생합니다. 상태를 읽는 모든 컴포저블은 그 상태가 변경될 때 리컴포지션됩니다.
하지만 계산 결과가 실제로는 바뀌지 않았는데도 입력값이 바뀌었다는 이유만으로 리컴포지션되면 성능이 크게 저하됩니다. 특히 리스트 필터링이나 정렬 같은 비용이 큰 연산에서는 치명적입니다.
바로 이럴 때 필요한 것이 derivedStateOf입니다. 여러 상태를 조합한 계산 결과를 만들되, 결과값이 실제로 변경될 때만 리컴포지션을 발생시켜 불필요한 재계산을 방지합니다.
개요
간단히 말해서, derivedStateOf는 다른 상태로부터 파생된 상태를 만들되, 결과값이 실제로 변경될 때만 구독자에게 알립니다. 왜 이 함수가 필요할까요?
Compose는 반응형 시스템이므로 상태를 읽으면 자동으로 구독이 생성됩니다. 하지만 계산 로직이 복잡하거나 결과가 자주 바뀌지 않는 경우, 매번 입력값 변경에 반응하는 것은 비효율적입니다.
예를 들어, 1000개의 상품 리스트를 가격 범위로 필터링하는 화면에서, 최소 가격을 변경했는데 결과가 똑같다면 리스트를 다시 그릴 이유가 없습니다. 기존에는 모든 의존 상태가 변경될 때마다 리컴포지션이 발생했다면, derivedStateOf를 사용하면 "결과가 실제로 바뀌었을 때만" 리컴포지션이 발생합니다.
이 함수의 핵심 특징은 첫째, 계산 결과를 캐싱하여 동일한 결과일 때 리컴포지션을 스킵하고, 둘째, 여러 상태를 조합한 복잡한 파생 상태를 만들 수 있으며, 셋째, Lazy 평가로 실제로 읽힐 때만 계산을 수행한다는 점입니다. 이러한 특징들이 중요한 이유는 대용량 리스트나 복잡한 계산에서 성능을 크게 개선할 수 있기 때문입니다.
코드 예제
@Composable
fun ProductListScreen() {
var searchQuery by remember { mutableStateOf("") }
val allProducts by viewModel.products.collectAsState()
// 나쁜 예: searchQuery가 변경될 때마다 모든 컴포저블 리컴포지션
val filteredProducts = allProducts.filter {
it.name.contains(searchQuery, ignoreCase = true)
}
// 좋은 예: 필터 결과가 실제로 바뀔 때만 리컴포지션
val filteredProducts by remember {
derivedStateOf {
allProducts.filter {
it.name.contains(searchQuery, ignoreCase = true)
}
}
}
// 복잡한 파생 상태 예제
val statistics by remember {
derivedStateOf {
ProductStatistics(
total = filteredProducts.size,
avgPrice = filteredProducts.map { it.price }.average(),
categories = filteredProducts.groupBy { it.category }
)
}
}
LazyColumn {
items(filteredProducts) { product ->
ProductItem(product)
}
}
}
설명
이것이 하는 일: derivedStateOf는 여러 상태를 읽어서 계산한 결과를 State 객체로 만들되, 내부적으로 이전 결과값과 비교하여 실제로 바뀌었을 때만 변경 알림을 전파합니다. 첫 번째로, 나쁜 예제를 보면 allProducts.filter(...)가 리컴포지션마다 실행되어 매번 새로운 리스트 인스턴스를 생성합니다.
searchQuery나 allProducts가 변경되면 당연히 재계산되지만, 완전히 관계없는 다른 상태 변경으로 인한 리컴포지션에서도 재계산됩니다. 이렇게 하는 것은 성능 낭비입니다.
두 번째로, 좋은 예제는 derivedStateOf로 감싸서 계산 로직을 "상태"로 만듭니다. searchQuery나 allProducts를 읽으므로 이들이 변경되면 재계산되지만, 계산 결과를 이전 값과 비교(==)하여 동일하면 구독자에게 알리지 않습니다.
내부적으로는 structural equality를 사용하므로 리스트 내용이 같으면 같다고 판단합니다. remember로 감싸는 이유는 derivedStateOf 객체 자체를 리컴포지션 사이에 유지하기 위함입니다.
세 번째로, 복잡한 계산 예제를 보면 여러 프로퍼티를 가진 객체를 반환합니다. filteredProducts가 변경되어도 통계 숫자가 동일하면 (예: 다른 상품으로 교체되었지만 개수와 평균은 같음) 리컴포지션이 발생하지 않습니다.
또한 derivedStateOf는 lazy하므로 statistics를 실제로 읽는 컴포저블이 없으면 계산 자체가 실행되지 않습니다. 여러분이 이 패턴을 사용하면 리스트 필터링/정렬 같은 비용이 큰 연산의 성능이 크게 향상되고, 불필요한 리컴포지션이 줄어들어 UI가 더 부드러워지며, 복잡한 계산 로직을 깔끔하게 캡슐화할 수 있습니다.
특히 검색, 필터, 정렬 기능이 있는 화면에서는 필수적인 최적화 기법입니다.
실전 팁
💡 derivedStateOf는 계산 비용이 크거나 결과가 자주 바뀌지 않을 때만 사용하세요. 간단한 계산(예: "$firstName $lastName")은 오히려 오버헤드가 더 클 수 있습니다.
💡 반환 타입이 data class이거나 List, Set 등 structural equality를 지원하는 타입이어야 비교가 제대로 동작합니다. 일반 class는 참조 비교만 하므로 항상 다르다고 판단됩니다.
💡 derivedStateOf 내부에서 상태를 변경하면 안 됩니다. 순수 함수여야 하며, 읽기만 수행해야 합니다. Side Effect가 필요하면 LaunchedEffect나 SideEffect를 사용하세요.
💡 ViewModel에서 사용할 때는 stateIn이나 combine 같은 Flow 연산자가 더 적합할 수 있습니다. derivedStateOf는 주로 Compose 레이어에서 사용합니다.
💡 여러 derivedStateOf를 체이닝하면 복잡한 파생 관계를 만들 수 있지만, 디버깅이 어려워질 수 있으니 적절한 수준으로 유지하세요.
5. DisposableEffect
시작하며
여러분이 Compose에서 외부 리스너를 등록했는데, 화면을 나갈 때 리스너를 제거하지 않아서 메모리 누수가 발생하는 경험을 해보셨나요? 아니면 센서나 위치 업데이트를 시작했는데 정리하는 것을 깜빡해서 백그라운드에서도 계속 배터리를 소모하는 문제를 겪어보셨나요?
이런 문제는 Compose의 생명주기와 외부 리소스 관리를 제대로 연결하지 못해서 발생합니다. View 시스템에서는 onDestroy에서 정리 작업을 했지만, Compose에서는 컴포저블이 언제 제거될지 명확하지 않습니다.
리스너, 콜백, 구독 등을 등록만 하고 해제하지 않으면 메모리 누수와 예상치 못한 동작이 발생합니다. 바로 이럴 때 필요한 것이 DisposableEffect입니다.
리소스를 안전하게 획득하고 해제하는 패턴을 제공하여, 컴포저블이 컴포지션을 떠날 때 자동으로 정리 작업을 수행합니다.
개요
간단히 말해서, DisposableEffect는 컴포저블이 컴포지션을 떠날 때 또는 key가 변경될 때 정리 작업을 수행하는 Side Effect입니다. 왜 이 함수가 필요할까요?
많은 안드로이드 API들은 등록/해제 쌍으로 동작합니다. LocationManager, SensorManager, BroadcastReceiver, Lifecycle Observer 등 수많은 컴포넌트가 "시작하면 반드시 멈춰야" 합니다.
예를 들어, 맵 화면에서 위치 업데이트를 시작했다면, 사용자가 다른 화면으로 이동할 때 반드시 중지해야 배터리 낭비를 방지할 수 있습니다. 기존에는 onCreate/onDestroy 또는 onStart/onStop 쌍에서 처리했다면, Compose에서는 DisposableEffect의 시작/onDispose 쌍으로 동일한 패턴을 구현할 수 있습니다.
이 함수의 핵심 특징은 첫째, onDispose 블록에서 반드시 정리 작업을 정의해야 하고, 둘째, key가 변경되면 이전 리소스를 정리하고 새로 생성하며, 셋째, 컴포저블이 제거되면 자동으로 onDispose가 호출된다는 점입니다. 이러한 특징들이 중요한 이유는 리소스 누수를 완전히 방지하고 안정적인 생명주기 관리를 보장하기 때문입니다.
코드 예제
@Composable
fun LocationScreen() {
val context = LocalContext.current
var location by remember { mutableStateOf<Location?>(null) }
DisposableEffect(Unit) {
// 리소스 획득: 위치 리스너 등록
val locationManager = context.getSystemService(LocationManager::class.java)
val listener = LocationListener { newLocation ->
location = newLocation
}
locationManager.requestLocationUpdates(
LocationManager.GPS_PROVIDER,
1000L,
10f,
listener
)
// 반드시 onDispose 반환
onDispose {
// 리소스 해제: 리스너 제거
locationManager.removeUpdates(listener)
}
}
// Lifecycle 이벤트 감지 예제
val lifecycleOwner = LocalLifecycleOwner.current
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
// 화면 포그라운드 진입
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
}
}
location?.let { Text("위도: ${it.latitude}, 경도: ${it.longitude}") }
}
설명
이것이 하는 일: DisposableEffect는 컴포저블이 컴포지션에 진입할 때 Effect 블록을 실행하고, 컴포지션을 떠나거나 key가 변경될 때 onDispose 블록을 실행하여 리소스 정리를 보장합니다. 첫 번째로, Effect 블록에서는 리소스를 획득합니다.
위 예제에서는 LocationManager에 리스너를 등록하여 위치 업데이트를 시작합니다. 이렇게 하는 이유는 사용자의 현재 위치를 실시간으로 추적하기 위함입니다.
리스너는 새 위치를 받을 때마다 상태를 업데이트하여 UI가 자동으로 갱신되도록 합니다. 두 번째로, 반드시 onDispose 블록을 반환해야 합니다.
컴파일러가 강제하므로 잊어버릴 수 없습니다. onDispose에서는 정확히 대칭적인 정리 작업을 수행합니다: requestLocationUpdates로 시작했으면 removeUpdates로 멈춥니다.
사용자가 다른 화면으로 이동하거나 뒤로가기를 누르면 컴포저블이 제거되고 onDispose가 자동으로 호출되어 위치 추적이 중단됩니다. 세 번째로, Lifecycle 예제를 보면 DisposableEffect(lifecycleOwner)처럼 key를 지정했습니다.
lifecycleOwner가 변경되면 (드문 경우지만 가능) 이전 observer를 제거하고 새 owner에 등록합니다. 내부적으로 LifecycleEventObserver를 사용하여 ON_RESUME, ON_PAUSE 같은 생명주기 이벤트에 반응할 수 있으며, 마찬가지로 onDispose에서 observer를 제거하여 메모리 누수를 방지합니다.
여러분이 이 패턴을 사용하면 모든 리소스가 확실히 정리되어 메모리 누수를 완전히 방지할 수 있고, 외부 라이브러리나 안드로이드 시스템 서비스를 Compose와 안전하게 통합할 수 있으며, 생명주기 관리를 Compose에 위임하여 실수를 줄일 수 있습니다. 센서, 위치, 네트워크 콜백, 애니메이션 등 모든 외부 리소스에 적용 가능합니다.
실전 팁
💡 onDispose는 항상 반환해야 합니다. 정리할 것이 없어도 onDispose { }처럼 빈 블록을 반환하세요. 이는 코드를 읽는 사람에게 "의도적으로 정리할 것이 없음"을 명확히 합니다.
💡 DisposableEffect 내부에서 launch를 사용하면 안 됩니다. 코루틴이 필요하면 LaunchedEffect를 사용하고, onDispose에서 Job을 취소하거나 별도의 DisposableEffect에서 정리하세요.
💡 key를 신중하게 선택하세요. key가 자주 변경되면 리소스가 계속 재생성되어 성능이 저하됩니다. 대부분의 경우 Unit이나 lifecycleOwner처럼 안정적인 값을 사용합니다.
💡 Flow 구독은 DisposableEffect보다 collectAsState()를 사용하는 게 더 간단합니다. 하지만 복잡한 구독 로직이 필요하면 DisposableEffect에서 collect를 호출하고 onDispose에서 취소할 수 있습니다.
💡 AndroidView나 ComposeView로 View와 Compose를 섞어 쓸 때 DisposableEffect가 매우 유용합니다. View의 리스너를 등록하고 onDispose에서 제거하는 패턴으로 안전하게 통합할 수 있습니다.
6. SideEffect
시작하며
여러분이 Compose 상태를 Compose 외부의 객체와 동기화해야 하는 상황을 만난 적 있나요? 예를 들어, Compose의 현재 선택된 탭을 Google Analytics에 로깅하거나, 상태 변경을 레거시 View 시스템에 전달해야 하는 경우입니다.
이런 문제는 Compose의 리컴포지션이 여러 번 발생하거나 취소될 수 있다는 특성 때문에 복잡합니다. LaunchedEffect를 사용하면 중복 실행을 막을 수 있지만, "매 리컴포지션마다 한 번씩" 실행되어야 하는 작업에는 적합하지 않습니다.
상태를 읽기만 하고 외부에 알리는 단순한 동기화 작업인데도 복잡한 key 관리가 필요해집니다. 바로 이럴 때 필요한 것이 SideEffect입니다.
리컴포지션이 성공적으로 완료될 때마다 실행되어, Compose 상태를 외부 세계와 동기화하는 간단하고 명확한 방법을 제공합니다.
개요
간단히 말해서, SideEffect는 리컴포지션이 성공적으로 완료될 때마다 실행되는 Effect로, Compose 상태를 non-Compose 코드에 전달하는 용도입니다. 왜 이 함수가 필요할까요?
Compose는 자체적인 상태 시스템을 가지고 있지만, 실제 앱에서는 외부 라이브러리, 레거시 코드, 시스템 API와 통합해야 합니다. 이들은 Compose의 상태 변경을 자동으로 감지하지 못하므로 명시적으로 알려줘야 합니다.
예를 들어, 현재 화면 정보를 Firebase Analytics에 보내거나, 선택된 아이템을 외부 콜백으로 전달하는 경우에 사용합니다. 기존의 다른 Effect들은 특정 조건이나 key에 반응하지만, SideEffect는 "모든 성공한 리컴포지션"에 반응합니다.
이 함수의 핵심 특징은 첫째, 리컴포지션이 완료될 때마다 매번 실행되고, 둘째, 리컴포지션이 취소되면 실행되지 않으며, 셋째, 상태를 변경해서는 안 되고 외부 객체만 업데이트해야 한다는 점입니다. 이러한 특징들이 중요한 이유는 Compose와 외부 세계의 일관성을 보장하면서도 성능 문제를 일으키지 않기 때문입니다.
코드 예제
@Composable
fun TabScreen(selectedTab: Int) {
// 나쁜 예: 직접 호출하면 리컴포지션마다 중복 실행됨
// analytics.logEvent("tab_selected", selectedTab) // 잘못됨!
// 좋은 예: SideEffect로 안전하게 동기화
SideEffect {
// 리컴포지션이 성공할 때마다 실행
analytics.logEvent("tab_selected", selectedTab)
}
TabRow(selectedTabIndex = selectedTab) {
// 탭 UI
}
}
// 레거시 View와 동기화 예제
@Composable
fun LegacyViewIntegration(data: String) {
val legacyController = rememberLegacyController()
SideEffect {
// Compose 상태를 레거시 View에 반영
legacyController.updateData(data)
}
AndroidView(factory = { legacyController.view })
}
// 외부 상태 관리 라이브러리와 동기화
@Composable
fun ExternalStoreSync(composeState: String) {
val externalStore = remember { ExternalStore() }
SideEffect {
// Compose -> External Store
externalStore.setState(composeState)
}
}
설명
이것이 하는 일: SideEffect는 컴포저블 함수가 성공적으로 리컴포지션을 완료한 직후에 블록을 실행하여, Compose의 상태를 Compose 외부의 객체나 시스템에 전달합니다. 첫 번째로, 나쁜 예제처럼 컴포저블 본문에서 직접 외부 함수를 호출하면 큰 문제가 발생합니다.
Compose는 최적화를 위해 리컴포지션을 여러 번 시도하거나 중간에 취소할 수 있습니다. 직접 호출하면 analytics.logEvent가 중복 실행되거나 실제로 UI에 반영되지 않은 상태로도 호출될 수 있습니다.
이렇게 하면 부정확한 분석 데이터가 쌓입니다. 두 번째로, SideEffect로 감싸면 Compose가 "이 리컴포지션이 실제로 성공했다"고 확정했을 때만 실행됩니다.
selectedTab을 읽으므로 selectedTab이 변경되면 리컴포지션이 발생하고, 리컴포지션이 완료되면 SideEffect가 실행되어 새로운 탭 정보가 로깅됩니다. 내부적으로 Compose의 스냅샷 시스템과 연동되어 최종 상태만 전달됩니다.
세 번째로, 레거시 View 통합 예제를 보면 AndroidView로 만든 View와 Compose 상태를 동기화합니다. data가 변경될 때마다 SideEffect가 실행되어 legacyController를 업데이트하므로, View와 Compose가 항상 동일한 상태를 유지합니다.
이는 점진적인 Compose 마이그레이션 시 매우 유용한 패턴입니다. 여러분이 이 패턴을 사용하면 Compose와 외부 시스템의 상태가 항상 동기화되어 일관성이 보장되고, 리컴포지션 취소로 인한 중복 실행이나 불완전한 상태 전달을 방지할 수 있으며, 간단하고 명확한 코드로 통합을 구현할 수 있습니다.
Analytics, 레거시 코드, 외부 상태 관리 등 다양한 상황에서 활용 가능합니다.
실전 팁
💡 SideEffect 내부에서는 절대 Compose 상태를 변경하지 마세요. 무한 리컴포지션 루프가 발생할 수 있습니다. "읽기만 하고 외부에 전달"하는 용도로만 사용하세요.
💡 대부분의 경우 LaunchedEffect나 다른 Effect가 더 적합합니다. SideEffect는 "매 리컴포지션마다"가 정말 필요할 때만 사용하세요. 예를 들어 애널리틱스 로깅은 SideEffect가 적합하지만, API 호출은 LaunchedEffect가 적합합니다.
💡 SideEffect는 매우 자주 실행될 수 있으므로 비용이 큰 작업(네트워크 호출, 데이터베이스 쓰기 등)을 하면 안 됩니다. 가벼운 동기화 작업에만 사용하세요.
💡 외부 객체의 메서드를 호출할 때는 멱등성(idempotent)을 보장하세요. 같은 값으로 여러 번 호출되어도 문제가 없어야 합니다. 예: setState(value)는 괜찮지만 increment()는 위험합니다.
💡 derivedStateOf와 함께 사용하면 강력합니다. derivedStateOf로 여러 상태를 조합한 결과를 만들고, SideEffect로 그 결과를 외부에 전달하면 최적화된 동기화를 구현할 수 있습니다.
7. Custom Layout
시작하며
여러분이 Row, Column, Box로는 표현할 수 없는 독특한 레이아웃을 만들어야 하는 상황을 경험해본 적 있나요? 예를 들어, 자식들을 원형으로 배치하거나, 물결 모양으로 흐르게 하거나, 복잡한 수학 공식으로 위치를 계산해야 하는 경우입니다.
이런 문제는 기본 레이아웃 컴포저블의 한계 때문에 발생합니다. Modifier로 어느 정도 커스터마이징이 가능하지만, 자식들 간의 관계나 복잡한 측정 로직이 필요하면 불가능합니다.
CSS나 ConstraintLayout으로도 해결되지 않는 디자인이 있습니다. 바로 이럴 때 필요한 것이 Custom Layout입니다.
자식들을 직접 측정하고 배치하여 완전히 자유로운 레이아웃을 만들 수 있는 저수준 API를 제공합니다.
개요
간단히 말해서, Custom Layout은 Layout 컴포저블을 사용하여 자식들의 측정과 배치를 직접 제어하는 패턴입니다. 왜 이 패턴이 필요할까요?
Compose의 기본 레이아웃들은 일반적인 용도에는 충분하지만, 특수한 디자인 요구사항을 만족시키기에는 부족합니다. Material Design이나 일반적인 UI 패턴을 벗어나는 창의적인 디자인이 필요할 때가 있습니다.
예를 들어, 게임 UI, 데이터 시각화, 예술적인 레이아웃 등에서는 픽셀 단위의 정밀한 제어가 필요합니다. 기존에는 View 시스템에서 onMeasure와 onLayout을 오버라이드했다면, Compose에서는 Layout 컴포저블로 동일한 제어를 할 수 있습니다.
이 패턴의 핵심 특징은 첫째, measurables로 자식들을 측정하고 placeables로 배치하며, 둘째, Constraints를 통해 부모의 크기 제약을 받아 처리하고, 셋째, 완전한 레이아웃 제어로 어떤 형태든 구현 가능하다는 점입니다. 이러한 특징들이 중요한 이유는 디자이너의 창의적인 아이디어를 코드로 구현할 수 있는 자유를 주기 때문입니다.
코드 예제
@Composable
fun CircularLayout(
modifier: Modifier = Modifier,
radius: Dp = 100.dp,
content: @Composable () -> Unit
) {
Layout(
modifier = modifier,
content = content
) { measurables, constraints ->
// 1단계: 모든 자식을 측정
val placeables = measurables.map { measurable ->
// 각 자식에게 제약 조건 전달
measurable.measure(constraints.copy(minWidth = 0, minHeight = 0))
}
// 2단계: 레이아웃 크기 결정
val layoutSize = (radius * 2).roundToPx()
// 3단계: 레이아웃 배치
layout(layoutSize, layoutSize) {
// 자식들을 원형으로 배치
placeables.forEachIndexed { index, placeable ->
val angle = (2 * Math.PI * index / placeables.size).toFloat()
val radiusPx = radius.toPx()
val x = (radiusPx + radiusPx * cos(angle) - placeable.width / 2).toInt()
val y = (radiusPx + radiusPx * sin(angle) - placeable.height / 2).toInt()
// 계산된 위치에 배치
placeable.placeRelative(x, y)
}
}
}
}
// 사용 예시
CircularLayout(radius = 150.dp) {
repeat(8) { index ->
Icon(Icons.Default.Star, "Item $index")
}
}
설명
이것이 하는 일: Layout 컴포저블은 자식 컴포저블들(measurables)과 부모로부터 받은 크기 제약(constraints)을 받아서, 각 자식을 측정하고 원하는 위치에 배치하는 커스텀 레이아웃 로직을 실행합니다. 첫 번째로, measurables.map으로 모든 자식을 측정합니다.
measurable.measure(constraints)를 호출하면 자식이 원하는 크기를 계산하여 placeable 객체를 반환합니다. 제약 조건을 수정할 수 있어서 constraints.copy(minWidth = 0)처럼 최소 크기를 제거하거나, 고정 크기를 강제할 수 있습니다.
이렇게 하는 이유는 각 자식의 실제 크기를 알아야 정확한 위치를 계산할 수 있기 때문입니다. 두 번째로, layout(width, height) 함수로 이 레이아웃 자체의 크기를 결정합니다.
위 예제에서는 원의 지름(radius * 2)을 레이아웃 크기로 설정합니다. 내부적으로 이 크기는 부모의 constraints 안에 있어야 하며, 그렇지 않으면 런타임 에러가 발생합니다.
layout 블록 안에서 배치 로직을 작성합니다. 세 번째로, placeable.placeRelative(x, y)로 각 자식을 배치합니다.
예제에서는 삼각함수로 원 위의 좌표를 계산하여 자식들을 동일한 간격으로 원형 배치합니다. forEachIndexed로 각 자식의 인덱스에 따라 각도를 계산하고, cos/sin으로 X/Y 좌표를 구합니다.
placeRelative는 RTL(Right-to-Left) 언어를 자동으로 지원하며, place는 절대 좌표를 사용합니다. 여러분이 이 패턴을 사용하면 디자이너가 요구하는 어떤 레이아웃도 구현할 수 있고, 성능이 중요한 복잡한 UI를 최적화할 수 있으며, 재사용 가능한 커스텀 레이아웃 컴포넌트 라이브러리를 만들 수 있습니다.
FlowLayout, MasonryLayout, CircularProgressIndicator 등 실무에서 자주 필요한 레이아웃을 직접 만들 수 있습니다.
실전 팁
💡 측정은 정확히 한 번만 해야 합니다. 같은 measurable을 두 번 측정하면 예외가 발생합니다. 측정 결과를 변수에 저장해서 재사용하세요.
💡 layout의 크기는 constraints를 만족해야 합니다. constraints.constrain(IntSize(width, height))로 검증하거나, 직접 min/max를 체크하세요.
💡 복잡한 레이아웃은 ParentDataModifier로 자식이 부모에게 정보를 전달하게 만들 수 있습니다. 예: RowScope.weight처럼 자식이 "나는 가중치 2를 원해"라고 알리는 방식입니다.
💡 성능이 중요하면 Modifier.layout을 사용하여 단일 컴포저블의 측정/배치만 커스터마이징할 수 있습니다. 이는 Layout보다 가볍습니다.
💡 디버깅할 때는 layoutId Modifier로 각 자식에 이름을 붙이고, measurables에서 layoutId로 찾을 수 있습니다. 복잡한 레이아웃에서 특정 자식을 구별할 때 유용합니다.
8. Slot API 패턴
시작하며
여러분이 재사용 가능한 컴포넌트를 만들었는데, 사용하는 곳마다 조금씩 다른 UI가 필요해서 파라미터가 끝없이 늘어나는 경험을 해보셨나요? 아니면 카드 컴포넌트를 만들었는데, 어떤 곳에서는 이미지가 필요하고 어떤 곳에서는 아이콘이 필요해서 여러 버전을 만들게 되는 상황을 겪어보셨나요?
이런 문제는 컴포넌트의 재사용성과 유연성 사이의 균형을 잘못 잡아서 발생합니다. 파라미터로 모든 것을 제어하려고 하면 복잡도가 폭발하고, 개별 케이스마다 컴포넌트를 만들면 중복 코드가 넘쳐납니다.
Material Design 컴포넌트들처럼 유연하면서도 일관성 있는 API를 만들기가 어렵습니다. 바로 이럴 때 필요한 것이 Slot API 패턴입니다.
컴포넌트의 특정 영역을 "슬롯"으로 남겨두고 사용자가 원하는 컴포저블을 끼워 넣을 수 있게 하여, 유연하면서도 일관성 있는 API를 제공합니다.
개요
간단히 말해서, Slot API는 컴포저블 람다 파라미터를 사용하여 컴포넌트의 일부를 외부에서 주입받는 설계 패턴입니다. 왜 이 패턴이 필요할까요?
좋은 컴포넌트는 "합리적인 기본값을 제공하면서도 커스터마이징 가능"해야 합니다. 모든 세부사항을 파라미터로 받으면 API가 복잡해지고 사용하기 어려워집니다.
반대로 너무 경직되면 재사용성이 떨어집니다. 예를 들어, Scaffold 컴포넌트는 topBar, bottomBar, floatingActionButton을 슬롯으로 받아서 무한히 다양한 화면을 만들 수 있습니다.
기존에는 모든 옵션을 Boolean이나 Enum 파라미터로 받았다면, Slot API는 "이 위치에 무엇이든 넣으세요"라는 유연성을 제공합니다. 이 패턴의 핵심 특징은 첫째, 컴포저블 람다를 파라미터로 받아 특정 위치에 배치하고, 둘째, nullable 람다로 선택적 슬롯을 만들 수 있으며, 셋째, Scope 객체로 슬롯 내부에서 사용 가능한 기능을 제공할 수 있다는 점입니다.
이러한 특징들이 중요한 이유는 컴포넌트 설계의 유연성과 확장성을 극대화하면서도 API를 간단하게 유지할 수 있기 때문입니다.
코드 예제
// 기본적인 Slot API 예제
@Composable
fun CustomCard(
modifier: Modifier = Modifier,
// 슬롯 1: 헤더 영역 (선택적)
header: (@Composable () -> Unit)? = null,
// 슬롯 2: 메인 콘텐츠 (필수)
content: @Composable () -> Unit,
// 슬롯 3: 액션 영역 (선택적)
actions: (@Composable () -> Unit)? = null
) {
Card(modifier = modifier) {
Column {
// 슬롯 1: 제공되었을 때만 표시
header?.let {
Box(Modifier.background(Color.LightGray).padding(16.dp)) {
it()
}
}
// 슬롯 2: 항상 표시
Box(Modifier.padding(16.dp)) {
content()
}
// 슬롯 3: 제공되었을 때만 표시
actions?.let {
Row(Modifier.padding(8.dp)) {
it()
}
}
}
}
}
// Scope 객체를 사용한 고급 Slot API
@Composable
fun CustomScaffold(
topBar: @Composable CustomScaffoldScope.() -> Unit = {},
content: @Composable CustomScaffoldScope.() -> Unit
) {
val scope = remember { CustomScaffoldScopeImpl() }
Column {
scope.topBar()
scope.content()
}
}
interface CustomScaffoldScope {
@Composable fun Title(text: String)
}
// 사용 예시
CustomCard(
header = { Text("헤더", style = MaterialTheme.typography.titleLarge) },
content = { Text("메인 콘텐츠입니다.") },
actions = {
TextButton(onClick = {}) { Text("취소") }
TextButton(onClick = {}) { Text("확인") }
}
)
설명
이것이 하는 일: Slot API 패턴은 컴포넌트가 UI의 특정 영역을 직접 그리지 않고 람다 파라미터로 받아서 배치함으로써, 사용자가 원하는 대로 커스터마이징할 수 있는 유연한 API를 제공합니다. 첫 번째로, `header: (@Composable () -> Unit)?
= null처럼 컴포저블 람다를 파라미터로 선언합니다. ?`로 nullable하게 만들면 선택적 슬롯이 되어 제공하지 않아도 됩니다.
기본값을 null 또는 빈 람다 {}로 설정할 수 있습니다. 이렇게 하는 이유는 모든 상황에 맞는 단일 컴포넌트를 만들면서도 API를 간단하게 유지하기 위함입니다.
두 번째로, 슬롯을 배치할 때는 header?.let { it() } 또는 직접 header()를 호출합니다. nullable 슬롯은 let으로 감싸서 null일 때는 아무것도 렌더링하지 않습니다.
내부적으로 각 슬롯에 적절한 스타일링과 레이아웃을 적용할 수 있습니다: header는 배경색을 넣고, content는 패딩만 주고, actions는 Row로 감싸는 식입니다. 이렇게 일관된 스타일을 강제하면서도 내용은 자유롭게 할 수 있습니다.
세 번째로, Scope 객체를 사용하면 더 고급 기능을 제공할 수 있습니다. topBar: @Composable CustomScaffoldScope.() -> Unit처럼 리시버 타입을 지정하면, 람다 내부에서 scope의 함수들을 직접 호출할 수 있습니다.
Material3의 Scaffold가 RowScope.weight처럼 특정 컨텍스트에서만 사용 가능한 Modifier를 제공하는 것과 같은 원리입니다. 이는 API를 더 직관적이고 안전하게 만듭니다.
여러분이 이 패턴을 사용하면 파라미터 폭발을 방지하고 API를 깔끔하게 유지할 수 있으며, 사용자에게 최대한의 유연성을 제공하면서도 일관된 디자인을 강제할 수 있고, Material Design 같은 전문적인 컴포넌트 라이브러리를 만들 수 있습니다. 재사용 가능한 UI 컴포넌트 설계의 핵심 패턴입니다.
실전 팁
💡 슬롯 이름은 역할을 명확히 드러내야 합니다. content, header, footer, leading, trailing 같은 직관적인 이름을 사용하세요. slot1, slot2는 피하세요.
💡 필수 슬롯은 non-null로, 선택적 슬롯은 nullable로 만드세요. 대부분의 경우 content는 필수이고 나머지는 선택적입니다.
💡 너무 많은 슬롯(5개 이상)은 오히려 복잡도를 높입니다. 정말 필요한 슬롯만 노출하고, 비슷한 역할은 하나로 합치세요.
💡 Scope 객체를 사용할 때는 interface로 정의하여 확장 함수를 추가할 수 있게 만드세요. 이렇게 하면 라이브러리 사용자도 커스텀 헬퍼를 만들 수 있습니다.
💡 Material 컴포넌트들(Scaffold, TopAppBar, Card 등)의 소스코드를 읽어보세요. Google이 어떻게 Slot API를 설계했는지 배울 수 있는 최고의 예제입니다.