Android 개발/Jetpack Compose

값을 화면 깊은 곳까지 파라미터로 줄줄이 내리던 자리 — CompositionLocal 제대로 쓴 일지

stackD 2026. 8. 29. 18:00
반응형

 

얼마 전에 화면 컴포저블에서 Label까지 userTheme 하나를 5단계로 줄줄이 내리다가, 함수 정의가 두 줄을 넘어가는 걸 보고 멈췄습니다. 이건 좀 아니다 싶었어요.

 

테마와 사용자 정보를 컴포저블마다 파라미터로 넘기다 지친 6년차가 CompositionLocal로 갈아탄 실전 일지입니다. compositionLocalOf 차이부터 남용 함정, 써도 되는 자리를 가른 기준까지 정리했어요.

 

젯팩 컴포즈(Jetpack Compose)를 쓰다 보면 누구나 한 번은 마주치는 골목인 것 같아요. 제가 그 골목에서 어떻게 빠져나왔고, 어디서 한 번 더 자빠졌는지를 풀어볼게요.

 

Prop Drilling, 5단계 파라미터 전달의 피로

당시 구조가 딱 이랬습니다. userTheme를 최상위 화면에서 받아서 Screen → Section → Card → Row → Label까지 다섯 단계로 내렸어요.

 

@Composable
fun ProfileScreen(userTheme: UserTheme) {
    ProfileSection(userTheme = userTheme)
}

@Composable
fun ProfileSection(userTheme: UserTheme) {
    ProfileCard(userTheme = userTheme) // 자기는 안 쓰고 넘기기만 함
}

// Card, Row 도 똑같이 받아서 넘기기만... 결국 Label 에서야 사용
@Composable
fun ProfileLabel(userTheme: UserTheme) {
    Text(text = "안녕하세요", color = userTheme.accentColor)
}

 

웃긴 게, 중간의 Section·Card·Row 세 개는 userTheme를 한 번도 안 썼습니다. 그냥 아래로 흘려보내는 통로였어요.

 

문제는 값 하나 추가할 때 터집니다. 강조 색상 하나 더 넣으려고 했더니, 시그니처를 고쳐야 하는 파일이 네 개나 됐거든요. 정작 그 값을 진짜로 읽는 곳은 한 군데뿐인데 말이죠.

 

그래도 이 방식엔 정직한 면이 있긴 합니다. 시그니처만 봐도 "아, 이 값은 화면 위에서 내려온 거구나" 하고 출처가 다 보이니까요. 지저분해도 거짓말은 안 하는 코드였습니다.

 

 

CompositionLocal로 트리 전역에 값 흘려보내기

CompositionLocal은 이 통로 노릇을 없애주는 장치예요. 트리 위에서 값을 한 번 꽂아두면, 아래 어느 컴포저블이든 직접 꺼내 쓸 수 있습니다. 쓰는 절차는 세 단계예요.

 

  1. 키 만들기: compositionLocalOf { ... } 로 Local 키를 정의합니다.
  2. 값 주입: CompositionLocalProvider(Key provides value) { ... } 로 값을 꽂습니다.
  3. 값 읽기: 하위에서 Key.current 로 꺼냅니다.

 

// 기본값을 error 로 두면 Provider 누락 시 런타임에 바로 잡힙니다
val LocalUserTheme = compositionLocalOf<UserTheme> {
    error("LocalUserTheme 가 제공되지 않았습니다")
}

@Composable
fun ProfileScreen(userTheme: UserTheme) {
    CompositionLocalProvider(LocalUserTheme provides userTheme) {
        ProfileSection() // 이제 파라미터가 없습니다
    }
}

@Composable
fun ProfileLabel() {
    val theme = LocalUserTheme.current
    Text(text = "안녕하세요", color = theme.accentColor)
}

 

기본값을 error()로 둔 게 핵심이에요. 누가 Provider로 안 감싸고 .current를 부르면 그 순간 런타임에서 터집니다. 조용히 잘못된 기본값이 들어가는 것보다, 시끄럽게 죽는 쪽이 디버깅엔 훨씬 낫더라고요.

 

적용하고 나니 중간 세 컴포저블의 userTheme 파라미터가 싹 사라졌습니다. 함수 시그니처가 한 줄로 짧아지니까 읽기가 한결 편해졌어요.

 

compositionLocalOf vs staticCompositionLocalOf 차이

키를 만드는 함수가 두 가지인데, 이 선택이 성능을 가릅니다. 처음엔 그냥 짧은 compositionLocalOf만 썼는데, 둘은 리컴포지션 범위가 다르거든요.

 

compositionLocalOf는 동적이에요. 값이 바뀌면 그 값을 .current로 읽는 컴포저블만 다시 그려집니다. 내부적으로 읽기를 추적하기 때문이지요. 스크롤 위치 따라 바뀌는 색상처럼 자주 변하는 값에 잘 맞습니다.

 

staticCompositionLocalOf는 정적입니다. 추적을 안 하는 대신, 값이 바뀌면 Provider 아래 서브트리 전체가 리컴포지션돼요. 대신 평소 읽을 때 추적 비용이 없습니다.

 

// 앱 시작 후 거의 안 바뀌는 값 → 정적
val LocalAppLocale = staticCompositionLocalOf<Locale> {
    error("Locale 미제공")
}

 

판단 기준은 이렇게 잡았습니다. 값이 자주 바뀌면 compositionLocalOf, 거의 안 바뀌면 staticCompositionLocalOf. 그리고 드물게 바뀌더라도 읽는 곳이 워낙 많으면, 상시 추적 비용보다 가끔 한 번 전체를 다시 그리는 비용이 더 쌀 수 있어요. 테마나 로케일을 정적으로 두는 게 권장되는 이유라고 봅니다.

 

 

CompositionLocal 남용의 대가, 암묵적 의존성

여기서 제가 한 번 크게 자빠졌습니다. 편한 맛에 들려서, 화면 ViewModel이랑 Repository까지 CompositionLocal로 넘겨버렸거든요.

 

처음엔 진짜 편했어요. 근데 두 달쯤 지나 그 화면을 다시 열었을 때 멘붕이 오더라고요. ProfileCard() 라는 시그니처만 봐서는 이게 대체 무엇에 의존하는지 1도 알 수가 없었습니다. 파라미터가 비어 있으니 의존성이 통째로 숨어버린 거예요.

 

테스트는 더 골치였습니다. 이 컴포저블 하나를 검증하려고 CompositionLocalProvider로 ViewModel, Repository를 전부 감싸 줘야 했어요. 명시적으로 파라미터만 넘겼으면 한 줄이면 끝날 일이었는데 말이죠.

 

결국 그건 전역 변수랑 비슷한 물건이 되어 있었습니다. 어디서든 닿을 수 있다는 건, 어디서 바꿨는지 추적이 안 된다는 뜻이기도 합니다. Prop Drilling이 지저분해도 정직했다면, 이건 깔끔한데 거짓말을 하는 코드였어요.

 

 

횡단 관심사에만 한정, CompositionLocal 사용 기준 3가지

그 사고 이후로 규칙을 세 개로 못 박았습니다.

 

  1. 허용 대상은 환경적 값뿐. 테마·로케일·LocalContext처럼 트리 어디서든 필요한 횡단 값에만 씁니다. 특정 화면 데이터, 비즈니스 로직은 금지예요.
  2. State Hoisting과 Slot API를 먼저 검토. 상태를 공통 상위로 끌어올리거나, @Composable () -> Unit 람다로 UI 조각 자체를 넘기는 방법이 안 통할 때만 최후 수단으로 꺼냅니다.
  3. "파라미터 넘기기 귀찮아서"는 사유가 안 됩니다. 편의성과 횡단 관심사는 전혀 다른 얘기예요.

 

개인적으로는 새 값을 CompositionLocal로 올릴지 말지 고민될 때, "이게 테마나 로케일 같은 부류인가?"를 먼저 자문합니다. 거기서 "아닌데, 그냥 이 화면 데이터인데" 싶으면 미련 없이 파라미터로 내려요. 그 한 번의 자문이 두 달 뒤의 저를 살리더군요.

 

CompositionLocal은 Prop Drilling의 대체재가 아니라, 횡단 관심사를 다루는 특수 공구라고 봅니다. 시그니처에서 사라진 파라미터는 없어진 게 아니라 숨겨진 거예요. 결국 저는 그 자문 한 번으로, 값 하나 바꾸려고 파일 네 개를 고치던 자리를 단 한 곳으로 줄였습니다.

 

 

반응형
LIST