Skip to content

char-labs/sdui-server-pattern

Repository files navigation

Commerce SDUI Reference

커머스 홈을 예시 도메인으로 삼아 Server-Driven UI를 Spring Boot/Kotlin 백엔드에 적용한 레퍼런스입니다.

이 프로젝트의 핵심 질문은 단순합니다.

화면이 자주 바뀌고, 실험과 개인화가 많고, 여러 클라이언트가 같은 전시 정책을 따라야 한다면 UI 결정권을 어디에 둘 것인가?

이 저장소는 그 답을 “서버가 화면 구조와 액션, 정책 상태, 추적 메타데이터를 내려주고 클라이언트는 이를 렌더링한다”는 방식으로 풀어봅니다. 커머스 홈 전시만 따로 떼어낸 예제가 아니라, 상품 옵션, 장바구니, 주문 quote, 재고 차감, 결제 실패 보상까지 함께 연결해 실제 서비스에서 SDUI를 도입할 때 생기는 경계도 같이 다룹니다.

실제 무신사, 올리브영, 29CM 데이터를 복제하지 않습니다. 공개적으로 관찰 가능한 커머스 전시 패턴을 fashion, beauty, lifestyle 샘플 도메인으로 일반화했습니다.

SDUI가 필요한 상황

SDUI는 “서버가 화면을 내려준다”는 기술 자체보다, 화면 변경의 주도권과 정책 일관성을 서버에서 관리해야 하는 상황에 맞는 선택입니다. 이 프로젝트에서는 커머스 홈을 기준으로 아래 상황을 SDUI 적용 후보로 봅니다.

상황 이때 필요한 것 SDUI로 옮기는 결정
홈, 랭킹, 기획전처럼 전시 구성이 자주 바뀐다 배너, 섹션 순서, 상품 rail, 랭킹 기준을 빠르게 바꿔야 합니다. 서버가 section/component 순서와 props를 조합합니다.
iOS, Android, Web이 같은 전시 정책을 따라야 한다 클라이언트마다 다른 분기와 릴리즈 속도를 맞춰야 합니다. 공통 component, action, tracking 계약을 서버 응답으로 관리합니다.
A/B 테스트나 개인화가 많다 사용자 세그먼트, 실험군, 앱 버전, 디바이스별 화면 차이가 생깁니다. experimentKey, userSegment, appVersion을 기준으로 builder가 화면을 선택합니다.
상품 정책 상태가 UI에 바로 드러난다 품절, 할인율, 쿠폰, 배송 배지, 좋아요, 장바구니 수량이 화면에 반영되어야 합니다. 공통 전시 composition 위에 사용자별 overlay와 정책 상태를 합성합니다.
노출/클릭 로그 정합성이 중요하다 어떤 사용자가 어떤 섹션과 상품을 봤는지 재현할 수 있어야 합니다. component와 action에 screenId, sectionId, componentId, position을 포함합니다.
운영자가 전시를 빠르게 교체해야 한다 클라이언트 배포 없이 랭킹, 큐레이션, 타임딜 구성을 바꿔야 합니다. 캐시 가능한 read model과 화면 builder를 통해 전시 응답을 재구성합니다.

이 저장소는 위 상황 중 커머스 홈, 랭킹, 큐레이션, 정책 상태 반영, 이벤트 추적을 하나의 샘플로 묶어 구현합니다. SDUI가 “필요한 이유”를 일반론으로 설명하기보다, 어떤 요구가 누적될 때 서버 주도 화면 계약이 선택지가 되는지 보여주는 쪽에 초점을 둡니다.

선택하지 않는 편이 나은 상황

SDUI는 모든 화면에 필요한 패턴은 아닙니다. 아래 상황에서는 먼저 일반적인 클라이언트 구현이나 단순 API 조합이 더 적합할 수 있습니다.

  • 화면 변화가 거의 없는 정적 설정 화면
  • 클라이언트의 고성능 인터랙션이 핵심인 편집기, 게임, 그래픽 도구
  • 오프라인 우선 동작이 중요하고 서버 화면 계약에 의존하기 어려운 기능
  • 컴포넌트 수가 적고 실험/개인화/운영 변경 요구가 낮은 초기 기능
  • schema version, renderer fallback, component contract를 관리할 준비가 아직 부족한 조직

고려해야 할 tradeoff

SDUI는 클라이언트 배포 없이 화면을 바꾸기 위한 은탄환이 아닙니다. 화면 결정권을 서버로 옮기는 만큼, 서버가 져야 하는 책임과 계약 관리 비용도 함께 커집니다.

얻는 것 함께 생기는 비용
화면 변경 속도 schema version, component contract, fallback 정책을 관리해야 합니다.
멀티 클라이언트 일관성 모든 클라이언트가 같은 컴포넌트 의미를 해석하도록 계약을 유지해야 합니다.
A/B 테스트와 개인화 cache key, user segment, 실험군 조합이 늘어나면서 캐시 효율이 떨어질 수 있습니다.
서버 기준 이벤트 추적 payload 크기, 로그 저장 비용, 이벤트 중복/누락 검증이 중요해집니다.
정책 상태의 즉시 반영 품절, 할인, 배송, 좋아요 같은 상태를 어느 시점에 합성할지 정해야 합니다.
운영 유연성 잘못된 JSON 계약 하나가 여러 클라이언트 화면을 동시에 망가뜨릴 수 있습니다.

이 프로젝트는 이런 tradeoff를 숨기지 않기 위해 전시 조회, 사용자 overlay, 주문/결제 command path를 분리했습니다. 캐시는 홈 composition에만 적용하고, 주문/결제/재고처럼 정합성이 중요한 흐름은 매번 다시 검증하도록 구성했습니다.

단점과 실패하기 쉬운 지점

SDUI 도입에서 가장 흔한 실패는 “서버가 화면을 내려준다”는 사실만 보고 계약 관리의 복잡도를 과소평가하는 것입니다.

  • 컴포넌트 타입이 빠르게 늘어나면 클라이언트 렌더러가 또 다른 거대한 분기문이 됩니다.
  • props를 자유로운 map으로만 키우면 문서화되지 않은 암묵적 계약이 늘어납니다.
  • 서버 장애나 지연이 곧 화면 렌더링 실패로 이어질 수 있습니다.
  • payload가 커지면 초기 로딩, 네트워크 비용, 캐시 효율이 나빠집니다.
  • 디자이너가 의도한 표현과 클라이언트가 가진 컴포넌트 능력이 어긋날 수 있습니다.
  • 화면 구성과 비즈니스 정책이 섞이면 builder가 도메인 서비스를 대신하는 비대한 객체가 됩니다.
  • 실험군, 앱 버전, 디바이스별 분기가 누적되면 어떤 사용자가 어떤 화면을 봤는지 재현하기 어려워집니다.

그래서 SDUI는 처음부터 모든 화면을 동적으로 만들기보다, 변경이 잦고 운영 가치가 큰 영역부터 작게 시작하는 편이 낫습니다.

점진적으로 개선하는 방법

이 프로젝트는 “처음부터 완전한 플랫폼”보다 “운영 가능한 단계를 하나씩 쌓는 방식”을 기준으로 구성했습니다.

단계 먼저 생각할 것 구현 관점
1. 읽기 전용 전시부터 시작 어떤 화면 영역이 자주 바뀌는가 홈 배너, 랭킹, 상품 rail처럼 command가 없는 section부터 SDUI로 분리합니다.
2. 계약을 버전으로 관리 클라이언트가 모르는 컴포넌트를 만나면 어떻게 할 것인가 schemaVersion, component type/version, unknown component fallback을 둡니다.
3. action과 tracking을 붙이기 클릭, 노출, 실험 로그를 어디서 일관되게 만들 것인가 component마다 action과 tracking metadata를 함께 내려줍니다.
4. 사용자 overlay를 분리 개인화 상태를 캐시 데이터에 섞어도 되는가 공통 composition은 캐시하고, 좋아요/장바구니 수량은 후처리로 덧입힙니다.
5. command path와 연결 장바구니, 주문, 결제는 SDUI와 어디까지 연결할 것인가 화면 action은 command API로 연결하되, 주문/결제/재고 검증은 별도 use case에서 수행합니다.
6. 운영 검증을 추가 어떤 화면이 누구에게 내려갔는지 추적 가능한가 payload 크기, cache hit ratio, 이벤트 누락, schema 호환성, 실험군 재현성을 관찰합니다.

이 단계를 지나면서 필요한 질문도 달라집니다. 초반에는 “이 컴포넌트를 클라이언트가 안정적으로 렌더링할 수 있는가”가 중요하고, 이후에는 “화면 계약 변경이 과거 앱 버전을 깨지 않는가”, “사용자별 overlay가 캐시를 오염시키지 않는가”, “장애 시 fallback 화면을 줄 수 있는가”가 더 중요해집니다.

이 프로젝트의 목적

이 저장소는 SDUI 개념 설명만을 위한 샘플이 아니라, 백엔드 개발자가 참고할 수 있는 구현 기준을 남기는 데 목적이 있습니다.

  • 커머스 홈 화면을 서버가 어떻게 조합할 수 있는지 보여줍니다.
  • SDUI read model과 주문/결제 command model을 어떻게 분리할지 보여줍니다.
  • API DTO, application model, domain model, JPA entity를 경계별로 나누는 방식을 보여줍니다.
  • 캐시 가능한 전시 데이터와 캐시하면 안 되는 주문/결제/재고 데이터를 분리합니다.
  • 클라이언트 렌더러가 알 수 없는 component type을 만나도 깨지지 않게 fallback을 둡니다.
  • Swagger와 문서 예시를 통해 API 계약을 확인할 수 있게 합니다.

간단한 기술 정의

용어 이 프로젝트에서의 의미
SDUI 서버가 화면 구조와 액션을 JSON 계약으로 내려주고 클라이언트가 렌더링하는 방식
Component tree section -> component -> children 형태의 화면 구성 데이터
Action OPEN_PRODUCT, ADD_TO_CART, TOGGLE_LIKE처럼 클라이언트가 실행할 행동
Tracking screenId, sectionId, componentId, position, experimentKey를 포함한 이벤트 메타데이터
Overlay 캐시된 공통 화면 위에 사용자별 좋아요/장바구니 수량을 덧입히는 처리
CQRS 홈 전시 조회 모델과 주문/결제 변경 모델을 분리하는 구조
Outbox 결제 실패 보상 실패처럼 나중에 복구해야 하는 이벤트를 저장하는 최소 장치

적용한 기술

  • Kotlin 2.3.21
  • Spring Boot 4.0.6
  • Gradle Kotlin DSL
  • Java 25 toolchain
  • Spring Web MVC, Spring Data JPA, Flyway
  • H2 local database
  • Caffeine local cache
  • springdoc-openapi Swagger UI
  • Mock payment gateway adapter

이렇게 구성했습니다

sdui
├── apps
│   ├── api            # Controller, request/response DTO, exception mapping, static renderer
│   ├── application    # CQRS use case, transaction boundary, SDUI builder
│   └── domain         # Domain model, policy, value object, port
├── storage
│   ├── rdb            # JPA entity, repository, projection query, Flyway migration/seed
│   └── cache          # Caffeine cache adapter
├── support
│   └── swagger        # Local Swagger/OpenAPI configuration
└── external
    └── pg             # Mock PG strategy adapter

의존 방향은 apps:api -> apps:application -> apps:domain입니다. storage:rdb, storage:cache, external:pg는 port 구현체이며 런타임 조립은 apps:api에서 담당합니다.

각 모듈은 기술 계층만으로 넓게 묶지 않고 도메인 역할을 기준으로 다시 나눴습니다.

  • apps/domain: cart, catalog, display, event, order, payment
  • apps/application: home/query, cart/command, cart/query, order/preview, order/command, payment/command
  • apps/api: v1/{domain}/request, v1/{domain}/response
  • storage/rdb: cart, catalog, display, event, order, paymentadapter/entity/repository

SDUI 응답 예시

GET /api/v1/display/screens/commerce-home은 versioned SDUI tree를 반환합니다. 기존 GET /api/v1/screens/commerce-home은 호환 경로로 유지합니다.

{
  "schemaVersion": "2026-05-27",
  "screenId": "commerce-home",
  "sections": [
    {
      "id": "section-8",
      "type": "RANKING",
      "title": "Beauty ranking",
      "components": [
        {
          "id": "products-8",
          "type": "RANKING_LIST",
          "version": 1,
          "props": {},
          "actions": [],
          "tracking": {
            "screenId": "commerce-home",
            "sectionId": "section-8",
            "componentId": "products-8",
            "position": 0,
            "experimentKey": "beauty-ranking-a"
          }
        }
      ]
    }
  ]
}

지원하는 component type은 HERO_BANNER, QUICK_MENU_GRID, PRODUCT_RAIL, RANKING_LIST, TIME_DEAL, EDITORIAL_COLLECTION, BRAND_SPOTLIGHT, ORDER_STATUS_CARD, TEXT, SPACER입니다.

지원하는 action type은 NAVIGATE, OPEN_PRODUCT, ADD_TO_CART, TOGGLE_LIKE, OPEN_ORDER_PREVIEW, TRACK_ONLY입니다.

구현한 흐름

영역 구현 내용
홈 전시 fashion, beauty, lifestyle 카테고리별 hero, ranking, rail, editorial, brand section
전시 feed surface + category 기준 feed, media, 관련 상품을 keyset cursor로 무한 스크롤
상품 상품 상세, 옵션, 이미지 URL, 재고 상태, 배송 badge, 할인/쿠폰/증정 상태
좋아요 사용자별 상품 좋아요 resource 생성/삭제
장바구니 같은 userId + productOptionId 추가 시 수량 증가, 수량 변경, 삭제
주문 quote 가격, 배송비, 재고, 상품 정책을 주문 전 재검증
주문 생성 Idempotency-Key 기반 중복 요청 방어, 조건부 재고 차감
결제 MOCK_CARD, MOCK_PAY, POINT 전략으로 Mock PG 실행
실패 보상 결제 실패 시 재고 release, 보상 실패는 outbox event로 보존
이벤트 홈 조회, 섹션 노출, 컴포넌트 노출, 상품 클릭, 좋아요, 장바구니 이벤트 기록

주요 설계 결정

전시 조회와 커머스 명령을 분리했습니다

홈 화면은 projection/read model 기반으로 구성합니다. 주문, 결제, 재고처럼 정합성이 필요한 command path는 전시 조회 형태에 끌려가지 않도록 application use case를 분리했습니다.

캐시는 전시 composition에만 적용했습니다

홈 화면의 공통 composition은 Caffeine으로 짧게 캐시합니다. 사용자별 좋아요 여부와 장바구니 수량은 cached composition을 조회한 뒤 overlay로 적용합니다. 주문, 결제, 재고 변경, 최종 checkout 검증은 캐시하지 않습니다.

금액은 domain Money로 계산했습니다

도메인 금액 계산은 BigDecimal 기반 Money value object로 처리합니다. API response와 JPA entity에서는 경계에 맞는 숫자 타입으로 변환하되, 가격 계산과 비교는 domain/application 레이어에서 수행합니다.

주문은 멱등성과 조건부 재고 차감을 함께 사용했습니다

주문 생성은 orders(user_id, idempotency_key) 유니크 제약과 application service의 재호출 처리를 함께 사용합니다. 재고는 DB 조건부 update로 차감해 동시 주문의 oversell을 막습니다.

결제 실패도 명시적인 흐름으로 남겼습니다

Mock PG 실패를 강제로 만들 수 있고, 실패 시 주문/결제 상태를 남긴 뒤 재고를 release합니다. release 보상이 실패하면 outbox event를 저장해 후속 복구 지점을 남깁니다.

확인할 수 있는 산출물

처음 프로젝트를 읽는 순서는 docs/onboarding-guide.md에 정리되어 있습니다. 자세한 요청/응답 예시는 docs/api-v1.md에 정리되어 있고, 협업 관점의 개발 가이드는 docs/sdui-collaboration-guide.md에 정리되어 있습니다. 로컬 실행 시에는 정적 렌더러와 Swagger UI로 화면 계약을 확인할 수 있습니다.

테스트는 다음 관점을 확인하도록 구성했습니다.

  • SDUI component builder mapping
  • 홈 API 계약
  • 정적 renderer resource
  • 장바구니 중복 추가
  • 주문 멱등성
  • 결제 실패 보상
  • API controller가 storage/external 구현체를 직접 import하지 않는 boundary guard

읽는 순서

처음 살펴볼 때는 아래 순서가 가장 빠릅니다.

  1. README.md에서 SDUI 적용 상황과 전체 구조를 확인합니다.
  2. docs/api-v1.md에서 외부 계약과 예시 payload를 확인합니다.
  3. apps/application/src/main/kotlin/com/sdui/application/home/query에서 홈 SDUI composition 흐름을 봅니다.
  4. apps/api/src/main/resources/static/app.js에서 클라이언트가 component tree를 렌더링하는 방식을 봅니다.
  5. apps/application/src/main/kotlin/com/sdui/application/orderapps/application/src/main/kotlin/com/sdui/application/payment에서 주문/결제 일관성 흐름을 봅니다.
  6. storage/rdb/src/main/resources/db/migrationstorage/rdb/src/main/resources/db/seed/local에서 schema와 deterministic seed를 확인합니다.

개발 가이드와 하네스

범위 밖

아래 항목은 v1에서 의도적으로 제외했습니다.

  • 실제 PG 연동
  • 실제 배송 추적
  • 환불/교환/정산
  • 전체 관리자 UI
  • 실시간 추천 알고리즘
  • 외부 서비스의 실제 데이터나 이미지 복제

About

SDUI 패턴

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors