계발자 블로그

[Android] Jetpack Compose @Preview 에러, Route - Screen 분리 패턴 본문

Android

[Android] Jetpack Compose @Preview 에러, Route - Screen 분리 패턴

더구더구 2026. 2. 8. 17:30

컴포즈 + mvi 패턴 공부용으로 개인 프로젝트를 진행하고 있습니다.

아직 안드로이드 뷰에 대한 mental model이 xml에 너무 익숙해져 있어 컴포즈 다운 사고가 잘 안돼서 어렵고 헷갈리는군요.

이 선언형 ui 컴포즈에 대한 포스팅을 따로 해보려고 합니다.

각설하고 본 글은 개발 중 흥미 있게 고민해 본 이슈에 대해 써봅니다.

 

@Preview에서 에러 발생

java.lang.IllegalStateException: Expected an activity context for creating a HiltViewModelFactory but instead found: com.android.layoutlib.bridge.android.BridgeContext

....

패키지 경로.CalculatorScreenKt.CalculatorScreen(CalculatorScreen.kt:416)

 

메인에서 프리뷰를 추가했더니 위와 같은 에러가 나오면서 화면이 렌더링 되지 않았습니다.

 

에러 로그를 분석해 보면

 

Hilt가 ViewModel을 만들려면 activity context가 필요한데 but 대신 발견 되었다 BridgeContext가.

 

그럼 BridgeContext가 뭘까요

BridgeContext는 Compose Preview가 사용하는 가짜 Context입니다.

실제 앱이 실행 중인 게 아니므로 생명주기나 DI 컨테이너(Hilt)가 존재하지 않는 빈 껍데기 환경입니다.

따라서 Preview 환경(BridgeContext)에서는 HiltViewModel을 생성할 수 있는 조건(Activity + Hilt 컨테이너)이

충족되지 않기 때문에hiltViewModel() 호출이 실패합니다.

 

 

 문제 코드:

@Composable
fun CalculatorScreen(
    // ❌ 범인: Preview는 hiltViewModel()을 해석할 수 없음
    viewModel: CalculatorViewModel = hiltViewModel() 
) { ... }

 

Composable 함수의 파라미터로 hiltViewModel()을 넣으면

Preview가 이 함수를 그릴 때도 Hilt에게 의존성을 요청하게 됩니다.

 

하지만 Preview 환경에서는

Activity ❌

NavBackStackEntry ❌

Hilt 컨테이너 ❌

 

=> 결국 에러 발생

 

해결 방법: Route - Screen 분리 패턴 (State Hoisting)

기존 스크린 화면을 데이터를 준비하는 부분(Route)과 그리기만 하는 부분(Screen)으로 분리하는 패턴입니다.

 

Route

  • Hilt ViewModel 주입
  • 데이터 수집(Collect)
  • 실제 앱 구동 시에만 호출

Screen

  • ViewModel 모름, 상태(State)와 Event만 받음
  • Preview 가능

즉 UI에서 ViewModel 생성을 제거하는 것입니다.

 

Route (상태 + DI 담당)

@Composable
fun CalculatorRoute(
    viewModel: CalculatorViewModel = hiltViewModel()
) {
    val state by viewModel.state.collectAsState()

    CalculatorScreen(
        state = state,
        onIntent = viewModel::processIntent
    )
}

 

Screen (순수 UI)

@Composable
fun CalculatorScreen(
    state: CalculatorState,
    onIntent: (CalculatorIntent) -> Unit
) {
    // UI만 담당
}

 

Preview

@Preview(showBackground = true)
@Composable
fun CalculatorScreenPreview() {
    CalculatorScreen(
        state = CalculatorState(expression = "123+45"),
        onIntent = {}
    )
}

 

Deep Dive

route와 screen을 분리하는 것은 단순히 preview 해결뿐 아니라

DI, Compose 컴파일러, ViewModel의 동작 방식과 맞물려 있습니다.

 

DI 

분리 전: Screen이 ViewModel이라는 구체적인 클래스에 의존함.

분리 후: Screen은 순수한 State와 Event lamda에만 의존함

➡️ ViewModel을 교체하거나 테스트용 가짜 ViewModel을 만들 필요 없이 독립적인 UI 테스트 가능.

 

Compose 컴파일러와 성능

Skipping 최적화: Compose 컴파일러는 recomposition 시 파라미터가 변경되지 않았으면 그리기를 건너뜁니다.

하지만 ViewModel 자체는 @Stable 하지 않기 때문에 Composable의 파라미터로 전달되면

Compose 컴파일러가 변경 여부를 판단하기 어렵고 그 결과 리컴포지션 스킵 최적화가 적용되지 않을 수 있습니다.

 

Immutable State: Route에서 StateFlow를 State로 바꿔서 Screen에 넘기면

Data Class는 val로 이루어진 불변(Immutable/Stable) 객체이므로, 컴파일러가 값이 안 바뀌었다 판단하고 skip 하여 최적화를 수행합니다.

결국 앱의 성능 최적화로 이어집니다.

 

ViewModel

viewModel의 역할은 UI를 그리는 것이 아니라 상태를 관리하는 것입니다.

Route가 ViewModel을 소유하면

  • 생명주기 명확
  • 테스트 가능
  • Navigation과 결합 가능

따라서 Screen은 ViewModel을 모르는 게 좋다.

 

마무리

route와 screen을 분리하는 패턴을 적용함으로써

  1. preview 사용 가능
  2. UI 테스트 용이
  3. 리컴포지션 최적화

라는 이점을 얻을 수 있습니다.

 

Preview 에러를 계기로 구조를 개선했지만

결과적으로 테스트, 성능, 확장성까지 함께 얻은 구조가 되었습니다.

 

개발할 때 구조 설계에 대한 정답은 없지만 좋은 코드로 가기 위해 생각하다 보면

자연스럽게 도달하게 되는 구조가 있다고 생각합니다.

오늘 배운 해당 패턴도 그러하다고 생각합니다.

 

읽어주셔서 감사합니다.

 

'Android' 카테고리의 다른 글

[Android] data class 트러블 슈팅 - @keep 어노테이션  (0) 2026.01.10
[DI] Dependency Injection과 Dagger-Hilt  (0) 2024.01.07
[JetPack] Navigation (JAVA)  (0) 2022.12.04
[JetPack] WorkManager  (2) 2022.10.01
[JetPack] DataBinding  (0) 2022.08.16