Compose 비즈니스 로직과 UI State Holder 가이드

GongBaek·2025년 9월 17일
post-thumbnail

— 컴포즈를 개발할 때 “왜 내 화면 상태는 점점 복잡해질까?”라는 생각을 해본 적 있으신가요?

안드로이드 앱을 만들다 보면 상태(State) 가 금방 불어납니다.

사용자 입력, 서버 데이터, 로딩/에러, 일시적 UI 토글, 애니메이션 트리거… 처음엔 편해서 ViewModel 한 바구니에 담아두지만, 어느 순간부터 의도치 않은 재구성, 꼬이는 의존성, 테스트 난이도 상승이 한꺼번에 찾아옵니다. 특히 Compose는 표현력이 좋아서 수식을 조금만 얹어도 금방 복잡해지죠.

이 포스트는 그 복잡도를 낮추기 위해 Business Logic State HolderUI Logic State Holder명확히 분리하는 실전 가이드입니다. 모 IT 회사의 코딩테스트 과제우아한테크코스 7기 안드로이드 미션을 동시에 진행하면서 고통받은 과정을 겪고, 최종적으로 구현한 카드 등록 화면을 기준으로 내가 실제로 쓴 방식을 정리했습니다.

구성은 이렇습니다.

개념 소개 → 적용 기준 → 코드 패턴 → 리팩터링 절차 → 테스트 전략 → 체크리스트


0) 요약 (TL;DR)

  • Business Logic State Holder: 화면 생명주기와 독립. 데이터 소스 접근/가공, 비즈니스 규칙 담당. 보통 ViewModel 이 그 역할을 담당합니다.
  • UI Logic State Holder: 화면 생명주기에 종속됩니다. 체크박스, 입력 유효성, 스크롤 위치, 다이얼로그 열림 등 순수 UI 로직 담당. 보통 remember/rememberSaveable + 작은 클래스로 캡슐화를 합니다.

핵심 문장 하나만 기억하면 됩니다.

“비즈니스는 ViewModel, 인터랙션은 UI State Holder.”


1) 왜 나눠야 하나?

1. UDF(단방향 데이터 흐름) 명확화

  • ViewModel → 도메인/데이터 접근 + UI 적합 모델 변환
  • UI Holder → 입력/인터랙션 처리(포맷·유효성·토글) → 이벤트 흐름이 단순해지고 디버깅이 쉬워짐

2. 재구성 비용 최소화

  • UI 전용 상태를 remember로 지역화 → 불필요한 관측 감소, 부분 재구성 용이

3. 테스트/교체 용이

  • ViewModel 테스트 = 비즈니스 규칙
  • UI Holder 테스트 = “입력 → 상태”만 보면 되는 작은 단위

2) 무엇을 어디에 둘까? (결정표)

질문위치
회전/재생성 후에도 반드시 유지?로그인 상태, 상세 정보Business(ViewModel)
외부 레이어 접근/비동기 필요?Repository/UseCase, 캐싱Business(ViewModel)
UI에서만 쓰는 일시적 상태?입력 유효성, 바텀시트 열림UI State Holder
재사용되는 UI 패턴?TextField + 로컬 검증, 스크롤 스냅UI State Holder (작은 클래스)

3) 최소 패턴: “ViewModel은 비즈니스, UI는 지역화”

3.1 카드 지갑—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() }
  • isBankSheetOpen 같은 순수 UI 토글은 UI Holder가 소유
  • canSave처럼 즉시 피드백이 필요한 계산은 UI에서 처리
  • 최종 저장·중복 검증·도메인 규칙은 ViewModel에서 확정

3.2 화면 결합 (내 카드 지갑 화면, 최소 연결만)

@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로 즉시 반영
}

포인트

  • 원천 데이터/최종 저장은 ViewModel
  • 입력 포맷/시트 열림/버튼 활성은 UI State Holder
  • 화면은 “UI와 비즈니스를 느슨하게 연결”만 한다

4) 한 걸음 더: 어디까지 UI에서, 어디서부터 비즈니스에서?

  • UI 즉시성이 필요한 것들 글자 수 제한, 로컬 포맷팅, 토글, 포커스 이동, 바텀시트 열림 → UI Holder가 갖고 있어야 반응성이 좋습니다
  • 정책 확정·영속성·서버 검증이 필요한 것들 중복 카드 검사, Luhn/BIN, 서버 저장, 실패 재시도, 보안 로깅 → ViewModel + 도메인/데이터 레이어에서 확정하는 게 안전합니다

규칙 한 줄:

“즉시 반응은 UI, 책임 추적은 비즈니스.”


5) 성능 팁

  • derivedStateOf { ... }로 계산 캐싱 버튼 활성, 요약 텍스트, 카운트 같은 값은 파생 상태로 묶어 불필요한 재구성을 줄입니다
  • snapshotFlow { state }로 읽기 지점 최소화 UI 상태 변화를 Flow로 다룰 때 처리 지점을 명확히 하고, 루프를 피하세요
  • rememberSaveable은 복구가 필요한 값에만 프로세스 사망 시 꼭 복구돼야 하는 폼 값만 저장하고, 나머지는 remember로 충분합니다
  • 리스트 성능 LazyXxx + key 안정화, 아이템 상태는 아이템 내부에 두기

6) 리팩터링 절차 (ViewModel에 다 몰려 있을 때)

  1. 분해 표 작성
    • 서버/캐시/도메인 접근? → ViewModel
    • 입력 포맷/토글/노출 전용? → UI Holder
  2. UI State Holder 먼저 설계 (API는 사용자 행동 중심: updateXxx, toggle())
  3. 화면에서 remember/rememberSaveable로 주입, 초기값은 ViewModel의 StateFlow로 동기화
  4. ViewModel에서 UI 노이즈 제거 확인
  5. 테스트 분리: ViewModel(비즈니스 전이) / UI Holder(입력→상태)

7) 테스트 전략

UI State Holder 단위 테스트는 가볍고 짧게

  • 토글/파생 상태/간단한 유효성 같은 순수 로직만 보면 됩니다
  • Compose 러닝타임 없이 순수 Kotlin 테스트로 가능

UI State Holder 단위 테스트 (예)

@Test fun `카드번호는 숫자만 16자리까지`() {
    val ui = NewCardUiStateHolder()
    ui.updateCardNumber("1234-5678-9012-3456-9999")
    assertEquals(16, ui.cardNumber.length)
}

ViewModel 테스트는 비즈니스 전이 중심

  • 저장 성공/실패, 중복 검증, 재시도 정책, 로딩/에러 플래그
  • UI Holder 없이도 테스트가 돌아가야 이상적입니다
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()
    }
}

8) 주의사항

  • 입력/토글을 전부 ViewModel로 올리기 → 매 입력마다 관측 + 리컴포지션 지옥. → UI로 지역화하세요
  • Mutable 객체를 remember에 그냥 저장 → 참조 동일성 문제. 불변 + copy 또는 mutableStateOf 활용
  • derivedStateOf 남발 → 싸구려 캐시 남발은 관리 비용만 늘립니다. → 비싼 계산에만 사용

9) 네이밍 & 구조 가이드

각자의 컨벤션에 따라 다르겠지만요...!

  • UI 전용: XxxUiState, XxxController 등
    • Saver가 있으면 rememberXxxUiState() 제공
  • Business 전용: XxxViewModel
    • 외부엔 UI 모델만 노출(UiModel/UiState), 내부에서 도메인 → UI 변환
  • 이벤트 이름: updateXxx, toggleXxx 등 — 의도 중심

10) 체크리스트

  • ViewModel에 포커스/열림/입력 포맷 같은 UI 전용 값이 올라가 있지 않은가?
  • UI Holder API가 사용자 행동 중심인가?
  • derivedStateOf는 비싼 계산에만 쓰고 있는가?
  • rememberSaveable이 복구 필요한 값에만 적용됐는가?
  • ViewModel 테스트와 UI Holder 테스트가 분리돼 있는가?

11) 마무리

“비즈니스는 ViewModel, 인터랙션은 UI State Holder.”

이 한 줄만 지켜도 화면은 눈에 띄게 단순해집니다.

  • 변경 이유가 분리되고,
  • 재구성 비용이 줄고,
  • 테스트/유지보수가 쉬워집니다.

어디서부터 시작할지 어려우시다면, 먼저 “UI에서만 필요한 건 무엇인가?”를 분리해 작은 UI State Holder로 캡슐화하세요. 코드가 한결 가벼워집니다.

profile
Junior Android Developer

1개의 댓글

comment-user-thumbnail
2025년 9월 19일

좋은 내용 감사합니다~

답글 달기