
프리뷰에서는 멀쩡한데, 앱 빌드해서 띄우면 색이랑 폰트가 다르게 보인 적 있으신가요? 매번 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.kts 의 freeCompilerArgs 로 해당 마커를 -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 같은 스크린샷 테스트 도구는 어노테이션을 안 읽고 그냥 컴포저블 함수를 호출해서 그리는 구조더라고요. 그래서 스크린샷 테스트로 옮기면 프리뷰에서는 멀쩡하던 컴포넌트가 다시 머터리얼 기본값으로 찍힙니다.
이때 살리는 지점이 PreviewWrapperProvider 의 Wrap 함수예요. 인터페이스 인스턴스를 그대로 만들어서 테스트 유틸에서 호출하면 됩니다.
fun ComposeContentTestRule.setThemedContent(
content: @Composable () -> Unit,
) = setContent {
ThemeWrapper().Wrap(content)
}
이러면 프리뷰에서 보던 그림과 스크린샷 테스트 결과가 같은 자리에서 출발합니다. Compose Multiplatform 의 프리뷰 측 지원 여부는 아직 별도 확인이 필요한 영역이라, 멀티플랫폼 프로젝트라면 우회 로직을 따로 둬야 할 수 있어요.

두 갈래 중 어느 쪽을 고를까
매번 함수마다 MyTheme { Surface { ... } } 두 줄을 깔던 방식이 익숙하면 사실 @PreviewWrapper 가 처음엔 한 단계 더 깐 것처럼 보입니다. Wrapper 클래스를 정의해야 하고, 어노테이션 임포트가 늘어나니까요. 그런데 컴포넌트 50개를 넘어가는 디자인 시스템 모듈이라면 저는 망설임 없이 후자를 택합니다. 누락이 생길 구간이 함수 본문 안쪽에서 어노테이션 한 줄로 옮겨가는 것만으로도, 코드 리뷰에서 잡아낼 확률이 확연히 올라가더라고요.
지금 디자인 시스템 모듈의 프리뷰 함수 본문을 한번 열어보세요. 모두 똑같이 시작하는 두 줄짜리 래퍼가 보인다면, 그 두 줄을 함수 밖으로 빼는 거래가 본인 프로젝트의 규모에 맞는 답인지 한번 따져보실 차례입니다.
'Android 개발 > Jetpack Compose' 카테고리의 다른 글
| 안드로이드 스크린샷 테스트 3종 비교 — Roborazzi·Paparazzi·Compose Preview 갈라 써본 한 주 (0) | 2026.07.05 |
|---|---|
| Compose Composition tracing으로 Layout Inspector가 놓친 스크롤 잰크 잡기 (0) | 2026.06.28 |
| LazyColumn 41번 재구성, Quail Layout Inspector로 진범 잡은 일주일 (0) | 2026.06.23 |
| Compose UI 테스트 느린 자리, runWithoutImplicitWait로 5초 잡은 일지 (0) | 2026.06.22 |
| 안드로이드 위젯 통합 테스트, remote-testing으로 뚫린 사각지대 (0) | 2026.06.03 |