많이 소요되는 출시 및 게시(리뷰 및 심사) 과정 때문에 중요한 업데이트 제공이 지연된다. • 느린 사용자 채택: 사용자가 업데이트를 수동으로 다운로드하고 설치해야 하므로 새로운 기능과 버그 수정의 채택이 느려짐. 강제 업데이트는 사용자 경험에 역효과가 생길 가능성이 높아진다. • 느린 기능 실험: 업데이트 주기가 느리기 때문에 팀이 테스트하고자 하는 특정 기능을 실험하고 반복하기가 어렵다. • 느린 피드백 루프: 업데이트 주기와 채택 속도가 느려서 사용자 피드백을 수집하고 신속하게 변경 사항을 반영하기가 어렵다.
앱 개발자들이 새롭게 웹앱이라는 분야를 위해 새로운 언어와 개발지식을 습득해야 하고, 직무 전환에 준하는 높은 러닝커브가 존재한다. • 성능 저하: 네이티브 기반의 렌더링 시스템이 아니라 WebView(Chromium)와 같은 web 브라우저 엔진을 통해 렌더링 해야 하므로, 네이티브만큼의 높은 성능은 기대하기 어렵다. • 인터넷 의존성: 일부 Progressive Web App (PWAs)는 오프라인 캐싱을 지원하지만, 대부분의 웹앱은 인터넷 연결에 강한 의존성을 지니고 있어 네트워크 환경이라는 제약이 따라온다. • 디바이스 기능 접근 제한: GPS, 카메라, NFC 등 디바이스의 하드웨어 기능에 대한 정밀한 접근 및 처리가 불가능하다. Progressive Web App에서 기능을 지원하지만 여전히 제한된 기능만을 제공한다. 또한, Javascript Interface를 통한 WebView와의 복잡한 프로토콜이 요구된다.
앱 업데이트 없이 새로운 기능(레이아웃)을 쉽게 수정하고 배포할 수 있어 피드백 루프가 빠르고, 실험 기능들을 신속하게 수행할 수 있다. • 일관된 UI: 안정된 컴포넌트 디자인 시스템을 구축하면, 핵심 사양이 유지되는 한 여러 앱 버전에서 일관된 UI와 동작을 제공한다. • 네이티브 성능: 서버 주도 UI의 유연성을 유지하면서도 웹앱 및 하이브리드 앱과 비교했을 때 네이티브 성능을 유지하면서 컴포넌트를 렌더링할 수 있다. • 모바일 개발자의 부담 감소: 레이아웃 설계는 주로 제품 관리자와 디자이너가 정의하고, "무엇을 할지"는 백엔드의 책임이므로, 모바일 개발자는 개별 컴포넌트 개발에 집중할 수 있다.
개발자도 빠르게 서버 구축이 가능하고, UI가 제공되는 대시보드를 통해 손쉽게 조작이 가능하다. • JSON 응답: JSON 파일을 직접 import/export해서 응답 생성이 가능하다. • 실시간: 데이터베이스 응답 변경을 실시간으로 관찰할 수 있는 클라이언트 SDK가 제공되어, 값의 변화를 실시간으로 반영하고 눈으로 확인할 수 있다. Firebase Realtime Database
전부 Java 코드로 작성되어 여전히 Callback 기반의 API를 제공한다. • Serialization 커스텀 불가: 공식 SDK와 커뮤니티의 라이브러리 모두 snapshot data에 대하여 커스텀 serialization 옵션을 제공하지 않는다. 따라서, nullability를 지원하지 않고 fallback 처리를 유동적으로 하는 것이 어렵다. 반드시 default값을 정의해야 한다. Firebase Realtime Database val listener = object : ValueEventListener { override fun onDataChange(snapshot: DataSnapshot) { val value = snapshot.child("post") // .. } override fun onCancelled(error: DatabaseError) { // .. } } @IgnoreExtraProperties data class Post( var uid: String? = "", var author: String? = "", var title: String? = "", ) { @Exclude fun toMap(): Map<String, Any?> { return mapOf( "uid" to uid, "author" to author, "title" to title, ..
TextUi( val text: String, val size: Int, val fontWeight: String ) : UiComponent @Serializable data class ImageUi( val url: String, val size: DpSizeUi = DpSizeUi(0, 0), val scaleType: String, val contentDescription: String = "", ) : UiComponent @Serializable data class DpSizeUi( val width: Int, val height: Int )
TextUi( val text: String, val size: Int, val fontWeight: String ) : UiComponent @Serializable data class ImageUi( val url: String, val size: DpSizeUi = DpSizeUi(0, 0), val scaleType: String, val contentDescription: String = "", ) : UiComponent @Serializable data class DpSizeUi( val width: Int, val height: Int ) @Serializable data class ListUi( val layout: String, val itemSize: DpSizeUi, val items: List<ImageUi>, ) : UiComponent fun String.toLayoutType(): LayoutType { return if (this == "grid") LayoutType.GRID else if (this == "column") LayoutType.COLUMN else LayoutType.ROW } enum class LayoutType(val value: String) { GRID("grid"), COLUMN("column"), ROW("row") } UiComponent 인터페이스로 컴포넌트 유형 단일화
data class TimelineCenterUi( val title: TextUi, val list: ListUi ): UiComponent @Serializable data class TimelineBottomUi( val title: TextUi, val list: ListUi ): UiComponent Component Design Systems @Serializable data class TimelineUi( val top: TimelineTopUi, val center: TimelineCenterUi, val bottom: TimelineBottomUi ) : UiComponent 최상위부터 최하위 객체까지 동일한 UiComponent로 일련의 응답을 구성한다.
val top: TimelineTopUi, val center: TimelineCenterUi, val bottom: TimelineBottomUi ) : UiComponent val timelineUi: StateFlow<List<UiComponent>> = repository.fetchTimelineUi() .flatMapLatest { result -> flowOf(result.getOrNull()?.buildUiComponentList()) } .filterNotNull() .stateIn() @Serializable data class TimelineTopUi( val banner: ImageUi (UiComponent) ): UiComponent @Serializable data class TimelineCenterUi( val title: TextUi, (UiComponent) val list: ListUi (UiComponent) ): UiComponent @Serializable data class TimelineBottomUi( val title: TextUi, (UiComponent) val list: ListUi (UiComponent) ): UiComponent UiComponent.buildUiComponentList()
0, "height": 250 }, "scaleType": "crop", "handler": { "type": "click", "actions": { "navigate": "to" } } } } Navigation, 딥링크 등 행동에 대한 동작 처리 Click, touch 등 컴포넌트에 대한 행동을 정의
actions: Map<String, String> ) enum class HandlerType(val value: String) { CLICK("click") } enum class HandlerAction(val value: String) { NAVIGATION("navigation") } enum class NavigationHandler(val value: String) { TO("to") } @Serializable data class ImageUi( val url: String, val handler: Handler? = null ) : UiComponent • 컴포넌트에 따라 action handler가 존재하지 않을 수 있으므로 optional • UiComponent와 같은 최상위 추상화 인터페이스에 정의하는 것도 좋은 방법
} } } @Serializable data class TimelineUi( val version: Int, val top: TimelineTopUi, val center: TimelineCenterUi, val bottom: TimelineBottomUi ) : UiComponent
} } } @Serializable data class TimelineUi( val version: Int, val top: TimelineTopUi, val center: TimelineCenterUi, val bottom: TimelineBottomUi ) : UiComponent enum class UiVersion(val value: Int) { VERSION_1_0(1), VERSION_2_0(2); companion object { fun toUiVersion(value: Int): UiVersion { return when (value) { VERSION_1_0.value -> VERSION_1_0 VERSION_2_0.value -> VERSION_2_0 else -> throw RuntimeException("undefined version!") } } } } 디자인 시스템의 버저닝 범위 설정 상황에 따라 화면 별로 버전 처리를 할 수도 있고, 컴포넌트 별로 할 수도 있다.
= UiVersion.VERSION_1_0, navigator: (UiComponent) -> Unit = {} ) { when (this) { is TextUi -> ConsumeTextUi( textUi = this, modifier = modifier, version = version ) .. else -> ConsumeDefaultUi( uiComponent = this, version = version ) } } 직렬화 실패 및 데이터 결함 등 여러 이유로 컴포넌트 매칭에 실패한 경우 적절한 fallback 처리가 필요하다.
버전 정보가 내려오는 것을 대비하여 적절한 fallback 처리가 필요하다. enum class UiVersion(val value: Int) { VERSION_1_0(1), VERSION_2_0(2); companion object { fun toUiVersion(value: Int): UiVersion { return when (value) { VERSION_1_0.value -> VERSION_1_0 VERSION_2_0.value -> VERSION_2_0 else -> throw RuntimeException("undefined version!") or else -> VERSION_1_0 } } } } fun String.toLayoutType(): LayoutType { return if (this == "grid") LayoutType.GRID else if (this == "column") LayoutType.COLUMN else LayoutType.ROW }
앱 심사 및 업데이트 없이 새로운 기능(레이아웃)을 쉽게 수정하고 배포할 수 있어 피드백 루프가 빠르고, 반복 작업이 신속하게 이루어진다. (PM/PO와 모바일 개발자 둘 다 행복) • 일관된 UI: 안정된 컴포넌트 디자인 시스템을 구축하면, 핵심 사양이 유지되는 한 여러 앱 버전에서 일관된 UI와 동작을 제공한다. • 네이티브 성능: 웹앱과 같은 UI의 유연성을 유지하면서도 네이티브 성능으로 컴포넌트를 렌더링할 수 있다. • 모바일 개발자의 부담 감소: "무엇을 할지"는 백엔드에서 알려주므로, "어떻게 보여줄지"에만 집중할 수 있다.
백엔드에서 레이아웃 정보까지 받아와야 하기 때문에, data-driven UI에 비해서 컴포넌트를 랜더링하고 표시하는 데 시간이 더 걸릴수 있다. • 복잡성 및 비용 증가: 팀 전체가 레이아웃 데이터를 렌더링하는 방식, 컴포넌트 버전 관리에 대해 명확한 역할 분담과 레이아웃 시스템을 구축해야한다. 그렇지 않으면 백엔드 팀이 레이아웃을 구성하는 부담을 떠안게 되거나, 팀의 전반적인 소통 비용이 증가한다. • Fallback 처리: 레이아웃 데이터가 다른 팀 (PM/디자이너/백엔드)를 거쳐 생성되므로, 데이터에 결함이 발생할 수도 있다. 이에 대한 충분한 fallback 처리가 되어야한다.
홈 (피드, 타임라인) 화면과 같이 유저가 앱 진입 시 가장 먼저 노출되며 자주 변경이 필요한 화면에 적용하면 큰 효과를 볼 수 있다. • 세션 타임이 높은 화면: 라이브 방송 및 엔터테인먼트와 같이 사용자가 앱에서 머무는 세션 타임이 가장 높은 화면에 사용하면 더 높은 사용 효과를 볼 수 있다.