[번역] 에어비앤비 SDUI(Server Driven UI) 딥 다이브

번역글 : graphQL로 SDUI 스키마 정의하기

** 원문을 번역한 글입니다**

배경: 서버 중심 UI

에어비앤비의 서버 중심 UI(SDUI) 구현에 대해 자세히 알아보기 전에 SDUI의 일반적인 개념과 기존 클라이언트 중심 UI에 비해 어떤 이점을 제공하는지 이해하는 것이 중요합니다.

기존 환경에서는 데이터가 백엔드에 의해 구동되고 UI는 각 클라이언트(웹, iOS, Android)에 의해 구동됩니다. 에어비앤비의 숙소 페이지를 예로 들어 보겠습니다. 사용자에게 숙소를 표시하기 위해 백엔드에서 숙소 데이터를 요청할 수 있습니다. 이 숙소 데이터를 받으면 클라이언트는 해당 데이터를 UI로 변환합니다.

여기에는 몇 가지 문제가 있습니다. 첫째, 각 클라이언트에는 리스팅 데이터를 변환하고 렌더링하기 위해 구축된 리스팅별 로직이 있습니다. 이 로직은 나중에 숙소가 표시되는 방식을 변경할 경우 빠르게 복잡해지고 유연성이 떨어집니다.

둘째, 각 클라이언트는 서로 동등성을 유지해야 합니다. 앞서 언급했듯이 이 화면의 로직은 빠르게 복잡해지고 각 클라이언트마다 상태 처리, UI 표시 등에 대한 자체적인 복잡성과 구체적인 구현이 있습니다. 클라이언트가 서로 빠르게 갈라지기 쉽습니다.

마지막으로 모바일에는 버전 관리 문제가 있습니다. 숙소 페이지에 새로운 기능을 추가해야 할 때마다 사용자가 최신 경험을 할 수 있도록 모바일 앱의 새 버전을 출시해야 합니다. 사용자가 업데이트하기 전까지는 사용자가 이러한 새로운 기능을 잘 사용하고 있는지 또는 반응이 좋은지 확인할 수 있는 방법이 거의 없습니다.

SDUI의 사례

고객이 제품 목록이 표시되고 있다는 사실조차 알 필요가 없다면 어떨까요? 클라이언트에게 직접 UI를 전달하고 리스팅 데이터는 아예 생략할 수 있다면 어떨까요? 이것이 바로 SDUI의 본질적인 기능입니다. UI와 데이터를 함께 전달하면, 클라이언트는 포함된 데이터에 관계없이 이를 표시합니다.

에어비앤비의 특별한 SDUI 구현을 통해 백엔드에서 데이터와 해당 데이터가 모든 클라이언트에 표시되는 방식을 동시에 제어할 수 있습니다. 화면 레이아웃, 해당 레이아웃에서 섹션이 배열되는 방식, 각 섹션에 표시되는 데이터, 사용자가 섹션과 상호작용할 때 취하는 조치까지 모든 것이 웹, iOS, Android 앱에서 단일 백엔드 응답으로 제어됩니다.

에어비앤비의 SDUI: 고스트 플랫폼 👻

Ghost Platform(GP)은 웹, iOS, Android에서 빠르게 반복하고 안전하게 기능을 출시할 수 있는 통합된 서버 기반 UI 시스템입니다. 에어비앤비 앱의 양대 축인 '게스트(Guest)'와 '호스트(Host)' 기능에 중점을 두고 있기 때문에 합쳐서 고스트(Ghost)라고 부릅니다.

GP는 개발자가 최소한의 설정만으로 서버 기반 기능을 개발할 수 있도록 각 클라이언트의 모국어(각각 타입스크립트, 스위프트, 코틀린)로 웹, iOS, 안드로이드 프레임워크를 제공합니다.

GP의 핵심 기능은 기능이 일반 섹션, 레이아웃, 동작 라이브러리를 공유할 수 있으며, 대부분 이전 버전과 호환되므로 팀이 더 빠르게 출시하고 복잡한 비즈니스 로직을 백엔드의 중앙 위치로 이동할 수 있다는 점입니다.

표준화된 스키마

고스트 플랫폼의 백본(기반..?)은 클라이언트가 UI 렌더링에 사용할 수 있는 표준화된 데이터 모델입니다. 이를 가능하게 하기 위해 GP는 Viaduct라는 통합 데이터 서비스 메시를 사용하여 백엔드 서비스 전반에서 공유 데이터 계층을 활용합니다.

서버 기반 UI 시스템을 확장 가능하게 만드는 데 도움이 된 핵심 결정은 웹, iOS, Android 앱에 단일 공유 GraphQL 스키마를 사용하는 것이었습니다. 즉, 모든 플랫폼에서 동일한 스키마를 사용하여 응답을 처리하고 강력하게 유형화된 데이터 모델을 생성하는 것입니다.

다양한 기능의 공통된 측면을 일반화하고 각 페이지의 특수성을 일관되고 사려 깊은 방식으로 설명하는 데 시간을 들였습니다. 그 결과, 에어비앤비의 모든 기능을 렌더링할 수 있는 범용 스키마가 탄생했습니다. 이 스키마는 재사용 가능한 섹션, 동적 레이아웃, 하위 페이지, 액션 등을 설명할 수 있을 만큼 강력하며, 클라이언트 애플리케이션의 해당 GP 프레임워크는 이 범용 스키마를 활용해 UI 렌더링을 표준화합니다.

GP 대응

GP의 첫 번째 기본 측면은 전체 응답의 구조입니다. GP 응답에서 UI를 설명하는 데 사용되는 두 가지 주요 개념은 섹션(Section)과 화면(Screen)입니다.

ghost-schema 그림 1. 사용자가 GP에서 에어비앤비 기능을 보는 방식과 GP가 동일한 기능을 화면과 섹션으로 보는 방식 비교.

  • 섹션: 섹션은 GP의 가장 기본적인 구성 요소입니다. 섹션은 표시할 정확한 데이터가 이미 번역, 현지화 및 형식이 지정된 응집력 있는 UI 구성 요소 그룹의 데이터를 설명합니다. 각 클라이언트는 섹션 데이터를 가져와서 UI로 직접 변환합니다.
  • 화면: 모든 GP 응답에는 임의의 수의 화면이 포함될 수 있습니다. 각 화면은 화면의 레이아웃과 섹션 배열의 섹션이 표시될 위치(배치라고 함)를 차례로 설명합니다. 또한 섹션을 렌더링하는 방법(예: 팝오버, 모달 또는 전체 화면) 및 로깅 데이터와 같은 기타 메타데이터도 정의합니다.
// GPResponse 예시
interface GPResponse {
	sections: [SectionContainer]
	screens: [ScreenContainer]

	# ... Other metadata, logging data or feature-specific logic
}

GP로 구축된 기능의 백엔드는 이 GPResponse를 구현하고(GPResponse 예시 참고) 사용 사례에 따라 화면과 섹션을 채웁니다. 웹, iOS 및 Android의 GP 클라이언트 프레임워크는 개발자가 최소한의 작업으로 GPResponse 구현을 가져와 UI로 변환할 수 있는 표준 처리 기능을 제공합니다.

섹션

섹션은 GP의 가장 기본적인 구성 요소입니다. GP 섹션의 핵심 기능은 다른 섹션 및 섹션이 표시되는 화면과 완전히 독립적이라는 것입니다.

섹션을 주변 컨텍스트에서 분리함으로써 특정 기능에 대한 비즈니스 로직의 긴밀한 결합에 대한 걱정 없이 섹션을 재사용하고 용도를 변경할 수 있습니다.

섹션 스키마 GraphQL 스키마에서 GP 섹션은 가능한 모든 섹션 유형의 조합입니다. 각 섹션 유형은 렌더링할 필드를 지정합니다. 섹션은 일부 메타데이터와 함께 GPResponse 구현에서 수신되며 섹션의 상태, 로깅 데이터 및 실제 섹션 데이터 모델에 대한 세부 정보가 들어 있는 SectionContainer 래퍼를 통해 제공됩니다.

# Sections 예시 스키마
type HeroSection {
	# Image urls
	images: [String]!
}

type TitleSection {
	title: String!
	titleStyle: TextStyle!

	# Optional subtitle
	subtitle: String
	subtitleStyle: TextStyle

	# Action to be taken when tapping the optional subtitle
	onSubtitleClickAction: IAction
}

enum SectionComponentType {
	HERO
	TITLE
	PLUS_TITLE
}

union Section = HeroSection | TitleSection

type SectionContainer {
	id: String!
	sectionComponentType: SectionComponentType
	section: Section
}

중요한 개념 중 하나가 SectionComponentType입니다. 이 유형은 섹션의 데이터 모델이 리액트 컴포넌트로 렌더링되는 방식을 제어합니다. 이를 통해 필요에 따라 하나의 데이터 모델을 여러 가지 방식으로 렌더링할 수 있습니다.

예를 들어, 아래 사진의 두 섹션 컴포넌트 유형인 TITLE과 PLUS_TITLE은 동일한 기본 타이틀 섹션 데이터 모델을 사용할 수 있지만, PLUS_TITLE 구현에서는 에어비앤비의 플러스 전용 로고와 타이틀 스타일을 사용해 타이틀 섹션을 렌더링합니다. 이렇게 하면 GP를 사용하는 기능에 유연성을 제공하는 동시에 스키마 및 데이터 재사용성을 높일 수 있습니다.

section-component-type

섹션 구성 요소

섹션 데이터는 “섹션 컴포넌트”를 통해 UI로 변환됩니다. 각 섹션 컴포넌트는 데이터 모델과 섹션 컴포넌트 유형을 UI 컴포넌트로 변환하는 역할을 담당합니다. 추상적인 섹션 컴포넌트는 각 플랫폼의 GP에서 해당 언어(예: 타입스크립트, 스위프트, 코틀린)로 제공되며 개발자가 확장하여 새로운 섹션을 만들 수 있습니다.

섹션 컴포넌트는 섹션 데이터 모델을 하나의 고유한 렌더링에 매핑하므로 하나의 섹션 컴포넌트 유형에만 해당합니다. 앞서 언급했듯이 섹션은 해당 섹션이 있는 화면이나 주변 섹션의 컨텍스트 없이 렌더링되므로 각 섹션 컴포넌트에는 기능별 비즈니스 로직이 제공되지 않습니다.

제목 섹션을 빌드하기 위해 아래와 같은 코드 조각이 있습니다(그림 5). 웹과 iOS에는 섹션 컴포넌트 빌드를 위한 유사한 구현이 각각 타입스크립트와 스위프트에 있습니다.

// 아래 어노테이션은 Map<SectionComponentType, SectionComponent> 타입 객체를 생성합니다.
@SectionComponentType(SectionComponentType.TITLE)
class TitleSectionComponent : SectionComponent<TitleSection>() {

    // Developers override this method and build UI from TitleSection corresponding to TITLE
    override fun buildSectionUI(section: TitleSection) {

        // Text() Turns our title into a styled TextView
        Text(
            text = section.title,
            style = section.titleStyle
        )

        // 서브타이틀이 필요할 때(PLUS_TITLE) 이 코드를 추가합니다.
        if (!section.subtitle.isNullOrEmpty() {

            Text(
                text = section.subtitle,
                style = section.subtitleStyle
            )
        })
    }
}

GP는 백엔드에서 구성 가능하고, 스타일을 지정할 수 있으며, 이전 버전과 호환되므로 모든 기능의 사용 사례에 맞게 조정할 수 있도록 위의 예제 TitleSectionComponent(그림 5)와 같은 많은 “핵심” 섹션 컴포넌트를 제공합니다. 그러나 GP에서 새로운 기능을 구축하는 개발자는 필요에 따라 새 섹션 컴포넌트를 추가할 수 있습니다.

section-component

화면(Screen)

화면은 GP의 또 다른 구성 요소이지만 섹션과 달리 화면은 대부분 GP 클라이언트 프레임워크에 의해 처리되며 사용 방식에 더 많은 의견이 반영됩니다. GP 화면은 섹션의 레이아웃과 구성을 담당합니다.

스크린 스키마 화면은 ScreenContainer 유형으로 수신됩니다. 화면은 screenProperties 필드에 포함된 값에 따라 모달(팝업), 하단 시트 또는 전체 화면으로 실행할 수 있습니다.

화면을 사용하면 화면의 레이아웃을 동적으로 구성할 수 있으며, LayoutsPerFormFactor 유형을 통해 섹션을 배열할 수도 있습니다. LayoutsPerFormFactor는 아래에서 자세히 설명할 ILayout이라는 인터페이스를 사용하여 컴팩트하고 넓은 중단점에 대한 레이아웃을 지정합니다. 그런 다음 각 클라이언트의 GP 프레임워크는 화면 밀도, 회전 및 기타 요소를 사용하여 렌더링할 ILayout fromLayoutsPerFormFactor를 결정합니다.

type ScreenContainer {
	id: String

	# Properties such as how to launch this screen (popup, sheet, etc.)
	screenProperties: ScreenProperties

	layout: LayoutsPerFormFactor
}

# Specifies the ILayout type depending on rotation, client screen density, etc.
type LayoutsPerFormFactor {
	# Compact is usually used for portrait breakpoints (i.e. mobile phones)
	compact: ILayout

	# Wide is usually used for landscape breakpoints (i.e. web browsers, tablets)
	wide: ILayout
}

ILayouts

screen-component

ILayouts을 사용하면 화면이 응답에 따라 레이아웃을 변경할 수 있습니다. 스키마에서 ILayout은 각 ILayout 구현이 다양한 배치를 지정하는 인터페이스입니다. 배치에는 응답의 가장 바깥쪽 섹션 배열에 있는 섹션을 가리키는 하나 또는 여러 개의 SectionDetail 유형이 포함됩니다. 인라인으로 포함하지 않고 섹션 데이터 모델을 가리킵니다. 이렇게 하면 레이아웃 구성 전반에서 섹션을 재사용하여 응답 크기를 줄일 수 있습니다.

interface ILayout {}

type SectionDetail {
  # References a SectionContainer in the GPResponse.sections array
  sectionId: String

  # Styling data
  topPadding: Int
  bottomPadding: Int
}

# A placement meat to display a single GP section
type SingleSectionPlacement {
  sectionDetail: SectionDetail!
}

# A placement meat to display multiple GP sections in the order they appear in the sectionDetails array
type MultipleSectionsPlacement {
  sectionDetails: [SectionDetail]!
}

# A layout implementation defines the placements that sections are inserted into.
type SingleColumnLayout implements ILayout {
  nav: SingleSectionPlacement
  main: MultipleSectionsPlacement
  floatingFooter: SingleSectionPlacement
}

GP 클라이언트 프레임워크는 ILayout 유형이 섹션보다 의견이 더 많기 때문에 개발자를 위해 ILayout을 부풀립니다. 각ILayout에는 각 클라이언트의 GP 프레임워크에 고유한 렌더러가 있습니다. 레이아웃 렌더러는 각 배치에서 각 SectionDetail을 가져와서 해당 섹션을 렌더링할 적절한 섹션 컴포넌트를 찾고, 해당 섹션 컴포넌트를 사용하여 섹션의 UI를 빌드한 다음, 마지막으로 빌드된 UI를 레이아웃에 배치합니다.

액션(Actions)

GP의 마지막 개념은 액션 및 이벤트 처리입니다. GP의 가장 획기적인 측면 중 하나는 네트워크 응답에서 화면의 섹션과 레이아웃을 정의하는 것 외에도 사용자가 버튼을 탭하거나 카드를 스와이프하는 등 화면의 UI와 상호 작용할 때 취하는 동작을 정의할 수 있다는 점입니다. 이는 스키마의 IAction 인터페이스를 통해 이루어집니다.


interface IAction {}

# screenId에 해당하는 화면으로 이동하는 액션
type NavigateToScreen implements IAction {
  screenId: String
}

# 서브타이틀을 클릭할 때 액션을 처리하는 샘플 제목 섹션
type TitleSection {
  ...

  subtitle: String

  # Action to be taken when tapping the subtitle
  onSubtitleClickAction: IAction
}

위 코드는 이전에 섹션 컴포넌트가 타이틀 섹션을 각 클라이언트에서 UI로 변환하는 역할을에 추가된 코드입니다. subTitle 텍스트를 클릭할 때 동적 IAction이 실행되는 동일한 제목 섹션 컴포넌트의 안드로이드 예시를 살펴봅시다.


@SectionComponentType(SectionComponentType.TITLE)
class TitleSectionComponent : SectionComponent<TitleSection>() {
    override fun buildSectionUI(section: TitleSection) {
        // Build title UI elements
        if (!section.subtitle.isNullOrEmpty() {
            Text(
                onClick = {
                  GPActionHandler.handleIAction(section.onSubtitleClickAction)
                }
            )
        })
    }
}

사용자가 이 섹션에서 자막을 탭하면 TitleSection의 onSubtitleClickAction 필드에 전달된 IAction이 실행됩니다. GP는 이 액션을 해당 기능에 대해 정의된 이벤트 핸들러로 라우팅하고, 이 핸들러는 실행된 IAction을 처리합니다.

화면으로 이동하거나 섹션으로 스크롤하는 것과 같이 GP가 보편적으로 처리하는 일반 액션의 표준 세트가 있습니다. 기능은 자체 IAction 유형을 추가하고 이를 사용하여 기능의 고유한 동작을 처리할 수 있습니다. 기능별 이벤트 핸들러는 기능으로 범위가 지정되므로 원하는 만큼 기능별 비즈니스 로직을 포함할 수 있으므로 특정 사용 사례가 발생할 때 사용자 지정 작업 및 비즈니스 로직을 자유롭게 사용할 수 있습니다.

모든 것을 하나로 묶어보기

몇 가지 개념을 살펴보았으므로 전체 GP 응답을 가져와 모든 것을 하나로 묶어 렌더링하는 방법을 살펴 보겠습니다.

{
	"screens": [
		{
			"id": "ROOT",
			"screenProperties": {},
			"layout": {
				"wide": {},
				"compact": {
					"type": "SingleColumnLayout",
					"main": {
						"type": "MultipleSectionsPlacement",
						"sectionDetails": [
							{
								"sectionId": "hero_section"
							},
							{
								"sectionId": "title_section"
							}
						]
					},
					"nav": {
						"type": "SingleSectionPlacement",
						"sectionDetail": {
							"sectionId": "toolbar_section"
						}
					},
					"footer": {
						"type": "SingleSectionPlacement",
						"sectionDetail": {
							"sectionId": "book_bar_footer"
						}
					}
				}
			}
		}
	],
	"sections": [
		{
			"id": "toolbar_section",
			"sectionComponentType": "TOOLBAR",
			"section": {
				"type": "ToolbarSection",
				"nav_button": {
					"onClickAction": {
						"type": "NavigateBack",
						"screenId": "previous_screen_id"
					}
				}
			}
		},
		{
			"id": "hero_section",
			"sectionComponentType": "HERO",
			"section": {
				"type": "HeroSection",
				"images": ["api.airbnb.com/..."]
			}
		},
		{
			"id": "title_section",
			"sectionComponentType": "TITLE",
			"section": {
				"type": "TitleSection",
				"title": "Seamist Beach Cottage, Private Beach & Ocean Views",
				"titleStyle": {}
			}
		},
		{
			"id": "book_bar_footer",
			"sectionComponentType": "BOOK_BAR_FOOTER",
			"section": {
				"type": "ButtonSection",
				"title": "$450/night",
				"button": {
					"text": "Check Availability",
					"onClickAction": {
						"type": "NavigateToScreen",
						"screenId": "next_screen_id"
					}
				}
			}
		}
	]
}

섹션 컴포넌트 만들기

GP를 사용하는 기능은 위에서 언급한 GPResponse를 구현하는 응답을 가져와야 합니다. GPResponse를 받으면 GP 인프라는 이 응답을 파싱하고 개발자를 위해 섹션을 빌드합니다.

섹션 배열의 각 섹션에는 섹션 컴포넌트 유형과 섹션 데이터 모델이 있다는 것을 기억하세요. GP에서 작업하는 개발자는 섹션 데이터 모델을 렌더링하는 방법에 대한 키로 SectionComponentType을 사용하여 섹션 컴포넌트를 추가합니다.

GP는 각 섹션 컴포넌트를 찾아 해당 데이터 모델을 전달합니다. 각 섹션 컴포넌트는 섹션에 대한 UI 컴포넌트를 생성하고, GP는 이를 아래 레이아웃의 적절한 위치에 삽입합니다.

screen-component

액션 처리하기

이제 각 섹션 컴포넌트의 UI 요소가 설정되었으므로 섹션과 상호 작용하는 사용자를 처리해야 합니다. 예를 들어 사용자가 버튼을 탭하면 클릭에 따른 액션을 처리해야 합니다.

앞서 GP가 이벤트를 적절한 핸들러로 라우팅한다는 점을 기억하세요. 위의 예제 응답(그림 12)에는 동작을 실행할 수 있는 두 개의 섹션, toolbar_section과 book_bar_footer가 포함되어 있습니다. 이 두 섹션을 구축하기 위한 섹션 컴포넌트는 IAction을 받아 언제 실행할지 지정하기만 하면 되는데, 두 경우 모두 버튼이 클릭될 때입니다.

각 클라이언트의 클릭 핸들러를 통해 이 작업을 수행할 수 있으며, 이 핸들러는 GP 인프라를 사용하여 클릭 이벤트에 대한 이벤트를 라우팅합니다.

화면 및 레이아웃 설정하기

사용자를 위한 완전한 대화형 화면을 준비하기 위해 GP는 화면 배열을 살펴보고 “ROOT” 아이디(GP의 기본 화면 아이디)를 가진 화면을 찾습니다. 그런 다음 GP는 중단점 및 사용자가 사용 중인 특정 디바이스와 관련된 기타 요소에 따라 적절한 ILayout 유형을 찾습니다. 간단하게 하기 위해 컴팩트 필드의 레이아웃인 싱글컬럼레이아웃을 사용하겠습니다.

그러면 GP는 싱글컬럼레이아웃에 대한 레이아웃 렌더러를 찾아 상단 컨테이너(탐색 배치), 스크롤 가능한 목록(메인 배치), 플로팅 푸터(바닥글 배치)로 레이아웃을 부풀릴 것입니다.

이 레이아웃 렌더러는 섹션 디테일 객체가 포함된 배치 모델을 가져옵니다. 이러한 섹션 디테일에는 일부 스타일링 정보와 부풀릴 섹션의 섹션Id가 포함됩니다. GP는 이러한 섹션 디테일 객체를 반복하고 앞서 빌드한 섹션 컴포넌트를 사용하여 섹션을 각각의 배치로 인플레이션합니다.

screen-component

GP의 다음 단계는 무엇인가요?

GP가 출시된 지 1년 정도밖에 되지 않았지만, 에어비앤비에서 가장 많이 사용되는 기능(예: 검색, 숙소 페이지, 결제)의 대부분은 GP를 기반으로 하고 있습니다. 엄청난 사용량에도 불구하고 GP는 아직 초기 단계에 있으며, 앞으로 해야 할 일이 훨씬 더 많습니다.

“중첩된 섹션"을 통해 보다 구성 가능한 UI를 만들고, Figma와 같은 디자인 도구를 통해 이미 존재하는 요소의 검색 가능성을 개선하며, 코드 없이 기능을 변경할 수 있는 섹션 및 배치의 WYSIWYG 편집을 지원할 계획이 있습니다.