이 레포가 무엇을 하는 물건인가.
보통 웹사이트 하나를 만들려면 CMS·프레임워크·호스팅·폼 서비스·이미지 CDN을 따로 계약하고, 각자 청구서와 대시보드와 새벽 장애를 하나씩 떠안는다. Instatic은 그 전부를 Bun 서버 한 대 안에 욱여넣고, 결과물은 view-source로 열어도 부끄럽지 않은 깨끗한 HTML로 낸다.
Instatic은 비주얼 캔버스 편집기, 콘텐츠 엔진, 퍼블리셔(발행기)가 전부 하나의 Bun 서버 프로세스로 돌아가는 셀프호스팅 CMS다. 어디서든 원하는 곳에 직접 띄울 수 있고, 데이터베이스는 SQLite 또는 Postgres 중 고른다. README의 표현을 빌리면 "일반적인 현대 웹사이트는 스택을 조립하는 일" — 헤드리스 CMS 하나, 프레임워크 하나, 호스트 하나, 폼 서비스 하나, 애널리틱스 벤더 하나, 이미지 CDN 하나, 각각 자기 청구서와 대시보드와 새벽 2시 장애를 달고 온다. Instatic은 그 반대편에 선다.
더 중요한 건 결과물이다. 발행된 페이지는 군더더기 없는 시맨틱(의미론적) HTML과 압축된 CSS로만 나가고, 편집기가 남긴 흔적(React 런타임, 빌더 속성, div 수프)이 전혀 없다. 대부분의 경우 페이지는 정적 파일 그 자체다. 명령어 하나 없이 git clone → bun install → bun run dev면 로컬에서 뜨고, 실제 서비스는 Railway 템플릿으로 원클릭 배포하면 약 2분 안에 살아난다.
트렌딩 이유 · 경쟁 제품 대비 장점.
Instatic은 2026년 4월 말에 처음 공개된, 아직 2개월 남짓 된 신생 프로젝트인데도 ★1,558개 · 포크 138개를 모았다. 빠르게 주목받는 이유는 크게 세 가지다.
① "이거 하나면 끝" 이라는 약속을 실제로 지킨다. 디자인(캔버스 + 디자인 토큰 엔진 Core Framework 내장) · 빌드(모듈·컴포넌트·템플릿·루프) · 관리(통합 데이터 모델, 롤 기반 접근 제어) · 분석(대시보드·감사 로그)까지 네 축을 한 제품이 다 맡는다. 웹빌더(Webflow류)는 콘텐츠 모델링이 약하고, 헤드리스 CMS(Strapi류)는 프론트엔드를 따로 짜야 한다.
② 발행 결과물이 "프레임워크 프리(free)"다. 캔버스에서 아무리 화려하게 편집해도, 방문자에게 나가는 페이지에는 React도, 편집기 런타임도, 하이드레이션(수화) 단계도 없다. 정적으로 구운 HTML 파일이거나, 극히 일부만 초경량(~668바이트) 런타임으로 지연 로드된다.
③ 플러그인이 진짜로 격리돼 있다. 대부분의 CMS 플러그인 생태계는 "플러그인 하나가 사이트 전체를 무너뜨린다"는 공포 스토리를 갖고 있다. Instatic 플러그인은 QuickJS-WASM 샌드박스 안에서만 돈다 — 파일시스템도, 환경변수도, 네트워크도 기본은 전부 차단이고 사이트 소유자가 하나씩 승인해야 열린다.
| 비교 대상 | 일반적인 접근 | Instatic의 접근 |
|---|---|---|
| 스택 구성 | CMS + 프레임워크 + 호스팅 + 폼 서비스 + CDN을 각각 계약 | Bun 서버 하나가 편집기·엔진·퍼블리셔·폼·미디어를 전부 처리 |
| 발행 결과물 | 프레임워크 런타임·하이드레이션이 페이지에 섞여 나감 | 순수 시맨틱 HTML + 압축 CSS, 정적 파일에 가까움 |
| 콘텐츠 모델 | 페이지·포스트·커스텀 필드가 각기 다른 테이블/개념 | 페이지·포스트·컴포넌트가 전부 data_tables+data_rows 하나 |
| 플러그인 안전성 | 호스트 프로세스 안에서 그대로 실행(전권 위험) | QuickJS-WASM 샌드박스, 권한은 명시적 승인제 |
헤드리스 CMS + 별도 프론트엔드 프레임워크 조합은 유연하지만, 서비스가 늘어날수록 청구서·대시보드·장애 지점도 함께 늘어난다. 콘텐츠 모델과 디자인 시스템이 서로 다른 도구에 흩어져 있으면 "색상 토큰 하나 바꾸는 일"조차 여러 시스템을 오가야 한다.
편집기·엔진·발행이 같은 Bun 프로세스에서 돌고, 페이지·포스트·컴포넌트가 같은 데이터 모델을 공유한다. 디자인 토큰(Core Framework)도 데이터로 저장돼, 토큰 하나를 바꾸면 그 토큰을 쓰는 모든 페이지가 즉시 갱신된다. 결과는 host 하나, 결제 하나, 그리고 view-source로 열어도 되는 페이지.
백엔드·프론트엔드·인프라 한눈에.
전체 코드베이스는 TypeScript 5,000여 개 소스 파일로 이루어져 있고, zod 같은 흔한 검증 라이브러리 대신 TypeBox 하나로 모든 "타입 없는 경계"(HTTP 응답, DB에 저장되는 JSON, 플러그인 매니페스트, 설정)를 통일했다. 이 규칙은 테스트(ai-driver-isolation.test.ts)로 강제된다 — 문서가 아니라 코드로 지키는 컨벤션이다.
| 레이어 | 기술 / 버전 | 역할 |
|---|---|---|
| 런타임 | Bun ≥1.3.0 <1.4.0 | 서버 실행 + 패키지 관리 + 빌드 도구 전부. |
| 언어 | TypeScript (전 영역) | 서버·어드민·에디터·플러그인 SDK 전부 타입 안전. |
| 서버 | Bun.serve + 자체 라우터 | Express 같은 프레임워크 없이 손으로 짠 라우팅(server/router.ts). |
| 어드민 UI | React 19 (React Compiler on) + Vite 8 | 편집기·대시보드 SPA. 발행 페이지에는 섞이지 않음. |
| 상태 관리 | Zustand + Mutative | 에디터 스토어. 불변성 갱신을 Mutative(구조적 공유)로 처리. |
| DB | SQLite(bun:sqlite) 또는 Postgres(Bun.sql) | DATABASE_URL로 선택. 리포지토리는 두 엔진 모두에 방언 중립적. |
| 검증 | TypeBox (zod 전면 금지) | 모든 미검증 경계에서 스키마가 진실의 원천. |
| 리치 텍스트 | Tiptap 3 (ProseMirror 기반) | 테이블·정렬 등 확장을 붙인 에디터. |
| 코드 에디터 | CodeMirror 6 | CSS/HTML/JS/JSON/Markdown 구문 강조. |
| 드래그앤드롭 | @dnd-kit | 캔버스에서 모듈을 끌어다 배치. |
| 이미지 처리 | sharp + BlurHash | 리사이즈·WebP 인코딩. 별도 Bun.Worker에서 처리. |
| 플러그인 샌드박스 | quickjs-emscripten (QuickJS-WASM) | 플러그인 서버 코드를 완전 격리 실행. |
| AI 연동 | @modelcontextprotocol/sdk + 자체 HTTP/SSE 드라이버 | Claude·OpenAI·OpenRouter·로컬 Ollama를 벤더 SDK 없이 REST로 직접 호출. |
| 인프라 | Docker + Caddy | 단일 이미지 배포. Caddy가 자동 HTTPS·리버스 프록시 담당. |
| 배포 | Railway 원클릭 템플릿 | SQLite/Postgres 템플릿 각각 제공, 볼륨·헬스체크 자동 구성. |
| 테스트 | bun test + Playwright | 단위/통합 테스트 + E2E. 아키텍처 규칙도 테스트로 검사. |
zod를 코드베이스에서 아예 제거했다 — "검증 라이브러리가 두 개 섞이면 결국 하나는 거짓말을 한다"는 판단.먼저 전체 그림, 그다음 "발행" 흐름 한 줄기를 끝까지 따라가기.
Instatic은 단일 Bun 프로세스가 방문자·어드민·API·스트리밍 AI 에이전트·업로드까지 전부 서빙한다. 메인 스레드를 막을 수 있는 무거운 작업 두 가지만 Bun.Worker(별도 스레드)로 빼냈다 — 이미지 변환(sharp+BlurHash)과 플러그인 서버 코드(QuickJS 샌드박스)다. 먼저 숲을 보자.
이제 대표 흐름 한 줄기를 손으로 따라가 보자. 방문자가 발행된 페이지 URL을 열었을 때 — 즉 "그 외 전부"로 빠지는 가장 흔한 경로다. 핵심 5%(해피 패스)만 본다.
이 프로젝트 특유의 "정상 모양새"를 짚자면: 저작 시점엔 아무도 "이건 동적이야"라고 토글하지 않는다. src/core/publisher/dynamicDetection.ts의 findDynamicNodeIds 하나가 노드 트리를 한 번 훑으며 4가지 규칙(동적 플래그·요청 의존 바인딩·루프 소스·동적 콘텐츠를 포함한 VC 참조)으로 자동 분류한다. 이 결과가 Layer A(빈 집합이면 완전 정적 굽기)와 Layer C(자리표시자 발행) 둘 다에 쓰인다 — 판정 로직이 하나뿐이라 "정적인 줄 알았는데 사실 동적이었다" 류의 불일치가 구조적으로 생기지 않는다.
<instatic-hole>로 지연 로드한다. 세 계층 모두 "저자가 손으로 지정"하지 않고 자동 분류에 의존하는 게 핵심 설계 결정.data_tables / data_rows
NodeTree<TNode> 자료구조 하나를 공유한다 — "트리를 다루는 코드"를 한 번만 짜면 두 군데서 다 쓴다는 뜻.어떤 폴더가 무슨 일을 하나. "기능별"이 아니라 "책임별"로 나뉜 게 특징.
README는 이렇게 못 박는다: "이 코드베이스는 오피니언(주관)이 강하고, 그 오피니언은 코드로 강제된다." 실제로 src/__tests__/architecture/에는 폴더 간 의존 방향을 검사하는 테스트가 진짜로 있다 — 문서가 아니라 bun test가 구조를 지킨다.
| 파일/폴더 | 역할 |
|---|---|
server/index.ts | 진입점 — DB 마이그레이션, 역할 동기화, 플러그인 활성화 후 Bun.serve 기동. |
src/core/publisher/render.ts | 핵심 — publishPage(). 페이지 트리를 HTML/CSS 문서로 바꾸는 진입 함수. |
server/publish/publicRouter.ts | 모든 방문자 요청의 관문. Layer A→B→라이브 렌더 순서로 시도. |
src/core/plugin-sdk/ | 플러그인 저자가 보는 API 표면. 여기가 "계약서"다. |
src/__tests__/architecture/ | 폴더 간 의존 규칙을 실제로 검사 — 구조 부패를 코드로 막는다. |
이 레포에서 훔쳐 배울 만한 것 + 어디를 보면 되는지.
Instatic의 모듈 render()는 DOM도, React도, 부수효과도 없는 순수 함수다(코드베이스 안에서 "Constraint #179"라는 번호로 관리되는 규칙). 입력(props+자식 HTML)이 같으면 출력이 항상 같다 — 그래서 서버에서 안전하게, 캐시 가능하게, 테스트하기 쉽게 페이지를 굽는다. React 컴포넌트를 짜 봤다면 "왜 렌더 함수에 부수효과를 넣으면 안 되는가"를 실전 CMS 규모로 체감할 수 있다. src/core/publisher/renderNode.ts부터 읽으면 된다.
"플러그인은 위험하다"는 걸 문서로 경고하는 대신, 물리적으로 못 하게 만든 사례다. 서버 엔트리포인트는 Bun.Worker 안의 QuickJS 가상머신에서 돌고, 파일시스템·환경변수·네트워크가 기본 전부 차단이다. 네트워크가 필요하면 networkAllowedHosts 화이트리스트를 명시적으로 열어야 한다. 브라우저 밖에서 "신뢰 안 되는 코드를 안전하게 실행하는 법"을 배우고 싶다면 server/plugins/quickjs/vm.ts가 좋은 교재다.
보통 CMS는 "이 블록은 캐시하지 마세요" 체크박스를 저자에게 맡긴다. Instatic은 그 체크박스 자체를 없앴다. dynamicDetection.ts의 findDynamicNodeIds 하나가 트리를 훑어 정적/동적을 판정하고, 그 결과를 Layer A와 Layer C가 같이 쓴다. "사람이 깜빡할 수 있는 설정"을 아예 없애고 로직으로 대체하는 이 패턴은 이 레포 전체에 반복된다 — 배울 가치가 있는 설계 습관이다.
런타임 검증 라이브러리가 두 개 이상 섞인 코드베이스는 결국 어느 한쪽이 타입을 거짓말하게 된다. Instatic은 zod를 코드에서 완전히 뽑아내고 TypeBox 하나로 통일한 뒤, 그 규칙을 ai-driver-isolation.test.ts 같은 실제 테스트로 게이트 걸었다. "컨벤션 문서"가 아니라 "떨어지는 CI"로 규칙을 지키는 법을 볼 수 있다.
플러그인이 CMS와 통신하는 API 표면 감을 잡으려면 SDK 시그니처를 훑어보자(개념 예시):
// src/core/plugin-sdk/ 의 API 형태(개념 요약)
api.cms.routes.register({ path: '/hello', method: 'GET' }, handler)
api.cms.storage.get(key) // 플러그인 전용 격리 저장소
api.cms.loops.registerSource(name, fn) // 루프가 끌어올 데이터 소스 추가
api.plugin.log('info', '설치 완료') // 호스트 로그로 전달(직접 console 접근 불가)
순수 소프트웨어라 특수 하드웨어는 필요 없다. 로컬 개발과 배포 기준.
| 항목 | 요구사항 |
|---|---|
| 런타임 | Bun ≥1.3.0 <1.4.0. 로컬 개발엔 이것만 있으면 됨(Node.js 불필요). |
| 데이터베이스 | 기본 SQLite(추가 서비스 없이 .tmp/dev.db). 다중 저자·관리형 백업이 필요하면 Postgres. |
| 배포(권장) | Railway 원클릭 템플릿 — 시크릿 키 생성, 볼륨 부착, 헬스체크까지 자동. 터미널 안 열어도 됨. |
| 자체 인프라 | 단일 Docker 이미지(ghcr.io/corebunch/instatic) + compose.prod.yml. VPS·Fly.io·Render 문서는 docs/deployment/. |
| 리버스 프록시/HTTPS | 자체 호스팅 시 Caddy 권장(자동 인증서). Caddyfile 기본 제공. |
| 브라우저(관리자용) | 최신 데스크톱 브라우저 — 캔버스 편집기가 최신 CSS/JS 기능을 사용. |
| AI 에이전트 기능(선택) | Claude·OpenAI·OpenRouter API 키 또는 로컬 Ollama. 없어도 CMS 본체는 정상 동작. |
읽기만 하지 말고 손에 익히는 단계. 난이도순.
git clone → bun install → bun run dev로 localhost:5173을 연다. 온보딩을 따라 사이트/소유자 계정을 만들고, 아무 페이지를 캔버스에서 편집한 뒤 발행해 본다. 그다음 발행된 페이지에서 브라우저 "페이지 소스 보기"를 열어 React나 편집기 흔적이 정말 없는지 눈으로 확인하는 게 목표.
/admin/data에서 커스텀 데이터 테이블(예: "팀원" — 이름·직함·사진 필드)을 만들고 행을 몇 개 채운다. 그다음 캔버스에서 루프(Loop) 모듈을 페이지에 놓고 이 테이블을 소스로 연결해, 카드 여러 장이 자동으로 찍혀 나오는 걸 확인한다. 변형(variant) 두 개를 주고 번갈아 나오는지도 관찰.
디자인 패널에서 브랜드 색상 토큰 하나를 바꿔 본다. 그 토큰을 참조하는 여러 페이지가 재발행 없이도 즉시 반영되는지, 그리고 자동 생성된 명도 스케일(더 밝은/어두운 틴트)이 어떻게 계산되는지 관찰한다. "디자인 시스템이 데이터로 저장된다"는 게 무슨 뜻인지 체감하는 실습.
examples/plugins/template/를 복사해 bun instatic-plugin dev로 실행 중인 CMS에 핫싱크한다. plugin.json에 권한(cms.routes 등)을 선언하고, 간단한 서버 라우트 하나를 추가해 본다. 권한을 선언하지 않은 API를 호출했을 때 실제로 막히는지 확인하면 샌드박스 격리를 체감할 수 있다.
src/core/publisher/dynamicDetection.ts의 findDynamicNodeIds를 정독한 뒤, 루프가 있는 페이지와 없는 페이지를 각각 발행해 uploads/published/current/에 실제로 파일이 구워지는지(Layer A 적중) 또는 <instatic-hole>이 남는지(Layer C 발동) 비교한다. 판정 결과가 예상과 다르면 어떤 규칙이 걸렸는지 코드로 역추적해 본다.
한 주씩, 이 레포를 교보재로 삼아 따라가는 계획.
| 주차 | 주제 | 학습 자료 / 볼 곳 |
|---|---|---|
| 1주차 | CMS 큰 그림 + 로컬 실행 | README · docs/architecture.md · bun run dev로 온보딩 체험 |
| 2주차 | Bun 서버 + 손으로 짠 라우터 | server/index.ts · server/router.ts · Bun 공식 문서(Bun.serve) |
| 3주차 | 퍼블리셔 — 페이지 트리 → HTML | docs/features/publisher.md · src/core/publisher/render.ts·renderNode.ts |
| 4주차 | 3계층 캐시(Layer A/B/C) 원리 | server/publish/publicRouter.ts · dynamicDetection.ts |
| 5주차 | 플러그인 샌드박스 & QuickJS-WASM | docs/features/plugin-system.md · server/plugins/quickjs/vm.ts · quickjs-emscripten 문서 |
| 6주차 | TypeBox 검증 패턴 + 내 프로젝트에 이식 | TypeBox 공식 문서 · src/core/*-schema/ 폴더들 비교 |
본문에 나온 용어 빠른 참조.
| 용어 | 의미 |
|---|---|
| Bun | JavaScript/TypeScript 런타임 + 패키지 매니저 + 번들러. Node.js 대체재로, Instatic은 서버·빌드·테스트 전부 Bun 하나로 처리. |
| 퍼블리셔 (Publisher) | 페이지 트리를 실제 HTML/CSS 문서로 바꾸는 엔진. 진입점은 publishPage(). |
| Layer A / B / C | 발행 파이프라인 3계층 — 정적 굽기(디스크) / 인메모리 LRU(동적 라우트) / 지연 로드 자리표시자(요청별 조각). |
| <instatic-hole> | 요청마다 달라지는 조각을 표시하는 자리표시자 태그. ~668바이트 러타임이 IntersectionObserver로 감지해 채운다. |
| dynamicDetection | 노드가 정적인지 동적인지 자동 판정하는 단일 워커. 저자가 수동으로 토글하지 않는다. |
| data_tables / data_rows | 페이지·포스트·컴포넌트·커스텀 컬렉션이 전부 공유하는 통합 콘텐츠 저장 테이블 두 개. |
| NodeTree<TNode> | 페이지 트리와 비주얼 컴포넌트 트리가 공유하는 트리 자료구조 원시 타입. |
| TypeBox | TypeScript 타입 정의와 런타임 JSON Schema 검증을 동시에 제공하는 라이브러리. Instatic 전역 표준. |
| QuickJS-WASM | 경량 JS 엔진을 WebAssembly로 컴파일한 것. 플러그인 서버 코드를 호스트와 완전히 격리해 실행. |
| Visual Component (VC) | 타입이 있는 파라미터와 이름 붙은 슬롯을 가진 재사용 가능한 컴포넌트. 하나 고치면 모든 인스턴스가 갱신. |
| 루프 (Loop) | 포스트·페이지·미디어 등 컬렉션을 반복해 레이아웃을 찍어내는 모듈. 변형(variant)을 번갈아 배치 가능. |
| Core Framework | Instatic에 내장된 디자인 토큰 엔진 — 색상 명도 스케일·유동 타이포 스케일·스페이싱 스케일을 자동 생성. |
| advisory lock (권고 잠금) | Postgres의 pg_try_advisory_lock 기반 리더 선출. 다중 인스턴스가 스케줄 작업을 중복 실행하지 않게 막음. |
| MCP (Model Context Protocol) | 외부 AI 클라이언트가 CMS 도구를 표준 방식으로 쓸 수 있게 하는 커넥터 프로토콜. |