Jetpack Compose에서의 매개변수 전달 문제
Jetpack Compose로 앱을 개발하다 보면, 다음과 같은 상황을 겪을 수 있습니다:
- 상위 컴포저블(Composable)에서 정의한 Theme, Color, Spacing, Localization 등 UI에서 자주 쓰이는 "공용 데이터"를 하위 컴포저블로 전달해야 한다.
- 직접 사용하지도 않는 중간 컴포저블에까지 매번 매개변수를 넘겨주어야 해서 코드 가독성과 유지보수가 어려워진다.
이 문제를 "prop drilling"이라고 부르는데, React Native나 Flutter 개발 경험이 있는 분이라면 익숙할 겁니다.
Jetpack Compose에서는 Composition Local을 통해 이 문제를 해결할 수 있습니다.
Composition Local이란?
Composition Local은 Jetpack Compose가 제공하는 기능으로, 상위 계층에서 제공한 값을 하위 컴포저블 어디에서든 조회할 수 있게 해줍니다.
간단히 말해, "필요한 데이터는 원하는 곳에서 직접 가져다 쓰세요"라는 접근을 가능케 하는 메커니즘이죠.
- React의 Context API와 유사합니다.
- 단순히 데이터만 전달하는 것보다는, "UI 차원"에서 필요한 속성(테마, 폰트, 간격 등)을 깔끔하게 공유하기에 적합합니다.
Composition Local의 대표적 장점
- 불필요한 파라미터 제거
중간 컴포저블이 데이터에 직접 의존하지 않아도 되므로, 파라미터를 줄이고 코드가 훨씬 간결해집니다. - 스코프 기반
특정 화면에서만 오버라이드할 수도 있고, 전역으로 적용할 수도 있습니다. - 테스트 용이
테스트 시에 Composition Local 값을 손쉽게 바꿔치기(override) 할 수 있으므로, UI 테스트나 스냅샷 테스트가 편리해집니다.
언제 Composition Local을 사용할까?
- 사용하기 좋은 경우
- 앱의 전반적 UI 콘셉트(테마, 색상 팔레트, 간격, 폰트 등)
- 키보드 컨트롤, 포커스 매니저 등 UI 인프라
- 특정 화면에서만 일시적으로 오버라이드해서 다른 스타일을 적용해야 할 때
- 지양해야 하는 경우
- 비즈니스 로직(Repo, ViewModel, 네트워크, DB 등)
- 사용자 상태나 인증 토큰처럼 앱의 작동 자체에 중요한 상태
- 전역적으로 필요한 앱 의존성을 우회적으로 넘겨주기(= 잘못된 의존성 주입)
즉, "UI적인 속성과 스타일"에만 집중해서 사용하는 것이 좋습니다. 앱의 메인 로직을 Composition Local로 감추면 코드 추적이 어려워지고, 아키텍처가 복잡해질 수 있으니 주의해야 합니다.
직접 만들어보기
1. Composition Local 선언
Composition Local을 만들 때는 두 가지 함수를 사용할 수 있습니다.
- compositionLocalOf { ... }
- 동적(Dynamic) 값에 적합
- 값이 바뀌면, 이 값을 읽고 있는 모든 컴포저블이 재구성(recompose)됨
- staticCompositionLocalOf { ... }
- 거의 혹은 전혀 변경되지 않는 값에 적합
- 런타임에 값이 변해도 자동 재구성이 일어나지 않음(성능상 이점)
아래는 간단한 "색상 팔레트" 예시입니다.
data class ColorPalette(
val primary: Color,
val secondary: Color,
val background: Color,
val error: Color
)
val LocalColorPalette = compositionLocalOf<ColorPalette> {
error("ColorPalette가 제공되지 않았습니다.")
}
위 코드에서 error(...)를 기본값으로 주는 이유는, 만약 Composition Local이 제대로 제공되지 않았다면 바로 에러를 띄워서 원인을 찾기 쉽게 하기 위함입니다.
Tip: 테마처럼 "변경될 수 있는 값"이라면 compositionLocalOf를, "일반적으로 바뀌지 않는 디자인 토큰"이라면 staticCompositionLocalOf를 쓰면 좋습니다.
2. 값 제공(Provide)
Composition Local을 선언했다면, 이제 실제로 값을 제공해주는 CompositionLocalProvider를 사용해야 합니다. 보통은 앱 최상위 혹은 특정 화면 진입 시점에서 제공합니다.
@Composable
fun MyApp() {
// 실제 사용될 팔레트
val appColorPalette = ColorPalette(
primary = Color(0xFF0F9D58),
secondary = Color(0xFF4285F4),
background = Color.White,
error = Color.Red
)
CompositionLocalProvider(
LocalColorPalette provides appColorPalette
) {
// 내부 어느 컴포저블에서든 LocalColorPalette.current로 접근 가능
MainScreen()
}
}
이렇게 MyApp() 전체 트리에 값을 제공하면, MainScreen() 내부의 모든 컴포저블은 LocalColorPalette 값을 자유롭게 읽을 수 있습니다.
3. 값 읽기(Consume)
하위 컴포저블에서 Composition Local 값을 사용하려면 LocalColorPalette.current로 접근합니다. 예시로, MyCard 컴포저블에서 기본 Card 배경색을 팔레트의 background로 쓰고 싶다면 다음과 같이 작성할 수 있습니다.
@Composable
fun MyCard(content: String) {
// Composition Local에 제공된 팔레트 읽기
val palette = LocalColorPalette.current
Card(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
backgroundColor = palette.background
) {
Text(
text = content,
modifier = Modifier.padding(16.dp),
color = palette.primary
)
}
}
MyApp()에서 제공한 appColorPalette에 정의된 색상을 가져와서 UI에 적용하는 것이죠.
예제: 스페이싱(Spacing) Composition Local
이번에는 간격(Spacing) 데이터를 Composition Local로 관리해보겠습니다. UI 레이아웃 구현 시 매번 dp 값을 전달하는 대신, Composition Local로 스페이싱 값을 공유하면 편리합니다.
- 데이터 클래스 정의
data class Spacing(
val small: Dp = 4.dp,
val medium: Dp = 8.dp,
val large: Dp = 16.dp
)
- Composition Local 선언
val LocalSpacing = staticCompositionLocalOf<Spacing> {
error("Spacing이 제공되지 않았습니다.")
}
- 제공(Provide)
@Composable
fun MyTheme(content: @Composable () -> Unit) {
CompositionLocalProvider(
LocalSpacing provides Spacing(small = 6.dp, medium = 12.dp, large = 20.dp)
) {
content()
}
}
- 소비(Consume)
@Composable
fun MessageCard(title: String, message: String) {
val spacing = LocalSpacing.current
Card(
modifier = Modifier.padding(spacing.medium)
) {
Column(modifier = Modifier.padding(spacing.medium)) {
Text(title, modifier = Modifier.padding(bottom = spacing.small))
Text(message)
}
}
}
이렇게 하면 "간격" 정보가 어떤 dp 값인지 하위 컴포저블에서 일일이 몰라도 되며, 추후 디자인 시안이 바뀌어도 중앙에서 한 번에 조정할 수 있습니다.
스코프 개념 이해하기
Composition Local은 제공되는 지점부터 하위 트리 범위까지만 유효합니다. 즉, 어디서 CompositionLocalProvider를 호출하느냐가 중요합니다.
@Composable
fun RootScreen() {
CompositionLocalProvider(
LocalColorPalette provides ColorPalette(...),
) {
// 이 내부의 모든 컴포저블은 ColorPalette를 읽을 수 있음
HomeScreen()
ProfileScreen() // 여기도 같은 ColorPalette
}
// 여기는 CompositionLocalProvider 범위 밖이므로
// LocalColorPalette.current를 쓰면 에러가 발생할 것
AnotherScreen()
}
또한, **하위에서 재제공(Re-provide)**하여 값을 덮어쓸 수도 있습니다. 이를 이용해 특정 화면에서만 특별한 테마나 간격을 쓸 수 있죠.
Preview에서의 주의사항
@Preview는 실제 앱 실행 컨텍스트와 분리되어 있습니다. 따라서 Preview 내에서 Composition Local을 사용하려면, 별도로 CompositionLocalProvider를 제공해야 합니다.
@Preview
@Composable
fun MessageCardPreview() {
CompositionLocalProvider(
LocalSpacing provides Spacing(),
LocalColorPalette provides ColorPalette(...)
) {
MessageCard(title = "Hello", message = "Preview Test")
}
}
만약 이렇게 제공하지 않으면, "Spacing이 제공되지 않았습니다." 같은 예외가 발생할 수 있습니다.
쓰레드 안전성과 Side-Effect
- 쓰레드 안전성:
Composition Local은 Compose의 메인 스레드에서 동작하는 "컴포지션 트리" 내부에서만 접근 가능합니다. 비동기 코루틴이나 IO 스레드에서 LocalXXX.current를 직접 읽으면 에러가 납니다. - Side-Effect 사용 시 주의:
LaunchedEffect나 SideEffect 블록 안에서 Composition Local 값을 사용하려면, 컴포저블에서 현재 값을 미리 읽어 파라미터로 넘겨주는 식으로 접근해야 합니다. 값이 변경되면 재구성이 일어나고, side-effect도 재실행됩니다.
@Composable
fun LoggingCard() {
val palette = LocalColorPalette.current
LaunchedEffect(palette) {
// palette가 바뀔 때마다 재실행
logAnalytics("ColorPalette changed: $palette")
}
// ...
}
MVI / MVVM 아키텍처와 함께 쓰기
MVI나 MVVM을 쓰고 있다면, **“로직이나 상태는 ViewModel에, UI 스타일/리소스는 Composition Local에”**라는 식으로 분리하면 좋습니다. 예를 들어,
- ViewModel: 서버에서 가져온 데이터, 사용자 이벤트 처리, 상태 변경 등
- Composition Local: 앱 테마, 간격, 폰트, 스낵바 매니저, A/B 테스트 플래그 등 UI 전반 요소
즉, UI적 측면에서 공용으로 필요한 데이터는 Composition Local로 처리하고, 비즈니스 로직은 ViewModel이나 다른 상태 관리 솔루션을 통해 관리하길 권장합니다.
예제 내용 정리
- UI 토큰 중심으로
Composition Local은 테마, 색상, 폰트, 간격 등 "UI 토큰"에 활용하기에 적합합니다. - 기본값 꼭 설정
compositionLocalOf { error(...) } 형태로 의도적인 에러나 의미 있는 기본값을 지정하세요. - 스코프 최소화
전역으로 제공하기보단 필요한 범위에만 제공해서 불필요한 의존성을 줄이세요. - Side-Effect에서 값 변경 시 주의
LaunchedEffect, SideEffect 사용 시, LocalXXX.current 값을 직접 읽기보단 컴포저블에서 변수로 받는 것이 안전합니다. - Preview 커스텀
Preview 상황에도 원하는 Composition Local을 주입할 수 있도록 래퍼(Wrapper) 컴포저블을 만들어두면 편리합니다.
마무리
Jetpack Compose의 Composition Local은 UI에서 자주 쓰이는 공용 정보를 "필요한 시점에" 간단히 가져올 수 있게 해주는 강력한 도구입니다. 덕분에 매개변수를 계속해서 타고 내려 보내지 않아도 되고, UI 트리 전체를 깔끔하게 유지할 수 있죠.
그러나 Composition Local을 "모든" 의존성 관리나 "모든" 상태 전파용으로 사용하면 안 됩니다.
꼭 UI적으로만 필요한 공용 데이터에 집중해, MVI/MVVM 등과 조합해 쓰는 것이 바람직합니다.
아키텍처 측면에서 "UI 토큰이나 공용 리소스"와 "비즈니스 로직"을 명확히 분리하면서 Composition Local을 적재적소에 활용해보세요. 그러면 코드가 더욱 깨끗해지고 유지보수도 쉬워질 것입니다.
예제 전체 코드 (간단 요약)
// 1. ColorPalette, Spacing 등 UI 데이터 구조
data class ColorPalette(...)
data class Spacing(
val small: Dp = 4.dp,
val medium: Dp = 8.dp,
val large: Dp = 16.dp
)
// 2. CompositionLocal 선언
val LocalColorPalette = compositionLocalOf<ColorPalette> {
error("ColorPalette가 제공되지 않았습니다.")
}
val LocalSpacing = staticCompositionLocalOf<Spacing> {
error("Spacing이 제공되지 않았습니다.")
}
// 3. 제공 (Provide)
@Composable
fun MyTheme(content: @Composable () -> Unit) {
CompositionLocalProvider(
LocalColorPalette provides ColorPalette(...),
LocalSpacing provides Spacing(...)
) {
content()
}
}
// 4. 소비 (Consume)
@Composable
fun ThemedButton(onClick: () -> Unit, text: String) {
val palette = LocalColorPalette.current
val spacing = LocalSpacing.current
Button(
onClick = onClick,
modifier = Modifier.padding(spacing.medium),
colors = ButtonDefaults.buttonColors(backgroundColor = palette.primary)
) {
Text(text, color = palette.background)
}
}
이처럼 Composition Local을 적절히 활용하면, 파라미터 트리(Props Drilling) 문제에서 자유로워지고, UI 토큰(테마, 간격, 폰트 등) 관리가 훨씬 편리해집니다. 앞으로 Compose 프로젝트를 진행할 때 Composition Local을 활용해보시길 바랍니다.