CareVision - Compose Preview가 갑자기 안보이는 이유

강유리 (Rein)·2025년 2월 12일
0

CareVision
환자 곁에서 늘 함께, 간호사의 눈이 되어주는 환자 모니터링 서비스
Github : https://github.com/SSU-Capstone-Aurora
2024 3-2 소프트웨어학부 캡스톤1 수업에서 진행했던 프로젝트를 계속해서 진행 중입니다.

🚨문제 상황 : Compose Preview가 갑자기 안보이는 이유

MVP 개발 시 또는 새로운 기능 개발 시 만들어두었던 Compose UI Screen이 개발도중 어느 순간부터 제대로 보이지 않을 때가 있다.

과연 Screen UI 코드 문제일까?

기존에는 잘 보이던 Preview가 왜 보이지 않을까? (with. render issues)


위와 같이 render issue가 나는 경우가 있을 것이다.
그러나 Android Studio의 Problems로 오류를 확인할 수가 없다.

그 이유는 아래 아티클을 통해 확인할 수 있다

effective-compose-preview

아티클을 정리하기 이전, Preview가 갑자기 보이지 않은 원인들로 아래 4가지 상황이 대표적이니 우선 이 4가지의 케이스인지 확인하면 좋다

  1. ViewModel을 직접 사용했을 때 (viewModel(), hiltViewModel()을 Preview에서 사용할 수 없음)
  2. 네트워크 요청, I/O 작업, 데이터베이스 접근이 포함된 경우
  3. CompositionLocal을 사용할 때 미리보기에서 제공되지 않는 값이 필요한 경우
  4. Previews가 빌드 캐시 문제로 인해 깨지는 경우

이제 정확한 원인을 알기 위해 해당 아티클의 내용을 정리해보자

👀Jetpack Compose Preview

JetpackCompose에서 Preview 기능은 프로젝트 전체를 빌드하지 않고도 UI개별 구성 요소를 단계적으로 개발하고 확인할 수 있다.

각 컴포넌트를 개별적으로 구축하고, 즉시 확인할 수 있기에 테스트가 가능하고 UI 개발 시 굉장히 유용하게 사용이 가능하다

Compose 컴포넌트는 상태에 의존하지 않도록 설계하는 것이 이상적이다.
이를 State Hoisting이라고 한다.

State Hoisting을 통해 UI로직과 비즈니스 로직을 분리할 수 있기에 재사용성과 테스트가 용이하다.

우선 아래는 권장하지 않는 Compose UI 코드이다.

@Composable
fun UserProfiles(viewModel: UserViewModel) {
    val users by viewModel.users.collectAsStateWithLifecycle()

    LazyColumn { 
        items(users) { user ->
            SingleProfile(
                modifier = Modifier.clickable { 
                    viewModel.onUserClick(user)
                },
                ..
            )
        }
    }
}

문제 이유 1. UserProfiles는 UserViewModel을 반드시 필요로 하기 때문이다.

그렇기에 해당 컴포넌트를 독립적으로 사용할 수 없어 확장성이 떨어진다.

문제 이유 2. 위와 같은 코드를 작성하면 Preview를 확인할 수 없다.

viewModel()과 hiltViewModel()은 Activity나 Fragment 내부에서만 호출 가능하므로 Preview에서 사용할 수 없다. 따라서 미리 보기 렌더링이 불가능하다.

이를 해결하기 위한 총 3가지 방법이 존재한다.

  1. ViewModel 의존성 최소화하기 (State Hoisting을 활용하여 UI를 독립적으로 설계)
  2. 가짜 ViewModel(Fake ViewModel) 생성하기 (Preview에서만 사용할 Mock Repository 활용)
  3. LocalInspectionMode 활용하기 (Preview 환경에서만 Mock 데이터를 제공하여 렌더링 문제 해결)

해당 아티클에서는 1. ViewModel 의존성 최소화 하기 방법에 대해 중점적으로 이야기 할 것이다.

코드 개선 방법

1️⃣ ViewModel 의존성 최소화하기 (State Hoisting 활용)

📌 핵심: ViewModel을 직접 사용하지 않고 UI 컴포넌트에서 데이터만 받도록 분리하기

viewModel을 직접 전달하는 것이 아닌, UI에서 사용할 데이터를 인자로 받아야 한다.

@Composable
fun UserProfiles(
    userList: List<User>,
    onUserClick: (User) -> Unit
) {
    // UI 구성
}

@Composable
fun ParentComposable(viewModel: UserViewModel = viewModel()) {
    val userList by viewModel.userList.collectAsStateWithLifecycle()

    UserProfiles(
        userList = userList,
        onUserClick = { user -> viewModel.onUserClick(user) }
    )
}

필요한 데이터만 인자로 받아서 동작하도록 변경하면 Preview에서 정상적으로 실행된다.

2️⃣ 가짜 ViewModel(Fake ViewModel) 생성하기

📌 핵심: Preview 전용 Mock Repository 및 Fake ViewModel 사용

class FakeLoginRepository : LoginRepository {
    override fun requestToken(authProvider: String, authIdentifier: String, email: String): Flow<ApiResponse<LoginInfo>> = flow {}
}

@Preview
@Composable
private fun FeedScreenPreview() {
    FeedScreen(
        viewModel = UserViewModel(
            loginRepository = FakeLoginRepository(),
        )
    ) 
}

3️⃣ LocalInspectionMode 활용하기

📌 핵심: Preview 환경에서는 Mock 데이터를 사용하여 UI가 렌더링되도록 처리

@Composable
fun UserProfiles(viewModel: UserViewModel) {
    val users by if (LocalInspectionMode.current) {
        remember { mutableStateOf(MockUtils.mockUsers) }
    } else {
        viewModel.users.collectAsStateWithLifecycle()
    }

    LazyColumn { items(users) { user -> /* UI 구성 */ } }
}

🚨CareVision에서 이 개념을 도입해보기

CareVision에서 급하게 구현된 여러 Composable Screen들이 State Hoisting을 고려하지 않은 구조로 개발되었다.
이로 인해 Jetpack Compose Preview가 정상적으로 렌더링되지 않는 문제가 발생했다.
특히, ViewModel을 직접 Composable에 주입하는 구조 때문에 Preview가 깨지는 문제가 있었고, 이를 해결하기 위해 Screen을 분리하였다.

아래는 단순히 유저의 정보를 보여주는 Mypage에 대한 기존 Screen코드이다.

before

@Composable
fun MypageScreen(
    onClickLogout: () -> Unit = {},
    viewModel: MypageViewModel = hiltViewModel()
) {

    val state = viewModel.state.collectAsState().value
   
    Column(
        modifier = Modifier
            .fillMaxSize()
            .background(White)
            .padding(horizontal = 24.dp),
    ) {
        Row(
            modifier = Modifier
                .fillMaxWidth()
                .padding(top = 48.dp),
            horizontalArrangement = Arrangement.SpaceBetween,
            verticalAlignment = Alignment.CenterVertically
        ) {
            Text(text = state.nurseName, style = CVTheme.typography.headingPrimary, color = Gray700)
            Text(
                text = "로그아웃",
                style = CVTheme.typography.captionImportance,
                color = Gray500,
                modifier = Modifier
                    .clip(RoundedCornerShape(16.dp))
                    .background(Gray100)
                    .padding(5.dp)
                    .clickable { onClickLogout() }
            )
        }

        if(!state.registeredAt.isNullOrBlank()) {
            Text(text = "${state.registeredAt} 가입", style = CVTheme.typography.textBody1Medium, color = Gray500, modifier = Modifier.padding(top = 4.dp))
        }

        Spacer(modifier = Modifier.padding(top = 24.dp))

        Box(modifier = Modifier
            .fillMaxWidth()
            .clip(RoundedCornerShape(12.dp))
            .background(color = Primary600)
            .padding(15.dp)) {

            Row(
                verticalAlignment = Alignment.CenterVertically,
                horizontalArrangement = Arrangement.Start
            ) {
                Image(painter = painterResource(id = R.drawable.ic_hopital_icon), contentDescription = "hospital image")
                Spacer(modifier = Modifier.width(12.dp))
                Column {
                    Text(text = state.hospitalName, style = CVTheme.typography.textBody1Importance, color = White)
                    Text(text = state.department, style = CVTheme.typography.textBody2Medium, color = White)
                }
            }
        }
    }
}

after

📝MypageScreen → UI만 담당하는 Composable
📝MypageRoute → ViewModel을 관리하는 Composable (State 관리)

@Composable
fun MypageRoute(
    onClickLogout: () -> Unit = {},
    viewModel: MypageViewModel = hiltViewModel()
) {

    val state = viewModel.state.collectAsState().value
    val context = LocalContext.current

    LaunchedEffect(key1 = Unit) {
        viewModel.sideEffect.collect{
            when(it){
                is NurseMypageSideEffect.GetNurseMypageSuccess -> {
                    // success
                }
                is NurseMypageSideEffect.GetNurseMypageFailure -> {
                    Toast.makeText(context, "네트워크 오류가 발생했습니다.", Toast.LENGTH_SHORT).show()
                }
                else -> {}
            }
        }
    }

    MypageScreen(
        nurseName = state.nurseName,
        onClickLogout = onClickLogout,
        registeredAt = state.registeredAt,
        hospitalName = state.hospitalName,
        department = state.department
    )
    
}

@Composable
fun MypageScreen(
    nurseName: String = "",
    onClickLogout: () -> Unit = {},
    registeredAt: String? = "",
    hospitalName: String = "",
    department: String = ""
){
    Column(
        modifier = Modifier
            .fillMaxSize()
            .background(White)
            .padding(horizontal = 24.dp),
    ) {
        Row(
            modifier = Modifier
                .fillMaxWidth()
                .padding(top = 48.dp),
            horizontalArrangement = Arrangement.SpaceBetween,
            verticalAlignment = Alignment.CenterVertically
        ) {
            Text(text = nurseName, style = CVTheme.typography.headingPrimary, color = Gray700)
            Text(
                text = "로그아웃",
                style = CVTheme.typography.captionImportance,
                color = Gray500,
                modifier = Modifier
                    .clip(RoundedCornerShape(16.dp))
                    .background(Gray100)
                    .padding(5.dp)
                    .clickable { onClickLogout() }
            )
        }

        if(!registeredAt.isNullOrBlank()) {
            Text(text = "${registeredAt} 가입", style = CVTheme.typography.textBody1Medium, color = Gray500, modifier = Modifier.padding(top = 4.dp))
        }

        Spacer(modifier = Modifier.padding(top = 24.dp))

        Box(modifier = Modifier
            .fillMaxWidth()
            .clip(RoundedCornerShape(12.dp))
            .background(color = Primary600)
            .padding(15.dp)) {

            Row(
                verticalAlignment = Alignment.CenterVertically,
                horizontalArrangement = Arrangement.Start
            ) {
                Image(painter = painterResource(id = R.drawable.ic_hopital_icon), contentDescription = "hospital image")
                Spacer(modifier = Modifier.width(12.dp))
                Column {
                    Text(text = hospitalName, style = CVTheme.typography.textBody1Importance, color = White)
                    Text(text = department, style = CVTheme.typography.textBody2Medium, color = White)
                }
            }
        }
    }
}

위와 같이 State Hoisting을 적용하여 Composable을 UI와 상태(State) 관리 부분으로 분리하면 이전과 같이 Preview가 보이게 된다.

@Composable
@Preview
fun MypageScreenPreview() {
    CVTheme {
        MypageScreen(
            nurseName = "김유진",
            registeredAt = "2021.09.01",
            hospitalName = "서울대학교병원",
            department = "내과"
        )
    }
}

이제 MypageScreen이 ViewModel에 의존하지 않기 때문에 Preview에서 원하는 데이터를 주입하여 미리보기 가능하다.


CareVision Repository

kangyuri1114 Github 보러가기

profile
(멋쨍이) Android Developer (하고싶다)

0개의 댓글

관련 채용 정보