아티클 목록으로 가기

Compose Preview의 내부 동작 원리

skydovesJaewoong Eum (skydoves)||12분 소요

Compose Preview의 내부 동작 원리

Compose를 사용하는 모든 안드로이드 개발자라면 컴포저블 함수 위에 @Preview를 작성하고, Android Studio의 디자인 패널에 UI가 렌더링되는 것을 경험해 보셨을 것입니다. 하지만 이 어노테이션 하나가 실제 렌더링된 픽셀로 변환되기까지 내부적으로 어떤 과정이 일어나는지 궁금하신 적은 없으신가요? 그 답을 찾아보면, 어노테이션 메타데이터, XML 레이아웃 인플레이션, 가짜 안드로이드 생명주기 객체, 리플렉션(reflection) 기반의 컴포저블 호출, 그리고 JVM 기반 렌더링 엔진이 서로 긴밀하게 협력하여 컴포저블이 마치 실제 Activity 안에서 실행되고 있다고 착각하게 만드는 정교한 구조가 드러납니다.

이 글에서는 @Preview 어노테이션이 렌더링된 이미지로 변환되는 전체 파이프라인을 살펴보겠습니다. 어노테이션 정의 자체부터 시작하여 렌더링을 총괄하는 FrameLayoutComposeViewAdapter, Compose 컴파일러의 ABI를 준수하면서 리플렉션으로 컴포저블을 호출하는 ComposableInvoker, 인스펙션 모드를 활성화하고 컴포지션 데이터를 기록하는 Inspectable, 그리고 렌더링된 픽셀을 소스 코드 라인에 다시 매핑해 주는 ViewInfo 트리까지, 그 여정을 하나하나 추적해 보겠습니다.

근본적인 문제: 직접 호출할 수 없는 함수의 렌더링

@Composable 함수는 일반 함수가 아닙니다. Compose 컴파일러는 모든 @Composable 함수를 변환하여 Composer 매개변수와 합성된 $changed, $default 정수 매개변수를 추가합니다. 함수 시그니처 변환뿐만 아니라, 컴포저블은 생명주기 소유자(lifecycle owner), ViewModelStore, SavedStateRegistry 등 다양한 안드로이드 프레임워크 객체를 제공하는 실행 환경을 필요로 합니다. 이러한 의존성은 실제로 실행 중인 Activity 안에서는 자동으로 제공되지만, Android Studio에서는 에뮬레이터나 디바이스 없이도 컴포저블을 렌더링해야 합니다.

따라서 Preview 도구는 컴포저블이 실제 Activity 안에 있다고 믿을 수 있을 만큼 안드로이드 런타임을 충분히 재구성해야 하고, 컴파일러가 변환한 시그니처와 정확히 일치하도록 리플렉션을 통해 컴포저블을 호출해야 하며, 이후 렌더링된 레이아웃 정보를 추출하여 Studio가 픽셀을 소스 코드에 매핑할 수 있게 해야 합니다. 바로 이 난제를 ui-tooling 라이브러리가 해결합니다.

@Preview 어노테이션: 동작이 아닌 메타데이터

@Preview 어노테이션 자체는 런타임에 아무런 동작도 수행하지 않습니다. 순수하게 Studio가 렌더링 환경을 설정하기 위해 읽어들이는 메타데이터일 뿐입니다. 어노테이션의 정의를 살펴보겠습니다.

@MustBeDocumented
@Retention(AnnotationRetention.BINARY)
@Target(AnnotationTarget.ANNOTATION_CLASS, AnnotationTarget.FUNCTION)
@Repeatable
annotation class Preview(
    val name: String = "",
    val group: String = "",
    @IntRange(from = 1) val apiLevel: Int = -1,
    val widthDp: Int = -1,
    val heightDp: Int = -1,
    val locale: String = "",
    @FloatRange(from = 0.01) val fontScale: Float = 1f,
    val showSystemUi: Boolean = false,
    val showBackground: Boolean = false,
    val backgroundColor: Long = 0,
    @AndroidUiMode val uiMode: Int = 0,
    @Device val device: String = Devices.DEFAULT,
    @Wallpaper val wallpaper: Int = Wallpapers.NONE,
)

세 가지 메타 어노테이션이 이 어노테이션의 동작 방식을 정의합니다.

  • @Retention\(BINARY\): 어노테이션이 바이트코드로 컴파일된 이후에도 유지됩니다. 덕분에 Studio는 소스 코드뿐만 아니라 컴파일된 클래스 파일을 스캔하여 Preview를 검색할 수 있습니다.
  • @Target(ANNOTATION_CLASS, FUNCTION): 함수(컴포저블)와 다른 어노테이션 클래스에 모두 적용할 수 있습니다. 두 번째 타겟인 ANNOTATION_CLASS가 바로 MultiPreview 기능을 가능하게 하는 핵심입니다.
  • @Repeatable: 하나의 함수에 여러 개의 @Preview 어노테이션을 중첩 적용하여 다양한 Preview 구성을 생성할 수 있습니다.

widthDp, heightDp, device, locale 등의 매개변수는 모두 순수한 설정 데이터입니다. Studio가 렌더링 뷰포트를 설정하는 데 활용할 뿐, 어노테이션 자체에는 런타임 동작이 전혀 포함되어 있지 않습니다.

MultiPreview: 어노테이션 위에 어노테이션

ANNOTATION_CLASS 타겟 덕분에 MultiPreview라 불리는 패턴이 가능해집니다. 여러 개의 @Preview가 적용된 커스텀 어노테이션을 만들어 활용하는 방식입니다. @PreviewLightDark의 정의를 살펴보겠습니다.

@Retention(AnnotationRetention.BINARY)
@Target(AnnotationTarget.ANNOTATION_CLASS, AnnotationTarget.FUNCTION)
@Preview(name = "Light")
@Preview(name = "Dark", uiMode = UI_MODE_NIGHT_YES or UI_MODE_TYPE_NORMAL)
annotation class PreviewLightDark

컴포저블에 @PreviewLightDark를 적용하면, Studio가 두 개의 @Preview 어노테이션을 재귀적으로(transitively) 해석하여 두 가지 Preview 구성을 생성합니다. 이 과정에는 별도의 컴파일러 플러그인이나 코드 생성이 전혀 관여하지 않으며, 순수하게 코틀린의 어노테이션 기능만으로 동작합니다.

어노테이션에서 XML까지: Studio가 Preview를 검색하는 방법

어노테이션에서 렌더링된 Preview로 이어지는 파이프라인은 Android Studio 내부에서 시작됩니다. Studio는 PSI와 UAST(내부 코드 분석 프레임워크)를 사용하여 코틀린 소스 파일에서 @Preview 어노테이션을 스캔합니다. MultiPreview도 재귀적으로 해석하여 모든 Preview 구성을 수집합니다. 이 스캔 과정은 Studio의 비공개 소스 코드에서 진행되지만, 스캔의 결과물은 전적으로 오픈소스입니다.

검색된 각 Preview에 대해, Studio는 tools: 네임스페이스 속성으로 ComposeViewAdapter를 참조하는 합성 XML 레이아웃을 생성합니다. 개념적으로 표현하면 다음과 같습니다.

<androidx.compose.ui.tooling.ComposeViewAdapter
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:composableName="com.example.MyPreviewKt.MyPreview"
    tools:parameterProviderClass="com.example.MyProvider" />

여기서 핵심적인 통찰은 비공개 소스인 Studio와 오픈소스 도구 계층 사이의 다리 역할을 하는 것이 바로 XML 레이아웃이라는 점입니다. 이는 안드로이드가 디자인 타임 렌더링에 오래전부터 사용해 온 메커니즘과 동일합니다. ComposeViewAdapterinit 메서드에서 이러한 속성들을 파싱합니다. 파싱 로직을 간략화하여 살펴보겠습니다.

private fun init(attrs: AttributeSet) {
    setViewTreeLifecycleOwner(FakeSavedStateRegistryOwner)
    setViewTreeSavedStateRegistryOwner(FakeSavedStateRegistryOwner)
    setViewTreeViewModelStoreOwner(FakeViewModelStoreOwner)
    addView(composeView)

    val composableName = attrs.getAttributeValue(TOOLS_NS_URI, "composableName")
        ?: return
    val className = composableName.substringBeforeLast('.')
    val methodName = composableName.substringAfterLast('.')

    val parameterProviderClass = attrs
        .getAttributeValue(TOOLS_NS_URI, "parameterProviderClass")
        ?.asPreviewProviderClass()

    init(className = className, methodName = methodName, ...)
}

이 메서드는 tools:composableName을 읽은 뒤 클래스명과 메서드명으로 분리하고, 선택적 매개변수 프로바이더 정보를 추출한 다음, 컴포지션을 구성하는 메인 init 메서드에 처리를 위임합니다. 이 모든 과정에 앞서, 컴포저블이 필요로 하는 가짜 생명주기 소유자를 먼저 설치해 둡니다.

ComposeViewAdapter: 전체 과정의 오케스트레이터

ComposeViewAdapter는 Preview 파이프라인의 중심에 위치한 FrameLayout입니다. 가짜 안드로이드 생명주기를 구성하고, 컴포저블을 호출하고, 예외를 포착하고, 결과를 Studio가 소비할 수 있는 형식으로 가공하는 역할을 합니다.

안드로이드 생명주기 위조하기

가짜 생명주기는 영화 세트장에 비유할 수 있습니다. 외부에서 보면 실제 건물처럼 보이기 때문에 배우(컴포저블)가 연기를 할 수 있지만, 외벽 뒤에는 아무것도 없는 것과 같습니다. 컴포저블은 LocalLifecycleOwner, LocalViewModelStoreOwner 등의 CompositionLocal 프로바이더를 통해 생명주기 소유자에 접근하는데, 실제 구현체가 없으면 컴포지션이 즉시 실패하게 됩니다.

FakeSavedStateRegistryOwnerSavedStateRegistryOwner를 구현하며 생명주기를 제공합니다.

private val FakeSavedStateRegistryOwner =
    object : SavedStateRegistryOwner {
        val lifecycleRegistry = LifecycleRegistry.createUnsafe(this)
        private val controller =
            SavedStateRegistryController.create(this).apply {
                performRestore(Bundle())
            }

        init {
            lifecycleRegistry.currentState = Lifecycle.State.RESUMED
        }

        override val savedStateRegistry: SavedStateRegistry
            get() = controller.savedStateRegistry
        override val lifecycle: LifecycleRegistry
            get() = lifecycleRegistry
    }

생명주기를 즉시 RESUMED 상태로 설정하여, 컴포저블이 마치 완전히 활성화된 Activity 안에서 실행되는 것처럼 동작하게 합니다. SavedStateRegistryController는 빈 Bundle로 복원되어, 컴포지션이 성공적으로 수행되기 위한 최소한의 상태 인프라를 제공합니다.

반면, FakeActivityResultRegistryOwner는 전혀 다른 접근 방식을 취합니다. 정상적으로 동작하는 구현체를 제공하는 대신, 의도적으로 예외를 던집니다.

private val FakeActivityResultRegistryOwner =
    object : ActivityResultRegistryOwner {
        override val activityResultRegistry =
            object : ActivityResultRegistry() {
                override fun <I : Any?, O : Any?> onLaunch(
                    requestCode: Int,
                    contract: ActivityResultContract<I, O>,
                    input: I,
                    options: ActivityOptionsCompat?,
                ) {
                    throw IllegalStateException(
                        "Calling launch() is not supported in Preview"
                    )
                }
            }
    }

이것은 의도적인 설계 결정입니다. 도구 계층은 컴포지션이 성공하기 위한 충분한 인프라만 제공할 뿐, 실제 Activity를 필요로 하는 사이드 이펙트(side-effects)까지 지원하지는 않습니다. 컴포저블이 Activity Result Contract를 시작하려고 시도하면, 조용히 실패하는 대신 명확한 오류 메시지를 받게 됩니다. 이러한 방식으로 개발자에게 Preview 환경의 제약을 분명하게 알려 줍니다.

WrapPreview와 컴포지션 체인

컴포저블이 실행되기 전에, ComposeViewAdapter는 필요한 모든 컨텍스트를 제공하기 위해 컴포지션 체인으로 감싸 줍니다.

@Composable
private fun WrapPreview(content: @Composable () -> Unit) {
    CompositionLocalProvider(
        LocalFontLoader provides LayoutlibFontResourceLoader(context),
        LocalFontFamilyResolver provides createFontFamilyResolver(context),
        LocalOnBackPressedDispatcherOwner provides FakeOnBackPressedDispatcherOwner,
        LocalActivityResultRegistryOwner provides FakeActivityResultRegistryOwner,
    ) {
        Inspectable(slotTableRecord, content)
    }
}

LayoutlibFontResourceLoader는 표준 폰트 로더를 대체합니다. Studio가 사용하는 JVM 기반 렌더링 엔진인 Layoutlib 내부에서는 ResourcesCompat가 폰트를 로드할 수 없기 때문입니다. 컴포지션 체인의 흐름은 WrapPreview -> Inspectable -> 사용자의 컴포저블 순서로 이어집니다.

예외 처리

컴포지션 도중 발생하는 예외에는 특별한 처리가 필요합니다. Compose는 예외가 전파되기 전에 내부 상태를 정리해야 하지만, Studio는 개발자에게 오류 정보를 표시해야 하기 때문입니다. 이 문제를 해결하기 위해 지연 예외 발생(delayed throw) 패턴을 사용합니다.

컴포지션 도중 포착된 예외는 delayedException 필드에 저장되었다가 onLayout 시점에 다시 던져집니다.

override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
    super.onLayout(changed, left, top, right, bottom)
    delayedException.throwIfPresent()
    processViewInfos()
    if (composableName.isNotEmpty()) {
        findAndTrackAnimations()
    }
}

Studio는 레이아웃 과정에서 발생하는 예외를 포착하여 Preview 오류 패널에 표시합니다. Preview가 실패했을 때 가공되지 않은 스택 트레이스 대신 읽기 쉬운 오류 메시지를 확인하실 수 있는 것이 바로 이 메커니즘 덕분입니다.

ComposableInvoker: 호출할 수 없는 함수 호출하기

ComposableInvoker는 파이프라인에서 기술적으로 가장 까다로운 부분을 담당합니다. Compose 컴파일러가 생성하는 정확한 바이너리 시그니처에 맞춰, @Composable 함수를 리플렉션으로 호출하는 역할입니다.

컴파일러의 숨겨진 매개변수

@Composable fun MyPreview()라고 작성하더라도 컴파일러는 매개변수가 없는 함수로 그대로 두지 않습니다. 컴파일된 결과물은 fun MyPreview($composer: Composer, $changed: Int)에 가까운 형태가 됩니다. 매개변수가 있는 컴포저블의 경우, 컴파일러는 각 매개변수가 기본값을 사용해야 하는지를 추적하는 $default 비트마스크 정수도 추가합니다. 인보커는 이렇게 변환된 시그니처와 정확히 일치하는 인자 배열을 구성해야 합니다.

ABI 계산

합성 매개변수의 개수는 실제 매개변수가 몇 개인지에 따라 달라집니다. 각 $changed 정수는 매개변수 슬롯당 3비트를 사용하여 해당 매개변수가 마지막 컴포지션 이후 변경되었는지를 추적합니다. 정수당 사용 가능한 비트가 31개이므로, 하나의 $changed 정수로 최대 10개의 매개변수 슬롯을 추적할 수 있습니다. $default 정수는 매개변수당 1비트를 사용하므로, 하나의 정수에 31개의 매개변수를 담을 수 있습니다.

인보커는 다음 두 함수로 이러한 개수를 계산합니다.

private const val SLOTS_PER_INT = 10
private const val BITS_PER_INT = 31

private fun changedParamCount(realValueParams: Int, thisParams: Int): Int {
    if (realValueParams == 0) return 1
    val totalParams = realValueParams + thisParams
    return ceil(totalParams.toDouble() / SLOTS_PER_INT.toDouble()).toInt()
}

private fun defaultParamCount(realValueParams: Int): Int {
    return ceil(realValueParams.toDouble() / BITS_PER_INT.toDouble()).toInt()
}

실제 매개변수가 없는 컴포저블이라도 $changed 정수 하나는 반드시 포함됩니다. 매개변수 수가 늘어나면 추가 슬롯을 수용하기 위해 정수가 추가됩니다. 예를 들어, 매개변수가 12개인 컴포저블은 $changed 정수가 2개(ceil(12/10) = 2), $default 정수가 1개(ceil(12/31) = 1) 필요합니다.

인자 배열 구성

매개변수 개수 계산이 끝나면, 인보커가 인자 배열을 구축합니다. 전략은 다음과 같습니다. 실제 매개변수 위치에는 제공된 값 또는 타입 기본값을 채우고, Composer를 전달한 뒤, 모든 $changed 정수를 0("불확실"을 의미하여 Compose가 모든 것을 재평가)으로 설정하고, 모든 $default 비트를 1("모든 매개변수에 기본값 사용")로 설정합니다.

invokeComposableMethod 내부의 인자 구성 로직을 간략화하여 살펴보겠습니다.

val arguments = Array(totalParams) { idx ->
    when (idx) {
        in 0 until realParams ->
            args.getOrElse(idx) { parameterTypes[idx].getDefaultValue() }
        composerIndex -> composer
        in changedStartIndex until defaultStartIndex -> 0
        in defaultStartIndex until totalParams -> 0b111111111111111111111
        else -> error("Unexpected index")
    }
}
return invoke(instance, *arguments)

$changed를 0으로 설정하면 Compose에 모든 매개변수 상태가 불확실하다고 알려 주므로, 스킵하지 않고 모든 것을 재평가합니다. $default를 모두 1로 설정하면 런타임에 선언된 기본값을 모든 매개변수에 적용하도록 지시합니다. Preview 환경에서는 Studio가 PreviewParameterProvider를 통해 매개변수 값을 제공하거나 기본값을 사용하기 때문에, 이 전략이 안전합니다.

공개 진입점

invokeComposable 함수가 모든 것을 하나로 엮어 줍니다. 클래스명으로 클래스를 로드하고, 컴포저블 메서드를 찾은 다음, 최상위 함수(정적 메서드로 컴파일됨)와 클래스 멤버 함수를 각각 처리합니다.

fun invokeComposable(
    className: String,
    methodName: String,
    composer: Composer,
    vararg args: Any?,
) {
    val composableClass = Class.forName(className)
    val method = composableClass.findComposableMethod(methodName, *args)
        ?: throw NoSuchMethodException(
            "Composable $className.$methodName not found"
        )
    method.isAccessible = true
    if (Modifier.isStatic(method.modifiers)) {
        method.invokeComposableMethod(null, composer, *args)
    } else {
        val instance = composableClass.getConstructor().newInstance()
        method.invokeComposableMethod(instance, composer, *args)
    }
}

인스턴스 메서드의 경우, 인보커는 빈 생성자를 사용하여 새 인스턴스를 생성합니다. 한 가지 주목할 만한 세부 사항은 메서드 검색 시 이름이 맹글링(mangling)된 메서드도 함께 확인한다는 점입니다. Compose 컴파일러는 inline class 매개변수를 사용하는 함수의 이름을 맹글링하여 MyPreview-xxxx와 같은 시그니처를 생성합니다. findComposableMethod 함수는 정확한 이름과 함께 it.name.startsWith("$methodName-") 패턴을 사용하여 맹글링된 변형도 검색합니다.

Inspectable: 도구 연결의 다리

Inspectable 함수는 컴포지션과 Studio의 인스펙션 도구 사이를 연결하는 다리 역할을 합니다. 단 몇 줄의 코드에 불과하지만, 전체 Preview 인스펙션 경험을 가능하게 하는 핵심 요소입니다.

@Composable
internal fun Inspectable(
    compositionDataRecord: CompositionDataRecord,
    content: @Composable () -> Unit,
) {
    currentComposer.collectParameterInformation()
    val store = (compositionDataRecord as CompositionDataRecordImpl).store
    store.add(currentComposer.compositionData)
    CompositionLocalProvider(
        LocalInspectionMode provides true,
        LocalInspectionTables provides store,
        content = content,
    )
}

각 라인이 고유한 목적을 담당합니다.

  1. collectParameterInformation\(\): Composer에게 컴포지션 과정에서 매개변수 값을 기록하도록 지시합니다. 프로덕션 코드에서는 컴포지션 이후 매개변수 값을 검사할 필요가 없으므로, 성능을 위해 기본적으로 이 과정을 생략합니다.
  2. store.add\(currentComposer.compositionData\): 현재 컴포지션의 데이터를 WeakHashMap 기반 집합에 추가합니다. 가비지 컬렉션을 방해하지 않으면서도 컴포지션의 슬롯 테이블(slot table)을 이후 인스펙션에 활용할 수 있게 해 줍니다.
  3. LocalInspectionMode provides true: 바로 이 라인이 컴포저블 안에서 LocalInspectionMode.currenttrue를 반환하게 만드는 핵심입니다. Preview에서 폴백(fallback) 동작을 제공하기 위해 if (LocalInspectionMode.current) { ... } 형태의 조건문을 작성할 때, 그 값이 바로 여기에서 제공됩니다.
  4. LocalInspectionTables provides store: 기록된 컴포지션 데이터를 Studio의 도구 계층에서 접근할 수 있도록 하여, ViewInfo 트리 구축에 활용합니다.

컴포지션에서 ViewInfo까지: 픽셀을 소스 코드에 매핑하기

컴포지션이 완료되고 레이아웃이 수행되면, Studio는 렌더링된 각 영역이 소스 코드의 어느 라인에 해당하는지 알아야 합니다. 이 역할을 담당하는 것이 바로 ViewInfo 데이터 구조입니다. ViewInfo를 지도의 범례(legend)에 비유할 수 있습니다. "이 좌표의 사각형은 이 파일의 이 라인에 있는 컴포저블이 생성한 것입니다"라고 Studio에 알려 주는 셈입니다.

ViewInfo 데이터 클래스를 살펴보겠습니다.

internal data class ViewInfo(
    val fileName: String,
    val lineNumber: Int,
    val bounds: IntRect,
    val location: SourceLocation?,
    val children: List<ViewInfo>,
    val layoutInfo: Any?,
    val name: String?,
)

ViewInfo는 소스 파일명, 라인 번호, 픽셀 경계(bounds), 그리고 자식 목록을 가지며, 컴포저블 호출 계층 구조를 반영하는 트리를 형성합니다.

processViewInfos 메서드는 기록된 컴포지션 데이터를 순회하며 이 트리로 변환합니다.

private fun processViewInfos() {
    viewInfos = slotTableRecord.store.makeTree(
        prepareResult = {},
        createNode = ::toViewInfoFactory,
        createResult = { _, out, _ -> out },
    )
}

이 메서드는 지연 예외 검사가 끝난 뒤 onLayout에서 호출됩니다. makeTree 함수는 Compose가 모든 컴포지션 상태를 저장하는 슬롯 테이블을 순회하며, toViewInfoFactory를 사용하여 ViewInfo 노드를 구축합니다. 이 팩토리 함수는 각 컴포지션 그룹에서 소스 위치와 바운딩 박스를 추출합니다. 최종 결과물인 트리를 Studio가 읽어들여, 디자인 패널에서 "클릭하면 소스 코드로 이동" 기능 같은 편의를 제공하게 됩니다.

디바이스에서 Preview 실행하기: PreviewActivity

IDE 내부 렌더링에 사용되는 동일한 ComposableInvoker가 디바이스에서 직접 Preview를 실행하는 기능도 지원합니다. PreviewActivity는 인텐트 엑스트라에서 컴포저블의 정규화된 이름(fully qualified name)을 읽어들여 호출하는 ComponentActivity입니다.

class PreviewActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        if (applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE == 0) {
            Log.d(TAG, "Application is not debuggable. Preview not allowed.")
            finish()
            return
        }
        intent?.getStringExtra("composable")?.let {
            setComposableContent(it)
        }
    }
}

Activity는 보안 조치로 먼저 FLAG_DEBUGGABLE 플래그를 확인합니다. Preview는 리플렉션을 통해 임의의 컴포저블을 호출할 수 있기 때문에, 디버그 가능한 빌드에서만 디바이스 Preview를 허용합니다. setComposableContent 메서드는 정규화된 이름을 클래스와 메서드로 분리한 다음, IDE Preview에서 사용하는 것과 동일한 리플렉션 기반 호출인 ComposableInvoker.invokeComposable을 직접 호출합니다. 다만 디바이스에서는 컴포저블이 실제 Activity와 실제 생명주기 안에서 실행되므로, 가짜 생명주기 객체가 필요하지 않다는 점이 차이입니다.

결론

이 글에서는 @Preview 어노테이션이 렌더링된 이미지로 변환되는 전체 파이프라인을 살펴보았습니다. 그 여정은 @Retention(BINARY)를 통해 바이트코드에 유지되는 어노테이션 메타데이터에서 시작하여, 합성 XML 레이아웃을 출력하는 Studio의 비공개 소스 스캔 계층을 거치고, XML을 파싱하여 가짜 생명주기 객체를 구성하는 오픈소스 ComposeViewAdapter로 진입합니다. 이후 Compose 컴파일러의 ABI에 맞춰 리플렉션으로 컴포저블을 호출하는 ComposableInvoker에 위임되고, 인스펙션 모드를 활성화하며 컴포지션 데이터를 기록하는 Inspectable을 거쳐, 최종적으로 픽셀을 소스 코드에 매핑하는 ViewInfo 트리가 생성됩니다.

이 파이프라인의 동작 원리를 이해하면, 그동안 수수께끼처럼 느껴지던 여러 동작을 설명할 수 있습니다. Preview 렌더링 실패는 실제 Activity Result나 네비게이션 컨트롤러처럼 가짜 생명주기가 제공하지 못하는 컴포넌트에 컴포저블이 의존할 때 주로 발생합니다. LocalInspectionMode.current 검사가 동작하는 이유는 Inspectable이 명시적으로 true 값을 제공하기 때문입니다. MultiPreview가 별도의 도구 지원 없이도 동작하는 이유는, Studio가 재귀적으로 해석하는 순수한 코틀린 어노테이션 기능이기 때문입니다.

렌더링되지 않는 Preview를 디버깅하거나, 런타임에서만 사용 가능한 리소스에 의존하는 컴포넌트에 LocalInspectionMode.current로 폴백 동작을 제공하거나, Compose 인스펙션 계층과 통합되는 커스텀 도구를 구축하실 때, 이 파이프라인의 동작 원리를 이해하고 있다면 Preview 시스템에 맞서 싸우기보다 함께 활용하실 수 있는 든든한 기반이 될 것입니다.

아티클 목록으로 가기