본문 바로가기
Android

[Android] Jetpack Compose에서 Props drilling을 피하는 패턴

by 날리는달 2025. 4. 11.

실무 코드를 보면서 고민한 내용들


안드로이드에서도 JetpackCompose를 사용하면서

점차 선언형 프로그래밍이 대세로 들어섰다.

 

그러나, 복잡한 도메인이나 구조를 짜면서 Callback을 넘겨주는 상태호이스팅을 사용할때,

호이스팅이 항상 정답은 아니다.

 

도메인이 복잡하거나, 요구사항으로 인해 로직구조가 여러군데서 파편화되거나,

같은 화면인데, 컴포넌트간 거리가 먼경우, props drilling이 매우 깊어질 수 있다.

 

 

즉 컴포즈함수를 5~7군데 이상이나 들어가는 복잡하고 유지보수가 어려운 구조가 생길 수 있다.

 


이에 대한 해결법은 무엇이 있을까?

1. 호이스팅을 그대로 사용

// 최상위 컴포저블
@Composable
fun ParentScreen(viewModel: ParentViewModel = viewModel()) {
    val state by viewModel.state.collectAsState()
    
    ChildComponent(
        onEvent = { event -> 
            viewModel.handleEvent(event) 
        }
    )
}

// 자식 컴포저블
@Composable
fun ChildComponent(onEvent: (Event) -> Unit) {
    Button(onClick = { onEvent(Event.ButtonClicked) }) {
        Text("Click me")
    }
}

 

컴포넌트 깊이가 짧은경우, 해당 방식을 사용하는 것이 바람직하다.

 

2. CompostionLocal 사용

val LocalEventHandler = compositionLocalOf<() -> Unit> { 
    { error("No handler provided") } 
}

// 상위에서 제공
CompositionLocalProvider(
    LocalEventHandler provides { viewModel.handleClick() }
) {
    // 하위 컴포넌트들
}

// 깊은 자식에서 사용
val handleClick = LocalEventHandler.current
Button(onClick = handleClick) { ... }

 

CompositionLocal의 사용은 다음과 같은 단점을 가질 수 있다:

1. 재사용성 저하 

특정 CompositionLocal에 의존하게 되면, 해당 컴포저블은 재사용이 어려워질 수 있다.

 

2. 휴먼 에러 발생 가능성

CompositionLocal을 주입하지 않았을 경우, 컴파일 타임이 아닌 런타임에서 오류가 발생하므로 디버깅이 어렵고, 실수로 인한 버그가 생길 가능성이 높아진다. 

 

 

3. 이벤트 핸들러 사용

// domain/event/AppEvent.kt
sealed interface AppEvent {
    // 각 계층별 이벤트 분리 가능
    sealed interface UserEvent : AppEvent {
        data object LoginClicked : UserEvent
        data class ProfileUpdated(val name: String) : UserEvent
    }

    sealed interface PaymentEvent : AppEvent {
        data object PurchaseInitiated : PaymentEvent
        data class RefundRequested(val orderId: String) : PaymentEvent
    }
}

// presentation/event/EventDispatcher.kt
class EventDispatcher(
    private val coroutineScope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
) {
    private val _events = MutableSharedFlow<AppEvent>(
        extraBufferCapacity = 64,
        onBufferOverflow = BufferOverflow.DROP_OLDEST
    )
    val events = _events.asSharedFlow()

    fun emit(event: AppEvent) {
        coroutineScope.launch { _events.emit(event) } 
    }
}

// presentation/viewmodel/RootViewModel.kt
class RootViewModel @Inject constructor(
    private val eventDispatcher: EventDispatcher,
    private val useCase: SomeUseCase
) : ViewModel() {

    init {
        observeEvents()
    }

    private fun observeEvents() {
        viewModelScope.launch {
            eventDispatcher.events.collect { event ->
                when (event) {
                    is AppEvent.UserEvent.LoginClicked -> 
                        useCase.executeLogin()
                    is AppEvent.PaymentEvent.RefundRequested ->
                        handleRefund(event.orderId)
                    // ...
                }
            }
        }
    }
}
// di/AppModule.kt
@Module
@InstallIn(SingletonComponent::class)
object EventModule {
    @Provides
    @Singleton
    fun provideEventDispatcher(): EventDispatcher = EventDispatcher()
}



// Hilt Wrapper 클래스로 추상화
object HiltInjector {
    // EntryPoint 정의
	@EntryPoint
	@InstallIn(SingletonComponent::class)
	interface EventEntryPoint {
		fun eventDispatcher(): EventDispatcher
	}

    fun getEventDispatcher(context: Context): EventDispatcher {
        return try {
            EntryPointAccessors.fromApplication(
                context.applicationContext,
                EventEntryPoint::class.java
            ).eventDispatcher()
        } catch (e: IllegalStateException) {
            FakeEventDispatcher // Preview용 대체 객체
        }
    }
}


// ui/deep/DeepestComponent.kt
@Composable
fun DeepestComponent() {
    val eventDispatcher = remember { 
        HiltInjector.getEventDispatcher(LocalContext.current)
    }

    Button(onClick = {
        eventDispatcher.emit(AppEvent.PaymentEvent.PurchaseInitiated)
    }) {
        Text("구매 시작")
    }
}

 

해당 패턴 또한 전역 이벤트 버스의 성질을 가진다.

Preview를 위해 Fake객체를 리턴하게 하면 된다.

 

장점

1. 컴포넌트간 의존성 분리

2. 이벤트 추적의 중앙집중화

3. 멀티모듈 대응

4. 이벤트 디버깅에서의 유연함(domain레이어에 안드로이드 의존성 설정안해도 됨)

5. 뷰모델 별, 동일이벤트를 다르게 처리가능

 

단점

1. 의존성이 명시적으로 드러나지 않음

- 이벤트가 어디서 발생하고 어디서 처리되는지 파악하기 위해선 전체 구조를 알아야 하며, 추적이 어려워질 수 있다.

2. 이벤트 남용 시 스파게티 코드 유발

- 단순한 UI 상호작용까지 전부 이벤트 버스를 통해 처리하게 되면, 이벤트가 난립하게 되어 구조를 해치게 된다.

 

 

따라서 UI 상태는 기존의 상태 호이스팅(state hoisting)을 사용하고, 도메인 수준의 의미 있는 이벤트만 EventDispatcher로 관리하는 것이 이상적이다.