Android 개발/Jetpack Compose

@PreviewWrapper 하나로 디자인 시스템 프리뷰 일관성 잡기

stackD 2026. 6. 26. 18:00
반응형

 

프리뷰에서는 멀쩡한데, 앱 빌드해서 띄우면 색이랑 폰트가 다르게 보인 적 있으신가요? 매번 MyTheme { Surface { ... } } 감싸다가 하나 빠뜨려서 그런 거거든요.

 

저도 작년에 디자인 시스템 모듈 정리하면서 안드로이드 스튜디오(Android Studio) 프리뷰 패널만 100개 가까이 깔아두고 작업했었는데요. 그중 한 다섯 개 정도는 꼭 테마 래퍼를 빼먹고 머터리얼 기본값으로 찍힌 상태로 PR 올라가곤 했어요. 4월에 Compose 1.11.0 에 들어온 @PreviewWrapper 가 정확히 이 지점에 들어맞아서, 지난 한 달 굴려본 정리를 옮겨둡니다.

 

Compose 프리뷰가 디자인 시스템이랑 다르게 렌더링되는 이유

원인은 크게 세 갈래입니다.

 

첫 번째는 CompositionLocal 의 분리예요. 프리뷰는 앱의 실행 컨텍스트와 떨어진 맥락에서 돕니다. 그래서 디자인 시스템에서 정의한 LocalColors, LocalTypography, LocalSpacing 같은 커스텀 컴포지션 로컬이 아예 안 들어가요. 그 결과 컴포저블 안에서 LocalColors.current.brand 같이 꺼내 쓰면 머터리얼 기본값으로 풀리거나, 아예 null 로 떨어져서 프리뷰가 깨지는 경우가 생깁니다.

 

두 번째는 Surface 누락입니다. 프리뷰는 배경이 투명이라 다크 테마 컴포넌트를 그냥 띄우면 흰색 위에 흰 글자가 찍히는 식으로 대비가 반전돼 보여요. 실제 앱에서는 Surface 가 깔려 있어서 멀쩡한데, 프리뷰 패널에서만 이상해 보이는 거지요.

 

세 번째가 사람 손이 자꾸 빠뜨리는 자리예요. 컴포넌트 30개에 프리뷰 두세 개씩 달다 보면 수십 개 함수에 매번 같은 래퍼 두 줄을 깔게 되는데, 이 중 한두 개는 꼭 누락이 생기더라고요.

 

 

PreviewWrapperProvider 사용법, 한 번만 정의하면 끝

@PreviewWrapper 어노테이션과 PreviewWrapperProvider 인터페이스를 묶어서 쓰는 구조입니다. 디자인 시스템 모듈에 래퍼를 한 번만 정의해두고, 프리뷰 함수에는 어노테이션만 붙여요.

 

// design-system 모듈
class ThemeWrapper : PreviewWrapperProvider {
    @Composable
    override fun Wrap(content: @Composable () -> Unit) {
        MyDesignSystemTheme {
            Surface(color = MaterialTheme.colorScheme.background) {
                content()
            }
        }
    }
}

 

이제 프리뷰 쪽은 이렇게 짧아집니다.

 

@Preview
@PreviewWrapper(ThemeWrapper::class)
@Composable
private fun MyButtonPreview() {
    MyButton(text = "Confirm", onClick = {})
}

 

함수 본문에서 MyDesignSystemTheme { ... } 두 줄이 사라지는 게 핵심이에요. 현재 experimental 마커가 붙어 있어 opt-in이 필요한데, 정확한 마커 이름은 공식 API 레퍼런스를 참고하시는 게 안전합니다. 매번 @OptIn 다는 게 귀찮으면 모듈 단위 @file:OptIn 또는 build.gradle.ktsfreeCompilerArgs 로 해당 마커를 -opt-in=... 한 줄 박아두시면 깔끔합니다.

 

 

다크모드·폰트 스케일도 @PreviewWrapper로 일괄 적용

진짜 효과가 큰 구간은 사실 테마 적용보다 다크모드·접근성 검증 쪽입니다. Wrapper 클래스를 목적별로 쪼개두면 어노테이션 한 줄 바꾸는 것만으로 전체 프리뷰의 렌더링 환경이 갈리거든요.

 

class DarkWrapper : PreviewWrapperProvider {
    @Composable
    override fun Wrap(content: @Composable () -> Unit) {
        CompositionLocalProvider(LocalAppTheme provides AppTheme.Dark) {
            MyDesignSystemTheme(darkTheme = true) {
                Surface { content() }
            }
        }
    }
}

class LargeFontWrapper : PreviewWrapperProvider {
    @Composable
    override fun Wrap(content: @Composable () -> Unit) {
        val density = LocalDensity.current
        val scaled = Density(density = density.density, fontScale = 1.8f)
        CompositionLocalProvider(LocalDensity provides scaled) {
            MyDesignSystemTheme { Surface { content() } }
        }
    }
}

 

폰트 스케일 1.8 로 강제해두면 버튼 안에서 텍스트가 잘리는 컴포넌트가 한눈에 보입니다. 개인적으로는 단일 Wrapper 안에 분기 처리로 다 몰아넣기보다, LightWrapper, DarkWrapper, LargeFontWrapper 식으로 나눠두는 쪽이 디버깅도 편하고 어노테이션 의미도 명확해서 추천드리고 싶네요.

 

 

기존 @Preview 마이그레이션과 Lint 룰로 누락 방지

이미 박혀 있는 수십 개 프리뷰에서 테마 래퍼 코드를 걷어내는 작업은 인텔리J(IntelliJ) 의 Structural Search & Replace 로 한 번에 처리 가능합니다. $Theme$ { Surface { $body$ } } 같은 패턴으로 찾아서 $body$ 만 남기는 식이에요. 실제로 제 프로젝트에서는 80~90개 프리뷰가 한 번의 일괄 치환으로 정리됐습니다.

 

이렇게 정리한 뒤에는 커스텀 멀티프리뷰와 결합이 자연스럽게 풀립니다. @DevicePreviews 같이 기기 조합을 묶어둔 어노테이션과 @PreviewWrapper(DarkWrapper::class) 를 같이 박으면 '기기 × 테마' 매트릭스가 자동으로 깔리거든요. 테마 래핑과 디바이스 분기를 따로 책임지는 구조라, 두 어노테이션이 서로 안 부딪힙니다.

 

누락 방지는 Lint 룰을 자체로 하나 정의해두는 게 깔끔합니다. 디자인 시스템 모듈 안의 @Preview 함수에 @PreviewWrapper 가 없으면 경고를 띄우게요. 저는 빌드 실패까지는 안 걸고 경고 수준으로만 두고 있는데, 그것만으로도 PR 리뷰 단계에서 잡히는 비율이 확실히 늘었습니다.

 

Paparazzi 스크린샷 테스트랑 Wrap 재사용하기

여기서 한 가지 주의하실 부분이 있어요. @PreviewWrapper 는 안드로이드 스튜디오 프리뷰 패널이 읽는 메타데이터입니다. Paparazzi 나 Roborazzi 같은 스크린샷 테스트 도구는 어노테이션을 안 읽고 그냥 컴포저블 함수를 호출해서 그리는 구조더라고요. 그래서 스크린샷 테스트로 옮기면 프리뷰에서는 멀쩡하던 컴포넌트가 다시 머터리얼 기본값으로 찍힙니다.

 

이때 살리는 지점이 PreviewWrapperProviderWrap 함수예요. 인터페이스 인스턴스를 그대로 만들어서 테스트 유틸에서 호출하면 됩니다.

 

fun ComposeContentTestRule.setThemedContent(
    content: @Composable () -> Unit,
) = setContent {
    ThemeWrapper().Wrap(content)
}

 

이러면 프리뷰에서 보던 그림과 스크린샷 테스트 결과가 같은 자리에서 출발합니다. Compose Multiplatform 의 프리뷰 측 지원 여부는 아직 별도 확인이 필요한 영역이라, 멀티플랫폼 프로젝트라면 우회 로직을 따로 둬야 할 수 있어요.

 

 

두 갈래 중 어느 쪽을 고를까

매번 함수마다 MyTheme { Surface { ... } } 두 줄을 깔던 방식이 익숙하면 사실 @PreviewWrapper 가 처음엔 한 단계 더 깐 것처럼 보입니다. Wrapper 클래스를 정의해야 하고, 어노테이션 임포트가 늘어나니까요. 그런데 컴포넌트 50개를 넘어가는 디자인 시스템 모듈이라면 저는 망설임 없이 후자를 택합니다. 누락이 생길 구간이 함수 본문 안쪽에서 어노테이션 한 줄로 옮겨가는 것만으로도, 코드 리뷰에서 잡아낼 확률이 확연히 올라가더라고요.

 

지금 디자인 시스템 모듈의 프리뷰 함수 본문을 한번 열어보세요. 모두 똑같이 시작하는 두 줄짜리 래퍼가 보인다면, 그 두 줄을 함수 밖으로 빼는 거래가 본인 프로젝트의 규모에 맞는 답인지 한번 따져보실 차례입니다.

 

반응형
LIST