SDK
6e2c1bf 기준입니다.
tambo는 개발자가 등록한 React 컴포넌트를 AI가 선택하고, 그 컴포넌트의 props를 스트리밍으로 채우도록 돕는 생성형 UI 도구 모음입니다. “AI가 UI를 만든다”는 표현만으로는 핵심을 놓치기 쉽습니다. 여기서 기본 흐름은 새 React 소스 파일을 생성하는 일이 아닙니다. 화면의 코드는 개발자가 준비하고, AI는 사용할 화면의 이름과 그 화면에 넣을 데이터를 결정합니다.
props는 React 컴포넌트에 전달하는 입력값입니다.
같은 SalesCard라도 제목과 금액을 다르게 전달하면 서로 다른 매출 카드가 됩니다.컴포넌트는 주방이 이미 만들 줄 아는 요리입니다. 설명문은 메뉴판, 스키마는 “이 요리는 인원수와 맵기 정보를 받아야 한다”는 주문 양식입니다. AI는 메뉴에 있는 항목과 필요한 입력값을 고르고, React는 준비된 조리법대로 화면을 그립니다.
메뉴 선택이 잘되어도 주방이 잘못된 재료를 처리할 수 있으면 문제가 생깁니다. 따라서 개발자는 데이터 검증, 비어 있는 props, 권한이 필요한 동작을 함께 설계해야 합니다.
propsSchema를 등록합니다.이 연결은 react-sdk/src/providers/tambo-registry-provider.tsx의 registerComponent(),
packages/client/src/utils/registry-conversion.ts의 toAvailableComponent(),
react-sdk/src/v1/components/v1-component-renderer.tsx의 ComponentRenderer에서 확인할 수 있습니다.
| 부품 | 실제 위치 | 담당하는 일 |
|---|---|---|
| React SDK | react-sdk/ | 컴포넌트 등록, 훅, React 상태, 렌더링 |
| 범용 클라이언트 | packages/client/ | 이벤트 처리, 도구 실행 등 React 밖에서도 쓸 수 있는 로직 |
| Cloud API | apps/api/ | 사용자·대화·실행 관리, 모델 호출 연결, 스트리밍 응답 |
| Cloud 대시보드 | apps/web/ | 프로젝트와 설정을 관리하는 Next.js 앱 |
따라서 SDK만 읽으면 컴포넌트 사용법은 배울 수 있지만, 대화가 저장되거나 동시 실행이 막히는 원리는 보이지 않습니다. 이 글은 브라우저부터 데이터베이스까지 연결해 읽습니다.
이 커밋의 README.md와 SELF-HOSTING.md는 신규 가입 중단,
2026-10-31까지 호스팅 API 운영 후 중단, 2026-11-30 사용자 데이터 삭제를 공지합니다.
README 안의 “5분 시작”이나 호스팅 선택지 설명은 이 공지와 함께 읽어야 합니다.
운영 일정의 출처는 해당 커밋의 README 공지입니다. 학습은 로컬 소스와 가짜 데이터로 진행하고, 서버를 운영하려면 자체 호스팅 구성을 검토합니다.
일반적인 채팅 UI에서는 “이번 달 매출이 1,250만 원입니다”라는 답변이 끝입니다. tambo에서는 같은 요청이 매출 카드나 차트의 입력값으로 연결됩니다. 이미 만든 디자인 시스템을 활용할 수 있고, 사용자가 결과 화면에서 다음 행동을 할 수 있다는 점이 관심을 끌 만합니다.
TrendShift Daily #29는 2026-10-23 기준 순위입니다. 아래 설명은 구현에 근거한 주목할 만한 설계에 대한 해석입니다. 순위만으로 인기의 원인이나 실제 사용 기업 수를 판단할 수는 없습니다.
도구 호출을 UI에 연결하려면 컴포넌트 목록, 입력 양식, 생성 중인 JSON, 대화 저장, 실행 취소가 필요합니다. tambo는 이를 SDK와 서버로 나눠 구현합니다. 이 레포의 학습 가치는 모델 호출 한 줄보다, 모델의 불완전한 응답을 제품 상태로 바꾸는 연결부에 있습니다.
| 비교 관점 | 직접 조립하는 경우 | tambo의 이점과 부담 |
|---|---|---|
| 문자열 채팅 UI | 응답 문자열을 메시지 목록에 표시하는 흐름부터 구현 | 컴포넌트 선택과 props 갱신까지 연결. 그만큼 상태와 서버 구성이 복잡함 |
| Vercel AI SDK 기반 조립 | 모델·도구 스트림 위에 앱의 UI 매핑과 저장 정책을 설계 | 등록 목록, UI 이벤트, 대화 실행 계층을 제공. 내부에서도 ai 패키지를 사용함 |
| CopilotKit·assistant-ui | README가 비교 대상으로 드는 에이전트·채팅 UI 도구들 | tambo는 자기 컴포넌트 등록과 props 스트리밍을 전면에 둠. 타 제품의 현재 지원 범위는 별도 확인 필요 |
| 앱 소스 생성 방식 | 생성된 코드를 검토하고 빌드하는 과정이 중심 | 이 레포의 기본 경로는 실행 중 등록 컴포넌트와 입력값을 고름. 허용 목록 설계가 핵심 |
이 표는 사용 방식과 개발자가 맡는 일을 비교한 것입니다. 타 제품에 관한 설명의 출처는 이 커밋의 README입니다. 특히 README의 경쟁 비교표에는 백엔드까지 MIT라고 적힌 행이 있으나 실제 Cloud 패키지는 Apache-2.0입니다. MCP 지원 유무나 특정 프레임워크 종속성에 관한 타 제품 평가는 그대로 옮기지 않았습니다.
생성형 컴포넌트: 답변에 매출 카드를 추가하는 식입니다.
메시지 안의 컴포넌트 블록을 ComponentRenderer가 읽어 화면을 그립니다.
상호작용 가능한 컴포넌트: 이미 있는 메모나 작업판을 AI가 갱신하도록 등록합니다.
실제 공개 함수는 withTamboInteractable이며 구현은 react-sdk/src/hoc/with-tambo-interactable.tsx에 있습니다.
README 예시에는 withInteractable이라는 이름이 남아 있습니다.
그러나 이 커밋의 공개 진입점인 react-sdk/src/v1/index.ts에서 내보내는 이름은 withTamboInteractable입니다.
예제를 붙여 넣기 전에 패키지 버전과 공개 진입점을 읽는 습관을 배울 수 있는 사례입니다.
다음 버전은 커밋 6e2c1bf의 의존성 파일 기준입니다.
^는 고정 버전이 아니라 허용 범위이고, package-lock.json은 구체적인 의존성 버전을 기록합니다.
package.json의 선언 범위, 루트 overrides, 잠금 파일에 기록된 버전을 함께 봐야 실제 의존성 구성을 이해할 수 있습니다.
| 기술 | 선언 범위와 잠금 버전 | 역할·근거 파일 |
|---|---|---|
| React | SDK 피어 ^18.0.0 || ^19.0.0 / 루트 강제·잠금 19.2.6 | 훅·Context·컴포넌트. react-sdk/package.json, 루트 package.json |
| Next.js | 웹 선언 ^16.2.12 / 잠금 루트 항목 16.3.0 | Cloud 대시보드. apps/web/package.json |
| TanStack Query | ^5.90.16 | API 조회·변경·캐시. SDK 내부 래퍼는 react-sdk/src/hooks/react-query-hooks.ts |
| Tailwind CSS | 웹 ^3.4.19 | 웹 UI 스타일. 컴포넌트 라이브러리와 SDK 코어를 구분해서 읽기 |
| tRPC | 웹 ^11.17.0 | 대시보드 내부 API의 타입 연결. 외부 SDK용 REST API와 역할이 다름 |
| Zod | SDK 피어 ^3.25.76 || ^4 | 입력 스키마 작성. 공개 API는 JSON Schema·Standard Schema 경로도 포함 |
| MCP SDK | ^1.30.0 | 도구·리소스 연결. React SDK와 범용 클라이언트 의존성에 존재 |
| 부분 JSON | partial-json ^0.1.7 | 문자열이 덜 도착한 동안 props를 구성. SDK 선언 기준 |
패키지 버전도 구별해야 합니다. @tambo-ai/react는 1.4.0,
@tambo-ai/client는 1.3.0, Cloud 웹은 0.136.0, API는 0.146.1입니다.
릴리스 이름 web-v0.136.0을 React SDK의 버전으로 읽으면 안 됩니다.
| 기술 | 선언 버전 | 구체적 용도와 위치 |
|---|---|---|
| NestJS | @nestjs/core ^11.1.26 | 컨트롤러·서비스·주입·가드. apps/api/package.json |
| Vercel AI SDK | API ai ^6.0.185 / backend ^6.0.138 | AISdkClient가 streamText()·generateText() 사용 |
| 모델 어댑터 | @ai-sdk/openai ^3.0.48, @ai-sdk/anthropic ^3.0.64 | 루트 및 packages/backend/package.json. 모델 제공자별 구현 연결 |
| Drizzle ORM | drizzle-orm ^0.45.2 | 관계형 테이블·타입·쿼리. packages/db/src/schema.ts |
| PostgreSQL 드라이버 | pg ^8.21.0 | DB 접속. packages/db/package.json |
| 마이그레이션 | drizzle-kit ^0.31.10 | DB 구조를 바꾸는 도구. 선언은 packages/db/package.json |
| AG-UI | 클라이언트 @ag-ui/core ^0.0.57 | 실행·텍스트·도구 이벤트의 공통 형식 |
| JSON Patch | 클라이언트 fast-json-patch 3.1.1 | props 변경분 적용. packages/client/src/utils/json-patch.ts |
| 관측 도구 | API @sentry/nestjs ^10.57.0, backend langfuse ^3.38.6 | 오류·모델 실행 추적 관련 의존성. 선언만으로 모든 배포에서 활성화됐다고 볼 수 없음 |
| 도구 | 확인값 | 무엇을 배울까 |
|---|---|---|
| Node.js·npm | 최소 Node >=22·npm >=11 / 지정값 22.22.0·11.7.0 | devEngines, .node-version, packageManager를 나눠 읽기 |
| TypeScript | 루트 ^5.9.3 / 잠금 5.9.3 | 패키지 간 타입 계약과 빌드 경계 |
| Turborepo | 루트 ^2.9.16 / 잠금 2.10.8 | turbo.json의 선행 작업과 결과 캐시 |
| 테스트·형식 | SDK jest ^30.4.2 / 루트 prettier ^3.8.3 | 컴포넌트·이벤트·서버 테스트를 각 패키지에 배치 |
| 컨테이너 | node:22-alpine, postgres:17, minio/minio:latest | Dockerfile 다단계 빌드와 Compose 서비스 연결. 이미지 태그는 불변 고정값이 아님 |
| CI | GitHub Actions의 lint·test·e2e·build | .github/workflows/ci.yml에서 품질 검사 흐름 읽기 |
turbo.json의 build.dependsOn: ["^build"]는 의존 패키지를 먼저 빌드하라는 뜻입니다.
API 개발 작업은 interruptible: true이며 core·backend·db의 소스 변경을 입력으로 지정합니다.
React SDK는 CJS와 ESM 출력을 따로 만들고, API Dockerfile은 turbo prune으로 필요한 워크스페이스를 추립니다.
패키지 이름을 외우기보다 “React가 필요 없는 코드인가?”, “DB에 접근하는가?”, “브라우저 밖에 남겨야 하는가?”를 묻습니다. 이 세 질문으로 SDK·범용 클라이언트·코어·백엔드·DB 패키지의 경계를 상당 부분 설명할 수 있습니다.
위 그림은 일반 LLM 경로를 중심으로 단순화한 것입니다.
V1Service.executeRun()은 ThreadsService.advanceThread()와 비동기 큐를 연결하고,
TamboBackend.runDecisionLoop()는 설정된 클라이언트 종류에 따라 일반 결정 루프 또는 에이전트 경로를 선택합니다.
MCP 서버도 연결 위치에 따라 브라우저 쪽과 서버 쪽 경로가 달라질 수 있습니다.
API와 React 사이에 있는 @tambo-ai/typescript-sdk는 이 레포의 워크스페이스가 아닌 외부 패키지입니다.
메시지 전송 훅과 범용 클라이언트가 이 패키지의 메서드를 호출합니다.
API 동작을 읽을 때는 호출부와 외부 패키지 내부 구현을 구분해야 합니다.
registerComponent()는 validateAndPrepareComponent()로 이름과 스키마를 확인한 뒤
실제 React 함수와 직렬화된 props 정의를 목록에 저장합니다.
서버로 전송할 때 toAvailableComponent()는 이 중 이름·설명·JSON Schema만 추립니다.
// packages/client/src/utils/registry-conversion.ts 발췌
return {
name: component.name,
description: component.description,
propsSchema: component.props,
};
함수 자체를 JSON으로 보내지 않는 것이 핵심입니다. 모델은 “이름이 SalesCard인 화면이 있고 이런 입력을 받는다”는 설명을 보고 선택합니다. 브라우저는 응답의 이름으로 로컬 목록에서 진짜 함수를 찾습니다.
ComponentRenderer는 getComponentFromRegistry()로 대상을 찾고
React.createElement(registeredComponent.component, validatedProps)로 요소를 만듭니다.
알 수 없는 이름 등으로 이 과정에서 예외가 나면 로그를 남기고 기본값 null 또는 지정된 대체 화면을 반환합니다.
택배 주문서에 공장 설계도를 넣는 대신 상품 코드와 옵션만 적는 방식입니다. 목록은 상품 코드와 실제 제품을 연결하고, 스키마는 옵션 입력 양식을 설명합니다. 허용 목록 밖의 이름은 정상 제품으로 연결되지 않습니다.
ComponentRenderer는 Standard Schema 검증 분기에서 실패를 경고하고 원래 props로 계속 그리며,
비동기 검증도 건너뜁니다. 일반 등록 경로의 props는 JSON Schema로 변환되어 저장되므로
그 분기의 isStandardSchema() 조건이 항상 성립하는 것도 아닙니다.
즉, props를 무조건 신뢰하거나 이 렌더러를 보안 검증 장치로 생각하면 안 됩니다. 특히 데이터 변경 권한은 실제 도구나 서버에서 검증해야 합니다. 컴포넌트 함수 내부에서 나중에 발생한 렌더링 오류까지 이 요소 생성용 try/catch가 전부 잡는다는 뜻도 아닙니다.
학습 질문: React 함수와 전송용 설명을 한 객체에서 출발시킨 뒤 왜 따로 분리할까요? 브라우저 전용 코드, 직렬화 가능한 데이터, 신뢰할 수 없는 모델 출력을 각각 구분해 보세요.
packages/backend/src/services/decision-loop/decision-loop-service.ts의 runDecisionLoop()는
UI 도구를 분류하고, 시스템 메시지와 이력을 준비한 다음 llmClient.complete({ stream: true, ... })를 호출합니다.
AISdkClient는 AI SDK의 streamText() 결과인 fullStream을 순회합니다.
UI 도구 이름에는 show_component_ 접두사가 쓰입니다.
예를 들어 모델의 도구 입력이 한 글자씩 도착하면 {"title":"서처럼 아직 완성되지 않은 JSON이 생깁니다.
여기서 매번 JSON.parse()만 호출하면 계속 실패하므로, 생성 중인 상태를 다룰 별도 경로가 필요합니다.
// 교육용 흐름 예시: 아직 닫히지 않은 입력도 도착할 수 있습니다.
도착 조각 1: {"title":"서
도착 조각 2: 울 매출","amount":
도착 조각 3: 1250}
시작: tambo.component.start
변경: tambo.component.props_delta
완료: tambo.component.end
packages/backend/src/util/component-streaming.ts의 ComponentStreamTracker.processJsonDelta()는
조각을 누적하고 partial-json으로 현재 해석 가능한 객체를 얻습니다.
이전 객체와 비교해 변경분을 JSON Patch로 내보냅니다.
finalize()에서는 완성된 입력을 JSON.parse()로 읽고 객체인지 확인합니다.
부분 해석은 입력을 끝까지 검증했다는 뜻이 아닙니다. props별 완료 상태도 “새 속성이 나타났는가” 같은 스트리밍 규칙으로 추적합니다. 화면은 중간에 없는 필드와 바뀌는 문자열을 견뎌야 하며, 저장·결제처럼 확정 입력이 필요한 작업은 이 중간 상태에 바로 연결하면 안 됩니다.
| 이벤트 | 담긴 의미 | 브라우저에서 할 일 |
|---|---|---|
| 시작 | tambo.component.start이 ID와 이름의 컴포넌트를 시작 | 빈 props 객체를 가진 블록 준비 |
| 속성 변경 | tambo.component.props_delta기존 props에 적용할 변경 목록 | 같은 컴포넌트의 props 갱신 |
| 완료 | tambo.component.end최종 props와 선택적 최종 state | 최종 상태와 완료 표시 반영 |
// JSON Patch의 교육용 예시
기존 props: {"title":"서울","amount":1000}
변경 목록: [{"op":"replace","path":"/amount","value":1250}]
적용 결과: {"title":"서울","amount":1250}
createJsonPatchPath()는 키에 들어 있는 /와 ~를 JSON Pointer 규칙대로 이스케이프합니다.
예를 들어 속성 이름이 sales/kr라면 경로는 /sales~1kr입니다.
단순히 슬래시를 붙이는 코드와 표준을 지키는 코드의 차이를 배울 수 있습니다.
누적 크기 제한 상수는 10 * 1024 * 1024입니다.
소스 주석은 10MB라고 부르지만 실제 비교는 delta.length의 누적값이므로
엄밀한 UTF-8 바이트 수 제한과는 다릅니다. 설정값뿐 아니라 그 값을 어떤 단위로 비교하는지도 읽어야 합니다.
packages/client/src/utils/event-accumulator.ts의 streamReducer(state, action)는
실행 시작·완료, 텍스트 조각, 도구 호출, 컴포넌트 변경 이벤트를 현재 대화 상태에 적용합니다.
React 쪽 TamboStreamProvider는 이 함수를 useReducer()에 연결합니다.
스트리밍 UI는 문자열을 길게 만드는 작업이 아니라, 이벤트 순서에 맞춰 같은 화면의 상태를 계속 갱신하는 작업입니다.
새 대화는 PLACEHOLDER_THREAD_ID = "placeholder"로 시작합니다.
메시지를 바로 보여 준 뒤 RUN_STARTED에서 실제 thread ID를 받으면,
리듀서는 임시 메시지를 실제 대화로 옮기고 임시 대화를 비웁니다.
사용자가 이미 다른 대화로 이동했다면 현재 선택을 무조건 빼앗지 않도록 조건도 둡니다.
// 교육용 의사 코드: 실제 함수는 streamReducer()
state = reducer(state, 사용자_메시지_이벤트);
state = reducer(state, 실제_대화_ID를_담은_RUN_STARTED);
state = reducer(state, 컴포넌트_시작_이벤트);
state = reducer(state, props_변경_이벤트);
state = reducer(state, 컴포넌트_완료_이벤트);
props 변경에는 applyJsonPatch()를 쓰며,
내부의 applyPatch(target, operations, true, false)는 패치 검증을 켜고 원본 직접 수정을 끕니다.
같은 객체를 몰래 바꾸면 React의 변경 감지와 이전 상태 비교가 꼬일 수 있기 때문입니다.
범용 TamboClient에는 getState()와 subscribe()도 있습니다.
외부 상태 저장소 형태를 배우기 좋은 코드지만, 현재 React 스트림 Provider의 실제 구현은 useReducer입니다.
인터페이스가 useSyncExternalStore와 호환된다는 설명을 곧바로 “현재 React SDK가 그 훅을 쓴다”로 바꾸면 안 됩니다.
ComponentRenderer의 안내처럼 렌더링 목록의 key를 content.id로 안정적으로 유지하는 것도 중요합니다.
props가 갱신될 때마다 다른 key를 주면 같은 카드가 새 카드로 교체되어 입력 상태를 잃을 수 있습니다.
이벤트 처리가 정확해도 같은 대화에서 두 요청이 동시에 모델을 돌리면 상태가 충돌할 수 있습니다.
V1Service.startRun()는 트랜잭션 안에서 실행 잠금을 획득하고,
도구 결과 저장·대기 도구 목록 정리·실행 생성·현재 run ID 설정을 묶습니다.
// packages/db/src/operations/runs.ts의 acquireRunLock() 핵심을 단순화
UPDATE threads
SET run_status = 'waiting'
WHERE id = :threadId AND run_status = 'idle'
RETURNING id;
실제 코드는 Drizzle 쿼리이며 위 SQL은 원리를 설명한 것입니다. 조회 후 따로 “실행 중”이라고 쓰는 대신 아직 idle일 때만 갱신합니다. 갱신된 행이 없으면 잠금을 얻지 못한 것입니다. 대기 중이던 도구 호출 목록도 스냅샷과 일치할 때만 지워, 중간에 바뀐 상태를 덮어쓰지 않습니다.
releaseRunLockIfCurrent()는 currentRunId === runId인 경우에만 잠금을 풉니다.
오래된 실행의 취소 처리가 뒤늦게 도착해 새 실행의 잠금을 풀어 버리는 문제를 막는 조건입니다.
이보다 앞에서는 V1ThreadInProjectGuard.canActivate()가
ensureThreadByProjectId()를 통해 프로젝트와 사용자 contextKey에 맞는 대화인지 확인합니다.
API 키가 있다고 모든 사용자의 대화를 읽을 수 있게 해서는 안 됩니다.
userKey 문자열 자체가 브라우저 사용자의 신원을 증명하는 토큰은 아니라는 점도 구별해야 합니다.
응답 경계도 세심합니다. V1Controller.createRun()은
Content-Type: text/event-stream으로 스트림을 열고 응답 완료 전에 연결이 닫히면 cancelRun()을 요청합니다.
헤더를 보낸 뒤 생긴 오류는 새 HTTP 오류 응답 대신 RUN_ERROR 이벤트로 보냅니다.
입장권 검사와 회의실 예약은 서로 다른 문제입니다. 가드는 “이 사람이 이 방에 들어와도 되는가”를 확인하고, 실행 잠금은 “같은 방을 두 팀이 동시에 사용하지 않는가”를 확인합니다. 둘 중 하나만 있어서는 안전한 대화 실행이 되지 않습니다.
모든 도구가 서버에서 실행되는 것은 아닙니다.
react-sdk/src/v1/hooks/use-tambo-v1-send-message.ts는 입력 대기 이벤트를 만나면
executeAllPendingTools()로 등록된 클라이언트 도구를 실행합니다.
executeToolsAndContinue()는 결과와 previousRunId를 담아 다음 스트림을 요청합니다.
범용 클라이언트에도 같은 책임을 가진 packages/client/src/utils/send-message.ts가 있습니다.
이 구현에서는 완료되지 않은 도구의 ID를 추적한 후 결과를 다음 요청에 연결합니다.
“한 번의 질문 = 한 번의 HTTP 요청”으로만 생각하면 이 왕복을 이해하기 어렵습니다.
컴포넌트 props는 화면을 그리기 위한 입력이고, 도구는 데이터를 가져오거나 동작을 수행하는 함수입니다. 두 가지가 모두 모델의 도구 호출 형식과 연결될 수 있어도, 실제 실행 장소와 부작용은 구분해서 설계해야 합니다.
위 트리는 학습에 필요한 부분만 추렸습니다.
package.json이 있는 저장소 루트를 기준으로 모든 파일 경로를 표기합니다.
react-sdk/src/index.ts에서 공개 API의 실제 출발점을 확인합니다.react-sdk/src/v1/providers/tambo-v1-provider.tsx에서 Provider 조합을 봅니다.react-sdk/src/v1/hooks/use-tambo-v1-send-message.ts에서 메시지 전송과 도구 왕복을 읽습니다.packages/client/src/utils/event-accumulator.ts에서 상태가 바뀌는 규칙을 읽습니다.apps/api/src/v1/v1.controller.ts·apps/api/src/v1/v1.service.ts에서 서버 경계를 봅니다.packages/backend/src/util/component-streaming.ts에서 모델 조각이 UI 이벤트가 되는 지점을 찾습니다.문서를 먼저 읽더라도 마지막에는 export와 호출부로 돌아오세요.
이 커밋의 react-sdk/AGENTS.md는 예전 Provider 계층을 설명하는 부분이 있지만,
실제 공개 경로는 v1입니다. 설계 설명과 현재 구현을 대조하는 습관 자체가 중요한 학습입니다.
| 분류 | 파일 수 | 물리 줄 수 |
|---|---|---|
| TypeScript (.ts) | 702 | 134,027 |
| TSX (.tsx) | 539 | 87,407 |
| JavaScript 계열 | 52 | 1,921 |
| CSS | 14 | 2,092 |
| SQL | 95 | 796 |
| 셸 스크립트 | 15 | 949 |
| YAML 계열 | 23 | 2,388 |
| TS·TSX 테스트 부분 | 249 | 86,053 — 위 TS·TSX에 이미 포함 |
git ls-files의 추적 파일 목록을 확장자로 분류한 물리 줄 수입니다.
마지막 개행이 없는 파일의 마지막 줄도 1줄로 계산했습니다.
빈 줄·주석·타입 선언·테스트·예제·추적된 생성 코드가 포함되며,
의존성 설치물·Git 내부 파일·JSON 잠금 파일·Markdown·스냅샷은 이 표에서 제외했습니다.
TS·TSX 합계는 221,434줄입니다. 그중 파일명에 .test. 또는 .spec.가 있는 파일은 약 38.9%입니다.
이것은 실행 커버리지가 아닙니다. 테스트 줄 수가 많다고 모든 분기가 검증되었다고 단정할 수 없습니다.
패키지별로는 TS·TSX 기준 apps/web 45,016줄, apps/api 40,246줄,
react-sdk 39,319줄, packages/backend 14,117줄,
packages/client 8,558줄입니다. 각 값에도 테스트가 포함됩니다.
TamboProvider는 단일 기능을 가진 여러 Provider를 조합합니다.
등록 목록, 인증, MCP, 상호작용, 스트리밍, 입력 상태를 하나의 거대한 훅에 전부 넣지 않습니다.
먼저 각 Provider가 무엇을 제공하는지 표로 적고, 그 값을 읽는 훅으로 화살표를 그려 보세요.
다음은 소스의 등록 형태를 따른 교육용 예시입니다. 모델을 호출하지 않고도 “데이터가 아직 덜 왔을 때 화면이 어떻게 보일까”를 생각할 수 있습니다. 아래 컴포넌트는 빈 값과 일부만 도착한 props도 표시하도록 구성했습니다.
import { z } from "zod";
import type { TamboComponent } from "@tambo-ai/react";
const salesSchema = z.object({
title: z.string().describe("카드에 표시할 매출 제목"),
amount: z.number().describe("매출액, 단위는 만 원"),
});
type SalesProps = z.infer<typeof salesSchema>;
function SalesCard({ title, amount }: Partial<SalesProps>) {
const shownTitle = title ?? "매출을 불러오는 중";
const shownAmount = typeof amount === "number"
? `${amount.toLocaleString("ko-KR")}만 원`
: "집계 중";
return (
<section>
<h3>{shownTitle}</h3>
<p>{shownAmount}</p>
</section>
);
}
export const components: TamboComponent[] = [
{
name: "SalesCard",
description: "지역별 또는 기간별 매출 금액 한 개를 표시한다",
component: SalesCard,
propsSchema: salesSchema,
},
];
Partial<SalesProps>는 컴포넌트가 중간 입력을 받아도 표시를 유지하도록 한 교육용 선택입니다.
스키마의 최종 필드 요구와 화면의 중간 표시 정책은 서로 다른 책임입니다.
금액이 0일 때도 정상 표시되도록 진릿값 검사 대신 숫자 타입을 확인한 이유도 설명해 보세요.
z.infer는 스키마로부터 개발 중 타입을 얻습니다.
그러나 네트워크로 온 JSON이 그 타입을 지키는지는 별도로 확인해야 합니다.
validateTool()의 속성 확인과 streamReducer()의 이벤트별 분기를 비교하면
“외부 입력을 확인하는 코드”와 “확인된 타입을 좁혀 사용하는 코드”의 차이가 보입니다.
실습 아이디어: 가짜 이벤트 세 개를 종이에 적고 type 값에 따라 어떤 속성을 읽을 수 있는지 표시하세요.
RUN_STARTED의 thread ID와 텍스트 이벤트의 message ID가 왜 서로 다른지 설명할 수 있어야 합니다.
propsSchema는 개발자가 작성하기 편한 정의이고,
API에 실리는 propsSchema는 JSON으로 직렬화할 수 있는 설명입니다.
validateAndPrepareComponent()와 toAvailableComponent()를 차례로 읽어
같은 이름의 데이터가 어느 단계에서 어떤 형태로 바뀌는지 적어 보세요.
실습 아이디어: 필드 세 개짜리 스키마를 만들었다고 가정하고, 각 필드의 타입뿐 아니라 단위·허용 범위·설명문을 쓰세요. “금액”만 적는 것과 “원 단위의 세금 포함 금액”이라고 적는 것의 차이가 모델 선택과 데이터 해석에 영향을 줍니다.
runDecisionLoop()는 async function*으로 여러 결과를 순차적으로 내보냅니다.
일반 Promise가 마지막 값 하나를 기다리는 방식이라면, 비동기 반복자는 도착하는 결과를 순서대로 처리하는 데 적합합니다.
for await...of를 만날 때는 “무엇을 기다리며, 취소 신호는 어디로 전달되는가”를 함께 확인하세요.
실습 아이디어: 입력 조각이 오지 않는 경우, 중간에 끊기는 경우, 마지막 JSON이 틀린 경우를 나눠 적고
processJsonDelta()와 finalize() 중 어디에서 처리해야 할지 분류합니다.
일반 JSON API에서는 마지막에 성공 또는 실패를 한 번 돌려줍니다.
SSE에서는 이미 보낸 결과를 되돌릴 수 없으므로 스트림 내부의 오류 표현이 필요합니다.
V1Controller.createRun()의 headersSent 분기가 그 차이를 보여 줍니다.
DB에서는 startRun()의 트랜잭션 안과 밖을 구분해 보세요.
모델 응답이 끝날 때까지 긴 DB 트랜잭션을 유지하는 것과, 짧은 트랜잭션으로 실행 상태를 예약하는 것은 다릅니다.
여기서는 실행 준비를 원자적으로 묶은 뒤 스트리밍 작업을 이어 갑니다.
| 테스트 주제 | 파일과 확인할 경계 |
|---|---|
| 부분 JSON | packages/backend/src/util/component-streaming.test.ts미완성 JSON, 최종 파싱 실패, 크기 제한, 슬래시·물결표가 있는 키 |
| 이벤트 누적 | react-sdk/src/v1/utils/event-accumulator.test.ts임시 대화 이동, 여러 컴포넌트, 메시지 병합, 이벤트 순서 |
| 패치 적용 | react-sdk/src/v1/utils/json-patch.test.ts패치 실패 메시지에 연산 요약과 대상 키를 포함하는지 검사 |
| API 응답 | apps/api/src/v1/__tests__/v1.controller.test.ts컨트롤러 요청·응답 처리 시나리오 |
테스트를 읽을 때는 정답 문자열보다, 작성자가 어떤 실패 상황을 별도 사례로 남겼는지 먼저 보세요.
내 말로 “스키마는 전송 전에 변환된다”, “중간 props는 완성된 입력과 다르다”, “이벤트와 DB 상태를 함께 관리한다”를 설명할 수 있으면 핵심을 잡은 것입니다. 모노레포의 모든 라이브러리 사용법을 한 번에 익힐 필요는 없습니다.
이 저장소는 자체 모델 가중치를 포함한 로컬 추론 엔진이 아닙니다. 기본적인 모델 응답 경로는 외부 제공자 호출입니다. tambo 소스를 공부하는 데 GPU는 필요 없고, 모델 실행 비용과 서버 운영 비용은 별도로 계산해야 합니다.
| 목적 | 확인된 요구사항 | 자원 판단 |
|---|---|---|
| 소스만 읽기 | 텍스트 편집기, Git, 줄 수 계산용 Python 등 | Node·Docker·모델 API 키 없이 가능 |
| 워크스페이스 개발 | 루트 기준 Node >=22, npm >=11 | 저장소 지정 버전은 Node 22.22.0·npm 11.7.0 |
| 자체 호스팅 | 공식 안내 기준 Docker·Compose, 모델 제공자 키 | PostgreSQL·API·웹 및 필요한 저장소 자원 고려 |
| GPU·VRAM | 일반 외부 모델 API 경로에 별도 GPU 요구 없음 | 로컬 호환 모델 서버를 따로 붙이면 그 서버 요구량을 추가 |
| CPU·RAM | 자체 호스팅 안내와 Compose에 고정 최소 사양 수치 없음 | 함께 실행할 서비스·빌드·테스트와 동시 요청량에 따라 산정 |
| 디스크 | 심볼릭 링크를 제외한 추적 파일 합계 81,293,032바이트, 약 77.5MiB | Git·의존성·이미지·빌드 결과·DB를 포함한 설치 크기는 별도 |
코드 규모만으로 필요한 RAM을 결정할 수는 없습니다. 동시 Next.js 빌드와 테스트, 컨테이너, 브라우저를 함께 켜는지에 따라 필요한 자원이 달라집니다. 코드 열람과 전체 서비스 운영에 필요한 자원을 구분해서 계획하세요.
| 서비스 | 개발·Compose에서 확인한 값 | 주의할 점 |
|---|---|---|
| Cloud 웹 | 개발 8260 / Compose 8260:3000 | 왼쪽은 호스트, 오른쪽은 컨테이너 포트 |
| API | 기본 개발 8261 / Compose 8261:3000 | 두 서비스가 컨테이너 안에서 각각 3000을 써도 충돌하지 않음 |
| PostgreSQL | Compose 5433:5432 | 컨테이너 간 DB 주소는 서비스 이름 postgres와 5432 |
| MinIO | 9000·9001 | S3 호환 저장 API와 관리 콘솔 |
| 시연·문서 앱 | 8262·8263 | 전체 Cloud 실행과 문서·SDK 개발 실행은 별도 작업 |
SELF-HOSTING.md는 웹·API·PostgreSQL 세 서비스를 중심으로 설명하지만,
실제 docker-compose.yml에는 MinIO도 정의되어 있습니다.
첨부 파일 기능은 별도의 S3 설정을 확인해야 합니다.
StorageConfigService.hasStorageConfig()는 클라이언트 구성, 비어 있지 않은 버킷, 서명용 비밀값을 확인합니다.
자체 호스팅 안내에는 DB 암호, API_KEY_SECRET, PROVIDER_KEY_SECRET,
NEXTAUTH_SECRET, 모델 제공자 키 등이 등장합니다.
이 이름들은 비밀값의 역할을 구분하기 위한 것이며 실제 값을 공부자료에 복사할 필요는 없습니다.
브라우저에서 사용하는 Tambo용 프로젝트 키와 모델 제공자의 비밀 API 키도 구분해야 합니다. 제공자 비밀 키는 서버에서 관리합니다. API 요청의 사용자 소유권은 프로젝트 키만으로 해결되지 않으므로 실제 앱에서는 사용자 토큰과 권한 흐름까지 설계해야 합니다.
아래 과제의 핵심은 가짜 데이터와 종이에 적은 상태 변화만으로도 익힐 수 있습니다. 패키지 설치, Docker 실행, DB 초기화, 모델 호출은 서비스 전체를 연결하는 단계에서 다룹니다. 실제 실행은 별도 연습 프로젝트와 본인이 관리하는 서버 구성을 준비한 뒤 진행합니다.
아래 읽기 명령은 저장소 루트에서 실행합니다.
확장 실습은 별도 학습 프로젝트에서 진행하며, react-sdk/의 구현과 비교해 볼 수 있습니다.
제공자 API나 운영 서버에 요청하지 않아도 모든 과제의 핵심 원리를 확인할 수 있습니다.
실습의 합격 기준은 “AI가 그럴듯하게 답했다”가 아니라, 불완전한 입력과 실패 상황에서도 상태 변화를 설명할 수 있는가입니다.
목표: 6절의 SalesCard에 region 필드를 추가한다고 가정하고,
화면 props·스키마·컴포넌트 설명문을 함께 작성합니다.
순서: 사용할 데이터 세 개를 적고, 스키마를 정의하고,
registerComponent()에서 서버 전송용 객체까지의 모양을 종이에 그립니다.
확장 실습에서는 새 학습 프로젝트의 정적 화면으로 카드만 만듭니다.
완료 기준: 실제 React 함수가 서버 전송용 JSON에 들어가지 않는 이유와, 필드의 이름·타입·단위가 모델에게 어떻게 전달되는지 설명할 수 있습니다.
읽을 곳: react-sdk/src/util/registry-validators.ts,
packages/client/src/utils/registry-conversion.ts.
목표: 완성 props만 가정하지 않고 빈 값·일부 값·최종 값에 대한 화면을 설계합니다.
순서: 아래 네 입력의 표시 결과를 먼저 적습니다. 새 학습 화면에서 수동으로 입력값을 바꾸는 방식으로 확장할 수 있으며 모델 연결은 필요 없습니다.
{}
{"title":"서울"}
{"title":"서울","amount":0}
{"title":"서울","amount":1250}
완료 기준: 0이 “집계 중”으로 사라지지 않고, 제목만 들어온 순간에도 예외 없이 의미 있는 표시를 유지합니다. 최종 데이터 검증과 로딩 표시의 책임도 각각 설명합니다.
읽을 곳: react-sdk/src/v1/components/v1-component-renderer.tsx.
목표: 메시지를 즉시 보여 주면서도 서버가 발급한 대화 ID로 연결하는 원리를 이해합니다.
순서: placeholder에 사용자 메시지를 넣은 상태를 그린 뒤,
RUN_STARTED에서 실제 ID를 받았을 때의 threadMap과 currentThreadId를 적습니다.
사용자가 이미 다른 대화로 이동한 경우도 추가합니다.
확장: 별도 파일에 필요한 세 가지 액션만 받는 작은 순수 리듀서를 직접 작성합니다. 레포 전체를 복사하지 말고 임시 대화 생성·이동·선택 규칙만 재현합니다.
완료 기준: 사용자 메시지가 중복되지 않고, 다른 대화를 보던 사용자의 선택도 유지됩니다.
읽을 곳: packages/client/src/utils/event-accumulator.ts의
streamReducer()와 RUN_STARTED 분기.
목표: JSON 객체의 키와 JSON Pointer 경로의 차이를 이해합니다.
순서: 키가 sales/kr, rate~raw, 빈 문자열인 객체를 만들었다고 가정합니다.
각각의 한 단계 패치 경로를 적고, 아래 정답과 비교합니다.
키 sales/kr -> 경로 /sales~1kr
키 rate~raw -> 경로 /rate~0raw
키 "" -> 경로 /
확장: 새 학습 파일에 문자열 이스케이프 함수를 작성하고, 바꾸기 전 객체를 보존하면서 변경분을 적용하는 간단한 사례를 설계합니다. 전체 RFC 구현을 한 번에 만들 필요는 없습니다.
완료 기준: /sales/kr와 /sales~1kr가 가리키는 위치가 다른 이유,
~ 치환 순서에 주의해야 하는 이유를 설명합니다.
읽을 곳: packages/backend/src/util/component-streaming.ts의
createJsonPatchPath(), 같은 폴더의 테스트.
목표: 실행 시작의 충돌과 이전 실행의 늦은 정리 작업을 구분해 막습니다.
순서: 같은 idle 대화에 요청 A·B가 도착하는 시간표를 그립니다. 조건부 갱신으로 A만 잠금을 얻은 뒤 A가 완료되고 C가 시작했다고 가정합니다. 이때 뒤늦게 A의 취소 정리가 도착하면 어떤 조건으로 C를 보호해야 할지 적습니다.
확장: 별도 파일의 가짜 저장소에 runStatus와 currentRunId만 두고,
기대 run ID가 일치할 때만 해제하는 규칙을 구현합니다.
실제 DB를 사용하지 않아도 순서별 기대 결과를 표로 검증할 수 있습니다.
완료 기준: A·B가 동시에 성공하지 않고, A의 정리 작업이 C의 잠금을 해제하지 않으며, 충돌을 재시도 가능한 실패로 구분합니다.
읽을 곳: packages/db/src/operations/runs.ts의
acquireRunLock()·releaseRunLockIfCurrent(), V1Service.startRun().
# 저장소 루트에서 사용하는 읽기 명령
git rev-parse --short HEAD
rg -n 'export \*|TamboProvider' react-sdk/src/index.ts
rg -n 'registerComponent|validateAndPrepareComponent' react-sdk/src
rg -n 'processJsonDelta|finalize' packages/backend/src/util/component-streaming.ts
rg -n 'acquireRunLock|releaseRunLockIfCurrent' packages/db/src/operations/runs.ts
검색 결과가 많으면 이름을 하나 골라 정의 파일과 호출부를 번갈아 읽으세요. 함수 정의만 읽으면 입력이 어디서 왔는지 놓치고, 호출부만 읽으면 실패 조건을 놓칩니다.
주차는 학습 분량을 나누기 위한 제안이며 고정 일정이 아닙니다. React 경험이 적다면 앞의 두 주를 충분히 반복하세요. 모델을 연결하기 전에 가짜 입력으로 화면과 이벤트 처리를 완성하면, 실패 원인을 한 번에 하나씩 배울 수 있습니다.
| 주차 | 배울 주제 | 소스와 주간 산출물 |
|---|---|---|
| 1주차 | React props·컴포넌트·안정적인 key | ComponentRenderer 읽기. 서로 다른 입력을 받는 매출 카드와 네 가지 중간 화면 설계 |
| 2주차 | TypeScript·Zod·JSON Schema | registry-validators.ts와 registry-conversion.ts. 함수 객체와 전송용 JSON을 분리한 도식 |
| 3주차 | 이벤트·리듀서·불변성 | event-accumulator.ts. 임시 대화가 실제 ID로 옮겨지는 상태표와 작은 리듀서 |
| 4주차 | 비동기 반복자·부분 JSON·JSON Patch | component-streaming.ts. 조각별 props·패치·완료 상태를 적은 추적표 |
| 5주차 | NestJS 가드·SSE·트랜잭션 | v1.controller.ts·runs.ts. 동시 요청과 취소의 순서도 |
| 6주차 | 모노레포·컨테이너·운영 경계 | turbo.json·Dockerfile·Compose. 서비스·포트·키·저장소 역할을 구분한 배포 계획서 |
운전 연습 때 계기판·페달·도로 규칙을 익힌 다음 복잡한 교차로에 가는 것과 같습니다. 처음부터 모델·MCP·DB·인증을 모두 연결하면 어디에서 틀렸는지 찾기 어렵습니다. 화면, 이벤트, 서버 경계를 차례로 익히면 마지막 연결 단계의 질문이 훨씬 구체적이 됩니다.
| 용어 | 쉬운 설명 |
|---|---|
| 생성형 UI | AI의 판단을 텍스트뿐 아니라 앱의 실제 화면 구성으로 표현하는 방식 |
| 컴포넌트 | 입력값을 받아 화면 일부를 그리는 재사용 가능한 React 함수 등의 단위 |
| props | 컴포넌트에 전달하는 제목·금액·목록 같은 입력값 |
| 등록 목록 | 컴포넌트 이름과 실제 함수·입력 설명을 연결해 둔 명부. 코드에서는 registry |
| 스키마 | 데이터가 가져야 할 필드·타입·제약을 적은 양식 |
| JSON Schema | 그 양식을 JSON으로 표현하는 표준. 다른 프로그램이나 모델 도구 정의에 전달하기 좋음 |
| Standard Schema | 스키마 라이브러리들이 검증 기능을 공통 모양으로 제공하도록 하는 인터페이스 |
| 직렬화 | 객체를 JSON처럼 전송·저장 가능한 형태로 바꾸는 일. React 함수를 그대로 보내는 것과 다름 |
| 도구 호출 | 모델이 실행할 함수의 이름과 인수를 요청하는 방식. 실제 실행은 애플리케이션이 담당 |
| 클라이언트 도구 | 브라우저 등 클라이언트 환경에서 실행하도록 등록한 함수 |
| MCP | AI 애플리케이션이 도구·리소스 등을 서버와 교환하기 위한 프로토콜 |
| AG-UI | 에이전트 실행의 텍스트·도구·상태 등을 UI에 전달할 때 사용하는 이벤트 형식 |
| SSE | 서버가 열어 둔 HTTP 응답으로 이벤트를 순서대로 보내는 방식. 양방향 WebSocket과 구분 |
| 델타 | 이전 값과 비교해 달라진 부분. 이 레포에서는 텍스트 조각이나 props 변경분 |
| 부분 JSON | 아직 마지막 괄호나 문자열이 닫히지 않은 생성 중 입력. 완성된 JSON과 처리 정책이 다름 |
| JSON Patch | 객체 전체 대신 추가·교체·삭제 같은 변경 명령을 전달하는 형식 |
| JSON Pointer | /amount처럼 JSON 안의 위치를 지정하는 경로 표기 |
| 리듀서 | 현재 상태와 사건을 받아 다음 상태를 계산하는 함수 |
| 불변성 | 기존 객체를 몰래 수정하는 대신 새 상태를 만들어 변경을 명확하게 하는 원칙 |
| 낙관적 UI | 서버 확인 전에 사용자 행동을 먼저 화면에 반영하는 기법 |
| thread | 여러 메시지와 실행이 이어지는 하나의 대화 단위. CPU 스레드와 다른 뜻 |
| run | 한 대화에서 모델과 도구 처리를 진행하는 실행 단위. 한 대화에 여러 run이 이어질 수 있음 |
| Provider·Context | React 하위 컴포넌트에 등록 목록이나 상태 같은 값을 공유하는 장치 |
| 가드 | 컨트롤러 처리 전에 요청이 접근 조건을 충족하는지 확인하는 NestJS 장치 |
| 트랜잭션 | 서로 연결된 DB 변경을 함께 성공시키거나 함께 취소하는 작업 단위 |
| 조건부 갱신 | 기대 상태와 일치하는 행만 변경하는 방식. 이 레포에서는 실행 충돌 방지에 사용 |
| 모노레포 | 여러 앱과 패키지를 하나의 저장소에서 관리하는 구조 |
| CJS·ESM | JavaScript 코드의 모듈 배포 방식 두 가지. SDK는 각각에 맞는 출력을 만듦 |
| 자체 호스팅 | 제공자의 관리 서버 대신 자신이 관리하는 환경에서 서비스 전체를 운영하는 방식 |
| shallow clone | 과거 이력 전체 대신 제한된 커밋 이력만 받은 복제본. 전체 개발 역사를 판단하기에는 부족 |
외워야 할 약어 목록으로 보지 마세요. “모델이 도구를 고른다 → 서버가 이벤트를 보낸다 → 리듀서가 props를 바꾼다 → React가 그린다”라는 문장에 각 용어를 넣어 설명하면 기능과 이름을 함께 기억할 수 있습니다.
| 항목 | 확인 내용 | 근거·범위 |
|---|---|---|
| 프로젝트 | tambo-ai/tambo · React용 생성형 UI SDK | 2026-10-23 GitHub 기준 |
| 선정 | TrendShift Daily #29 | 2026-10-23 TrendShift 기준 |
| 스타 / 포크 | 11,200 / 570 | 2026-10-23 GitHub 기준 |
| 생성일 | 2024-06-15 | 2026-10-23 GitHub 기준 |
| 주 언어 | TypeScript | 2026-10-23 GitHub 기준 |
| 최신 릴리스 | web-v0.136.0 · 2026-10-09 | 2026-10-23 GitHub 기준. 전체 패키지의 공통 버전은 아님 |
| 최근 push | 2026-10-21 | 2026-10-23 GitHub 기준. 특정 커밋의 작성 시각과 구분 |
| 소스 기준 커밋 | 6e2c1bf · 2026-10-09 · chore(main): release web 0.136.0 (#2977) | 10월 21일 push 이전의 고정 커밋 |
| 코드 규모 | TS·TSX 1,241파일 / 221,434줄 | 추적 파일의 물리 줄 수. 테스트·예제·빈 줄 포함 |
| 테스트 규모 | TS·TSX 테스트 249파일 / 86,053줄 | 코드 규모에 포함. 커버리지 측정값 아님 |
| 추적 파일 | 2,069개 / 약 77.5MiB | 심볼릭 링크 1개 제외. 파일 크기의 합이며 설치 크기·Git 내부 제외 |
| 라이선스 | GitHub 표시·루트·SDK MIT / Cloud 웹·API·backend·db Apache-2.0 | 2026-10-23 GitHub 및 각 LICENSE·package.json 기준 |
| 호스팅 상태 | 신규 가입 중단, 2026-10-31까지 API 운영 후 중단, 2026-11-30 데이터 삭제 예정 | 커밋 6e2c1bf의 README·자체 호스팅 공지 기준 |
메타데이터 날짜는 2026-10-23이지만 소스 기준은 2026-10-09의 6e2c1bf입니다.
최근 push 시각이 더 늦다고 해서 그 변경이 이 글에 반영된 것은 아닙니다.
이력이 제한된 shallow clone만으로 전체 커밋 수·기여자 비중·개발 속도를 판단하기는 어렵습니다.
코드 예시는 등록 구조와 중간 props 처리를 설명하는 학습용입니다. 실제 앱에 적용할 때는 사용하는 패키지 버전에 맞춰 빌드와 타입 검사를 진행하세요.
공식 README의 종료 공지, 일부 오래된 API 이름, 백엔드 라이선스 비교표는 실제 구현·라이선스 파일과 함께 읽어야 합니다. 위의 사용 방식 비교는 제품 순위나 성능 우열을 뜻하지 않습니다.
스키마는 모델 입력 계약에 도움을 주지만 렌더러의 검증 정책에는 한계가 있습니다. 자체 호스팅을 선택하면 사용자 인증, 도구 권한, 제공자 키, 백업과 운영 책임을 별도로 설계해야 합니다. README에 호스팅 선택지가 남아 있다는 이유만으로 신규 Cloud 가입이 가능하다고 안내하지 마세요.