안드로이드 SDK에서의 서버 드리븐 UI: RevenueCat SDK 내부 구조 살펴보기
RevenueCat의 Android SDK의 내부 구조를 살펴보며 서버 드리븐 UI에 대한 개념을 학습합니다.

전통적인 모바일 애플리케이션은 백엔드에서 수신한 도메인 데이터를 기반으로 UI 레이아웃을 렌더링 하는 형태를 이루고 있기 때문에 레이아웃 구조는 클라이언트 측에서 결정됩니다. 이는 클라이언트와 백엔드의 역할을 명확하게 분리한다라는 장점이 있지만, 반대로 모든 UI 구조 설계와 렌더링은 100% 클라이언트의 책임이기 때문에 새로운 UI를 적용하고 출시하려면 매번 새로 앱을 빌드하고, 출시하고, 리뷰 프로세스를 거치고, 결과적으로 유저들이 해당 버전으로 업데이트를 해야 의미가 있습니다.
이러한 기존의 방식을 부분적으로 해결하기 위해 서버 드리븐(server-driven) UI라는 접근법이 존재합니다. 해당 접근법에서는 백엔드가 도메인 데이터를 포함하는 레이아웃 정보를 모바일 클라이언트로 전송합니다. 결과적으로, 클라이언트는 백엔드에서 제공하는 구조를 기반으로 UI를 렌더링 합니다. 최근 이와 같은 유동성의 장점을 취하고자 웹뷰 등을 사용하는 기업들이 점차 늘어나는 한편으로, 서버 드리븐 UI를 사용하면 웹뷰처럼 성능적인 트레이드오프 없이 네이티브의 성능을 그대로 유지하면서 레이아웃을 렌더링 할 수 있습니다.
이를 통해 팀은 코드에서 레이아웃을 수정하고, 스토어에 앱을 게시하고, 리뷰 프로세스를 기다리고, 사용자에게 앱을 업데이트하도록 요청하는 기존의 릴리스 주기를 거치지 않고도 UI 레이아웃을 업데이트할 수 있습니다. 이것의 장점은 빠른 A/B 테스트 및 각종 새로운 UI/UX에 대한 실험 등을 아주 빠르 게 처리할 수 있다는 것입니다.
이 포스에서는 서버 드리븐 UI의 개념을 살펴보고, RevenueCat의 Paywall Editor를 통해 RevenueCat의 Android SDK를 사용하여 앱을 업데이트하지 않고도 페이월을 구축하고 원격으로 업데이트하는 방법과 내부 구조를 중점적으로 살펴봅니다.
RevenueCat의 페이월 에디터
RevenueCat의 페이월 에디터는 Figma와 유사한 인터페이스를 제공하여 웹 대시보드에서 바로 페이월 화면을 디자인하고 커스텀할 수 있습니다. 이를 작업하는 데는 모바일 개발 전문 지식이 전혀 필요하지 않기 때문에, 비개발 직군인 PM/PO나 디자이너들 또한 해당 화면을 직접적으로 디자인하고 반영할 수 있다는 장점이 있습니다.
따라서, 디자이너나 제품 관리자는 페이월 화면을 직접 쉽게 업데이트할 수 있으며, 변경 사항이 게시되면 개발자 및 사용자, 두 측 모두 앱을 직접 업데이트하지 않고도 현재 설치되어있는 앱에 즉시 반영됩니다.

RevenueCat SDK를 사용하여 페이월을 개발하는 주요 이점 중 하나는 팀이 A/B 테스트, 다양한 상품 및 UI 레이아웃 실험, 구독 유도를 위한 메시징 전략 개선에 더욱 집중할 수 있다는 것입니다. 앱 업데이트, 리뷰 프로세스 주기, 그리고 사용자가 최신 버전을 설치할 때까지 기다리는 과정 거칠 필요가 없습니다.
최근 베타 버전이었던 Paywalls v2가 출시되어 안정화되고 정식 출시 되었습니다. Paywall Editor를 사용하여 화면을 빈화면부터 시작해서 완전히 커스텀 가능한 네이티브 기반의 페이월 UI를 웹에서 원격으로 구현할 수 있습니다. 또한, 해당 화면을 안드로이드뿐만 아니라, iOS까지 한 번에 적용시켜 일관된 UI 경험을 제공할 수 있습니다. 자세한 내용은 Announcing RevenueCat Paywalls v2 GA – Fully native, free, high-performance paywalls를 참고하시길 바랍니다.
서버 드리븐 UI란?
그렇다면 서버 드리븐 UI(SDUI)의 핵심 메커니즘은 무엇이며, RevenueCat의 페이월 에디터는 어떻게 이를 가능하게 할까요? 앞서 언급했듯이 SDUI는 API가 원시 타입의 도메인 데이터뿐만 아니라 레이아웃 정보까지 반환하도록 하여 레이아웃의 설계에 대한 책임을 클라이언트에서 서버로 이전합니다. 이를 통해 클라이언트 측에서 UI를 동적으로 렌더링할 수 있어 아래 그림과 같이 플랫폼별 코드의 필요성을 최소화할 수 있습니다.

위 그림에서 볼 수 있는 것처럼 백엔드는 도메인 데이터와 UI 레이아웃 정의를 모두 포함하는 페이로드를 구성하여 클라이언트로 보내줍니다. 이로 인해 클라이언트 측은 응답을 수신하고 내려온 응답 구조대로 UI를 렌더링하는 등 더욱 수동적인 역할을 하게 됩니다. 따라서, 모바일 개발자는 복잡한 레이아웃 설계나 도메인 데이터를 UI에 붙이는 등의 로직을 직접 관리하는 대신 모듈화되고 재사용 가능한 UI 컴포넌트를 구축하는 데 집중할 수 있습니다.
하지만 완전한 SDUI 시스템을 처음부터 구축하는 것은 결코 쉬운 일이 아닙니다. 백엔드에서 전체 레이아웃 구조를 정의해야 하며, 결과적으로 “누군가”는 레이아웃 설계에 대한 책임을 져야 합니다. 또한, 모바일 팀과 백엔드 팀 간의 협업 비용도 무시할 수 없습니다. 그 외 프로토콜 정의, 스키마 디자인, UI 버전 관리를 관리하여 이전 버전과의 호환성을 보장해야 합니다.
RevenueCat은 이러한 오버헤드를 완전히 부담합니다. Paywall 에디터를 사용하여 커스텀 SDUI 인프라를 작성하거나 새로운 앱을 빌드하지 않고도 Paywall 화면을 쉽게 디자인, 게시 및 업데이트할 수 있습니다.
서버 드리븐 UI의 주요 장점
서버 드리븐 UI는 아래와 같이 확실한 이점을 취할 수 있는 환경에서 매우 유리하게 작동합니다.
빠른 기능 테스트: 앱 업데이트 없이 새로운 페이월 레이아웃을 빠르게 배포하여 피드백 루프를 더욱 가속화하고, 에자일 하게 실험을 수행할 수 있습니다. 특히, 다양한 전략 시도를 많이 해보고 싶은 PM과 이에 따라 계속 같은 화면을 업 데이트해야 하는 개발자 간의 의견 차이를 해결하는 데 의외로 큰 도움이 됩니다. 의외로 팀 간의 소통 문제로 발생할 수 있는 “자꾸만 바뀌는 기획과 디자인 무한 루프 (비즈니스적으로 중요한) vs. 같은 화면 & 컴포넌트만 5번 10번째 업데이트하는 개발자”와 같은 흔한 상황을 개선하는 것만으로도 팀 전반적인 생산성에 큰 도움이 됩니다.
네이티브 성능: 웹뷰와 같은 유연성을 가져오면서, 완전한 네이티브 컴포넌트로 이루어진 렌더링 퍼포먼스를 유지할 수 있습니다.
클라이언트 개발자의 오버헤드 감소: 비개발 직군인 PM이나 디자이너가 직접 레이아웃 및 페이월 구조를 관리할 수 있기 때문에, 모바일 개발자는 백엔드 기반 프로토콜에 대응하는 재사용 가능한 컴포넌트를 개발하는 데 집중할 수 있습니다.
플랫폼 및 버전 간 일관된 사용자 경험: 명확하게 디자인 시스템과 백엔드 프로토콜을 정의해 놓는다면, 사용자는 Android 및 iOS 등 다양한 앱 버전과 플랫폼에서 일관된 UI와 동작을 경험할 수 있습니다.
물론 모든 상황에서 이득만을 가져다주는 완전한 솔루션은 없습니다. 앞서 살펴보았듯이, 서버 드리븐 UI를 직접 구축하기 위해서는 기본적으로 많은 비용이 들어갑니다. 따라서, 위와 같은 장점을 효율적으로 취할 수 있는 서비스에서 그 효율을 극대화하실 수 있습니다.
한편으로, 인앱 결제 화면과 관련하여 RevenueCat의 API를 사용하여 화면을 구축하면 비개발 팀원들이 코딩 없이 원격으로 페이월을 쉽게 업데이트할 수 있습니다. 이를 통해 팀 전체가 A/B 테스트와 실험을 보다 유연하게 실행하여 지속적으로 최적화하고 더 높은 수익을 창출하는데 집중할 수 있습니다.
SDK 내부 구조 살펴보기
이제 SDK 내부 구조를 살펴보면서 실제 어떻게 동작하는지 살펴보겠습니다. Paywalls Components 문서는 Figma와 유사한 스타일의 Paywall Editor를 사용하여 동적으로 빌드할 수 있는 UI 컴포넌트 전체 목록을 제공합니다. 텍스트, 이미지, 아이콘, 스택, 구매 버튼, 캐러셀, 소셜 기능, 리스트와 같은 컴포넌트를 지원하며, 에디터에서 각 컴포넌트 속성을 (텍스트, 커스텀 폰트, 이미지, 사이즈, 색상, 패딩, 마진 등) 직접 조정하여 손쉽게 화면 하나를 통째로 커스텀 할 수 있습니다.
기본적으로 RevenueCat 백엔드는 모든 컴포넌트 데이터와 레이아웃 구조를 JSON 페이로드로 패키징하여 클라이언트 측에서 유연하고 동적인 렌더링을 가능하게 합니다. Android에서 해당 JSON 응답은 PaywallComponentSerializer를 사용하여 역직렬화됩니다. PaywallComponentSerializer
는 아래 코드와 같이 서버에서 제공한 데이터를 네이티브 렌더링에 사용 가능한 객체로 변환합니다.
1@Serializable(with = PaywallComponentSerializer::class)
2sealed interface PaywallComponent
3
4@InternalRevenueCatAPI
5internal class PaywallComponentSerializer : KSerializer<PaywallComponent> {
6
7 override fun deserialize(decoder: Decoder): PaywallComponent {
8 val jsonDecoder = decoder as? JsonDecoder
9 ?: throw SerializationException("Can only deserialize PaywallComponent from JSON, got: ${decoder::class}")
10 val json = jsonDecoder.decodeJsonElement().jsonObject
11 return when (val type = json["type"]?.jsonPrimitive?.content) {
12 "button" -> jsonDecoder.json.decodeFromString<ButtonComponent>(json.toString())
13 "image" -> jsonDecoder.json.decodeFromString<ImageComponent>(json.toString())
14 "package" -> jsonDecoder.json.decodeFromString<PackageComponent>(json.toString())
15 "purchase_button" -> jsonDecoder.json.decodeFromString<PurchaseButtonComponent>(json.toString())
16 "stack" -> jsonDecoder.json.decodeFromString<StackComponent>(json.toString())
17 "sticky_footer" -> jsonDecoder.json.decodeFromString<StickyFooterComponent>(json.toString())
18 "text" -> jsonDecoder.json.decodeFromString<TextComponent>(json.toString())
19 "icon" -> jsonDecoder.json.decodeFromString<IconComponent>(json.toString())
20 "timeline" -> jsonDecoder.json.decodeFromString<TimelineComponent>(json.toString())
21 "carousel" -> jsonDecoder.json.decodeFromString<CarouselComponent>(json.toString())
22 "tab_control_button" -> jsonDecoder.json.decodeFromString<TabControlButtonComponent>(json.toString())
23 "tab_control_toggle" -> jsonDecoder.json.decodeFromString<TabControlToggleComponent>(json.toString())
24 "tab_control" -> jsonDecoder.json.decodeFromString<TabControlComponent>(json.toString())
25 "tabs" -> jsonDecoder.json.decodeFromString<TabsComponent>(json.toString())
26 else -> json["fallback"]
27 ?.let { it as? JsonObject }
28 ?.toString()
29 ?.let { jsonDecoder.json.decodeFromString<PaywallComponent>(it) }
30 ?: throw SerializationException("No fallback provided for unknown type: $type")
31 }
32 }
33
34 ..
35}
위의 코드에서 볼 수 있듯이, PaywallComponentSerializer
는 JSON 컴포넌트 데이터를 정규화된 PaywallComponent
Sealed 인터페이스로 파싱합니다. ButtonComponent
, ImageComponent
, TextComponent
와 같은 각 컴포넌트는 이 인터페이스를 구현하며, UI 측에서 렌더링을 위해 사용됩니다.
TextComponent
를 자세히 살펴보면, 이 클래스는 텍스트 요소를 스타일링하고 렌더링하는 데 필요한 세부 속성들을 모두 포함하고 있습니다. 이후 이 정보는 시각적 스타일링을 위한 별도 클래스인 TextComponentStyle
로 변환됩니다.
이와 같은 변환 과정에서 문자열 로컬라이제이션, 커스텀 폰트 적용, 색상 및 배경 설정, 패딩과 마진 처리, 가시성 조정 등 다양한 추가 처리가 함께 이루어집니다.
1@Serializable
2@SerialName("text")
3class TextComponent constructor(
4 @get:JvmSynthetic
5 @SerialName("text_lid")
6 val text: LocalizationKey,
7 @get:JvmSynthetic
8 val color: ColorScheme,
9 @get:JvmSynthetic
10 val visible: Boolean? = null,
11 @get:JvmSynthetic
12 @SerialName("background_color")
13 val backgroundColor: ColorScheme? = null,
14 @get:JvmSynthetic
15 @SerialName("font_name")
16 val fontName: FontAlias? = null,
17 .. // properties for describing a Text component
18 )
19
20private fun StyleFactoryScope.createTextComponentStyle(
21 component: TextComponent,
22): Result<TextComponentStyle, NonEmptyList<PaywallValidationError>> = zipOrAccumulate(
23 // Get our texts from the localization dictionary.
24 first = localizations.stringForAllLocales(component.text),
25 ..
26 ) { texts, presentedOverrides, color, backgroundColor, fontSpec ->
27 val weight = component.fontWeight.toFontWeight()
28 TextComponentStyle(
29 texts = texts,
30 color = color,
31 fontSize = component.fontSize,
32 fontWeight = weight,
33 fontSpec = fontSpec,
34 textAlign = component.horizontalAlignment.toTextAlign(),
35 horizontalAlignment = component.horizontalAlignment.toAlignment(),
36 backgroundColor = backgroundColor,
37 ..
38 )
39}
TextComponentStyle
은 이후 RevenueCat의 Paywall Editor에서 사용자가 정의한 커스터마이징을 Jetpack Compose UI 컴포넌트인 TextComponentView
에 적용하는 데 사용됩니다. 이를 통해 백엔드에서 설정한 스타일링이 클라이언트에서 실제로 렌더링되는 화면까지 연결됩니다.
1@Composable
2internal fun TextComponentView(
3 style: TextComponentStyle,
4 state: PaywallState.Loaded.Components,
5 modifier: Modifier = Modifier,
6) {
7 // Get a TextComponentState that calculates the overridden properties we should use.
8 val textState = rememberUpdatedTextComponentState(
9 style = style,
10 paywallState = state,
11 )
12
13 // Process any variables in the text.
14 val text = rememberProcessedText(
15 state = state,
16 textState = textState,
17 )
18
19 val colorStyle = textState.color.forCurrentTheme
20 val backgroundColorStyle = textState.backgroundColor?.forCurrentTheme
21
22 // Get the text color if it's solid.
23 val color = when (colorStyle) {
24 is ColorStyle.Solid -> colorStyle.color
25 is ColorStyle.Gradient -> Color.Unspecified
26 }
27 // Create a TextStyle with gradient if necessary.
28 // Remove the line height, as that's not configurable anyway, so we should let Text decide the line height.
29 val textStyle = when (colorStyle) {
30 is ColorStyle.Solid -> LocalTextStyle.current.copy(
31 lineHeight = TextUnit.Unspecified,
32 )
33 is ColorStyle.Gradient -> LocalTextStyle.current.copy(
34 lineHeight = TextUnit.Unspecified,
35 brush = colorStyle.brush,
36 )
37 }
38
39 if (textState.visible) {
40 Markdown(
41 text = text,
42 modifier = modifier
43 .size(textState.size, horizontalAlignment = textState.horizontalAlignment)
44 .padding(textState.margin)
45 .applyIfNotNull(backgroundColorStyle) { background(it) }
46 .padding(textState.padding),
47 color = color,
48 fontSize = textState.fontSize.sp,
49 fontWeight = textState.fontWeight,
50 fontFamily = textState.fontFamily,
51 horizontalAlignment = textState.horizontalAlignment,
52 textAlign = textState.textAlign,
53 style = textStyle,
54 )
55 }
56}
궁극적으로 TextComponentView
, StackComponentView
, CarouselComponentView
등 모든 컴포넌트 타입은 ComponentView
에서 처리되어 렌더링됩니다. 해당 컴포저블 함수는 전달받은 스타일 인스턴스를 기반으로 어떤 컴포넌트를 구현할지 결정하며, 각 컴포넌트가 해당 설정에 맞게 적절히 렌더링되도록 합니다.
1@Composable
2internal fun ComponentView(
3 style: ComponentStyle,
4 state: PaywallState.Loaded.Components,
5 onClick: suspend (PaywallAction) -> Unit,
6 modifier: Modifier = Modifier,
7) = when (style) {
8 is StackComponentStyle -> StackComponentView(style = style,state = state,clickHandler = onClick,modifier = modifier)
9 is TextComponentStyle -> TextComponentView(style = style,state = state,modifier = modifier)
10 is ImageComponentStyle -> ImageComponentView(style = style, state = state, modifier = modifier)
11 is ButtonComponentStyle -> ButtonComponentView(style = style, state = state, onClick = onClick, modifier = modifier)
12 is StickyFooterComponentStyle -> StickyFooterComponentView(
13 style = style,
14 state = state,
15 clickHandler = onClick,
16 modifier = modifier,
17 )
18 is PackageComponentStyle -> PackageComponentView(
19 style = style,
20 state = state,
21 clickHandler = onClick,
22 modifier = modifier,
23 )
24 ..
25)
이제 이를 사용하는 가장 higer-level API쪽은 사용성이 매우 간단해집니다. 원격 에디터에서 완전히 커스터마이징된 컴포넌트와 레이아웃을 사용해 전체 화면이나 섹션을 구성하려면, UI의 적절한 위치에 ComponentView
만 배치하면 됩니다. 내부적으로 전체 페이월 화면을 렌더링하는 Paywall
컴포저블은 아래 예시처럼 간단하게 구성할 수 있습니다.
1@Composable
2internal fun LoadedPaywallComponents(
3 state: PaywallState.Loaded.Components,
4 clickHandler: suspend (PaywallAction) -> Unit,
5 modifier: Modifier = Modifier,
6) {
7 val configuration = LocalConfiguration.current
8 state.update(localeList = configuration.locales)
9
10 val style = state.stack
11 val footerComponentStyle = state.stickyFooter
12 val background = rememberBackgroundStyle(state.background)
13
14 Column(modifier = modifier.background(background)) {
15 ComponentView(
16 style = style,
17 state = state,
18 onClick = clickHandler,
19 modifier = Modifier
20 .fillMaxWidth()
21 .weight(1f)
22 .verticalScroll(rememberScrollState()),
23 )
24 footerComponentStyle?.let {
25 ComponentView(
26 style = it,
27 state = state,
28 onClick = clickHandler,
29 modifier = Modifier
30 .fillMaxWidth(),
31 )
32 }
33 }
34}
가장 상위 수준에서 페이월 API는 아래 예시처럼 매우 간단하며, 깔끔하고 최소한의 코드를 통해 매우 복잡한 서버 드리븐 UI를 렌더링 할 수 있도록 합니다.
1/**
2 * Composable offering a full screen Paywall UI configured from the RevenueCat dashboard.
3 * @param options The options to configure the [Paywall] if needed.
4 */
5@Composable
6fun Paywall(options: PaywallOptions) {
7 InternalPaywall(options) // wrapper of the `LoadedPaywallComponents`
8}
Conclusion
이번 포스트에서는 서버 드리븐 UI(Server-Driven UI)의 핵심 개념, RevenueCat의 페이월 시스템이 이를 어떻게 활용하는지, 그리고 RevenueCat 백엔드에서 전달받은 컴포넌트를 Android SDK가 어떻게 동적으로 렌더링하는지를 살펴보았습니다.
서버 드리븐 UI는 빌드 재작업과 스토어 배포 없이 UI를 수정할 수 있어 피드백 루프를 단축하고, 새로운 오퍼링을 빠르게 실험해 수익을 최적화할 수 있다는 명확한 장점이 있습니다. 이러한 시스템을 처음부터 구축하는 것은 많은 자원이 소모되지만, RevenueCat을 사용하면 복잡한 작업은 모두 처리되므로 팀은 가장 중요한 일, 즉 비즈니스 성장에 집중할 수 있습니다.
늘 그렇듯, 즐거운 코딩 하시길 바랍니다!