생각없이 자식 Composable을 선언하다보면 문득 멈칫할 때가 있다.
Composable 함수들은 Statelss
해야한다. Stateless
하기 때문에 강력하고, UI시스템 부터 테스트 코드까지 쉽게 구현할 수 있다. 그런데 Stateless
하게 Composable함수를 만들다보면 파라미터로 너무 많은 데이터를 넘겨주게 된다.
그리고 가장 상위 Composable부터 하위 Composable까지 이벤트나 데이터를 꽂아 내려주려고 하다보면 중간 Composable 들이 모두 영향을 받게 되는 불합리함을 경험하게 된다.
SampleBottomSheet(
fullScreen = false,
totalComments = state.commentsTotal,
orderType = state.commentOrderType,
onOrderTypeChanged = viewModel::transferCommentOrderType,
myComment = state.myWrongComment,
comments = state.allComments,
myCommentCreateAt = state.commentCreateAtWithDiff,
isWriteComment = state.isWriteComment,
onMyCommentChanged = viewModel::updateWrongComment,
onHeartComment = viewModel::heartWrongComment,
onSendComment = viewModel::writeChallengeComment,
onDeleteComment = viewModel::deleteChallengeComment,
onIgnoreComment = viewModel::ignoreUser,
onReportComment = viewModel::reportChallengeComment,
)
혹시 위와 같이 모든 콜백을 파라미터로 넘겨주는것에 신물이 났다면 한 번쯤 고민해볼 일이다.
CompositionLocalProvider은 Stateful하다
중간 Composable함수들에게 영향을 주지않고 최상단 부모가 자식 Composable에게 데이터를 전달해줄 수 있는 방법은 CompositionLocalProvider
로 current
를 넘겨주는 것이다.
예를 들어, SampleParentScreen
에서 ClickableItem
에게 이벤트 버스를 CompositionLocalProvider
로 뚫어서 내부로 전달해주는 것이다.
class SampleActivity: ComponenetActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
CompositionLocalProvider(
LocalSampleProvider provides object : LogEvent {
override fun onClickEvent(title: String) {
viewModel.onClickEvent(title)
}
override val list: List<String>
get() = viewModel.getList()
}
) {
SampleParentScreen()
}
}
}
}
@Composable
fun SampleParentScreen() {
MiddleComponenetScreen()
}
@Composable
private fun MiddleComponenetScreen() {
MainListScreen()
}
@Composable
private fun MainListScreen() {
val list = LocalSampleProvider.current.list
LazyColumn {
items(list){
ClickableItem(
title = it.titme,
description = it.description
)
}
}
}
@Composable
private fun ClickableItem(
title: String,
description: String,
) {
val sampleEvent = LocalSampleProvider.current
Column {
Text(text = text, modifier = Modifier.clickable {
sampleEvent.onClick(title)
})
...
}
}
이러면 MiddleComponentScreen
과 MainListScreen
에게 파라미터로 데이터를 넘겨주지 않고 원하는 UI 구조를 만들 수 있게 된다.
문제가 해결된 것처럼 보이지만 사실 큰 불편함을 안고있는 구조가 되버린다.
- 내부적으로 상태값을 가지고 있게되니 관련해서 테스트 코드 작성이 불가능해진다.
- 재사용할 때마다 같은
CompositionLocalProvider
로 꼭 데이터를 넘겨줘야하고, 그렇지 않을 경우에 컴파일단에서 해당 에러를 알 수가 없다.
Compose를 사용한다면 Composable함수가 Stateful해지는 것을 지양해야한다. 이는 재사용이 불가능해지거나 내부 UI코드에 이상한 비즈니스 로직이 구현되어야 하는 상황을 만들게 된다.
그러므로 CompositionLocalProvider
는 적당한 대안이 될 수 없을 것 같다.
Sealed interface와 OCP 원칙
그렇다면 결국 파라미터로 데이터를 모두 넘겨줘야만 할까?
DDD관점에서 코드를 구현한다면 사실 그게 맞다. Composable함수의 파라미터는 해당 컴포넌트의 역할과 UI를 예측할 수 있게 설계되어야 한다.
@Composable
fun ClickableItem(
title: String,
description: String
) {
val sampleEvent = LocalSampleEvent.current
Column {
Text(
text = title,
modifier = Modifier.clickable {
sampleEvent.onClickTitle(title)
}
)
Text(
text = description,
modifier = Modifier.clickable {
sampleEvent.onClickDescription(title)
}
)
...
}
}
위 Composable함수는 클릭 이벤트 자체를 테스트할 수도 없으며, 추후에 재사용하려는 개발자 입장에서도 title과 description만 넘겨주면 해당 컴포넌트는 정상 동작하겠구나
라는 오해까지 불러일으킬 수 있다.
그렇다고 onClickTitle
과 onClickDescription
, 그리고 추후에 추가될 콜백함수들을 전달해주기 위해 모든 Composable에게 파라미터로 뚫어주는것 또한 비효율적인 것을 우리는 경험했다.
그래서 합의해야한다. Stateless
, Testable
, Reusable
하면서도 객체지향의 개방폐쇠 원칙을 지켜 유지보수에 용이하게 하는 방법을 찾아야만 한다.
sealed interface이다.
sealed interface SampleClickEvent {
class ClickTitle(val title: String): SampleClickEvent
class ClickDescription(val description: String): SampleClickEvent
data object ClickSampleButton: SampleClickEvent
}
위와 같이 UI내에서 사용되는 이벤트들을 정의하고 콜백 함수를 sealed interface 자체로 넘겨주는 것이다. 개인적으로 상당히 Kotlin스러운 해결방법이라고 생각이 든다.
@Composable
private fun ClickableItem(
title: String,
description: String,
onClick: (SampleClickEvent) -> Unit
) {
Column {
Text(
text = title,
modifier = Modifier.clickable {
onClick(SampleClickEvent.ClickTitle(title))
}
)
Text(
text = description,
modifier = Modifier.clickable {
onClick(SampleClickEvent.ClickDescription(description))
}
)
...
}
}
@Composable
private fun SampleParentScreen() {
val onClick = (SampleClickEvent) -> Unit = { event ->
when(event) {
is ClickTitle -> // TODO
is ClickDescription -> // TODO
ClickSampleButton -> // TODO
}
}
MiddleComponenetScreen(
onClick = onClick
)
}
이렇게 되면 다른 모듈에서 재사용하더라도 이벤트를 정의해야하므로 오해 없는 코드를 작성할 수 있고, 테스트 코드 또한 작성할 수 있게 된다.
그리고 이벤트가 추가된다고 한들 이벤트를 처리해주는 가장 최상단의 Composable함수의 when
문에서 케이스가 하나 추가되는 것 말고는 다른 코드를 건드릴 필요도 없다.
@Composable
private fun SampleParentScreen() {
val onClick = (SampleClickEvent) -> Unit = { event ->
when(event) {
is ClickTitle -> // TODO
is ClickDescription -> // TODO
ClickSampleButton -> // TODO
ClickAnythingElseButton -> // 이 부분만 추가
}
}
MiddleComponenetScreen(
onClick = onClick
)
}
Compose는 UI 로직 이외의 작업을 SideEffect
라고 규정하며 코드의 관심사를 분리할 수 있는데, 이벤트 타입과 데이터를 sealed interface로 넘겨받는다면 콜백함수가 호출되는 로직 또한 완전히 분리될 수 있다.
확장에 대해 열려있고, 수정에 대해서는 닫혀있는 코드를 Testable하게 구현할 수 있을 것이다.
그럼에도 CompositionLocalProvider
그럼에도 불구하고 CompositionLocalProvider
가 제공해주는 이벤트 버스를 사용할 수 있다는 장점을 버릴 필요는 없다.
테스트 할 필요가 없고, 재사용될 여지가 없는 Composable에서는 current로 데이터를 넘겨받아 사용하는 것을 권장한다. 예를 들어,
@Composable
private fun SomethingDomainScreen() {
val sampleProvider = LocalSampleProvider.current
MainListScreen(
title = "검색",
list = sampleProvider.list
)
}
도메인 추상화 레벨을 맞춰주기 위해 위와 같은 Composable 함수를 많이 선언해서 사용하곤 한다.
위와 같은 코드에서는 MainListScreen
은 필요한 데이터를 주입받아야 하지만 이를 호출하는 부모 Composable은 맥락상 리스트 데이터를 주입받을 필요는 없다.
테스트도 Stateless
한 MainListScreen
만 해주면 되고 재사용될 여지도 없는 Composable 함수이기 때문에 선언시 접근제어자만 잘 사용해준다면 문제되지 않을 것이다.
비즈니스 로직과 약간 동떨어져있는 기능을 구현할 때는 CompositionLocalProvider가 도움이 됐다. 예를 들면 이벤트 코드이다.
Firebase
같은 서드파티 라이브러리에서 유저의 행동을 내부 DB에 쌓고 싶을때가 있다.
예를 들면
이 유저가 해당 버튼을 얼마나 눌렀는지 알고 싶어
라는 기획자의 요구는 비즈니스 로직과는 관심사가 아예 다르다. 이런 코드들은 UI레벨에서 테스트할 필요도 없고 제품 코드와는 최대한 물리적으로 분리될 수록 좋다.
CompositionLocalProvider(
FirebaseEventCollector provides object : LogEvent {
override fun onClickCTAButton() {
viewModel.sendEvent()
}
}
) {
SampleParentScreen()
}
와 같이 원하는 이벤트를 최상위 Composable에서 정의해준다면 직접적으로 하위 Composable에게 접근할 수 있으니 간단하게 코드를 구현할 수 있다.
마치며
Compose는 안드로이드 개발자들을 명령형의 지옥에서 벗어나게 해주었고 재사용성을 극대화해 몇 줄 안되는 코드로도 UI 시스템을 만들 수 있게 해주는 편리함을 가져다 주었다.
재사용의 양면성을 조심하면서도 최대한의 많은 이점을 안전하게 누릴 때 Compose의 진면목을 볼 수 있지 않을까 한다.
오늘도 객체지향의 연전연승이다.