들어가며
안녕하세요, 올리브영 웰니스서비스 개발팀에서 안드로이드 앱을 개발하고 있는 차니혀니 입니다. (집에서는 두 아들의 아빠이기도 합니다.)
앱을 운영하다 보면 스토어 심사라는 벽 앞에서 한숨 쉬어본 경험, 다들 있으실 겁니다. 특히 빠르게 변화하는 이커머스 환경에서 네이티브 앱의 업데이트 속도는 비즈니스의 발목을 잡기도 합니다. 이러한 '속도의 한계'를 극복하고, 스토어 심사없이 서버 설정만으로 화면을 변경하는 라는 여정을 시작했습니다. 이 글에서는 저희가 SDUI를 어떻게 설계하고, 어떤 문제들을 고민하였는지 그 경험을 공유하고자 합니다.
우리의 고민: 속도, 일관성, 효율 그리고 SDUI
웹과 달리 네이티브 앱은 배포 후 이슈가 생겨도 스토어 심사로 인해 즉각 대응이 어렵습니다. 그래서 빠르게 수정이 필요할 때 사용자에게 반영되기까지 시간이 소요됩니다. 그래서 저희의 고민은 명확했습니다. 앱 배포를 어떻게 해야 빠르면서, 일관성있고, 효율적일까? 이런 맥락에서 속도, 일관성, 효율 이라는 세 가지 핵심 문제를 극복하기 위한 솔루션이 바로 SDUI(Server-Driven UI) 입니다. SDUI는 화면 정보를 서버에서 내려주고, 앱은 그 데이터를 기반으로 UI를 그려내는 방식입니다. 서버는 ‘무엇을, 어떤 순서로’ 보여줄지에 집중하고, 클라이언트는 그 정보를 받아 ‘어떻게’ 빠르고 자연스럽게 그릴지에 집중할 수 있습니다.
아키텍처 정의: 작게 시작하여 점진적으로!
처음에는 역할을 나누고, 리스크를 낮추기 위해 스코프를 작게 잡은 뒤 점진적으로 확장하는 전략을 택했습니다. 우선 서버와 클라이언트가 각자 무엇에 집중해야 할지부터 정의했습니다.
-
서버의 역할: 강제 업데이트, 앱 버전 등 앱의 '운영'과 관련된 큰 그림을 관리하고, 화면을 그릴 '설계도(스키마)'를 제공하는 데 집중합니다.
-
클라이언트의 역할: 서버가 준 설계도를 네이티브 UI로 '렌더링'하고, 알 수 없는 정보가 오더라도 앱이 죽지 않도록 '안전하게 폴백'하며, 사용자의 행동(이벤트, 트래킹)을 실행합니다.
특히 초기 범위 결정이 중요했습니다. 처음부터 모든 레이아웃 속성(margin, padding 등)까지 서버가 제어하려 하면 복잡도가 걷잡을 수 없이 커질 거라 판단했습니다. 그래서 초기 범위는 '섹션, 컴포넌트, 데이터, 액션' 4가지로 한정하고, 레이아웃은 클라이언트의 디자인 시스템에 위임하여 단순함을 유지했습니다.
확장 가능한 설계의 핵심: 스키마 정의
SDUI의 성패는 서버와 클라이언트 간의 '약속', 즉 스키마를 얼마나 잘 설계하는지에 달려있습니다. 스키마가 너무 복잡하면 유지보수가 어렵고, 너무 단순하면 SDUI의 장점을 살릴 수 없습니다. 저희는 '이커머스 서비스'의 특성을 고려하여 다음과 같이 4가지 핵심 요소를 정의했습니다.
- 섹션 (Section): '오늘의 특가', '실시간 랭킹'처럼 독립적인 목적을 가진 컴포넌트 이상의 단위
- 컴포넌트 (Component): 섹션을 구성하는 '상품 카드', '헤더 타이틀' 같은 최소 단위
- 데이터 (Data): 컴포넌트에 채워질 실제 내용 (예: 상품명, 가격, 이미지 URL)
- 액션 (Action): 버튼 클릭 시 특정 페이지로 이동하거나, 분석 로그를 보내는 등 사용자의 행동을 정의
앞서 네 가지 핵심 요소를 정리했으니, 이제 각 요소의 역할과 스키마 예시를 차례로 살펴보겠습니다. 첫 번째로 '섹션'입니다.
섹션
정확한 목적을 가지고 있는 독립적인 UI 단위라 설명할 수 있습니다. 예를 들면 전시화면에서의 상단 배너섹션, 타임딜 섹션 등을 예로 들 수 있을 것 같습니다. 섹션은 아래 코드와 같이 꼭 컴포넌트 단위의 구성이 아니더라도, 해당 섹션만을 위한 필드들을 구성할 수 있습니다. 그럼 서버 관점에서 매번 컴포넌트(header, more_button, title)를 조합하지 않고 미리 정의된 데이터만 채워서 내려주면 되니 단순해집니다. 물론 공통의 요소들을 컴포넌트 단위로 정의하고 사용한다면 재사용성을 높이고 일관된 UI를 표현할 수 있는 장점도 있습니다.
// 타임딜 섹션 샘플
{
"type": "TIME_DEAL_SECTION",
"id": "10",
"header": {
"title": "타이틀",
"style": "HEADER_LARGE"
},
"limitTime": "2025-12-31T23:59:59Z",
"totalCount": 2,
"itemList": [
{
"type": "CARD_TYPE",
"id": 1,
"code": "상품code",
"title": "올리브영 상품1"
},
{
"type": "CARD_TYPE",
"id": 2,
"code": "상품code",
"title": "올리브영 상품2"
}
]
}컴포넌트
컴포넌트는 하나의 섹션을 만들기 위해 여러 개의 컴포넌트가 조합될 수 있는 요소로 정의할 수 있습니다. 물론 하나의 컴포넌트로 한 영역을 담당 할 수 도 있습니다. 예를 들면 header, productCard가 될 수 있겠네요. 아래 코드를 통해 몇 가지 샘플을 살펴보겠습니다.
{
"type": "HEADER_COMPONENT",
"id": 10,
"title": "타이틀",
"style": "HEADER_LARGE" // HEADER_MEDIUM, HEADER_REGULAR
}{
"type": "HEADER_COMPONENT",
"id": 11,
"title": "타이틀",
"handler": "이벤트 처리"
}{
"type": "PRODUCT_CARD",
"id": 12,
"code": "10001",
"title": "올리브영 상품",
"price": 9900,
"imageUrl": "https://...",
"badge": "HOT"
}{
"type": "FOOTER_COMPONENT",
"id": 13,
"title": "더 보기",
"handler": "이벤트 처리"
}위의 3가지를 조합하여(HEADER_COMPONENT + PRODUCT_CARD + FOOTER_COMPONENT) 하나의 섹션을 만들어 사용할 수 있습니다. 상황에 따라 헤더 또는 푸터가 없을 수도 있어서 스키마 존재에 따라 유연하게 반응해야 합니다. 가로형 상품카드에 쓰이는 상품카드 타입은 단독으로도 노출할 수 있어야 하며, 다양한 섹션 또는 컴포넌트와 조합 가능하도록 구조화가 필요합니다.
데이터: 모든 플랫폼의 '공통 언어'
스키마의 세 번째 요소는 컴포넌트에 실제 내용을 채우는 '데이터'입니다. SDUI는 Android, iOS, Web이 모두 같은 스키마를 바라보기 때문에, 이 '공통 언어'의 규칙을 명확히 정의하는 것이 무엇보다 중요했습니다. (must not be null 오류를 수도 없이 경험했던 기억이... 😅)
저희는 다음과 같은 엄격한 데이터 규칙을 세웠습니다.
- 필수값 (Required): id, type처럼 이 값이 없으면 렌더링 자체가 불가능한 값
- 옵션값 (Optional): subtitle처럼 값이 없어도 UI가 깨지지 않는 값
- 기본값 (Default): isShow = false처럼, 값이 누락되었을 때 클라이언트가 안전하게 처리할 기본 상태
- 포맷 (Format): date 포맷, ImageUrl 경로 등 데이터의 형식을 통일
{
"type": "PRODUCT_CARD",
"id": "12",
"title": "상품명",
"price": 9900,
"imageUrl": "이미지 URL",
"badge": "SALE",
"rating": 4.6,
"reviewCount": 0,
"isShow": false
}액션
각 컴포넌트의 이벤트 및 로그 등을 담당하게 됩니다. 해당 설계방향은 서비스 특성에 따라 달라지게 됩니다.
{
"type": "PRODUCT_CARD",
// .....
"handler": {
"action": {
"type": "Link",
"value": "url 정보"
},
//1안
"event1": {
"amplitude": {
"name": "product_select",
"parameters": { "param1": "value1", "param2": "value2" }
},
"appsflyer": {
"name": "product_select",
"parameters": { "param1": "value1", "param2": "value2" }
}
},
//2안
"event2": [
{
"type": "amplitude",
"event_type": "impression",
"name": "product_impression",
"parameters": { "param1": "value1", "param2": "value2" }
},
{
"type": "appsflyer",
"event_type": "click",
"name": "product_select",
"parameters": { "param1": "value1", "param2": "value2" }
}
]
}
}Action의 경우 Type에 따라 동작이 달라집니다. 아래와 같이 정의해보았습니다.
- Link : URL을 통해 외부,내부 브라우저로 이동 하거나 딥링크가 동작
- API : API BaseUrl을 제외한 Path 정보를 제공함으로써 앱에서 별도의 하드코딩 없이 API를 요청
{ @GET suspend fun getPageInfo( @Url path: String, @Query("cursor") cursor: String? ): Result<ComponentInfoResponse> }
Event의 경우 1안은 명시적이고, 이벤트 타입이 한 곳에 모여 있는 장점이 있지만, 새로운 이벤트 추가 시 확장성이 떨어지는 단점이 있습니다.
2안이 적합한 이유에는 '확장성' 외에 '플랫폼 간 일관성'이나 '로깅 정책 통일' 관점에서도 유효한 점이 있어 권장하지만, 각 이벤트별로 파라미터 및 제공하는 스펙이 다르면 데이터 구조를 통일하는 어려움이 있습니다.
하지만 확장성을 고려하면 가장 이상적인 방법이라 볼 수 있고, 자체 로그 시스템이 존재한다면 이벤트별 라우팅을 담당하도록 위임하는게 가장 좋습니다.
사용자 경험을 극대화하는 SDUI 성능 최적화 전략
SDUI는 서버가 정의한 데이터를 클라이언트가 그대로 렌더링하는 데서 그치지 않습니다. 다양한 상황에 즉각 대응하기 위해 여러 각도에서 고려해야 할 요소들이 있습니다. 이번에는 그 고려 사항을 공유해보겠습니다.
ATF(Above-The-Fold)
스크롤 없이 가장 처음에 보이는 초기화면 영역입니다. 사용자는 해당 영역으로 서비스의 가치를 판단하기도 합니다.
- ATF 전용 API 분리: 첫 화면 로딩에 필수적인 데이터만 담은 가벼운 API를 먼저 호출하고, 스크롤해야 보이는 하단 영역 데이터는 별도 API로 분리하여 비동기 처리했습니다.
- 초기 데이터 최소화: ATF 영역에 표시되는 섹션의 종류와 개수를 최소화하여, 클라이언트가 화면을 그리는 데 필요한 연산 부담을 줄였습니다.
이러한 최적화는 서버 응답 시간(Latency)과 클라이언트 렌더링 성능을 꾸준히 프로파일링하며 최적의 균형점을 찾아나가는 과정이 중요합니다.
Pagination: 끊김 없는 스크롤 경험
Pagination은 사용자가 스크롤을 내릴 때, 다음 페이지가 로드되고 있다는 사실조차 느끼지 못하게 만드는 것이 핵심입니다.
저희는 사용자가 리스트의 마지막에 도달하기 전, 특정 인덱스의 섹션이 보이기 시작할 때 다음 페이지 데이터를 미리 요청하도록 구현했습니다. '미리 로딩'을 시작하는 시점(인덱스)은 섹션이 화면을 차지하는 높이나 개수에 따라 달라질 수 있으므로, 각 화면의 특성에 맞게 유연하게 결정하는 것이 좋습니다.
Lazy Loading
서버 응답 시간에 영향을 미치는 개인화 영역이 있습니다. 개인화 영역의 일부 섹션이나 컴포넌트는 서버에서 캐싱 처리가 어렵기 때문입니다. 이때 Lazy Loading 기법을 활용할 수 있습니다.
Lazy Loading을 활용한 개인화 영역은 처음부터 모든 데이터를 받지 않습니다. 서버 응답을 받을 때 스켈레톤 로딩을 해당 영역에 노출하고 비동기로 API를 호출하여 유저 경험에 부정적 영향을 주지 않도록 합니다.
Lazy Loading 동작 메커니즘
- 페이지정보 요청
- 섹션 타입에 스켈레톤 Prefix가 붙은 섹션은 Lazy Loading이 필요한 요소
- path를 통해서 해당 섹션에 대해 비동기 통신
- 새로운 응답값을 통해 UI 바인딩 처리
// 개인화 추천 섹션 템플릿
{
"type": "SKELETON_SECTION_HORIZONTAL_CAROUSEL_LIST",
"id": "5",
"path": "해당 스켈레톤 섹션을 통해 요청할 API Path"
}
// Path를 통해 새롭게 받아온 개인화 데이터
{
"type": "HORIZONTAL_CAROUSEL_LIST",
"id": "5",
"header": { "title": "타이틀" },
"items": [
{ "type": "PRODUCT_CARD", "id": "상품1" },
{ "type": "PRODUCT_CARD", "id": "상품2" }
],
"footer": null
}그 외에 고민한 요소는?
데이터가 비어있을 경우(Empty Section)
전시지면에서 정상적인 상품 리스트를 받아오지 못한 경우 에러뷰를 노출 합니다. 이런 경우에 아래와 같이 Error Section을 관리하면 에러 타입별 문구와 재시도 동작 또한 정의해서 사용할 수 있습니다.
{
"type": "ERROR_SECTION",
"id": "5",
"imageUrl": "이미지 Path",
"title": "상품이 존재하지 않습니다",
"subtitle": "다시 검색해주세요",
"buttonTitle": "재시도",
"handler": {
"action": { "type": "API", "value": "재시도할 API PATH" }
}
}클라이언트 상태와 서버 상태의 동기화
위에서 설명한 필터 버튼을 클릭한 후 활성화 여부를 클라이언트에서 관리하지 않습니다. 아래 코드에서 handler를 통해 API를 요청하고, 응답받은 데이터에 id가 5인 필터는 isSelected = true 값으로 내려오고, 이전에 선택된 필터는 false로 내려오도록 동기화되어 있습니다.
이 부분은 응답 속도에 따라 영향을 받을 수 있어, 먼저 선택한 값을 활성화하고 응답 여부에 따라 다시 롤백 또는 유지도 가능하기 때문에 상황에 맞게 메커니즘을 결정하면 됩니다.
{
"type": "FILTER_CHIP",
"id": "5",
"title": "필터1",
"isSelected": false,
"handler": {
"action": {
"type": "API",
"value" : "API PATH"
}
}
}결과: 그래서 무엇이 달라졌나?
아직 정식 배포 전이지만, 저희는 이미 SDUI를 설계하고 구축하는 과정에서부터 문제를 즉각 통제할 수 있다는 강력한 변화를 체감하고 있습니다.
과거에는 치명적인 핫픽스조차 스토어 배포와 심사라는 긴급 대응이 불가능한 프로세스에 의존해야 했습니다. 하지만 이제는 문제가 된 섹션을 서버 설정만으로 즉시 비활성화 하거나 안전한 구성으로 대체할 수 있는 기술적 기반이 마련되었습니다.
A/B 테스트와 같은 실험을 진행하는 방식 자체도 근본적으로 달라졌습니다.
과거에는 동일한 실험을 위해 iOS, Android 각 플랫폼별로 개별 개발과 배포 일정을 조율하는 복잡한 과정이 필요했습니다. 이제는 기획이 확정되면 서버에서 스키마를 정의하는 단 한 번의 작업으로, iOS, Android, Web 모든 플랫폼에 '동시에' 새로운 UI를 노출할 수 있게 되었습니다.
이는 팀이 같은 타이밍에 같은 실험을 시작할 수 있음을 의미하며, 실험 준비에 드는 리드 타임이 '플랫폼 배포 주기(수일에서 수주)'에서 '서버 배포 주기(수분에서 수시간)'로 바뀌는 질적인 변화를 의미합니다.
마치며: SDUI는 만능인가?
지금까지 네이티브 앱의 고질적인 '속도의 한계'를 극복하기 위해 저희가 SDUI를 탐색하고 고민해 온 여정을 공유해 드렸습니다. 이 여정을 거치며 저희가 내린 결론은, SDUI는 만능 해결책이 아니라는 것입니다.
모든 기술이 그렇듯, SDUI 역시 명확한 트레이드오프가 존재합니다. 서버와 클라이언트가 '스키마'라는 강력한 약속으로 연결되기에 초기 설계의 복잡도가 높고, 문제가 발생했을 때 양쪽을 모두 살펴봐야 하므로 디버깅 난이도도 올라갑니다. 무엇보다 사용자의 네트워크 환경에 절대적으로 의존한다는 제약도 무시할 수 없습니다.
그렇기 때문에 핵심은 모든 화면을 SDUI로 전환하는 것이 아니라, 각 화면의 목적에 맞게 네이티브, 웹, SDUI를 전략적으로 조합하여 최적의 사용자 경험을 제공하는 것이었습니다. 어떤 화면은 네이티브의 성능이 절대적으로 중요하고, 어떤 화면은 웹의 범용성이 필요합니다.
그렇다면 SDUI의 진정한 가치는 어디에 있을까요? 저희는 SDUI가 단순히 콘텐츠를 보여주는 기술을 넘어, 네이티브의 느린 대응 속도를 보완하는 효율적인 운영 도구가 되는 지점에 있다고 생각합니다.
SDUI 도입을 고민해봤거나, 하고 있는 분들에게 저희의 경험이 조금이나마 도움이 되기를 바랍니다. 그리고 기회가 된다면, SDUI와 안드로이드 Jetpack Compose를 결합하여 적용한 사례도 소개하는 시간을 만들어 보겠습니다.
[Appendix] SDUI 도입을 위한 실전 가이드
자주 받는 질문(FAQ)
- 어떤 화면부터 SDUI로 바꾸면 좋을까요?
- 변화가 잦고, 실험이 활발하며, 크리티컬하지만 네이티브 변경 부담이 큰 전시/리스트 영역부터 시작하는 것을 추천합니다.
- 모든 레이아웃을 서버로 옮겨야 하나요?
- 아닙니다. 초기에는 레이아웃을 제외하고 섹션/컴포넌트/데이터/액션만 다루는 것이 안정적입니다.
- 실험/롤백은 어떻게 빠르게 하나요?
- 스키마 버저닝과 기능 토글(서버 설정)을 결합해 섹션 단위로 비활성화/치환/롤백이 가능하도록 합니다.
도입 시 흔한 함정과 회피법
- 스키마 과설계: 너무 많은 옵션/레이아웃을 한 번에 넣지 않습니다. 항상 최소 스펙으로 시작합니다.
- 알 수 없는 타입 처리 누락: 모르는 타입/필드는 안전 폴백하고 로그로만 남기는 것이 좋습니다.
- 단일 거대 API: ATF 전용 API를 분리해 첫 화면 체감 속도를 먼저 확보합니다.
- 추상화 누락: 공통 컴포넌트(상품 카드, 헤더 등)는 재사용을 전제로 스키마와 뷰를 함께 설계합니다.
빠른 도입 체크리스트
- 섹션/컴포넌트/데이터/액션 스키마 초안
- 알 수 없는 타입/필드 폴백 정책
- ATF 전용 API, 하단 영역 분리 API
- 페이지네이션, Lazy Loading 동작 기준
- 이벤트/트래킹 스펙(공통 파라미터, 라우팅)
- 섹션 단위 비활성화/치환/롤백 방법

