GenUI 도입 — 모델이 정하는 게 인자인가 배치인가 코드인가

언제 — 누군가 “우리도 GenUI 해보자”고 말했을 때. 회의에서 그 단어가 나오면 셋 중 뭔지부터 확정한다.

이름 모델이 정하는 것 실행되는 코드의 출처 검증 표면
Static/Controlled 툴 인자 우리 코드 툴 스키마
Declarative 컴포넌트 트리·배치·props 우리 코드 카탈로그(Zod)
Open-ended 코드 전체 모델 출력 없음 (샌드박스만)

직교하는 두 번째 축은 transport — 우리 앱에 그리나 남의 호스트에 그리나. “어디에 그리나”와 “얼마나 자유롭나”는 별개 결정이고 섞을 수 있다.


R1. 지금 GenUI 가 필요한지 판별

  1. 사용자 입력이 열거 가능한 선택지인가 자유 텍스트인가?
    • 열거 가능 → 여기서 중단. 필터 UI 가 답이다.
  2. 자유 텍스트일 때 나올 수 있는 화면 종류가 몇 개인가?
    • 5개 이하로 셀 수 있다 → Static/Controlled. 대부분 여기서 끝난다.
  3. 구성이 달라지는 게 데이터 양 때문인가 관심사 조합 때문인가?
    • 데이터 양(0건/1건/N건) → Static 으로 충분. 컴포넌트 3개로 커버된다
    • 관심사 조합(어떤 요청은 날씨+일정, 어떤 요청은 예산+체크리스트) → Declarative
  4. 우리 부품으로 표현이 아예 불가능한 일회성 위젯인가? → 그 조각만 Open-ended. 페이지 전체는 아님.

함정 — “AI 붙이자”가 실제로는 “검색이 안 좋다”인 경우가 많다. 랭킹 문제를 GenUI 로 덮으면 비용만 늘고 문제는 남는다. 그리고 데모에서 인상적이었던 건 대부분 Declarative 인데 실제 필요한 건 대부분 Static 이다.


R2. 인텐트 계약 (GenUI 도입 여부와 무관하게 먼저)

언제 — 기획 → 디자인 → 구현 사이에서 케이스가 자꾸 누락될 때.

  1. 기능 하나를 인텐트 문장 하나로 쓴다. “사용자가 조건을 말하면 조건에 맞는 장소를 찾아준다.”
  2. 입력 인자 집합을 확정한다. 무엇이 필수고 무엇이 옵션인지.
  3. 결과 상태 집합을 전부 열거한다. 이게 핵심이다. 정상 경로가 아니라 나머지를 적는다.
- 결과 N건 / 결과 1건 (레이아웃이 다르면 별도 상태)
- 조건은 유효한데 결과 0건 / 조건 자체를 해석 못 함
- 부분 실패 (3개 소스 중 1개 죽음)
- 권한 없음 (데이터 없음과 반드시 구분)
- 오래된 캐시를 보여주는 중
  1. 상태마다 반환 필드를 정한다. 최소한 “왜 이 결과가 나왔는지”를 UI 가 설명할 수 있어야 한다 — { query, appliedFilters, items, totalMatched } 에서 appliedFilters 가 없으면 “왜 이게 나왔죠?“에 답할 수 없다.
  2. 기획·디자인·프론트·서버가 같이 승인한다. 이게 계약이다.

확인 — 디자인 시안 개수 == 결과 상태 개수인가? 적으면 누군가 런타임에 즉흥으로 정하게 된다. QA 가 이 목록만 보고 테스트 케이스를 쓸 수 있는가?

왜 먼저 하나 — Static 으로 갈 때 이 정의가 그대로 툴 스키마가 된다. 순서가 뒤집히지 않는다. GenUI 를 끝내 안 하기로 해도 이 작업만으로 커뮤니케이션 비용이 준다.


R3. Static/Controlled 도입 (기본값)

기존 컴포넌트를 쓴다 — 새로 안 만든다.

  1. 인텐트 계약을 툴 스키마로 옮긴다. 모델이 채울 수 있는 건 입력 인자뿐이다.
  2. 툴 본체를 앱 코드로 쓴다. 필터·랭킹·권한 검사 전부 여기.
  3. 반환값에 결과 상태를 판별할 필드를 넣는다.
  4. 결과 상태 → 컴포넌트 매핑을 결정적 코드로 쓴다.
// 이 분기는 코드다. 모델이 정하지 않는다.
if (totalMatched === 0) return <EmptyMatch filters={appliedFilters} />
if (totalMatched === 1) return <PlaceCard place={places[0]} />
return <PlaceResults places={places} />
  1. 모델에게는 반환된 결과에 대한 설명만 쓰게 하고, 결과 밖의 이야기를 못 하게 프롬프트로 못박는다.

확인 — 툴 호출을 목으로 고정하면 UI 가 항상 같은 화면을 내는가?(아니면 결정성이 새고 있다) 모델을 완전히 죽여도 기존 필터 UI 로 기능이 동작하는가?(폴백 경로)

함정 — 툴이 반환하지 않은 항목을 모델이 설명에 끼워 넣는다. 반환 목록을 프롬프트에 명시하고 후처리로도 검사한다. 그리고 필터링을 모델에게 시키고 싶은 유혹 — 시키는 순간 재현성과 감사 로그가 동시에 사라진다.


R4. Declarative 도입 (R1 의 3번일 때만)

여기부터 비용이 계단식으로 뛴다. 백엔드는 그대로고(에이전트는 안 바뀐다 — 이게 이 경로의 실용적 장점), 새로 만들어야 하는 유일한 것은 컴포넌트 카탈로그다.

에이전트  →  전송 스펙  →  UI 서술 스펙  →  React 컴포넌트
 (서버)   (어떻게 흐르나)  (모델이 뭘 생성)   (여전히 우리 코드)
  1. 도메인 툴은 그대로 둔다.
  2. 런타임이 렌더 툴을 주입하고 모델에 카탈로그 스키마를 노출한다. 모델이 뭘 쓸 수 있는지 아는 유일한 경로.
  3. 순서를 강제한다 — 데이터 툴 먼저 → 렌더 서술은 마지막에 정확히 1회.
  4. 미들웨어가 인자를 스키마 검증 후 UI operation 으로 확장한다.
  5. 클라이언트로 스트리밍. 로딩 상태와 완성 화면이 같은 messageId 를 쓰게 한다(append 아니라 replace).

확인 — 카탈로그에 없는 컴포넌트를 요청했을 때 조용히 빠지는가, 에러인가, 폴백인가? 셋 중 하나를 명시적으로 고른다. 렌더 툴이 두 번 호출되면? 스트림이 중간에 끊기면?

함정정책이 타입 시스템이 아니라 프롬프트에 쌓인다. 타입 검사도 테스트도 안 되는 규칙 더미가 자라는 게 이 경로의 진짜 비용이다. 조합 폭발도 사라진 게 아니라 카탈로그 유지보수로 이동했을 뿐이다. 그리고 데모가 죄다 read-only 대시보드인 건 우연이 아니다 — 상호작용·로컬 상태·폼 검증이 이 방식에서 가장 약하다.


R5. 카탈로그 설계 (R4 실제 작업량의 대부분)

카탈로그 = Zod 정의(모델이 요청할 수 있는 것) + React 렌더러(실제 실행되는 것) + 버전 붙은 catalogId.

  1. 허용 목록이지 힌트가 아니다. 목록 밖은 표현 자체가 불가능해야 한다.
  2. props 를 최대한 좁힌다. className 같은 탈출구를 열면 카탈로그가 무의미해진다. 자유 문자열보다 열거형(variant: "compact" | "full"). href·src반드시 허용 도메인 검사 — 외부 이미지 하나가 비콘이 된다.
  3. 레이아웃 컴포넌트를 명시적으로 넣는다. 안 넣으면 모델이 텍스트로 레이아웃을 흉내 내기 시작한다.
  4. 버전을 붙인다. 카탈로그 변경은 API 변경과 같은 무게다.
  5. 접근성은 렌더러가 소유한다. 헤딩 레벨·포커스 순서·라벨을 모델에게 맡기면 요청마다 a11y 트리가 달라진다.

소유권 질문이 “누가 UI 를 주도하는가”의 실제 답이다 — 서버 스키마를 계약으로 두면 백엔드가, 인텐트+결과 상태 집합이면 기획/도메인이, 컴포넌트 카탈로그면 디자인 시스템이 주도권을 갖는다. 카탈로그를 소유한 팀이 사실상 UI 결정권을 갖는다. 암묵적으로 두지 말고 명시한다.


R6. Open-ended 를 안전하게 한 조각만

  1. 샌드박스 iframe. allow-scripts 만, allow-same-origin 은 주지 않는다(둘을 같이 주면 샌드박스가 무의미).
  2. 부모와의 통신은 좁은 postMessage 채널 하나로만. origin 검사 필수.
  3. 네트워크는 CSP 로 차단하거나 허용 목록만.
  4. 페이지 안에서의 크기·스크롤 소유권을 고정한다.

함정 — 디자인 시스템 충돌, 이중 스크롤바, 성능 비용, 그리고 직접 관리해야 하는 보안 경계가 생긴다. 지연·캐시 불가·감사 곤란이 전부 여기 몰려 있다. 프로덕션 기본값으로 쓰지 않는다.


R7. 신뢰 경계 점검 (배포 전)

“JSON 이라 안전하다”는 절반만 맞다. 임의 코드 실행은 막지만 나머지는 안 막힌다.

  1. props 공격면href/src 가 카탈로그에 있고 도메인 검사가 있는가?
  2. 프롬프트 인젝션이 유도한 정당해 보이는 UI ← Declarative 의 실제 최대 위협. 검색 결과나 외부 문서 텍스트가 모델 컨텍스트로 들어오는가? 카탈로그에 폼·버튼·링크가 있으면 모델이 만든 피싱 화면은 스키마 검증을 통과한다. 대응은 외부 텍스트를 데이터로만 격리, 입력형 컴포넌트를 카탈로그에서 분리·화이트리스트, 렌더 결과 후처리 검사.
  3. 권한 — 모델이 본 데이터 == 이 사용자가 볼 수 있는 데이터인가? 툴 레벨에서 걸렀는가?
  4. 감사 — 어떤 요청이 어떤 UI 를 만들었는지 재현 가능한가?(모델 출력 + catalogId + 입력을 함께 로깅)

코드 실행이 아니라 사회공학이 declarative 의 진짜 위협이고, 카탈로그는 이걸 막지 못한다.


R8. 테스트·회귀

UI 가 비결정적이 되는 순간부터 스냅샷/E2E 가 그냥 무너진다.

  1. 층을 나눠서 테스트한다. 툴 함수 → 순수 단위 테스트(모델 없음, 가장 많은 테스트가 여기). 컴포넌트 → 고정 props. 매핑 로직 → 결과 상태별 스냅샷. 모델 → 여기만 비결정적.
  2. 모델 출력을 픽스처로 고정한다. 매 CI 마다 모델을 부르지 않는다.
  3. 스키마 위반을 계약 테스트로. 카탈로그 변경 시 기존 픽스처가 깨지는지 본다.
  4. 모델 품질은 별도 평가 세트로. 대표 쿼리 N개 → 기대 컴포넌트 집합, 통과율을 지표로. E2E 와 섞지 않는다.

함정 — 모델이 낀 E2E 를 CI 에 넣으면 플레이키가 되고 결국 다들 무시한다.


R9. 실패 모드 정의 (안 정하면 장애 때 즉흥으로 정한다)

상황 정해야 할 것
모델 타임아웃 기존 UI 폴백? 에러 화면?
카탈로그에 없는 컴포넌트 요청 무시 / 전체 실패 / 플레이스홀더
스트리밍 중단 부분 화면 유지 vs 롤백
도메인 툴 3개 중 1개 실패 부분 렌더 + 실패 표시 (조용히 빼지 말 것)
비용·쿼터 초과 비-AI 경로로 강등

킬 스위치 — 모델 경로를 끄면 R2 의 결정적 UI 로 돌아가는 플래그. 이게 있으면 훨씬 과감하게 실험할 수 있다.


R10. 회의에서 쓰는 질문 세트

  1. AI 가 정하는 게 정확히 뭐예요? 인자? 배치? 코드?
  2. 나올 수 있는 화면 종류를 셀 수 있어요, 없어요?
  3. 카탈로그는 누가 소유해요?
  4. “이 케이스는 왜 이렇게 나왔죠”라고 물으면 어디를 보고 답해요?
  5. 모델을 끄면 이 기능은 어떻게 돼요?

1번만 확정돼도 나머지 논의가 전부 명확해진다.


기억해둘 반론 — “스펙이 코드를 대체한다”는 절반만 맞다. 스펙과 SDK 도 코드다. 실제로 일어난 일은 추상화 상승이 아니라 조합 결정 시점이 컴파일 타임에서 런타임으로 이동한 것이고, 그 대가로 타입 안정성을 프롬프트 정책과 맞바꾼 것이다. 남은 열린 문제(지연·캐시·감사·일관성·비용, 상호작용 이후의 상태 왕복, 접근성 보장, 비결정적 UI 의 회귀 전략)는 전부 이 이동의 청구서다.

#587raw