LIAN KIM
ResumeProjectsBlog
이력서

On this page

문제검토와 판단설계 판단들element 설계런타임 방어이벤트와 데이터 바인딩동적 폼 검증스타일 시스템한계결과더 읽기

Server-Driven UI(SDUI) 시스템 설계

브랜드마다 컴포넌트를 만들지 않은 이유

제품에 부착된 QR 코드를 스캔하면 나타나는 제품 인증 페이지를 만들었다. 사용자는 이 페이지에서 제품 정보를 확인하고, 브랜드 스토리를 열람하고, 소유권을 등록한다. 문제는 브랜드마다 요구하는 UI가 완전히 다르다는 점이었다.

문제#

색상이나 폰트만 다른 것이 아니었다. 어떤 브랜드는 제품 이미지를 상단에 크게, 어떤 브랜드는 로고를 중앙에, 어떤 브랜드는 정보를 리스트로 정리해 달라고 했다. 레이아웃 구조, 정보 배치, 인터랙션까지 브랜드마다 달랐다.

이걸 컴포넌트 분기로 감당하면 브랜드가 늘 때마다 컴포넌트가 늘고, 번들이 커지고, 수정 범위가 넓어진다. 반면 그 화면들 안에서 돌아가는 비즈니스 프로세스는 공통이었다. 소유권 등록, 국가에 따라 갈리는 인증(국내는 SMS, 해외는 이메일), 폼 검증과 전자 서명, 4개 언어 지원. 달라지는 것은 화면의 구조와 스타일이고, 같은 것은 로직이었다.

검토와 판단#

테마 시스템부터 검토했다. 색상·폰트·간격을 토큰으로 뽑아 브랜드별로 갈아 끼우는 방식이다. 하지만 이 방식은 구조가 같고 겉모습만 다를 때 유효하다. 우리는 구조 자체가 브랜드마다 달랐기 때문에 테마로는 해결할 수 없었다.

컴포넌트 분기는 늘어나는 비용을, 테마는 구조의 차이를 감당하지 못한다. 달라지는 부분(UI 구조)과 같은 부분(비즈니스 로직)을 분리하고, 달라지는 부분을 코드 밖으로 밀어내는 방향으로 결정했다. React는 데이터를 받아 UI를 그린다. 그렇다면 UI 구조 자체를 데이터(JSON)로 표현하고, 서버가 브랜드별 JSON을 내려주면 클라이언트는 그것을 해석해 렌더링하면 된다. Server-Driven UI(SDUI)를 설계하기로 했다.

설계 판단들#

element 설계#

element를 HTML 태그 수준의 최소 단위로 설계했다. 각 element는 실제 태그를 결정하는 type과, 어떤 React 컴포넌트로 렌더링할지 결정하는 role을 가진다. 변형이 필요하면 새 컴포넌트가 아니라 prop으로 흡수한다. 예를 들어 제목과 본문은 다른 컴포넌트가 아니다.

json
{ "type": "h1", "role": "text", "content": { "kr": "제품 소유권 등록" } }
{ "type": "p",  "role": "text", "content": { "kr": "아래 정보를 입력해 주세요" } }

같은 Text 컴포넌트가 type에 따라 h1도 되고 p도 된다. 제목용, 본문용, 캡션용 컴포넌트를 따로 만들지 않는다.

이렇게 30여 개의 role만으로 모든 브랜드 화면을 조합했다. 컨테이너, 텍스트, 이미지, 폼 요소, 조건부 렌더링, 데이터 바인딩까지. 브랜드가 늘어도 컴포넌트는 늘지 않고 JSON 조합만 달라지는 구조를 유지하는 것이 목표였다.

JSON의 role이 React 컴포넌트로 매핑되는 재귀 렌더링 흐름
JSON의 role이 React 컴포넌트로 매핑되는 재귀 렌더링 흐름

런타임 방어#

role별 element 인터페이스를 타입으로 정의해 저작의 기준으로 삼았다. text에는 content가 오고 image에는 src가 온다는 규약이다. 다만 서버에서 오는 JSON은 타입의 보호 밖에 있으므로, 방어는 런타임에 뒀다. 필수 값이 없는 element는 그 요소만 렌더링에서 제외되고, 등록되지 않은 role은 기본 컨테이너로 폴백한다. 데이터 일부가 잘못돼도 페이지의 나머지는 그려지는 것을 원칙으로 했다.

이벤트와 데이터 바인딩#

정적인 화면만으로는 소유권 등록 같은 프로세스를 감당할 수 없었다. 그래서 인터랙션도 JSON으로 선언하게 했다. 버튼의 event에 핸들러 이름과 인자를 적으면(openModal, submit, goNextStep 등) 클라이언트가 미리 등록된 핸들러를 찾아 실행한다.

json
  "event": {
    "type": "onClick",
    "handler": "openModal",
    "args": ["productDetailModal"]
  }

제품 상세 모달을 여는 버튼의 선언이다. 여기서 지킨 원칙은 선언과 실행의 분리다. JSON은 무엇을 할지를 선언할 뿐이고, 그것을 어떻게 실행할지는 코드에 등록된 핸들러만이 안다. 서버 데이터가 임의 동작을 만들 수 없고, 가능한 동작의 목록은 항상 클라이언트 코드가 통제한다.

서버 데이터 표시는 dataPath 기반 바인딩으로 처리했다. 중첩 객체의 경로를 배열로 선언하면 값을 찾아 렌더링하고, 값이 없을 때의 대체 텍스트도 JSON에 함께 선언한다. 보증 기간처럼 조건에 따라 보여줄 UI는 비교 연산자를 선언하는 조건부 컨테이너로 풀었다.

json
{
  "dataPath": ["product", "name"]
}

product 객체의 name 값을 가리키는 선언이다.

동적 폼 검증#

폼 검증을 코드에 고정하면, 브랜드마다 다른 입력 요구(필수 여부, 글자 수, 패턴)가 생길 때마다 배포가 필요해진다. 그래서 JSON의 validation 객체를 런타임에 Zod 스키마로 변환하고, react-hook-form의 resolver에 연결했다. 에러 메시지도 4개 언어로 JSON에 선언된다. 검증 규칙이 바뀌어도 서버 JSON 수정으로 끝난다.

스타일 시스템#

인라인 스타일 수준의 JSON 스타일로는 브랜드들이 원하는 인터랙션을 표현할 수 없었다. hover에 반응하는 버튼, 검증 실패 시 빨개지는 입력 필드 같은 것들이다.

그래서 스타일을 2차원으로 모델링했다. 한 축은 상태(default, active), 다른 축은 base와 의사 선택자(:hover, :focus, ::before 등)다. 상태별 스타일을 우선순위 규칙에 따라 병합해 Emotion의 css로 변환하면, 미디어 쿼리를 포함한 대부분의 CSS 표현이 JSON으로 가능해진다.

한계#

화면 정의가 코드에서 데이터로 옮겨갔을 뿐, 복잡도가 사라진 것은 아니다. 브랜드 화면을 만드는 일은 이제 JSON을 작성하는 일이 되었고, 그 JSON은 직접 작성해야 했다. 주석이 없는 JSON의 특성 때문에 description 필드를 주석 용도로 두는 수작업이 이어졌고, JSON을 더 간단히 작성할 수 있도록 돕는 편집 도구가 필요하다는 생각이 들었다. 또한 폼 입력값은 Zod로 검증했지만, element 구조 자체의 스키마 검증은 과제로 남았다.

결과#

신규 브랜드 화면을 추가할 때 기존 element를 JSON으로 조합해 대응했다. 레이아웃, 스타일, 인터랙션, 폼 검증 규칙까지 배포 없이 서버 JSON 수정만으로 즉시 반영된다. 이전에는 작은 UI 수정에도 클라이언트 배포가 필요했지만, 이 구조에서는 프론트엔드 배포 주기와 브랜드 요구사항의 변경 주기가 분리되었다.

더 읽기#

  • [블로그] SDUI 시스템 구축기 (1): JSON 기반 UI 렌더링 설계 : ElementBase 인터페이스, role 매핑, 렌더링 파이프라인, 초기 시행착오의 기록
  • [블로그] SDUI 시스템 구축기 (2): 스타일 시스템 확장 : 상태별 스타일과 의사 선택자를 JSON으로 제어하기까지의 과정