개발 노트
비주얼 노벨은 이미지의 모음이다. Builder는 왜 이렇게 복잡해졌을까?
Fibber라는 작은 프로토타입이 협업, 버전 기반 게시, 하나의 공유 스토리 runtime을 갖춘 분산 플랫폼으로 성장한 과정.
겉에서 보면 비주얼 노벨은 거의 단순해 보인다. 배경 하나, 투명한 캐릭터 하나, 텍스트 상자, 다음 장면으로 이어지는 몇 가지 선택지, 그리고 음악이면 된다. 첫 버전을 만들기 시작했을 때 나도 정확히 그렇게 생각했다.
지금도 핵심은 단순하다고 생각한다. 오늘날에는 특히 AI의 도움을 받으면 거의 모든 개발자가 비주얼 노벨 플레이어를 만들 수 있다. 어려움은 한 편의 이야기를 만드는 데서 끝나지 않고, 다른 사람, 어쩌면 어린이까지도 자기 이야기를 만들고 시험하고 게시하고 계속 발전시킬 수 있는 도구를 만들 때 시작된다.
첫 버전의 역할은 하나였다
Romergo가 Romergo라는 이름을 얻기 전에는 Fibber였다. 연결된 장면으로 하나의 퀘스트를 구성하고, 장면에 텍스트와 이미지를 넣고, 분기 선택지를 만들 수 있었다. 기술 스택은 TypeScript, React, Strapi, Ant Design이었다.
그 버전은 제 역할을 했다. 플로 그래프로 이야기가 보였고, 장면을 편집하고 결과를 미리 볼 수 있었다. 프로토타입으로는 충분했고, 진짜 문제를 드러내기에도 충분했다.
첫 번째 진짜 병목은 코드가 아니었다
실제 퀘스트에 텍스트와 이미지를 채우기 시작하자 10분짜리 장면 하나를 조립하는 데 거의 하루가 걸리기도 했다. 장면을 만든다. 배경을 올린다. 캐릭터를 올린다. 배치한다. 대사를 넣는다. 다음 장면과 연결한다. 다시 반복한다.
일부는 인터페이스 문제였다. UX를 개선하고 업로드를 빠르게 만들고 게시를 더 안정화했다. 하지만 가장 큰 비용은 그대로였다. 사람이 모든 작은 동작을 여전히 직접 해야 했다.
두 사람이 동시에 만들 수 있다면?
첫 답은 간단했다. 한 사람이 병목이라면 여러 사람이 같은 이야기를 함께 작업하게 하자. Liveblocks로 시작해 그 모델에서 배웠고, 이후에는 Yjs를 중심으로 자체 협업 레이어를 만들기 시작했다.
아래 실험은 두 편집기 창이 함께 움직이는 것처럼 보인다. 그 아래에는 제품이 생각하는 방식의 더 큰 변화가 있다. 편집 내용이 서로 떨어진 폼 제출이 아니라 공유 문서의 업데이트가 된다. 여기서 참여자 상태, 재연결, 충돌 처리, 권한, 그리고 프로젝트는 언제나 살아 있어야 한다는 기대가 생긴다.
유용한 지름길은 결국 경계가 되었다
Strapi는 backend를 빠르게 얻는 훌륭한 방법이었지만, 이야기 전용 기능이 늘어날 때마다 CMS가 조금씩 CMS답지 않게 동작해야 했다. Ant Design은 첫 인터페이스에 속도와 일관성을 주었지만, 시간이 지나면서 제품을 다른 사람의 시각 체계 안에 가두기 시작했다.
이후 Remix 기반 serverless 버전은 중요한 진전이었다. 그래도 애플리케이션, 미디어, 배포를 묶은 방식은 제품이 필요로 하기 시작한 수평적 자유를 주지 못했다. 어느 선택도 실수는 아니었다. 각각이 다음 한계를 발견할 시간을 벌어 주었다.
이미지는 첨부 파일이 아니라 인프라다.
미디어는 그 자체로 하나의 교훈이 되었다. 이야기 이미지는 한 번 업로드하고, 필요할 때 변환하고, 안전하게 보관하고, 세계 어디의 플레이어에게든 빠르게 전달해야 한다. 이미 커지고 있던 애플리케이션 서버 옆에 두면 backend만 무거워졌기 때문에 첫 번째 분리 솔루션으로 Cloudinary를 선택했다.
어느 저녁 대시보드를 열어 보니 한 사용자가 같은 이미지를 수백 번 올려 약 500MB를 사용했다. 긴급 수정으로 이미지 해시를 비교해 중복을 거부했다. 효과는 있었지만 요청 제한과 할당량이 그 행동을 더 직접적으로 막았을 것이다. 더 놀라운 것은 대역폭이었다. 저녁 한 번의 테스트로 무료 허용량의 10퍼센트 이상을 쓸 수 있었다.
그 일은 모델을 바꾸었다. 미디어는 더 이상 장면의 한 필드일 수 없었다. 저장, 중복 제거, 전송, 캐시, 접근을 위한 독립 pipeline이 필요했다.
단순한 아이디어의 현재 모습
현재 Romergo는 Builder와 플레이어를 분리하지만 둘 다 같은 스토리 runtime을 사용한다. 편집기 안의 미리 보기와 게시된 플레이는 같은 규칙을 따르므로 계약의 차이가 생기기 훨씬 어렵다.
Yjs는 협업자 사이에서 편집 가능한 프로젝트를 동기화한다. 게시는 이야기 데이터와 미디어의 검증된 snapshot을 만든다. 덕분에 제작자는 플레이어가 이미 실행 중인 버전을 몰래 바꾸지 않고도 초안을 계속 수정할 수 있다. API는 Hono로 만들었고, 인터페이스는 shadcn과 Radix 프리미티브를 사용하며, 클라우드 레이어는 분산 조정, 객체 저장소, CDN 전송을 제공한다.
공유 runtime은 이제 브라우저와 PWA, Telegram, 동기화된 Discord 경험으로 이야기를 전달한다. 버전 호환성, 오프라인 동작, 멀티플레이 상태는 더 이상 페이지 주변의 부수 효과가 아니라 독립적인 제품 시스템이다.
현재 아키텍처
편집 가능한 하나의 이야기. 게시된 하나의 계약.
Romergo 현재 아키텍처의 개괄도.
1. 편집 가능한 프로젝트
- Builder UI: 장면 · 플로 · 미디어
- AI / MCP: 인증된 편집 작업
- Realtime / Yjs: Durable Objects · 공유 작업 문서
- Editor API: CAS 쓰기 · 초안 구체화
- 초안 runtime snapshot: PublishedQuestLocalizedContentV2 · D1 / R2
- 초안 미디어: R2 객체 · D1 메타데이터
2. 버전 기반 게시
- 게시 검사: Runtime schema · 작성자 확인 · 미디어 존재 여부
- 변경 불가능한 게시본 vN: Runtime snapshot · 복사 및 재매핑된 미디어
- 공개 범위 계약: 공개 · 목록 비공개 · 비공개
3. 하나의 실행 계약
- 초안 snapshot: 최신 편집 상태
- 게시본 vN: 안정된 플레이어 상태
- 공유 player-runtime: 장면 · 전환 · 조건 · 변수 · 저장
- Builder 미리 보기: 같은 엔진으로 초안을 실행
4. 재생 채널
- Web / PWA: 브라우저 플레이어
- Telegram Mini App: 내장 플레이어
- Discord Activity: 동기화된 그룹 플레이
5. 플랫폼 인프라
- Clerk + OAuth: 사용자 식별과 MCP 접근
- Hono Workers: API 및 MCP 서비스
- Cloudflare D1: 프로젝트 · 버전 · 메타데이터
- Cloudflare R2: 미디어 · 대형 runtime snapshots
- Durable Objects: Yjs 룸 · Discord 세션
- 플레이어 진행도: D1 동기화 · 로컬 오프라인 대기열
비주얼 노벨은 여전히 이미지의 모음이다
재미있는 점은 처음의 가정이 여전히 맞다는 것이다. 비주얼 노벨은 지금도 배경, 캐릭터, 텍스트, 선택, 소리로 이루어진다. Romergo가 복잡해진 이유는 이 단순한 핵심 주위의 모든 것이 신뢰할 수 있어야 했기 때문이다. 협업, 미디어, 게시, 버전 관리, 오프라인 플레이, 그리고 같은 이야기를 경험하는 여러 방식이 모두 그렇다.
이 변화가 내게 한 가지를 가르쳤다면, 마지막 화면을 그리는 코드뿐 아니라 사람이 거치는 전체 경로를 측정해야 한다는 점이다. 플레이어에는 다섯 가지 기본 요소만 필요할 수 있다. 하지만 아이디어를 완성된 플레이 가능한 이야기로 바꾸도록 돕는 제품에는 시스템이 필요하다.