[Android] Compose의 CompositionLocal 이란?
CompositionLocal을 활용한 지역 범위 데이터 관리
CompositionLocal은 컴포지션(Composable 함수 트리)을 통해 데이터를 암묵적으로 하위로 전달할 수 있도록 해주는 도구입니다.
이 페이지에서는 다음 내용을 자세히 다룹니다:
- CompositionLocal이란 무엇인지
- 직접 CompositionLocal을 생성하는 방법
- CompositionLocal이 특정 상황에 적절한 해결책인지 판단하는 방법
쉽게 말해, CompositionLocal은 매개변수를 일일이 전달하지 않고도 컴포저블 트리의 하위 컴포저블에서 공통 데이터에 접근할 수 있도록 해줍니다.
CompositionLocal 소개
Compose에서는 일반적으로 데이터가 컴포저블 함수의 매개변수(parameter)를 통해 UI 트리 아래로 전달됩니다.
이 방식은 각 컴포저블이 어떤 데이터에 의존하는지를 명시적으로 보여주는 장점이 있습니다.
하지만 컬러(Color)나 텍스트 스타일(Type Style)처럼 자주 사용되고 널리 참조되는 데이터를 계속 매개변수로 전달하는 것은 번거롭고 불편할 수 있습니다.
예를 들어 다음과 같은 코드가 있다고 가정해봅시다:
@Composable
fun MyApp() {
// 일반적으로 테마 정보는 앱의 루트 근처에서 정의됩니다.
val colors = colors()
}
// 계층 구조상 깊은 위치에 있는 컴포저블
@Composable
fun SomeTextLabel(labelText: String) {
Text(
text = labelText,
color = colors.onPrimary // ← 여기서 colors에 접근해야 함
)
}
대부분의 컴포저블에 colors를 명시적인 매개변수로 전달하지 않아도 되도록 하기 위해, Compose는 CompositionLocal을 제공합니다.
CompositionLocal은 UI 트리에서 트리 범위로 작동하는 이름이 있는 객체(named object)를 생성할 수 있게 하며, 이를 통해 데이터를 암묵적으로 전달할 수 있게 해줍니다.
CompositionLocal 요소는 일반적으로 UI 트리의 특정 노드에서 값이 제공(provide)됩니다.
이 값은 해당 노드의 하위 컴포저블들에서 매개변수로 CompositionLocal을 명시하지 않고도 사용할 수 있습니다.
Composition과 UI트리는 뭐가 다른가요?
Composition: 컴포저블 함수들의 호출 관계(call graph)를 기록한 구조입니다.
UI 트리(UI tree) 또는 UI 계층(UI hierarchy): LayoutNode들의 트리 구조로, Composition 과정에 의해 생성되고 업데이트되며 유지됩니다.
즉, CompositionLocal은 Composition 단계에서 컴포저블 간 호출 관계에 따라 값이 연결되고, 그 값은 실제 렌더링되는 UI 트리 내에서 하위 컴포저블이 자유롭게 접근할 수 있도록 해주는 메커니즘입니다.
예를들어, 아래 코드가 있을 때
@Composable
fun App(){
Header() Body()
}
@Composable
fun Header() {
Title()
}
@Composable
fun Body() {
Content()
}
Compose는 다음과 같이 호출 관계 트리를 만듭니다. 이 구조를 Composition이라고 부릅니다.
App()
├── Header()
│ └── Title()
└── Body()
└── Content()
각 함수가 어떤 다른 함수들을 호출했는지를 기록한 함수 호출 트리(Call Graph)죠.
CompositionLocal은 이 호출 트리를 따라 값을 하위 컴포저블에게 전달합니다.
예를 들어, App() 에서 어떤 값을 CompositionLocal로 제공하면, Header()나 Body() 같은하위 호출 함수들 에서 해당 값을 별도 매개변수 없이 사용할 수 있게 되는 거예요.
CompositionLocal은 Material 테마가 내부적으로 사용하는 핵심 메커니즘입니다.
MaterialTheme은 세 가지 CompositionLocal 인스턴스를 제공하는 객체로, 각각은 다음과 같습니다:
- colorScheme
- typography
- shapes
이들은 Compose의 컴포지션 트리 내에서 하위 컴포저블들이 언제든지 접근할 수 있도록 해줍니다.
구체적으로는 LocalColorScheme, LocalTypography, LocalShapes라는 CompositionLocal을 통해 구현되어 있습니다.
우리는 이를 MaterialTheme.colorScheme, MaterialTheme.typography, MaterialTheme.shapes처럼 접근할 수 있습니다.
@Composable
fun MyApp() {
// Theme을 적용하고 그 값을 내부 content로 전달합니다.
MaterialTheme {
// 이 영역 안에 있는 모든 컴포저블은
// colorScheme, typography, shapes 값을 참조할 수 있습니다.
// ... 실제 UI 콘텐츠 ...
}
}
// MaterialTheme의 깊은 하위에 위치한 컴포저블
@Composable
fun SomeTextLabel(labelText: String) {
Text(
text = labelText,
// MaterialTheme 내부의 LocalColors CompositionLocal에서 primary 컬러를 가져옴
color = MaterialTheme.colorScheme.primary
)
}
이렇게 하면 상위에서 테마 정보를 명시적으로 전달하지 않아도, 하위 컴포저블에서 테마 정보(primary color 등)를 암묵적으로 사용할 수 있습니다.
이것이 바로 CompositionLocal의 핵심 장점입니다 — 데이터를 트리 깊숙한 곳까지 편리하게 전달할 수 있다는 것!
CompositionLocal 인스턴스는 컴포지션(Composition)의 일부분에 범위(scope)가 지정되므로, 트리의 서로 다른 레벨에서 서로 다른 값을 제공할 수 있습니다.
어떤 CompositionLocal의 현재 값은 해당 컴포저블의 조상 중 가장 가까운 곳에서 제공된 값입니다.
새로운 값을 CompositionLocal에 제공하려면 CompositionLocalProvider를 사용합니다.
이때 provides라는 infix 함수를 사용해, 특정 CompositionLocal 키에 값을 연결합니다.
CompositionLocalProvider의 content 람다 내부에 있는 컴포저블들은 CompositionLocal.current를 통해 제공된 값을 참조할 수 있습니다.
새로운 값이 제공되면, Compose는 해당 CompositionLocal을 읽는 컴포지션의 일부를 재구성(recompose) 합니다.
즉, 값이 바뀌면 그 값을 사용하는 부분만 효율적으로 다시 그려지는 것입니다.
예를들어, LocalContentColor는 현재 배경에 대비되는 텍스트나 아이콘 색상을 지정하는 데 사용됩니다.
아래 예시에서는 CompositionLocalProvider를 통해 컴포지션의 각기 다른 부분에 서로 다른 값을 제공하는 방법을 보여줍니다.
@Composable
fun CompositionLocalExample() {
MaterialTheme {
// Surface는 기본적으로 contentColorFor(MaterialTheme.colorScheme.surface)를 제공합니다.
// 이는 텍스트나 기타 콘텐츠가 배경과 적절히 대비되도록 자동으로 설정하기 위함입니다.
Surface {
Column {
Text("Surface에서 제공하는 content color를 사용합니다.")
CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.primary) {
Text("LocalContentColor를 통해 Primary 컬러를 제공합니다.")
Text("이 Text 역시 primary 컬러를 사용합니다.")
CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.error) {
DescendantExample()
}
}
}
}
}
}
@Composable
fun DescendantExample() {
// CompositionLocalProvider는 컴포저블 함수 간에도 작동합니다.
Text("이 Text는 이제 error 컬러를 사용합니다.")
}
이전 예제에서는 CompositionLocal 인스턴스들이 Material 컴포저블 내부에서 사용되었습니다.
CompositionLocal의 현재 값을 가져오려면, 해당 인스턴스의 current 프로퍼티를 사용합니다.
다음 예제에서는 Android 앱에서 흔히 사용되는 LocalContext CompositionLocal의 현재 Context 값을 가져와 텍스트를 포맷하는 방식입니다:
@Composable
fun FruitText(fruitSize: Int) {
// 현재 LocalContext에서 resources를 가져옵니다
val resources = LocalContext.current.resources
val fruitText = remember(resources, fruitSize) {
resources.getQuantityString(R.plurals.fruit_title, fruitSize)
}
Text(text = fruitText)
}
참고:
CompositionLocal 객체나 상수는 보통 Local이라는 접두어를 붙여 네이밍합니다.
이는 IDE의 자동 완성 기능에서 더 쉽게 찾을 수 있도록 도와줍니다.
나만의 CompositionLocal 만들기
CompositionLocal은 컴포지션(Composition)을 통해 데이터를 암묵적으로 하위로 전달하는 도구입니다.
CompositionLocal을 사용해야 하는 주요 상황 중 하나는, 특정 하나의 기능이나 화면에만 국한되지 않고, 여러 화면이나 컴포넌트에서 공통적으로 필요한 경우입니다.
즉, 중간 계층의 컴포저블들이 해당 매개변수의 존재를 알지 못하게 유지해야 할 경우입니다.
중간 계층에서 이를 인식하게 되면, 그 컴포저블의 재사용성과 유연성이 제한되기 때문입니다.
예를 들어, 안드로이드에서 권한을 요청하는 기능은 내부적으로 CompositionLocal을 통해 처리됩니다.
이러한 구조 덕분에, 미디어 선택(Media Picker) 컴포저블은
- 권한이 필요한 콘텐츠에 접근하는 새 기능을 추가하면서도,
- 외부 API를 변경하지 않고,
- 호출하는 쪽에서 별도의 맥락이나 권한 처리를 인식하지 않아도 됩니다.
하지만, CompositionLocal이 항상 최선은 아닙니다.
CompositionLocal의 과도한 사용은 권장되지 않습니다. 그 이유는 다음과 같습니다:
- CompositionLocal은 컴포저블의 동작을 추론하기 어렵게 만듭니다.
암묵적 의존성을 만들기 때문에, 해당 컴포저블을 호출하는 쪽은 모든 CompositionLocal에 대해 값이 제공되고 있는지를 확인해야 합니다. - 또한, CompositionLocal 값은 컴포지션의 어느 위치에서든 변경될 수 있으므로
명확한 단일 출처(source of truth)를 가지기 어렵습니다.
따라서 문제가 발생했을 때, 어디서 값이 제공되었는지 컴포지션을 거슬러 올라가야 하므로 디버깅이 더 어려워질 수 있습니다.
이를 보완하기 위해 IDE의 Find usages 기능이나 Compose Layout Inspector 등의 도구를 활용하면 문제를 완화할 수 있습니다.
CompositionLocal 사용 여부 결정하기
다음과 같은 조건을 만족할 때, CompositionLocal은 유용한 해결책이 될 수 있습니다:
- CompositionLocal은 적절한 기본값(default value)을 가져야 합니다.
기본값이 없다면, 반드시 개발자가 해당 CompositionLocal에 값을 빠짐없이 제공하도록 강제해야 합니다.
그렇지 않으면 테스트 작성이나 프리뷰(preview) 시, 매번 값을 명시적으로 제공해야 하므로 문제가 발생하거나 실수할 가능성이 커집니다. - 트리 범위(tree-scoped) 또는 하위 계층 범위(sub-hierarchy scoped)로 생각되지 않는 개념에는 CompositionLocal을 사용하지 마세요.
CompositionLocal은 트리의 모든 하위 컴포저블에서 사용될 가능성이 있는 경우에 적합합니다.
단지 일부 컴포저블에서만 필요한 데이터라면 CompositionLocal은 적절하지 않습니다. - 위 조건에 맞지 않는 경우, CompositionLocal을 만들기 전에 "다른 대안이 있는지" 검토해보는 것이 좋습니다.
CompositionLocal의 잘못된 예시
- 특정 화면의 ViewModel을 담은 CompositionLocal을 만들어
화면 내 모든 컴포저블이 ViewModel에 접근하도록 하는 경우
이러한 방식은 좋은 방법이 아닙니다.
왜냐하면 UI 트리의 모든 하위 컴포저블이 ViewModel을 알아야 할 필요는 없기 때문입니다.
좋은 방식은?
- 각 컴포저블에 필요한 정보만 전달하세요.
- 상태는 내려 보내고(state down),
- 이벤트는 위로 올리는(event up) 방식으로 흐름을 구성하는 것이 가장 좋습니다.
이러한 접근은 컴포저블을 더 재사용 가능하고, 테스트하기 쉬운 구조로 만들어줍니다.
CompositionLocal 생성하기
CompositionLocal을 생성하는 방법에는 두 가지 API가 있습니다:
- compositionLocalOf
이 API를 사용할 경우, current 값을 읽는 부분만 정확히 추적되어
값이 변경되면 그 값을 읽은 컴포저블만 재구성(recompose) 됩니다. - staticCompositionLocalOf
compositionLocalOf와 달리, current 값에 대한 읽기 추적이 일어나지 않습니다.
따라서 값이 변경되면, 해당 CompositionLocal이 제공된 전체 content 람다 영역이 재구성됩니다.
값이 거의 바뀌지 않거나 절대 바뀌지 않는 경우에 사용하면 성능상 이점이 있습니다. ( 값이 바뀔 일이 거의 없으면, 추적 자체가 낭비이므로 추척 안하고 쓰는 것이 빠름.)
예를 들어, 앱의 디자인 시스템에서 UI 컴포넌트에 그림자(shadow)를 통해 elevation 값을 정의하는 경우,
앱 전반에 걸쳐 이 값을 전달해야 하므로 CompositionLocal을 사용합니다.
이 값은 시스템 테마에 따라 조건부로 결정되므로, compositionLocalOf를 사용하는 것이 적합합니다.
// LocalElevations.kt 파일
data class Elevations(val card: Dp = 0.dp, val default: Dp = 0.dp)
// 기본값을 가진 CompositionLocal 글로벌 객체 정의
// 앱 내 모든 컴포저블에서 접근 가능
val LocalElevations = compositionLocalOf { Elevations() }
CompositionLocal에 값 제공하기
CompositionLocalProvider 컴포저블은 특정 컴포지션 계층에 대해 CompositionLocal 인스턴스에 값을 바인딩합니다.
새로운 값을 CompositionLocal에 제공하려면, provides infix 함수를 사용해 CompositionLocal 키와 값을 연결합니다.
class MyActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
// 시스템 테마에 따라 elevation 값을 계산
val elevations = if (isSystemInDarkTheme()) {
Elevations(card = 1.dp, default = 1.dp)
} else {
Elevations(card = 0.dp, default = 0.dp)
}
// 계산된 elevation 값을 LocalElevations에 바인딩
CompositionLocalProvider(LocalElevations provides elevations) {
// ... 이 안에 들어가는 콘텐츠 ...
// 이 컴포지션 영역 내에서는
// LocalElevations.current를 통해 위에서 제공한 elevations 값을 참조할 수 있음
}
}
}
}
이렇게 생성된 LocalElevations는 컴포지션 트리 전체에서 사용할 수 있으며,
필요 시 CompositionLocalProvider를 통해 다른 값으로 오버라이드할 수 있습니다.
CompositionLocal 사용하기 (소비하기)
CompositionLocal.current는 해당 CompositionLocal에 값을 제공하는 가장 가까운 CompositionLocalProvider에서 제공한 값을 반환합니다.
@Composable
fun SomeComposable() {
// 전역으로 정의된 LocalElevations 변수를 통해
// 현재 이 컴포지션 영역에서의 Elevations 값을 가져옵니다
MyCard(elevation = LocalElevations.current.card) {
// 콘텐츠
}
}
고려할 수 있는 다른 대안들
일부 사용 사례에서는 CompositionLocal이 과도한 해결책일 수 있습니다.
만약 사용하려는 목적이 앞서 설명한 “CompositionLocal 사용 여부 결정” 조건에 부합하지 않는다면,
다른 방식이 더 적절할 수 있습니다.
명시적인 매개변수 전달
컴포저블의 의존성을 명확하게 전달하는 것은 좋은 습관입니다.
각 컴포저블에는 정말로 필요한 정보만 전달하는 것이 바람직합니다.
이러한 접근은 컴포저블 간의 결합도를 낮추고, 재사용성과 테스트 용이성을 높여줍니다.
@Composable
fun MyComposable(myViewModel: MyViewModel = viewModel()) {
// ...
MyDescendant(myViewModel.data)
}
❌ 아래와 같은 예시는 잘못된 예시입니다.:
전체 ViewModel을 전달하거나, CompositionLocal로 ViewModel을 암묵적으로 주입하지 마세요.
@Composable
fun MyDescendant(myViewModel: MyViewModel) { /* ... */ }
정말 필요한 데이터만 전달해야합니다.
ViewModel은 일반적으로 UI 계층의 최상위 또는 Navigation 대상(Screen 단위)에서 관리하는 것이 좋습니다.
제어의 역전(Inversion of Control)
앞서 언급했듯이, ViewModel을 하위 컴포저블에 직접 전달하는 방식은 바람직하지 않습니다.
컴포저블에 불필요한 의존성 전달을 피하는 또 다른 방법은 제어의 역전(Inversion of Control)을 활용하는 것입니다.
하위 컴포저블이 어떤 로직을 직접 실행하기 위해 의존성을 받아오는 대신,
상위 컴포저블이 책임을 지고, 하위 컴포저블은 단순히 콜백만 호출하게 만듭니다.
다음은 하위 컴포저블이 데이터를 로드하기 위해 ViewModel에 의존하는 예시입니다:
@Composable
fun MyComposable(myViewModel: MyViewModel = viewModel()) {
// ...
MyDescendant(myViewModel)
}
@Composable
fun MyDescendant(myViewModel: MyViewModel) {
Button(onClick = { myViewModel.loadData() }) {
Text("Load data")
}
}
이 경우, MyDescendant가 너무 많은 책임을 가지게 될 수 있으며,
ViewModel을 의존성으로 전달받기 때문에 재사용이 어려워집니다.
제어의 역전 방식 적용
ViewModel을 하위에 전달하지 않고, 상위에서 로직을 처리한 후 콜백 함수만 전달합니다:
@Composable
fun MyComposable(myViewModel: MyViewModel = viewModel()) {
// ...
ReusableLoadDataButton(
onLoadClick = {
myViewModel.loadData()
}
)
}
@Composable
fun ReusableLoadDataButton(onLoadClick: () -> Unit) {
Button(onClick = onLoadClick) {
Text("Load data")
}
}
이처럼 제어의 흐름을 상위로 넘기고, 하위는 UI와 사용자 이벤트 처리에만 집중하게 만드는 것이 Jetpack Compose에서 권장하는 설계 방식 중 하나입니다.
상위 컴포저블이 더 복잡해지는 대신, 하위 컴포저블이 더 유연하고 재사용 가능한 구조가 됩니다.
이점 요약
책임 분리 | 상위는 로직을 담당하고, 하위는 UI에 집중 |
재사용성 향상 | ReusableLoadDataButton은 어떤 로직이든 콜백만 주면 사용 가능 |
테스트 용이성 | 콜백만 확인하면 되므로 단위 테스트가 쉬움 |
결합도 감소 | 하위 컴포저블이 ViewModel이나 다른 비즈니스 로직에 얽매이지 않음 |
비슷한 방식으로, @Composable content 람다를 사용하면 같은 이점을 얻을 수 있습니다:
@Composable
fun MyComposable(myViewModel: MyViewModel = viewModel()) {
// ...
ReusablePartOfTheScreen(
content = {
Button(
onClick = {
myViewModel.loadData()
}
) {
Text("Confirm")
}
}
)
}
@Composable
fun ReusablePartOfTheScreen(content: @Composable () -> Unit) {
Column {
// ...
content()
}
}
이처럼 content 슬롯에 UI 로직을 위임하면,
ReusablePartOfTheScreen은 로직을 전혀 몰라도 되고,
상위 컴포저블에서 동작과 표현을 자유롭게 제어할 수 있어 재사용성과 유연성이 크게 향상됩니다.
끝.
참고자료
https://developer.android.com/develop/ui/compose/compositionlocal
CompositionLocal을 사용한 로컬 범위 지정 데이터 | Jetpack Compose | Android Developers
이 페이지는 Cloud Translation API를 통해 번역되었습니다. CompositionLocal을 사용한 로컬 범위 지정 데이터 컬렉션을 사용해 정리하기 내 환경설정을 기준으로 콘텐츠를 저장하고 분류하세요. Composition
developer.android.com