토스의 Server Driven UI — 서버가 화면을 결정하는 세상
토스 SLASH 23 리뷰 — Server Driven UI로 앱 업데이트 없이 UI를 바꾸는 아키텍처를 파헤쳐본다.
#서버가 화면을 결정한다고?
처음 이 제목을 봤을 때 솔직히 좀 혼란스러웠다.
프론트엔드 개발자로서 UI는 당연히 클라이언트의 영역이라고 생각해왔는데, 서버가 UI를 결정한다니.

그런데 토스 SLASH 23에서 발표된 두 세션을 보고 나니, 이게 왜 토스 같은 조직에서 강력한 무기가 되는지 이해할 수 있었다.
오늘은 **Server Driven UI(이하 SDUI)**가 무엇이고, 토스가 이걸 어떻게 활용하는지 정리해본다.
Server Driven UI란?
간단하게 말하면 이렇다.
서버가 "이 화면에는 이런 컴포넌트들을 이 순서대로 보여줘"라고 JSON으로 내려주면, 클라이언트는 그걸 그대로 렌더링한다.
기존 방식에서는 서버가 데이터만 내려주고, 클라이언트가 그 데이터를 어떤 컴포넌트에 넣을지 직접 결정했다.
SDUI에서는 서버가 데이터 + UI 구조를 함께 내려준다. 클라이언트는 사실상 렌더링 엔진의 역할만 하는 셈이다.
기존 방식 vs SDUI
| 구분 | Data-Driven UI | Server-Driven UI |
|---|---|---|
| UI 결정 주체 | 클라이언트 | 서버 |
| 서버 응답 | 순수 데이터 (JSON) | UI 구조 + 데이터 (JSON) |
| UI 변경 시 | 앱 업데이트 필요 | 서버 배포만 하면 됨 |
| A/B 테스트 | 클라이언트 로직 필요 | 서버에서 분기 처리 |
| 디버깅 | 클라이언트 + 서버 | 서버 중심 |
토스는 왜 SDUI를 선택했을까?
토스 홈팀의 발표를 보면, SDUI를 도입한 이유가 명확하다.
1. 앱 업데이트 없이 UI 변경
토스 앱의 홈 화면을 생각해보자. 수많은 카드, 배너, 위젯들이 있는데, 이걸 바꿀 때마다 앱 스토어 심사를 기다려야 한다면? 빠르게 실험하는 토스 입장에서는 치명적이다.
SDUI를 쓰면 서버 배포 한 번이면 즉시 반영된다.
2. 클라이언트 버그 감소
UI 로직이 서버에 있으니, 클라이언트에서 "이 조건일 때 이 컴포넌트를 보여주고, 저 조건일 때 저 컴포넌트를 보여주고..." 같은 분기 처리가 사라진다. 클라이언트 코드가 단순해지니 버그도 줄어든다.
3. A/B 테스트가 쉬워진다
서버에서 사용자 그룹별로 다른 UI 구조를 내려주면 끝이다. 클라이언트에서 실험 코드를 넣고 빼는 지옥에서 벗어날 수 있다.
4. 디버깅이 편해진다
문제가 생기면 서버 응답만 확인하면 된다. "서버가 뭘 내려줬는지"만 보면 화면이 왜 그렇게 그려졌는지 바로 알 수 있다.

아키텍처 — 어떻게 동작하는가
SDUI의 핵심 흐름은 이렇다.
- 클라이언트가 서버에 화면 데이터를 요청한다
- 서버가 컴포넌트 트리를 JSON 스키마로 응답한다
- 클라이언트는 컴포넌트 레지스트리에서 타입에 맞는 컴포넌트를 찾아 렌더링한다
JSON 스키마 구조
서버가 내려주는 응답은 대략 이런 모습이다.
// 서버 응답 예시 — 홈 화면
interface SDUIResponse {
screen: string;
version: string;
components: SDUIComponent[];
}
interface SDUIComponent {
type: string; // "banner", "card", "list" 등
id: string;
props: Record<string, unknown>;
children?: SDUIComponent[];
actions?: SDUIAction[];
}
interface SDUIAction {
type: "navigate" | "api_call" | "open_modal" | "share";
payload: Record<string, unknown>;
}
// 실제 서버 응답 JSON
const serverResponse: SDUIResponse = {
screen: "home",
version: "2.1.0",
components: [
{
type: "hero_banner",
id: "banner-001",
props: {
title: "토스로 간편송금",
subtitle: "수수료 없이 빠르게",
imageUrl: "/images/banner-send.png",
backgroundColor: "#0064FF",
},
actions: [
{
type: "navigate",
payload: { screen: "send_money" },
},
],
},
{
type: "horizontal_card_list",
id: "cards-001",
props: { title: "오늘의 추천" },
children: [
{
type: "product_card",
id: "card-001",
props: {
title: "토스 증권",
description: "주식 시작하기",
iconUrl: "/icons/stock.png",
},
actions: [
{
type: "navigate",
payload: { screen: "stock_intro" },
},
],
},
],
},
],
};컴포넌트 레지스트리 패턴
클라이언트에는 서버가 보내줄 수 있는 모든 컴포넌트 타입이 미리 등록되어 있어야 한다.
import { ComponentType } from "react";
// 각 SDUI 컴포넌트의 Props 타입
type SDUIComponentProps = {
props: Record<string, unknown>;
children?: SDUIComponent[];
actions?: SDUIAction[];
};
// 컴포넌트 레지스트리 — type 문자열을 실제 React 컴포넌트에 매핑
const componentRegistry: Record<string, ComponentType<SDUIComponentProps>> = {
hero_banner: HeroBanner,
horizontal_card_list: HorizontalCardList,
product_card: ProductCard,
text_section: TextSection,
divider: Divider,
notification_card: NotificationCard,
};
// 레지스트리에서 컴포넌트를 가져오는 헬퍼
function getComponent(type: string): ComponentType<SDUIComponentProps> | null {
return componentRegistry[type] ?? null;
}동적 렌더러 컴포넌트
레지스트리를 기반으로 서버 응답을 재귀적으로 렌더링하는 핵심 컴포넌트다.
function SDUIRenderer({ components }: { components: SDUIComponent[] }) {
return (
<>
{components.map((component) => {
const Component = getComponent(component.type);
if (!Component) {
// 알 수 없는 타입은 무시 — 하위 호환성 보장
console.warn(`Unknown component type: ${component.type}`);
return null;
}
return (
<Component
key={component.id}
props={component.props}
actions={component.actions}
children={component.children}
/>
);
})}
</>
);
}
액션 핸들링
컴포넌트에서 사용자 인터랙션이 발생하면, 서버가 정의한 액션을 실행한다.
import { useRouter } from "next/navigation";
function useSDUIAction() {
const router = useRouter();
const handleAction = async (action: SDUIAction) => {
switch (action.type) {
case "navigate":
router.push(action.payload.screen as string);
break;
case "api_call":
await fetch(action.payload.url as string, {
method: action.payload.method as string,
body: JSON.stringify(action.payload.body),
});
break;
case "open_modal":
// 모달 열기 로직
break;
case "share":
await navigator.share({
title: action.payload.title as string,
url: action.payload.url as string,
});
break;
}
};
return { handleAction };
}이렇게 하면 서버가 "이 버튼을 누르면 이 화면으로 이동해"라고 지정할 수도 있고, "이 API를 호출해"라고 지정할 수도 있다. 클라이언트는 그냥 시키는 대로 실행하면 된다.
토스의 마지막 어드민
두 번째 세션에서는 SDUI를 어드민 시스템에 적용한 사례를 다뤘다.
토스 내부에는 수많은 어드민 페이지가 있고, 매번 프론트엔드 개발자가 어드민 화면을 만들어야 했다. 하지만 SDUI를 적용하면 백엔드 개발자가 JSON 스키마만 정의하면 어드민 화면이 자동으로 생성된다.
프론트엔드 개발자 없이도 어드민을 만들 수 있다. 이것이 "마지막 어드민"의 의미다.
이건 정말 현실적인 문제를 해결하는 접근이라고 생각한다. 어드민 페이지는 보통 우선순위가 낮아서 프론트엔드 리소스를 할당받기 어려운데, SDUI로 이 병목을 없앤 것이다.

SDUI가 적합한 경우 vs 오버킬인 경우
SDUI가 만능은 아니다. 적합한 상황과 그렇지 않은 상황을 정리해보면 이렇다.
SDUI가 빛나는 경우
- 콘텐츠 중심 화면: 홈, 피드, 상품 목록처럼 자주 바뀌는 화면
- A/B 테스트가 빈번한 서비스: 실험 속도가 곧 경쟁력인 조직
- 크로스 플랫폼: iOS, Android, Web이 동일한 UI를 보여줘야 할 때
- 어드민/백오피스: 반복적인 CRUD 화면이 많은 경우
SDUI가 오버킬인 경우
- 복잡한 인터랙션이 필요한 화면: 드래그앤드롭, 캔버스, 복잡한 애니메이션
- 오프라인 지원이 중요한 앱: 서버 응답에 의존하므로 네트워크가 필수
- 작은 규모의 프로젝트: 레지스트리, 스키마 설계 등 초기 비용이 크다
- 퍼포먼스에 극도로 민감한 화면: JSON 파싱 + 동적 렌더링의 오버헤드
React Server Components와의 연결고리
SDUI를 공부하면서 React Server Components(RSC)가 떠올랐다.
둘 다 서버와 클라이언트의 경계를 흐리게 만든다는 점에서 비슷한 방향성을 가지고 있다.
- SDUI: 서버가 UI 구조를 결정하고, 클라이언트가 렌더링한다
- RSC: 서버에서 컴포넌트를 실행하고, 결과를 클라이언트에 스트리밍한다
RSC가 좀 더 React 생태계에 네이티브하게 통합된 접근이라면, SDUI는 프레임워크에 독립적이고 모바일 앱에서도 쓸 수 있는 범용적인 패턴이다.
어쩌면 미래에는 이 두 가지가 합쳐질 수도 있지 않을까 생각한다. 서버에서 RSC로 컴포넌트를 실행하되, 그 구조 자체를 SDUI 방식으로 동적으로 결정하는 식으로 말이다.

정리하며
처음엔 서버가 UI를 결정한다는 게 이상했지만, 토스처럼 빠르게 실험하는 조직에서는 최강의 무기라는 걸 알게 됐다.
이전에 정리했던 토스의 가독성 좋은 코드, 프론트엔드 최적화, Deus 디자인 편집기 글들을 보면, 토스가 일관되게 추구하는 것이 보인다.
"개발자의 병목을 제거하고, 제품의 속도를 최대한 끌어올린다."
SDUI도 결국 같은 맥락이다. UI 변경에 앱 업데이트라는 병목이 있으니, 그 병목을 서버로 옮겨서 제거한 것이다.
나도 언젠가 팀에서 이런 아키텍처를 설계할 기회가 오면, SDUI를 진지하게 고려해볼 것 같다. 특히 홈 화면이나 이벤트 페이지처럼 자주 바뀌는 영역에서는 확실히 ROI가 클 것이라고 생각한다.
