안드로이드를 만들며 7 — 화면이 콜백 열세 개를 들고 있었어요 - MVI로 입구를 하나로
상태 끌어올리기를 하다 보니 화면 컴포저블의 시그니처가 끝없이 길어졌어요. 버튼 하나를 더할 때마다 화면·껍데기·ViewModel 세 곳을 고쳐야 했습니다. 사용자의 행동을 sealed interface 하나로 묶어 입구를 하나로 줄인 이야기예요.
지난 글에서는 데이터를 한 줄기로 모은 이야기를 했어요.
저장소가 Room과 DataStore 둘인데 화면은 하나의 Flow만 보게 만든 이야기였죠.
그렇게 아래로 내려오는 길을 정리하고 나니, 이번엔 위로 올라가는 길이 눈에 띄었습니다. 화면에서 로직 층으로 올라가는 그 길이요.
시그니처가 계속 길어지고 있었어요
Compose에서 상태 끌어올리기(State Hoisting)를 하다 보면 최상단 화면 컴포저블의 시그니처가 무한정 길어집니다.
CalculatorScreen이 정확히 그랬어요.
사용자가 계산기에서 할 수 있는 행동이 다양한데, 그걸 전부 개별 람다로 열어뒀거든요.
@Composable
fun CalculatorScreen(
state: CalcState,
onMode: (CalcKind) -> Unit,
onBuyInput: (BuyInput) -> Unit,
onFocus: (Slot) -> Unit,
onPaste: (Slot, String) -> Unit,
onKey: (KeypadKey) -> Unit,
onCloseKeypad: () -> Unit,
onPickStock: () -> Unit,
onSave: (Long) -> Unit,
onReset: () -> Unit,
// … 열세 개까지 갔습니다
)
이게 왜 문제였냐면, 버튼 하나를 더할 때마다 고칠 자리가 셋이었어요.
CalculatorScreen의 시그니처- 그걸 호출하는 껍데기
ViewModel의 함수와 그 매핑
한 줄 더하는 일이 세 파일을 여는 일이 되면, 사람은 그 일을 안 하게 됩니다.
이건 5편에서 AppViewModel에 한 줄 더하는 게 무서워졌던 것과 정확히 같은 병이었어요.
화면은 “무슨 함수를 부를지”를 알 필요가 없어요
여기서 한 가지를 다시 봤습니다.
onSave, onReset, onKey… 이 이름들은 전부 “무엇을 실행하라”는 말이에요.
그런데 화면이 정말 알아야 하는 건 그게 아니었습니다.
화면이 아는 것은 “사용자가 방금 무엇을 했다”뿐이에요. 그걸 받아서 무슨 함수를 태울지는 로직 층의 일이고요.
그래서 MVI(Model-View-Intent)의 아이디어를 빌려서,
사용자의 모든 행위를 sealed interface 하나로 묶었습니다.
/**
* 계산기 화면에서 발생하는 모든 사용자 행동을 하나로 묶는다.
*/
sealed interface CalculatorAction {
data class ChangeMode(val mode: CalcKind) : CalculatorAction
data class ChangeBuyInput(val input: BuyInput) : CalculatorAction
data class Focus(val slot: Slot) : CalculatorAction
data class Paste(val slot: Slot, val text: String) : CalculatorAction
data class KeyPressed(val key: KeypadKey) : CalculatorAction
data object CloseKeypad : CalculatorAction
data object PickStock : CalculatorAction
data class Save(val now: Long) : CalculatorAction
data object Reset : CalculatorAction
}
이제 화면의 시그니처는 이렇게 됐어요.
@Composable
fun CalculatorScreen(
state: CalcState,
result: CalcResult,
errors: Map<Slot, InputError>,
stocks: List<Stock>,
onAction: (CalculatorAction) -> Unit, // 열세 개가 하나로
modifier: Modifier = Modifier,
)
ViewModel도 마찬가지로 public 메서드를 전부 닫고 입구를 하나만 열었습니다.
fun onAction(action: CalculatorAction) {
when (action) {
is CalculatorAction.ChangeMode -> _calc.update { … }
is CalculatorAction.ChangeBuyInput -> _calc.update { … }
// …
}
}
when이 sealed interface를 받으니 케이스를 빠뜨리면 컴파일이 안 됩니다.
액션을 새로 더하면 컴파일러가 “여기도 처리해야 한다”고 알려줘요.
전에는 람다를 하나 더 열어두고 연결을 잊어도 아무 말이 없었거든요.
이게 4편의 결론과 같은 이야기입니다. 사람의 기억에 맡기던 것을 컴파일러에게 넘긴 것.
Save(val now: Long) — 액션이 시각을 들고 옵니다
작은 부분인데 마음에 들어서 적어둘게요.
저장 액션은 data object가 아니라 data class Save(val now: Long)입니다.
저장 시각을 화면이 넘겨줘요.
ViewModel 안에서 System.currentTimeMillis()를 부르면 편하죠.
그런데 그 순간 그 함수는 시험할 수 없게 됩니다.
같은 입력을 줘도 결과가 매번 달라지니까요.
시각이 액션에 실려 들어오면 로직은 그냥 받은 값을 씁니다. 테스트에서는 원하는 시각을 넣으면 되고요.
바깥 세상에서 오는 값은 인자로 받습니다. 안에서 꺼내오지 않고요.
같이 한 것 — 껍데기에서 길 찾기를 떼어냈어요
같은 날 AppShell.kt도 손봤습니다. 600줄 가까이 가 있었거든요.
이 파일 안에 두 가지가 섞여 있었어요.
- 스캐폴드와 오버레이 — 바텀바, 시트, 확인창 같은 껍데기
- 라우팅 — 어느 화면이 어디로 가고, 어떤 애니메이션으로 가는가
둘은 같이 살 이유가 없었습니다.
라우팅을 ui/navigation/AppNavigation.kt로 옮겼어요.
NavGraphBuilder의 확장 함수 하나입니다.
internal fun NavGraphBuilder.appGraph(
data: AppData,
calcViewModel: CalculatorViewModel,
shell: ShellState,
navController: NavController,
activity: Activity?,
// …
)
지금은 AppShell.kt 486줄, AppNavigation.kt 171줄이에요.
합은 비슷한데, “화면이 어떻게 이어지는가”를 보고 싶을 때 여는 파일이 하나가 됐습니다.
5편에서 ShellState를 지우지 않고 역할만 줄였던 것과 같은 판단이었어요.
쓸모없어서 걷는 게 아니라, 맡을 것만 맡기는 것.
이번에 확인한 것
- 화면 컴포저블의 콜백이 열 개를 넘어가면, 그건 인자가 많은 게 아니라 화면이 로직을 너무 많이 알고 있다는 신호예요.
- 콜백 이름이
on<할 일>이면 화면이 실행할 함수를 아는 거고,on<일어난 일>이면 화면은 사실만 전달합니다. 후자여야 해요. sealed interface+when은 연결을 잊는 실수를 컴파일 오류로 바꿉니다.- 바깥 세상의 값(시각, 난수)은 액션에 실어 받습니다. 안에서 꺼내면 시험할 수 없어요.
다시 같은 일을 한다면
이번 글은 화면 층에서 책임을 덜어낸 이야기였어요. 그런데 같은 날, 그보다 더 오래 눌러앉아 있던 곳이 하나 더 있었습니다.
앱을 만들면 누구나 하나씩 갖고 있는, 온갖 SDK의 진입점이 되어버린 그 클래스요.