
실시간으로 자신의 UI를 다시 작성하는 채팅 앱을 만들었습니다
요약
AI가 텍스트 응답을 넘어 HTML, CSS, JavaScript를 직접 생성하여 실시간으로 UI를 재구성하는 'FlowChat' 프로젝트를 소개합니다. 사용자의 요청에 따라 채팅 인터페이스가 게임 보드나 테마가 적용된 캔버스로 즉각 변하는 혁신적인 사용자 경험을 제공합니다.
핵심 포인트
- 모델이 생성한 HTML/JS를 DOM에 직접 주입하는 스트리밍 프로토콜 활용
- 단순 텍스트 렌더링을 넘어 플레이 가능한 게임 및 인터랙티브 UI 구현
- 사용자 요청에 따라 실시간으로 인터페이스 테마 및 기능 변경 가능
계속해서 제 머릿속을 맴도는 아이디어가 하나 있었습니다.
모든 AI 채팅 앱은 작동 방식이 동일합니다. 무언가를 입력하면, 모델이 텍스트나 마크다운 (Markdown)을 반환하고, UI가 이를 보기 좋게 서식이 지정된 단락으로 렌더링합니다. 답변을 얻고 싶은 것이라면 그것으로 충분합니다. 하지만 실제로 무언가를 만들고 싶은 것이라면, 그것은 진심으로 지루한 일입니다.
만약 AI가 클릭할 수 있는 작동하는 게임 보드로 응답할 수 있다면 어떨까요? 만약 "바비(Barbie) 테마로 만들어줘"라고 말했을 때, 당신이 지켜보는 동안 인터페이스 전체가 실제로 변한다면 어떨까요? 만약 "배경에 별이 빛나는 들판을 추가해줘"라고 했을 때, 실시간으로 채팅 뒤에 애니메이션 캔버스 (Canvas)가 나타난다면 어떨까요?
저는 정확히 그것을 만들기 위해 몇 주를 보냈습니다. 저는 이것을 FlowChat이라고 부릅니다.
라이브 버전은 여기 있습니다: https://flowchat-public.varshithvh.workers.dev
그리고 네, 누군가가 즉시 틱택토 (Tic Tac Toe) 게임을 해달라고 요청하더니, 게임 도중에 Oppenheimer 테마로 바꿔달라고 요청했습니다. 이보다 더 자랑스러울 순 없습니다.
아이디어
일반적인 AI 채팅: 모델이 마크다운 (Markdown)을 반환하면, 클라이언트 (Client)가 이를 텍스트로 렌더링합니다. 단순하고, 예측 가능하며, 지루합니다.
FlowChat: 모델이 CSS와 JavaScript가 포함된 가공되지 않은 HTML을 반환하면, 클라이언트가 브라우저의 네이티브 템플릿 시스템 (Native template system)을 기반으로 구축된 스트리밍 프로토콜 (Streaming protocol)을 사용하여 이를 DOM에 직접 주입합니다.
이 단 하나의 변화가 전체 경험을 다르게 만듭니다. 당신은 게임에 대해 읽는 것이 아니라, 게임을 플레이하고 있는 것입니다. 당신은 바비 (Barbie) 색상 팔레트에 대해 읽는 것이 아니라, 그 안에 앉아 있는 것입니다.
AI는 단순히 질문에 답하는 것이 아닙니다. AI는 자신의 응답으로부터 UI를 다시 구축합니다.

실제로 무엇을 할 수 있는가
기술적인 부분으로 들어가기 전에 이것이 실제로 무엇을 의미하는지 체감시켜 드리고 싶습니다. 왜냐하면 데모가 그 어떤 아키텍처 (Architecture) 다이어그램보다 더 흥미롭기 때문입니다.
게임 (Games): 틱택토 (Tic Tac Toe)를 만들어 달라고 요청해 보세요. 플레이 가능한 보드, 클릭하여 이동하는 기능, AI 상대방, 승리 감지 기능이 나타납니다. 커넥트 4 (Connect 4)를 요청하거나, 스네이크 (Snake) 게임을 요청해 보세요. 게임은 채팅창 내에서 폼 (Form)이 포함된 에이전트 버블 (Agent bubble) 형태로 렌더링됩니다. 각 움직임은 LLM (Large Language Model)으로 전송되며, LLM은 이를 처리하여 변경된 셀 (Cell)만 업데이트합니다.
테마 (Themes): "바비 (Barbie) 테마로 바꿔줘"라고 말해 보세요. 모델이 CSS 오버라이드 (CSS overrides)를 주입하여 전체 인터페이스가 분홍색으로 변합니다. 메시지, 테두리, 버튼, 프롬프트 박스 (Prompt box)까지 모두 말이죠. "오펜하이머 (Oppenheimer) 테마로 해줘"라고 말하면, 어두운 세피아 톤과 묵직한 타이포그래피 (Typography)가 적용됩니다. 사이드바 (Sidebar)와 탑바 (Topbar)는 고정되어 셸 (Shell)이 깨지지 않도록 유지되지만, 채팅 내부의 모든 것은 변합니다.
배경 (Backgrounds): "별이 빛나는 들판 (Starfield)을 추가해줘"라고 말해 보세요. 메시지 뒤로 애니메이션 캔버스 (Canvas)가 렌더링됩니다. "DVD 바운스 애니메이션 (DVD bounce animation)을 넣어줘"라고 하면, 로고가 채팅 뷰포트 (Viewport) 안에서 통통 튑니다. "우주 이미지를 사용해줘"라고 하면 이미지가 배경을 채웁니다. 이 모든 것은 격리된 레이어 (Layer) 내에서 작동하므로 실제 UI를 가리지 않습니다.
전체 인터페이스 장악 (Full interface takeover): 어느 시점에는 페이지를 위키피디아 (Wikipedia)처럼 보이게 해달라고 요청했습니다. 모델은 프롬프트 박스를 링크들로 교체했습니다. 어떤 링크든 클릭하면 LLM으로 폼이 전송되었고, LLM은 채팅 콘텐츠를 대체하는 새로운 문서를 생성했습니다. 저는 일요일 오후에 직접 만든 채팅 앱에서 로마 제국에 관한 글을 읽고 있었습니다.


기술 스택 (The Tech Stack)
모든 것은 Cloudflare의 엣지 인프라스트럭처 (Edge infrastructure)에서 실행됩니다. 전통적인 서버는 없습니다. 유지해야 할 Node.js 프로세스도 없습니다. 걱정해야 할 관리형 데이터베이스 (Managed database)도 없습니다.
Cloudflare Workers는 모든 요청마다 TypeScript를 실행합니다. 콜드 스타트 (Cold starts)는 전 세계적으로 50ms 미만입니다. 전체 워커 (Worker)는 라우팅 (Routing), 인증 (Auth), 속도 제한 (Rate limiting), WebSocket 업그레이드, 그리고 LLM 스트리밍 (Streaming)을 처리하는 단일 파일입니다.
Cloudflare Durable Objects는 이 시스템을 작동하게 만드는 핵심 부분입니다. 각 채팅방은 하나의 Durable Object입니다. 즉, 자체 SQLite 데이터베이스, 자체 인메모리 큐 (In-memory queue), 그리고 자체 WebSocket 연결을 가진 상태 유지 액터 (Stateful actor)입니다. 당신과 친구가 동일한 채팅 URL을 열면, 두 사람 모두 동일한 DO에 연결됩니다. 동기화 (Sync)는 직접 구축할 필요가 없습니다. 그것은 단지 아키텍처 (Architecture)가 작동하는 방식일 뿐입니다.
각 DO는 다음을 저장합니다:
- SQLite에 저장된 전체 LLM 메시지 히스토리 (Message history)
- 클라이언트 세션 기록 (Client session records)
- 대기 중인 프롬프트 큐 (Pending prompts queue, 최대 5개)
- 브라우저/IP별 속도 제한 상태 (Rate limit state)
- 읽기 전용 스냅샷을 위한 포크 인덱스 (Fork index)
Hibernatable WebSockets는 DO를 계속 활성화 상태로 유지하지 않고도 연결을 유지합니다. Cloudflare가 핑/퐁 (Ping/pong)을 자동으로 처리합니다. DO는 메시지가 도착하면 깨어나고, 메시지 사이에는 다시 잠듭니다.
better-auth는 선택적 인증을 처리합니다. 설정하지 않으면 앱은 모두에게 공개됩니다. 설정하면 Google, GitHub, 그리고 역할 기반 액세스 제어 (Role-based access control; admin, dev, chat, view, blocked)를 갖춘 이메일/비밀번호 인증을 사용할 수 있습니다.
Inception Labs Mercury-2는 응답을 생성하는 모델입니다. 이는 자기회귀 (Autoregressive) 방식이 아닌 확산 기반 (Diffusion-based) 언어 모델로, GPT나 Claude와는 다르게 생성한다는 것을 의미합니다. 실제로 사용해 보면 빠르다고 느껴지며, 제가 필요로 하는 HTML 출력 형식을 진정으로 이해하는 것처럼 보입니다.

프로토콜 (The Protocol)
이 부분은 설계하면서 가장 즐거웠던 부분이자 제가 가장 자랑스럽게 생각하는 부분입니다.
AI는 응답 스트림에 가공되지 않은 HTML을 그냥 쏟아부을 수 없습니다. 단일 응답이 페이지의 서로 다른 세 부분을 독립적으로 업데이트해야 할 수도 있기 때문입니다. 틱택토 (Tic Tac Toe)의 한 수는 전체 판을 다시 그리는 것이 아니라 하나의 셀만 업데이트해야 합니다. 배경 애니메이션이 사이드바에 영향을 주어서는 안 됩니다. 한 플레이어에게 보내는 개인 메시지가 다른 플레이어의 채팅창에 나타나서도 안 됩니다.
그래서 저는 구분자 기반의 스트리밍 프로토콜 (streaming protocol)을 구축했습니다. 모델은 모든 DOM 업데이트를 구조화된 봉투 (envelope)로 감쌉니다:
PpqUtcLGQdYN4oqc:BODY_START
<template for="/chat/append-message">
<div class="message message-user" data-client-id="1">틱택토 하자</div>
...
템플릿의 for 속성은 DOM 내의 이름이 지정된 마커 (marker)를 타겟팅합니다. 클라이언트 런타임 (client runtime)은 문서 트리 (document tree)를 탐색하며 일치하는 이름을 가진 처리 지침 (processing instructions)을 찾아 이를 템플릿 콘텐츠로 교체합니다. 정교하게 말이죠. 페이지의 다른 어떤 부분도 건드리지 않고 말입니다.
단일 AI 응답은 분할 구분자 (split delimiter)로 구분된 여러 메시지를 포함할 수 있습니다:
PpqUtcLGQdYN4oqc:SPLIT_MESSAGE
따라서 모델은 모든 사용자에게 공개 채팅 확인 메시지를 보내는 동시에, 서버가 WebSockets를 통해 전달하기 전에 제거할 SERVER_PROPS 라우팅 지침을 포함함으로써 단 한 명의 플레이어에게만 개인 메시지를 라우팅할 수 있습니다.
이 모든 것은 Chrome에 도입될 예정인 동적 부분 업데이트 (Dynamic Partial Update) 사양을 구현하는 두 가지 브라우저 폴리필 (polyfills)을 기반으로 구축되었습니다.

시스템 프롬프트 작성하기 (Writing the System Prompt)
AI가 이 프로토콜 형식 내에서 일관되게 유효한 HTML을 생성하도록 만드는 데는 많은 반복 작업이 필요했습니다. 최종 시스템 프롬프트 (system prompt)는 약 300줄에 달하며, 솔직히 프롬프트라기보다는 API 계약 (API contract)에 가깝게 느껴집니다.
이 프롬프트는 디자인 시스템 내 모든 CSS 변수(CSS variable)의 정확한 16진수(hex) 값을 포함하고 있어, 모델이 색상을 추측하는 대신 var(--accent)를 정확하게 작성할 수 있도록 합니다. 테두리 반경(border-radius), 그림자 값(shadow values), 애니메이션 타이밍(animation timing)에 대한 규칙도 포함되어 있습니다. 또한 Chart.js와 d3를 위한 비동기 CDN 로딩 패턴도 포함되어 있는데, 이는 모델이 라이브러리가 로드되기 전에 계속해서 new Chart()를 호출했기 때문입니다.
제가 추적했던 가장 큰 버그는 이것이었습니다: 모델이 append-message 마커를 앱 컨테이너 div의 뒤가 아니라 안에 계속 배치하는 문제였습니다. 이로 인해 이후의 모든 채팅 메시지가 게임 보드 안으로 주입되었습니다. 저는 프롬프트에 '잘못된 예시 vs 올바른 예시'를 넣어 이 문제를 해결했습니다:
<!-- 잘못된 예: 마커가 app div 내부에 있음, 다음 메시지가 영원히 여기에 주입됨 -->
<div id="ttt-app-1">
...board...
...
명시적인 주석이 달린 잘못된 예시는 올바른 예시만 있는 문서보다 더 유용합니다. 모델은 단순히 성공적인 경로(happy path)뿐만 아니라, 실패 모드(failure mode)가 어떤 모습인지도 알아야 합니다.
또한 Mercury-2와 같은 확산 모델(diffusion models)은 자기회귀 모델(autoregressive models)과는 약간 다른 프롬프팅이 필요하다는 점도 배웠습니다. 응답이 타자기처럼 찍히는 것이 아니라 콘텐츠가 실체화되는 느낌에 가깝습니다. 이는 이 사용 사례와 자연스럽게 어우러집니다.
기본적으로 지원되는 멀티 유저 (Multi-User by Default)
모든 채팅 URL은 공유됩니다. 동일한 링크를 두 개의 브라우저 탭에서 열면, 두 탭 모두 WebSockets를 통해 실시간으로 모든 AI 응답을 받습니다. 각 클라이언트는 고유한 ID를 부여받습니다. 사용자 버블(User bubbles)은 클라이언트별로 색상이 지정됩니다.
LLM은 각 클라이언트의 ID를 알고 있습니다:
[1]: 비밀 단어를 맞히고 싶어요
[2]: 힌트를 주고 싶어요
모델은 두 플레이어 모두에게 보이는 메시지 하나와, 클라이언트 1에게만 보이는 비밀 내용이 담긴 두 번째 메시지를 보낼 수 있습니다. 이는 서버 측에서 라우팅되며, 잘못된 브라우저에 도달하기 전에 WebSocket 페이로드(payloads)에서 제거됩니다.
저는 별도의 특별한 멀티 유저 로직을 추가하지 않았습니다. Durable Object 아키텍처가 이를 자연스럽게 작동하게 만듭니다. 모든 클라이언트는 동일한 DO 인스턴스에 연결됩니다. DO가 WebSocket 연결을 보유하고 있습니다. LLM이 응답하면, DO는 모든 클라이언트에게 브로드캐스트(broadcast)합니다.

UI
순수 CSS (Pure CSS)를 사용했습니다. 프레임워크(framework), Tailwind, 컴포넌트 라이브러리(component library)는 전혀 사용하지 않았습니다. Inter 폰트는 비차단(non-blocking) 방식으로 로드되며, 딥 네이비(deep navy) 팔레트(#06091a에서 #101630)와 페리윙클 인디고(periwinkle indigo) 액센트 컬러(#5b6ef5)를 사용했습니다.
앱 셸(app shell)은 사이드바(sidebar)와 상단 바(topbar)가 있는 메인 영역으로 구성됩니다. 사이드바와 상단 바는 항상 물리적으로 불투명(opaque)합니다. 채팅 뷰포트(chat viewport)만이 테마와 배경이 렌더링될 수 있는 유일한 구역입니다. 이는 AI가 실수로 우주 사진을 사용하여 내비게이션(navigation)을 가려버리는 것을 방지하기 위함인데, 그렇게 하지 않으면 반드시 그런 일이 발생합니다. 제가 격리(containment) 문제를 해결하기 전에는 실제로 여러 번 그런 일이 일어났기 때문에 잘 알고 있습니다.
.chat-viewport {
position: relative;
isolation: isolate;
...
배경 레이어(background layer)는 z-index: 0에 위치합니다. 채팅 메시지는 z-index: 2에 위치합니다. 사이드바와 상단 바는 뷰포트 외부에 완전히 분리된 요소입니다. 구조적 격리(structural containment)는 !important나 뮤테이션 옵저버(MutationObservers)를 사용하여 강제하려는 것보다 훨씬 효과적입니다. 저는 처음에 뮤테이션 옵저버를 시도했으나, 페이지 전체를 얼려버리는 무한 루프(infinite loop)를 유발했습니다. 뼈아픈 교훈을 얻었습니다.
타이핑 인디케이터(Typing indicator), 낙관적 UI(optimistic) 사용자 버블, 메시지에 적용된 스프링 진입 애니메이션(spring entrance animations) 등이 포함되어 있습니다. 전송 버튼에는 글로우(glow) 효과가 있습니다. 사소한 부분들이지만 모이면 큰 차이를 만듭니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기