
— 컴포즈를 개발할 때 “왜 내 화면 상태는 점점 복잡해질까?”라는 생각을 해본 적 있으신가요?
안드로이드 앱을 만들다 보면 상태(State) 가 금방 불어납니다.
사용자 입력, 서버 데이터, 로딩/에러, 일시적 UI 토글, 애니메이션 트리거… 처음엔 편해서 ViewModel 한 바구니에 담아두지만, 어느 순간부터 의도치 않은 재구성, 꼬이는 의존성, 테스트 난이도 상승이 한꺼번에 찾아옵니다. 특히 Compose는 표현력이 좋아서 수식을 조금만 얹어도 금방 복잡해지죠.
이 포스트는 그 복잡도를 낮추기 위해 Business Logic State Holder와 UI Logic State Holder를 명확히 분리하는 실전 가이드입니다. 모 IT 회사의 코딩테스트 과제와 우아한테크코스 7기 안드로이드 미션을 동시에 진행하면서 고통받은 과정을 겪고, 최종적으로 구현한 카드 등록 화면을 기준으로 내가 실제로 쓴 방식을 정리했습니다.
구성은 이렇습니다.
개념 소개 → 적용 기준 → 코드 패턴 → 리팩터링 절차 → 테스트 전략 → 체크리스트
핵심 문장 하나만 기억하면 됩니다.
— “비즈니스는 ViewModel, 인터랙션은 UI State Holder.”
1. UDF(단방향 데이터 흐름) 명확화
2. 재구성 비용 최소화
3. 테스트/교체 용이
| 질문 | 예 | 위치 |
|---|---|---|
| 회전/재생성 후에도 반드시 유지? | 로그인 상태, 상세 정보 | Business(ViewModel) |
| 외부 레이어 접근/비동기 필요? | Repository/UseCase, 캐싱 | Business(ViewModel) |
| UI에서만 쓰는 일시적 상태? | 입력 유효성, 바텀시트 열림 | UI State Holder |
| 재사용되는 UI 패턴? | TextField + 로컬 검증, 스크롤 스냅 | UI State Holder (작은 클래스) |
입력 포맷·유효성·시트 열림 같은
순수 UI 로직
@Stable
class NewCardUiStateHolder(
isBankSheetOpenInit: Boolean = false
) {
var isBankSheetOpen by mutableStateOf(isBankSheetOpenInit)
private set
fun updateBankSheet(open: Boolean) {
isBankSheetOpen = open
}
val canSave: Boolean
get() = /* 입력 유효성 검증 결과를 종합해 true/false */
}
@Composable
fun rememberNewCardState(): NewCardUiStateHolder =
rememberSaveable { NewCardUiStateHolder() }
@Composable
fun NewCardScreen(
onSaved: (CardUiModel) -> Unit = {},
onFinish: () -> Unit = {},
) {
val holder = rememberNewCardState()
val viewModel: NewCardViewModel = hiltViewModel()
NewCardTopBar(
onBackClick = onFinish,
onSaveClick = {
if (holder.canSave) {
val model = holder.createCardUiModel()
viewModel.save(model) // 비즈니스 위임
onSaved(model) // 필요 시 콜백
}
}
)
// Preview 카드 클릭 → holder.updateBankSheet(true)
// TextField들은 holder.updateXXX(...)에만 연결
// 저장 버튼 활성/비활성은 holder.canSave로 즉시 반영
}
포인트
규칙 한 줄:
“즉시 반응은 UI, 책임 추적은 비즈니스.”
UI State Holder 단위 테스트 (예)
@Test fun `카드번호는 숫자만 16자리까지`() {
val ui = NewCardUiStateHolder()
ui.updateCardNumber("1234-5678-9012-3456-9999")
assertEquals(16, ui.cardNumber.length)
}
class NewCardViewModelTest {
private val fakeRepository = FakeCardRepository()
private val testDispatcher = StandardTestDispatcher()
@Before
fun setup() {
Dispatchers.setMain(testDispatcher)
}
@Test fun `카드 저장 성공 시 상태가 Saved로 전환된다`() = runTest {
val viewModel = NewCardViewModel(fakeRepository)
val model = CardUiModel("1234567812345678", "1225", "HOLDER", BankType.KB)
viewModel.save(model)
testDispatcher.scheduler.advanceUntilIdle()
assertEquals(
NewCardUiState.Saved(model),
viewModel.uiState.value
)
}
@Test fun `중복 카드 저장 시 에러 상태로 전환된다`() = runTest {
val viewModel = NewCardViewModel(fakeRepository)
val model = CardUiModel("1111222233334444", "1225", "HOLDER", BankType.KB)
fakeRepository.setDuplicate(model.numberDigits)
viewModel.save(model)
testDispatcher.scheduler.advanceUntilIdle()
assert(viewModel.uiState.value is NewCardUiState.Error)
}
@After
fun tearDown() {
Dispatchers.resetMain()
}
}
각자의 컨벤션에 따라 다르겠지만요...!
“비즈니스는 ViewModel, 인터랙션은 UI State Holder.”
이 한 줄만 지켜도 화면은 눈에 띄게 단순해집니다.
어디서부터 시작할지 어려우시다면, 먼저 “UI에서만 필요한 건 무엇인가?”를 분리해 작은 UI State Holder로 캡슐화하세요. 코드가 한결 가벼워집니다.
좋은 내용 감사합니다~