Parameter vs CompositionLocal: 언제 무엇을 사용해야 하는가?

Gio·2025년 9월 21일
2

서론

Composable 내부에서 액티비티의 메서드 호출이 필요한 상황은 종종 발생한다.

예를 들어 뒤로가기, 권한 요청, 외부 앱 호출 등 시스템과 상호작용해야 하는 경우가 있다.

@Composable
private fun Screen(
    modifier: Modifier = Modifier,
) {
    Column(
        modifier = modifier
    ) {
        IconButton(onClick = {
            // TODO: 액티비티 종료
        }) {
            Icon(Icons.AutoMirrored.Filled.ArrowBack, null)
        }
    }
}

이를 해결하는 방법은 대표적으로 두 가지가 있을 것이다. 인자를 통해 Activity에서 내려받거나, CompositionLocal을 사용하거나.

// 방법 1: Activity에서 인자를 통해 내려주기
class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        setContent {
            TestAppTheme {
                Screen(
                    onBackButtonClick = ::finish,
                    modifier = Modifier.fillMaxSize()
                )
            }
        }
    }
}

@Composable
private fun Screen(
    onBackButtonClick: () -> Unit,
    modifier: Modifier = Modifier,
) {
    Column(
        modifier = modifier
    ) {
        IconButton(onClick = onBackButtonClick) {
            Icon(Icons.AutoMirrored.Filled.ArrowBack, null)
        }
    }
}
// 방법 2: CompositionLocal을 사용하기
@Composable
private fun Screen(
    modifier: Modifier = Modifier,
) {
    val activity: Activity? = LocalActivity.current

    Column(
        modifier = modifier
    ) {
        IconButton(onClick = { activity?.finish() }) {
            Icon(Icons.AutoMirrored.Filled.ArrowBack, null)
        }
    }
}

인자와 CompositionLocal, 어떤 것을 선택할지 결정하는 기준은 무엇일까?

본론

CompositionLocal?

CompositionLocal은 Composable 트리에 데이터를 암시적으로 전달하는 도구이다.

일반적으로 UI 트리의 하위에 있는 컴포저블에 데이터를 전달할 때는 인자를 사용해야 한다. 이렇게 하면 각 컴포저블의 의존성을 명시적으로 확인할 수 있다.

하지만 거의 모든 컴포저블에서 자주 사용되는 값들(테마 등)의 경우도 전부 인자를 사용하면 번거로울 수 있다.

@Composable
fun MyApp() {
    val colors = colors()
	  ...
}

// 트리 계층 구조의 하위에 존재하는 컴포저블
@Composable
fun SomeTextLabel(labelText: String, colors: Colors  ...) {
    Text(
        text = labelText,
        color = colors.onPrimary
        ...
    )
}

따라서 인자 없이 암시적으로 필요한 의존성을 충족시키기 위해 컴포즈는 CompositionLocal을 사용한다. 이를 통해 UI 트리의 스코프 동안 필요한 데이터를 제공하는 객체를 생성하고 암시적으로 활용할 수 있다.

대표적으로 MaterialTheme가 내부적으로 CompositionLocal을 사용하고 있다.

object MaterialTheme {
    val colorScheme: ColorScheme
        @Composable @ReadOnlyComposable get() = LocalColorScheme.current

    val typography: Typography
        @Composable @ReadOnlyComposable get() = LocalTypography.current

    val shapes: Shapes
        @Composable @ReadOnlyComposable get() = LocalShapes.current
}

언제 CompositionLocal을 사용해야 하는가?

안드로이드 공식문서에서 CompositionLocal을 사용하기 좋은 몇 가지 조건을 확인할 수 있다.

  • 좋은 기본값을 가질 수 있을 때

    val LocalSpacing = compositionLocalOf { 0.dp } // 기본값: 0dp
    val LocalUser = compositionLocalOf<User> { error("No User provided") } // 기본값 X
    
    @Composable
    fun Example() {
        val spacing = LocalSpacing.current
        Text(
            "Hello!",
            modifier = Modifier.padding(spacing)
        )
        
        val user = LocalUser.current
        Text("Hello, ${user.name}!")
    }

    기본값이 없으면 테스트나 프리뷰에서 매번 값을 명시적으로 제공해야 해서 번거롭고, 실수로 누락 시 오류로 이어질 수 있다.

  • 트리 전역 또는 특정 하위 트리에 걸쳐 의미 있는 값일 때
    CompositionLocal은 하위 모든 컴포저블이 잠재적으로 접근할 수 있다는 점이 특징이다. 따라서 일부 컴포저블만 쓰는 값이라면 적합하지 않다.

    val LocalSpacing = compositionLocalOf { 8.dp }
    
    @Composable
    fun MyApp() {
        CompositionLocalProvider(LocalSpacing provides 16.dp) {
            Column {
                Header()
                Content()
                Footer()
            }
        }
    }
    
    @Composable
    fun Header() {
        val spacing = LocalSpacing.current
        Text("Header", modifier = Modifier.padding(spacing))
    }
    
    @Composable
    fun Content() {
        val spacing = LocalSpacing.current
        Text("Content", modifier = Modifier.padding(spacing))
    }
    
    @Composable
    fun Footer() {
        val spacing = LocalSpacing.current
        Text("Footer", modifier = Modifier.padding(spacing))
    }

    여기서 LocalSpacing트리 전역적으로 의미가 있는 값이므로 CompositionLocal을 사용하는 것이 자연스럽다.

  • 뷰모델 같은 화면 단위의 구체적 의존성을 담지 않을 것
    화면 전체의 ViewModelCompositionLocal로 노출하는 건 권장되지 않는다. 모든 자식이 접근할 수 있지만 실제로 필요한 건 일부 컴포저블뿐이기 때문이다.

    // 부적합한 예시
    val LocalCardViewModel = compositionLocalOf<CardViewModel> { error("No VM") }
    
    @Composable
    fun CardScreen() {
        val viewModel = LocalCardViewModel.current
        Column {
            CardHeader()
            CardContent()
            CardFooter()
        }
    }
    
    @Composable
    fun CardHeader() {
        val viewModel = LocalCardViewModel.current
        Text("User: ${viewModel.userName}")
    }

LocalActivity?

LocalActivity는 시스템 레벨 API에 접근할 수 있도록 Compose에서 제공하는 특수한 CompositionLocal이다.

이를 통해 Activity가 필요한 권한 요청, startActivityForResult, WindowInsets 제어 같은 상황에서 사용할 수 있다.

그러나 LocalActivity를 남용하면, 컴포저블이 Activity에 직접 의존하게 되고, 재사용성과 테스트성이 떨어진다.

따라서 일반적인 UI 이벤트(뒤로가기 버튼 등)는 콜백 인자를 통해 외부에서 전달하는 방식이 더 바람직하다.

@Composable
fun CardScreen(onBackClick: () -> Unit) {
    Button(onClick = onBackClick) { Text("Back") }
}

이 방식은 화면 구조가 바뀌거나, Compose Navigation을 사용하더라도 유연하게 대응할 수 있다.

결론

  • CompositionLocal: 전역적, 환경적 값에 적합. 기본값이 안전하고, 거의 모든 하위 컴포저블이 필요로 하는 값이어야 한다.
  • 콜백 인자: 특정 화면/컴포저블 동작에 속하는 값이나 이벤트에는 기본적으로 권장되는 방식. 재사용성과 테스트 용이성이 높다.
  • LocalActivity: 학습용이나 불가피한 시스템 API 접근 시 사용할 수 있지만, 일반 UI 이벤트에서는 인자 전달 방식을 우선적으로 선택하는 것이 좋다.

REF

Locally scoped data with CompositionLocal  |  Jetpack Compose  |  Android Developers

profile
틀린 부분을 지적받기 위해 업로드합니다.

0개의 댓글