---
id: vzzk
type: project
doc: portfolio
url: https://auejin.com/ko/projects/vzzk/portfolio/
lang: ko
---

# vzzk — 프로덕션 데스크톱 제품 포트폴리오

> **대상 제품**: `vzzk` — 치지직 스트리머용 Tauri v2 + Angular 17 데스크톱 봇 앱
> **부속 시스템**: `vzzk-api` — NestJS 클라우드 API (원격 오버레이 동기화, 인증, 자격 검증)
> **기간**: 2024-05-22 첫 커밋 ~ 2026-08-06 (커밋 1,196개, 릴리스 태그 82개)
> **기준일 두 계열**: GA4/BigQuery에서 나온 수치는 대시보드 스냅샷 **2026-08-06** 기준이고 원본 집계는 `.agent/tmp/portfolio/facts.json`에 있다. 커밋·태그·테스트·파일 수처럼 레포에서 나온 수치는 같은 날짜의 `vzzk` / `vzzk-api` 작업 트리 기준이며 facts.json에는 없다.
> **인용 규칙**: 모든 기술적 주장은 실제 파일을 읽고 확인한 뒤 레포 기준 상대 경로 `파일:라인` 또는 커밋 해시로 인용한다. 브라우저·플랫폼·벤더 인프라의 동작처럼 파일로 증명할 수 없는 주장은 공식 문서를 링크하고, 링크를 댈 수 없으면 **"실측으로 확인했고 벤더 문서로는 확인하지 못했다"**를 그 자리에 적는다.

---

## 목차

1. [Executive Summary](#1-executive-summary)
2. [문제 정의](#2-문제-정의)
3. [시스템 아키텍처](#3-시스템-아키텍처)
4. [패러다임별 기술 하이라이트](#4-패러다임별-기술-하이라이트)
5. [정량 임팩트](#5-정량-임팩트)
6. [사고와 구조적 해결 — 케이스 스터디](#6-사고와-구조적-해결--케이스-스터디)
7. [개발 방법론 — AI 에이전트용 규칙·워크플로·스킬 정의로 1인 개발을 확장한 방식](#7-개발-방법론--ai-에이전트용-규칙워크플로스킬-정의로-1인-개발을-확장한-방식)
8. [한계와 로드맵](#8-한계와-로드맵)
9. [부록: 기술 스택](#9-부록-기술-스택)

---

## 1. Executive Summary

vzzk는 치지직 채팅·후원 이벤트를 입력으로 받아 **11종의 봇**을 돌리고, 그 결과를 OBS 방송 화면 위에 실시간 오버레이로 그리는 데스크톱 앱이다. 자막·TTS(채팅 읽기)·자동응답처럼 켜두면 계속 도는 상시형 봇, 투표·추첨·룰렛·후원목표처럼 스트리머가 순간에 발동시키는 이벤트형 봇, 후원과 직접 연결되는 던지기·신청곡, 그리고 원격 오버레이 동기화(sync)와 방송 프롬프터(prompter)까지 11종이다. 뒤의 둘은 기능 성격상 인프라에 가깝지만 다른 봇과 같은 `BotService` 계약 위에서 같은 생명주기로 돌기 때문에 같은 수로 센다.

759일 관측 구간에서 누적 세션 55,193, 최근 30일 평균 DAU 90.6이다. 98일짜리 원본 이벤트 창에서 vzzk의 수익 연동 봇(던지기·룰렛·후원목표·신청곡)이 처리한 후원 이벤트의 `price` 합은 **₩30,027,318**이고 이를 받은 스트리머는 **44명**이다. 후원 자체는 치지직에서 발생해 스트림 이벤트로 앱에 들어오므로(`src/app/service/tits.service.ts:94`), 이 금액은 vzzk의 매출이 아니고 vzzk가 이 후원을 유발했는지도 이 데이터로는 판정할 수 없다. 최근 30일 세션당 에러율은 0.16%(5건 / 3,128세션)이고 그 5건은 전부 외부 플랫폼 로그인 실패 한 종류다. 다만 이 분자는 **명시적으로 로깅한 `%Fail` 이벤트만** 센 값이다. 미처리 예외나 크래시를 수집하는 텔레메트리가 없으므로, "봇 로직 실패 0건"은 결함이 없다는 증거가 아니라 그 계열을 관측할 장치가 없다는 뜻으로 읽어야 한다(8.2).

이 문서가 기술적으로 주장하는 것은 세 가지다.

**첫째, 방송이라는 실행 환경에 맞춰 실패 모드를 설계했다.** 오버레이는 OBS 브라우저 소스 안에서, 그것도 송출 중에 처음 실행된다. OBS의 CEF는 원격 디버깅 포트를 켜면 콘솔을 붙일 수 있지만 기본 상태에서는 노출되지 않고, **송출 중에는 그 절차를 밟을 수 없다.** 그래서 클라우드 push를 로컬 렌더링 경로에서 `tokio::spawn`으로 떼어내 원격 장애가 프레임에 전파되지 않게 했고(분리 지점 `src-tauri/src/overlay.rs:318-322`, 대상 함수 `src-tauri/src/sync.rs:234-291`), 브라우저의 오리진당 6-연결 한도를 포트 10개 바인딩으로 우회했으며(`src-tauri/src/overlay.rs:879-896`), 번들러를 거치지 않는 오버레이 HTML을 위해 AST 기반 정적 린트를 직접 만들었다(`script/lint-overlays.js`).

**둘째, 클라이언트를 신뢰 경계 밖에 두는 인증 구조로 전환했다.** 보안 감사에서 배포 바이너리 안의 대칭 비밀 4종을 확인하고, 로그인을 PKCE 교환 코드로, Pro 자격 검증을 Ed25519 공개키 검증으로, 토큰 저장을 OS 자격증명 저장소로 옮겼다. 구버전 호환 경로는 "공격자가 고를 수 있는 우회로"로 명명하고, 감으로 지우는 대신 경로별 호출 카운터를 볼륨에 영속화해 **연속 14일 0**을 제거 조건으로 삼았다.

**셋째, 지표를 믿기 전에 지표를 검증했다.** GA4 원본 이벤트를 BigQuery로 내보내 759일 통합 시계열을 만들고 26개 쿼리를 병렬 실행해 서버 없는 정적 HTML 대시보드로 굽는다. 2026-08-06 이 문서를 위한 수치를 뽑다가 "자막 봇 활성화→동작 전환율 842%"라는 정의상 불가능한 값을 보고 멈춰서, 쿼리 정의를 전수 재검토해 **집계 결함 4건을 찾아 수정**했다. 이 문서의 모든 수치는 수정 후 값이다.

세 주장에 공통된 태도는 **한계를 지표와 함께 적는 것**이다. DAU는 차원 중복 합산 때문에 상한값이고, 누적 수익은 관측 구간 26개월 중 22개월(2024-07~2026-04)을 98일치 관측으로 역추정한 값이라 구간 상단이 하단의 3.5배다. 이 문서는 그 사실을 각 수치 옆에 같이 적는다.

---

## 2. 문제 정의

### 2.1 제품이 다루는 문제

라이브 스트리밍에서 시청자 참여 도구(투표·추첨·룰렛·후원 목표·신청곡)는 두 개의 서로 다른 화면에 동시에 존재해야 한다. 스트리머가 조작하는 **제어 화면**과, 시청자가 보는 **방송 화면 위의 오버레이**다. 이 둘은 다른 프로세스에서 돌고, 그중 하나는 송출이 시작되면 개발자가 손을 댈 수 없는 환경이다.

OBS 공식 문서는 브라우저 소스를 "It is, quite literally, a web browser that you can add directly to OBS"로 규정한다(https://obsproject.com/kb/browser-source). 즉 오버레이의 배포 단위는 설치형 앱이 아니라 **URL 하나**이고, 렌더 타깃은 OBS가 내장한 Chromium(CEF)이다. 이 사실이 이 제품의 거의 모든 아키텍처 제약을 만든다.

- 오버레이는 앱 창 안에 그릴 수 없다. 송출 대상이 되려면 별도 문서여야 한다.
- 브라우저 소스는 씬 전환 시 새로고침되고 비표시 시 종료되는 라이프사이클을 갖는다. 재연결과 상태 복구가 선택이 아니라 필수다.
- 스트리머가 OBS에 한 번 저장한 URL은 앱을 껐다 켜도, 앱을 업데이트해도 그대로 동작해야 한다. URL을 깨뜨리는 변경은 전 사용자의 방송 화면을 깨뜨린다.

### 2.2 시장 주장 대신, 관측된 분포

이 문서는 시장 규모 주장을 하지 않는다. 크리에이터 이코노미 전체 TAM 같은 수치는 광고·커머스를 포함한 값이라 치지직 스트리머용 데스크톱 도구가 실제로 겨냥하는 범위와 자릿수가 다르고, 그 축소 추정을 수행하지 않았기 때문이다. 2.3에서 경쟁 조사를 하지 않았으므로 시장 공백을 주장하지 않는 것과 같은 이유다.

대신 관측된 분포만 적는다. 98일 창에서 후원을 1원이라도 받은 것으로 관측된 스트리머는 **44명**이고, 그중 상위 5명이 관측 총액의 **70.8%**, 상위 1명이 **26.8%**를 가져간다. 즉 후원 수취는 소수에 극단적으로 몰려 있다. vzzk는 이 분포를 바꾸는 도구가 아니라 이 분포 위에 얹히는 도구다.

> **분모를 붙이지 않은 이유**: 이 44명을 PRO 전환 쿼리가 관측한 사용자 749명으로 나누면 5.9%가 되지만, 두 값은 서로 다른 쿼리의 산출물이고 모집단이 같은지 확인하지 않았다. 같은 98일 창의 잔존율 코호트는 746명으로 또 다르다. 비율은 쓰지 않는다.

### 2.3 치지직이라는 선택과 그 대가

vzzk는 치지직 전용이다. 이 선택은 명확한 대가를 갖는다.

**얻은 것**: 시장이 한국어권 단일 플랫폼으로 좁아지면서, 1인이 도메인 전체(채팅 프로토콜, 후원 이벤트 형태, 사용자 문화)를 파악하고 커버할 수 있는 규모가 됐다. 관측된 최근 30일 OS 분포는 Windows 단일이고, 이는 지원해야 할 플랫폼 조합도 실질적으로 좁다는 뜻이다. 단 이 값은 raw VIEW(`advanced_events`)에서 OS 차원이 붙은 2,821세션 기준이며, 같은 창의 통합 VIEW 총 세션 3,128건(5.4절)과 307건 차이가 난다 — 차이의 원인은 확인하지 않았다.

**포기한 것**: 플랫폼 리스크가 분산되지 않는다. 실제로 최근 30일 관측된 런타임 에러 5건이 **전부 `chzzk_loginFail`** 한 종류다 — 봇 로직 자체의 실패는 0건이고, 관측되는 실패는 전량 외부 플랫폼 로그인 경로에 있다. 제품 안정성의 상한이 외부에 있다는 뜻이다.

> **검증하지 않은 것**: "치지직 생태계에 이런 도구가 없었다"는 형태의 시장 공백 주장은 이 문서에서 경쟁 제품 조사를 수행하지 않았으므로 하지 않는다. 대신 이 절은 vzzk가 실제로 채운 자리를 관측된 사용량과 후원 수취액으로만 기술한다.

### 2.4 1인 개발이라는 제약이 설계에 미친 영향

1인 개발의 제약은 통계가 아니라 산술에서 나온다. 유지해야 할 것이 하나 늘면 그것을 유지할 사람은 여전히 한 명이고, 자동화되지 않은 절차는 전부 그 한 명의 가용 시간에서 차감된다. 이 전제가 vzzk의 여러 결정을 직접 설명한다.

- **대시보드에 백엔드를 두지 않았다.** 서버는 인증·배포·요금·장애 대응이 영구히 따라붙는 부채이고, 이걸 유지할 사람은 한 명뿐이다. 26개 쿼리를 굽는 정적 HTML을 택해 운영 부담을 0으로 만들었다.
- **테스트 게이트를 릴리스 파이프라인 안에 심었다.** 이 저장소는 프라이빗 + 무료 플랜이라 브랜치 보호도 리포지터리 룰셋도 켤 수 없다 — 2026-08 기준 `gh api repos/:owner/:repo/branches/main/protection`과 `.../rulesets`가 모두 403 `Upgrade to GitHub Pro or make this repository public`을 반환한다. 다만 켤 수 있었더라도 1인 저장소에서는 자기 PR을 자기가 병합하므로 강제력이 약하다. 그래서 게이트를 되돌리기 어려운 지점 — 태그 push가 곧 4개 플랫폼 빌드로 이어지는 자리 — 앞에 `needs`로 걸었다(`.github/workflows/release.yml:57-58`).
- **판단이 필요한 절차는 체크리스트로, 기계적으로 정확해야 하는 것은 코드 생성으로 나눴다.** 봇 하나를 추가하는 데 필요한 지점이 16곳인데, 전부 자동화하면 생성기가 프로젝트만큼 복잡해진다.

---

## 3. 시스템 아키텍처

전체는 네 개의 참여자로 구성된다: Angular WebView(제어 UI), Rust actix-web 로컬 서버(상태 보유 + SSE), OBS 브라우저 소스(오버레이 렌더), 그리고 선택적으로 vzzk-api(원격 오버레이 동기화). **OBS는 Angular를 거치지 않고 Rust 서버에 직접 붙는다.**

### 3.1 (a) 앱 ↔ 로컬 actix-web 서버 ↔ OBS 오버레이

```mermaid
flowchart LR
  subgraph APP["vzzk 데스크톱 앱 · 단일 Tauri 프로세스"]
    direction TB
    NG["Angular 17 WebView<br/>BotService 11종 · Signal 상태"]
    RS["Rust actix-web 서버<br/>src-tauri/src/overlay.rs"]
    ST["AppState<br/>Arc Mutex 최신 상태 + broadcast 채널 용량 100"]
    NG -->|"Tauri IPC · tauri-specta 생성 바인딩"| RS
    NG -->|"POST /vzzk/vote/update<br/>Host 루프백 + Origin 허용목록 + 컨트롤 토큰"| RS
    RS --- ST
  end

  subgraph OBS["OBS Studio · CEF 브라우저 소스 · file 스킴 로드"]
    direction TB
    V["vote.html · 포트 26213"]
    S["subtitle.html · 포트 26216"]
    P["sr.html + sr-player iframe · 포트 26218"]
  end

  RS -->|"SSE /events · 첫 청크로 init 주석 강제 플러시"| V
  RS -->|"SSE /events"| S
  RS -->|"SSE /events + 플레이어 명령 스트림"| P
  V -->|"GET /init · 늦은 구독자 Pull 복구"| RS
  P -->|"POST 하트비트 500ms · 공용 포트 26210"| RS
  P -->|"GET /status · 재생기 역방향 상태"| RS
```

읽는 법 세 가지.

**포트 10개는 라우팅이 아니라 오리진 구분자다.** 같은 `App` 팩토리가 `26210`~`26219` 열 개 리스너에 붙고(`src-tauri/src/overlay.rs:852-897`), 상태는 `web::Data<AppState>` 하나를 공유한다(`:679-722`). 그래서 26213으로 들어온 `POST /update`도 26210에 붙은 구독자에게 전달되고, 오버레이가 자기 포트에서 SSE를 열면서 하트비트만 26210으로 보내도 문제가 없다. 포트가 하는 유일한 일은 브라우저에게 "다른 오리진"이라고 말해 주는 것이다.

**`": init\n\n"` 첫 청크는 SSE 스펙이 권고하는 주석 줄을 핸드셰이크에 끌어다 쓴 것이다.** 4.2 패러다임 표에 적은 대로 주석 줄 자체는 스펙 권고 사항이고, 그 목적은 프록시 타임아웃 방지다. 여기서는 목적을 바꿔 `onopen` 발화 시점을 결정적으로 만드는 데 쓴다 — 조용한 채널에 붙은 `EventSource`는 첫 바이트가 나올 때까지 `onopen`이 뜨지 않고, `onopen`에 걸어 둔 복구 로직이 통째로 죽는다. 그래서 모든 `/events` 스트림이 구독 직후 주석 한 줄을 먼저 흘린다(`src-tauri/src/overlay.rs:267-276`). 전제인 "actix-web은 첫 데이터 청크가 나오기 전까지 `200 OK` 헤더를 내보내지 않는다"는 실측으로 확인했고 벤더 문서로는 확인하지 못했다.

**`localhost`가 아니라 `127.0.0.1` 리터럴이다.** OBS의 CEF가 `localhost`를 `::1`로 먼저 푸는데 서버는 IPv4에만 바인딩되므로 즉시 연결 거부가 나는 것을 실측했다(벤더 문서로는 확인하지 못했다). 가독성을 포기하고 리터럴 하드코딩을 택했다. 반대로 Tauri WebView에서 도는 앱 코드는 `localhost`를 그대로 쓴다(`src/app/service/overlay.service.ts:28`) — 같은 규칙이 아니다.

### 3.2 (b) 앱 ↔ vzzk-api ↔ 원격 오버레이 sync

원격 오버레이는 스트리머가 다른 기기(듀얼 PC, 스마트폰)에서 방송 화면을 열 수 있게 하는 PRO 기능이다. 핵심 설계는 **로컬과 클라우드를 대등한 두 경로가 아니라 주(主)/부(副)로 계층화**한 것이다.

```mermaid
flowchart TB
  NG["Angular · SyncService<br/>BotService를 상속 · 콘솔 토글 하나로 켜짐"]
  RS["Rust actix-web<br/>POST /vzzk/:type/update"]
  LSSE["로컬 SSE 브로드캐스트<br/>OBS 오버레이 · 동기 경로 · 항상 동작"]
  SY["src-tauri/src/sync.rs<br/>tokio spawn 분리 · 실패 시 stderr 한 줄"]

  API["vzzk-api · NestJS · Railway 단일 인스턴스"]
  MEM["인메모리 Map<br/>channelId → overlayType → 최신 상태 1건 덮어쓰기"]
  FB["Firebase RTDB<br/>재연결 복구용 캐시 · 미설정 시 no-op 격하"]
  RSSE["원격 SSE<br/>45초 ping · 14분 reconnect_required · 채널당 18연결 FIFO"]
  REMOTE["원격 오버레이 브라우저<br/>같은 HTML · location.protocol로 모드 분기"]
  VIEWER["시청자 액션 · 곡 신청 등"]
  INB["Inbound SSE → inboundCommand 이벤트 버스<br/>도메인 서비스가 filter로 자율 구독"]

  NG --> RS
  RS ==>|"① 상태 갱신 + 로컬 브로드캐스트"| LSSE
  RS -->|"② 비동기 분리"| SY
  SY -->|"POST /sync/push · Bearer 토큰<br/>대상 채널을 헤더가 아닌 토큰에서 유도"| API
  API --> MEM
  API -.->|"쓰기 실패를 허용"| FB
  MEM --> RSSE
  RSSE --> REMOTE
  FB -.->|"인메모리 캐시가 비었을 때만 init 폴백"| RSSE
  VIEWER -->|"POST /sync/action/:channelId<br/>세션 복호화 후 viewerChannelId 주입"| API
  API --> INB
  INB --> NG
```

굵은 화살표(①)가 방송을 책임지는 경로다. 클라우드를 파이프라인에 직렬로 넣으면 원격 지연·장애가 로컬 OBS 프레임을 그대로 멈춘다. `tokio::spawn`으로 push를 완전히 분리한 대가는 **순서 보장 상실**이고, 실제로 종료 시 `deactivate`와 마지막 push가 경합하는 문제가 발생해 "봇의 `onBeforeClose()`에서 overlay push 금지"라는 불변 규칙으로 회피한다.

인프라 두 층이 서로 다른 이유로 SSE를 끊는다 — Cloudflare 유휴 100초, Railway 절대 15분. 두 값은 운영 중 끊김 패턴에서 실측한 것이고 벤더 문서로는 확인하지 못했다. 클라이언트 자동 재연결에만 맡기면 끊긴 사실을 늦게 알아 화면이 수 초간 멈춘다. 그래서 **"끊기기 전에 우리가 먼저 끊는다"**로 뒤집어, 45초마다 ping을 보내고 14분에 서버가 `reconnect_required`를 보내 예정된 전환을 하게 한다(`vzzk-api/src/sync/sync.service.ts:216-227`, `:282-292`). 그 대가인 주기적 재연결 부하를 견디려고 재연결 직후 캐시를 즉시 전송한다(`:248-252`).

같은 HTML이 로컬과 원격 양쪽에서 돈다. `window.location.protocol`로 판별해 URL 조립부 10줄만 분기시키고 렌더링 코드는 100% 공유한다(`src-tauri/overlay/subtitle.html:195-205`). 그 대가로 원격 서버가 로컬 Rust 서버의 응답 스키마를 정확히 흉내내야 하는 결합이 생겼고, 그래서 vzzk-api의 기본값 상수에 "로컬 Rust 서버의 `create_app_state()` 기본값과 반드시 일치해야 함"이라는 주석이 박혀 있다(`vzzk-api/src/sync/sync.types.ts:73-94`).

### 3.3 (c) PKCE 인증 시퀀스

RFC 8252 §6은 "공개 네이티브 앱 클라이언트는 PKCE를 MUST 구현한다"고 규정하고, §8.5는 여러 사용자에게 배포된 앱에 정적으로 포함된 비밀을 기밀로 취급하지 말라고 명시한다.

**다만 vzzk는 이 규정을 그대로 적용한 것이 아니다.** 아래 시퀀스에서 치지직 인가 서버와 authorization code를 교환하는 주체는 앱이 아니라 vzzk-api다 — 자체 백엔드가 기밀 클라이언트로 상류를 담당한다. PKCE가 걸린 구간은 **앱 ↔ 자체 백엔드의 1회용 교환 코드**이고, RFC 8252 §6이 규정하는 구간(네이티브 앱 ↔ 인가 서버)과 다르다. 규정을 따랐다기보다, 규정이 막으려는 것(공개 클라이언트가 가로챈 코드를 쓰는 것)을 다른 구간에서 재현한 형태다.

```mermaid
sequenceDiagram
    autonumber
    participant A as vzzk 앱 · src-tauri/src/oauth.rs
    participant B as 시스템 브라우저
    participant S as vzzk-api
    participant C as 치지직 OAuth

    A->>A: code_verifier·state 생성 후 프로세스 메모리에만 보관
    A->>B: 인가 시작 URL 열기 · code_challenge 동봉
    B->>S: GET 로그인 시작
    S->>B: challenge와 클라이언트 state는 HttpOnly 쿠키로, nonce는 state와 쿠키 양쪽에 심고 302
    B->>C: 인가 화면 · 사용자 승인
    C->>S: 콜백 · authorization code
    S->>S: state의 nonce와 쿠키 nonce 대조 후 쿠키 즉시 폐기 · 로그인 CSRF 차단
    S->>C: authorization code를 액세스 토큰으로 교환
    S->>S: 1회용 교환 코드 발급 · TTL · 프로세스 메모리 보관
    S->>B: 302 딥링크 · 교환 코드만 실림 · 토큰은 싣지 않음
    B->>A: 딥링크 전달
    A->>A: 스킴 + host 고정 + state + TTL + 1회 소비 검사
    A->>A: 대기 상태를 네트워크 왕복 전에 take · 중복 딥링크 방어
    A->>S: POST 교환 요청 · 교환 코드 + code_verifier
    S->>S: verifier와 challenge 대조 · 불일치 시 거부
    S-->>A: 응답 본문으로만 토큰 반환
    A->>A: OS 자격증명 저장소에 보관 · 실패해도 평문 파일로 되돌리지 않음
```

**PKCE challenge는 state에 인코딩하지 않고 서버 세션 쿠키에 뒀다**(`vzzk-api/src/common/security/oauth-state.ts:55-68`). state는 상류(치지직)를 왕복하며 길이 제약을 받지만, 쿠키는 자체 도메인 안에서만 오가고 클라이언트가 건드릴 수 없다. 이것을 표준 이탈로 부르지는 않는다 — 서버가 중개자로 들어간 배치에서는 통상적인 세션 패턴이고, PKCE의 관용구는 "클라이언트가 verifier를 보관한다"이지 "challenge를 state에 싣는다"가 아니다. 얻은 것은 상류 스펙 변화에 대한 결합이 줄어든 것이다.

순서를 뒤집은 지점이 하나 더 있다 — 12번 단계다. 대기 상태를 **네트워크 왕복 전에** 꺼내 비우고 실패해도 되돌리지 않는다(`src-tauri/src/oauth.rs:119-132`, `:220-234`). Windows에서 단일 인스턴스 argv와 deep-link 플러그인이 같은 딥링크를 두 번 전달하는 문제가 있었고, 이 순서가 두 번째 호출을 서버에 닿기 전에 끝낸다(커밋 `8b11c8d`). 코드가 1회용이라 어차피 같은 코드로 재시도할 수 없다는 것이 정당화 근거로 코드 주석에 남아 있다.

대가도 명확하다. 교환 코드가 프로세스 메모리에 있으므로 **API 서버를 수평 확장할 수 없다**(`vzzk-api/src/auth/token-exchange.service.ts:17-21`). 8장의 로드맵에 그대로 남아 있는 제약이다.

### 3.4 (d) GA4 → BigQuery → 정적 대시보드 파이프라인

```mermaid
flowchart LR
  APP["vzzk 앱<br/>src/app/service/analytics.service.ts<br/>단일 계측 지점 · event_name = category_action"]
  GA4["GA4 속성"]
  BF["GA4 Data API 백필<br/>script/ga4-backfill.js"]
  EX["GA4 Daily Export<br/>2026-05-01 활성화"]
  V1["통합 VIEW daily_summary<br/>759일 · 차원별 집계 행"]
  V2["raw VIEW advanced_events<br/>98일 · session_id · user_id · price"]
  Q["script/generate-dashboard.js<br/>BigQuery 쿼리 26개 · Promise.all 병렬"]
  MC["script/monte-carlo-revenue.js<br/>10,000회 시뮬레이션 · 표본 불확실성 구간"]
  HTML["vzzk-dashboard.html<br/>단일 정적 파일 · 차트 19개 · 서버 없음 · 월 비용 0달러"]

  APP --> GA4
  GA4 -->|"과거 구간은 집계만 반환"| BF
  GA4 -->|"원본 이벤트 · 켠 날부터만"| EX
  BF -->|"UNION ALL + date NOT IN<br/>export가 있는 날짜는 백필에서 제외"| V1
  EX --> V1
  EX --> V2
  V1 --> Q
  V2 -->|"raw 의존 차트는 데이터가 없으면 플레이스홀더"| Q
  Q --> MC
  MC --> HTML
  Q --> HTML
```

이 파이프라인의 설계는 **"GA4가 과거를 raw로 돌려주지 않는다"**는 하나의 제약에서 파생됐다. Daily Export는 켠 날부터만 쌓이므로 2024-07~2026-04 구간은 Data API 집계로 백필하고, 2026-05 이후는 raw를 쓰되 두 소스를 `UNION ALL` + `date NOT IN`으로 겹치지 않게 봉합해 759일 연속 시계열을 만들었다(`script/ga4-backfill.js:334-357`).

대가는 정직하게 남았다. **세션 단위·가격 단위 분석(잔존율, 세션 깊이, 전환율, 수익)은 98일 창에서만 가능하다.** 이 제약이 5장에 나오는 거의 모든 한계의 출처다. 통합 뷰에 raw 정밀도를 욱여넣으면 과거 구간이 전부 NULL이 되어 대시보드 전체가 깨지므로, 뷰를 둘로 쪼개고 테이블이 아직 없으면 쿼리 에러를 삼켜 빈 배열을 반환하게 했다(`script/generate-dashboard.js:110-112`). 그 결과 19개 차트 중 raw 의존 차트만 개별적으로 대기 상태가 된다.

---

## 4. 패러다임별 기술 하이라이트

§4.0은 **이 아키텍처가 지금 만들어내는 차이**를 먼저 정리한다. §4.1부터는 각 축을
**업계 패러다임(정의와 출처) → vzzk의 구현(파일:라인) → 무엇을 포기하고 무엇을 얻었나** 순으로 쓴다.

### 4.0 현재 상태 — 네이티브 앱이라서 가능한 것들

이 절은 개선 과정이 아니라 **완성된 결과**를 다룬다. 아래 네 가지는 전부 "웹 서비스로 만들었다면
구조적으로 불가능하거나 훨씬 비쌌을 것"에 해당하며, 각각 코드로 확인된다.

#### ① 이벤트 처리가 전부 방송 PC 안에서 끝난다 — 서드파티 데스크톱 앱과의 결합

치지직 채팅·후원 이벤트를 받아 처리하는 지점이 **스트리머의 PC**이므로, 같은 PC에서 돌아가는
다른 방송 도구와 직접 대화할 수 있다. 호스팅형 웹 서비스는 이 경계를 넘지 못한다.

| 결합 대상 | 방식 | 웹 서비스로는 |
|---|---|---|
| TITS (아바타에 물건 던지기) | 로컬 WebSocket `ws://localhost:42069` 의 데이터·이벤트 두 엔드포인트에 직접 연결 (`.agent/skills/tits-api/SKILL.md:17-18`) | 사용자 PC의 로컬 포트에 도달할 수 없다 |
| TTS 음성 출력 | Rust `rodio`로 **OS 오디오 장치에 직접 재생**(`src-tauri/src/tts.rs`). 브라우저 자동재생 정책·탭 포커스·오디오 권한의 영향을 받지 않고, 가상 오디오 케이블로 OBS에 라우팅할 수 있다 | 브라우저 샌드박스 안에서만 재생된다 |
| 신청곡 재생 | `yt-dlp`를 자식 프로세스로 실행해 스트림 URL을 추출하고, 로컬 HTTP Range 프록시로 중계한다(`src-tauri/src/youtube_proxy.rs`) | 사용자 PC에서 외부 실행 파일을 띄울 수 없다 |
| 실시간 자막 | OS 음성 인식 권한을 앱이 직접 획득해 마이크 스트림을 처리한다(macOS TCC 대응 포함) | 탭이 닫히거나 백그라운드로 가면 끊긴다 |

**이 선택에는 웹 서비스에 없는 위험이 따라온다.** 로컬 서버가 고정 포트에 떠 있으므로, 스트리머가
방송 중 아무 웹페이지나 열면 **그 페이지의 자바스크립트가 브라우저를 통해 로컬 서버에 요청을 보낼 수
있다.** 포트가 고정이라 주소를 추측할 필요도 없다. 그래서 로컬 오버레이 서버에 접근 통제를 따로 뒀다
(`src-tauri/src/local_guard.rs`). 네이티브를 택하면 이 방어를 **직접** 만들어야 한다는 것이 대가다.

#### ② 서버가 스트리머의 신원 정보를 갖고 있지 않다 — 유출 사고의 폭발 반경

2026-07, 치지직은 자사 채널과 연동되는 외부 플랫폼에서 개인정보 유출이 발생했다며 스트리머 주의
안내를 공지했다. 공지에 적힌 유출 항목은 **이메일·닉네임·내부 사용자 ID**(전체 이용자)와
**실명·생년월일·외국인 여부·전화번호·통신사·스트리밍 플랫폼 계정 정보**(일부 이용자)였다.

vzzk-api는 위 항목을 **하나도 수집하지 않는다.** 저장소를 검사한 결과:

| 확인 항목 | 결과 |
|---|---|
| ORM·마이그레이션·사용자 테이블 | **존재하지 않는다.** TypeORM/Prisma/Mongoose 의존성이 없고 엔티티 정의도 없다 |
| 이메일·전화번호·실명·생년월일 수집 | 소스 전체에서 해당 필드를 다루는 코드가 없다 |
| 시청자 세션에 담기는 것 | `channelId`, `channelName`, `expiresAt` 뿐이다(`vzzk-api/src/auth/viewer-auth.controller.ts:109-110`). **치지직 accessToken은 담지 않는다** |
| 스트리머 토큰 보관 위치 | 서버가 아니라 사용자 PC의 **OS 자격증명 저장소**(`src-tauri/src/keychain.rs`) |
| 로그인 방식 | PKCE 교환 코드. 딥링크에 토큰이 실리지 않고, 교환 코드는 프로세스 메모리에 TTL 2분으로만 존재한다 |

즉 **"털릴 개인정보 데이터베이스가 애초에 없다."** 이것은 사고 대응의 결과가 아니라
설계 시점의 선택이며, §4.3의 "클라이언트를 신뢰 경계 밖에 둔다"는 원칙이 서버 쪽에서 대칭으로
적용된 형태다.

**과장하지 않기 위해 함께 적는다.** vzzk가 아무것도 보관하지 않는다는 뜻은 아니다. 원격 오버레이
상태(자막 텍스트, 신청곡 큐, 시청자 닉네임 등)는 Firebase RTDB에 캐시되고, **그 조회 경로에는 아직
인증이 없다**(§8.1에 미해결로 기재). 채널 ID를 아는 제3자가 방송 중 그 상태를 받아갈 수 있다.
차이는 **데이터의 종류**다 — 유출 시 피싱·스미싱의 재료가 되는 신원 정보 계열을 보유하지 않는 것과,
방송 중 공개적으로 화면에 표시되는 상태를 캐시하는 것은 위험의 성격이 다르다.

#### ③ Tauri 배포 파이프라인을 상용 서비스 없이 직접 운영한다

Tauri 앱의 서명·배포·자동 업데이트는 보통 CrabNebula Cloud 같은 상용 서비스를 구독해 해결한다.
vzzk는 이 경로를 GitHub Actions와 오브젝트 스토리지만으로 직접 구성했다.

| 구성 요소 | 구현 |
|---|---|
| 빌드 | 4플랫폼 매트릭스 — Windows MSI, macOS arm64/x64, Linux AppImage (`.github/workflows/release.yml`) |
| 서명 | 아티팩트마다 minisign 서명(`.sig`)을 함께 업로드. 업데이터가 공개키로 검증한다 |
| 배포 | 공개 오브젝트 스토리지 버킷에 업로드하고, 업데이터 매니페스트(`latest.json`)만 `Cache-Control: no-cache`로 올려 갱신 지연을 없앤다 |
| 자격증명 | 코드 서명 인증서 생성·갱신과 CI 시크릿 반영을 스크립트 한 번으로 처리(`script/generate-cert.js`), 릴리스 직전 시크릿 유효성을 검증하는 워크플로를 따로 둔다 |

**비용은 스토리지와 이그레스뿐이고 구독료가 없다.** 다만 이 절의 §5.4에 적었듯 스토리지·이그레스
실사용량을 계측하지 않았으므로 "얼마를 아꼈다"는 금액은 주장하지 않는다. 주장할 수 있는 것은
**상용 배포 서비스 없이 4플랫폼 서명 배포와 자동 업데이트가 돌아가고 있다**는 사실이다.

#### ④ 제품 판단을 데이터로 하는 환경이 상시 가동된다

GA4 이벤트 택소노미를 활성화·가치창출·에러 3계층으로 설계하고, 원본 이벤트를 BigQuery로 내보내
759일 연속 시계열을 만들고, 26개 쿼리를 정적 HTML 대시보드로 굽는다(§3.4, §5.0). 결과적으로
**어떤 봇이 실제로 쓰이는지, 어디서 이탈하는지, 어떤 에러가 세션을 끝내는지를 질문 단위로 조회할 수
있는 상태**가 유지된다. 이 환경이 있었기 때문에 §5.3의 "이벤트형 봇 전환율 1.8~7.9%" 같은 판단이
가능했고, §6 케이스 3의 지표 결함 4건도 이 환경 안에서 발견됐다.

---

이 네 가지의 공통점은 **런타임 경계를 어디에 둘 것인가**라는 하나의 결정에서 파생된다는 점이다.
처리를 사용자 PC에 두면 로컬 통합과 데이터 최소화를 얻고, 그 대가로 배포·서명·로컬 접근 통제를
직접 만들어야 한다. §4.1~§4.4는 그 대가를 어떻게 치렀는지를 다룬다.

### 4.1 데스크톱 · 배포 — local-first와 공급망 무결성

#### 패러다임

| 패러다임 | 정의 | 출처 |
|---|---|---|
| Local-first / offline-first | 앱 상태의 1차 저장소를 서버가 아니라 사용자 기기에 두고, 서버는 동기화 보조 역할만 하는 설계 | https://www.inkandswitch.com/essay/local-first/ |
| 시스템 WebView 경량 런타임 | Tauri는 OS의 WebView를 재사용해 "최소 앱 600KB 미만"을 주장하고, Electron은 Chromium·Node.js를 번들해 그 취약점 대응 책임을 앱 개발자가 진다 | https://v2.tauri.app/start/ , https://www.electronjs.org/docs/latest/tutorial/security |
| 권한 경계 기반 보안 모델 | 프론트엔드(낮은 신뢰)와 네이티브(높은 신뢰) 사이에 명시적 trust boundary를 긋고 호출 가능한 기능을 선언적으로 화이트리스트 | https://v2.tauri.app/security/ |
| 서명 검증 자동 업데이트 | Tauri 업데이터는 업데이트가 신뢰된 출처에서 왔는지 검증하기 위해 서명이 필요하며 이를 비활성화할 수 없다고 명시하고, 공개키를 설정 파일에 **값으로** 박도록 강제한다 | https://v2.tauri.app/plugin/updater/ |
| 빌드 프로버넌스 (SLSA) | L1 프로버넌스 존재 / L2 호스팅 빌드 플랫폼이 서명 / L3 빌드 격리. 대응 위협은 각각 릴리스 실수 / 빌드 후 변조 / 빌드 중 변조 | https://slsa.dev/spec/v1.0/levels |

#### vzzk의 구현

**local-first를 문자 그대로 적용했다.** 오버레이 HTML은 HTTP로 서빙하지 않고 file 스킴으로 로드된다(`src/app/service/overlay.service.ts:85-90`, `src-tauri/tauri.conf.json:50`). HTTP로 서빙하면 앱이 꺼진 동안 OBS 소스가 빈 화면이 되지만, file 스킴은 앱과 무관하게 로드되고 SSE만 나중에 붙는다. 서버 수명주기도 UI 수명주기와 분리해 `setup()`에서 `async_runtime::spawn`으로 띄우고 창 이벤트에 묶지 않는다(`src-tauri/src/lib.rs:264-267`) — 이전에는 종료 시퀀스를 `WindowEvent::Destroyed`에 걸어 두어 핫리로드나 창 변경에서 서버가 조용히 죽었다. **"누수 없는 종료"의 정확성보다 "방송 중 서버가 살아 있음"을 택했다.**

그 대가는 두 가지다. 오버레이 문서가 불투명 오리진이 되어 오리진 기반 방어가 약해지고, YouTube iframe이 file 스킴에서 동작하지 않아 재생기 하나만 2단 구조(로더 `sr.html` + HTTP iframe)로 예외 처리해야 했다(`src-tauri/src/overlay.rs:442-451`).

**시스템 WebView를 재사용한 대가는 크기가 아니라 런타임 버전 통제다.** 위 표에 인용한 "최소 앱 600KB 미만"은 벤더의 hello-world 수치이고, vzzk의 실제 Windows 설치본은 약 7.8 MB다(로컬 빌드 산출물 `src-tauri/target/release/bundle/nsis/`, v1.2.12 기준). 크기보다 무거운 대가는 **사용자 기기의 WebView2/WKWebView 버전을 개발자가 정할 수 없다**는 것이다 — 특정 OS 버전에서만 재현되는 렌더링·JS 동작 차이는 앱 코드로 고칠 수 없고, 오버레이가 OBS의 CEF에서 돌기 때문에 런타임이 하나 더 늘어난다. 지금까지 그런 사례를 관측한 적은 없다. 다만 2.3에 적었듯 관측된 OS가 Windows 단일이므로, 다른 조합에서 무슨 일이 일어나는지는 데이터로 알지 못한다.

**권한 경계는 읽기/쓰기 비대칭으로 그었다.** 미들웨어가 모든 요청에 `Host` 루프백 + `Origin` 허용 목록을 검사하고, 상태를 바꾸는 POST에만 프로세스 메모리의 컨트롤 토큰을 추가로 요구한다(`src-tauri/src/overlay.rs:729-770`, `src-tauri/src/local_guard.rs:94-140`). 불투명 오리진을 허용해야 오버레이가 사는데, 공격 페이지도 샌드박스 iframe으로 같은 오리진을 만들 수 있다. **읽기는 열어 주고 쓰기만 잠근 이유는 방송 화면 조작은 막되 사용자가 OBS에 저장한 URL은 깨뜨리지 않기 위해서다.** 완전 차단은 오버레이를 HTTP + URL 토큰으로 옮겨야 가능하고, 그러면 저장된 경로가 전부 바뀐다.

**업데이트 무결성은 minisign, 실행 평판은 포기했다.** 업데이터 공개키는 `tauri.conf.json`에 상수로 들어가고 개인키는 저장소 밖의 동기화 폴더에서 읽어 환경변수로 주입한다(`script/build-signed.js:16-20`, `:44-49`). CI는 같은 값을 GitHub Secrets로 받는다. 배포는 GCS 공개 버킷이므로 서명이 없으면 버킷 접근권 하나가 전 사용자 RCE가 된다. 반면 코드 서명은 자체 서명이라 SmartScreen 경고가 남고, 그 결과가 릴리스 노트의 사용자 안내 문구다 — **무결성(변조 탐지)은 minisign이 담당하고 평판은 인증서 비용 대신 안내로 대체한 분업이다.**

minisign 키의 대가는 회전 불가능성이다. 개인키를 분실하면 자동 업데이트 경로 자체를 잃고(`README.md:52`), 키를 교체하면 기존 사용자는 수동 재설치가 필요하다(`README.md:74`). 두 경고 모두 README에 적혀 있다.

**빌드는 전부 CI에서 돈다.** 태그 push → 4플랫폼 매트릭스 빌드(macOS arm64/x64, Ubuntu, Windows) → 서명 → 플랫폼별 부분 매니페스트 병합 → GCS 업로드 → 공개 저장소 릴리스 생성. 개발 워크스테이션이 Windows 단일이므로 **macOS·Linux 아티팩트는 로컬에서 생산할 방법이 없다 — 이건 시간 절약이 아니라 없던 능력이다.**

**다만 이 파이프라인을 SLSA 레벨로 부를 수는 없다.** 위 표의 정의대로 SLSA는 아티팩트 서명이 아니라 **빌드 프로버넌스**의 존재·서명·격리로 레벨을 나누는데, `.github/workflows/`에는 프로버넌스를 생성하는 스텝이 없다(`attest` / `provenance` 계열 액션 0건). 가진 것은 minisign 아티팩트 서명뿐이고, 그것은 SLSA가 세는 항목이 아니다 — 프로버넌스가 없으므로 L1도 충족하지 못한다. 8장 미해결 항목으로 남긴다.

여기에 릴리스 순서가 만든 고유 문제가 하나 있다. GCS 업로드의 첫 동작이 기존 파일 삭제이고 공개 저장소 릴리스 생성은 그 뒤다. 뒤쪽 토큰이 죽어 있으면 릴리스 노트 없이 바이너리만 교체된 상태가 남고, 그 사실을 태그를 민 뒤에 안다. 되돌리기 어려운 순서를 바꾸는 대신 **사전 검증 워크플로**를 추가했다 — 체크아웃은 읽기 권한만 증명하므로 저장소 권한을 직접 조회하고, 버킷은 임시 객체를 올렸다 지워 쓰기까지 확인한다(`.github/workflows/verify-release-secrets.yml:21-32`, `:42-49`, 커밋 `c198a2f`). 대가는 수동 실행이라는 점이고, 부르는 것을 잊으면 소용없다.

### 4.2 실시간 — 전송 방식 선택과 연결 수명주기

#### 패러다임

| 패러다임 | 정의 | 출처 |
|---|---|---|
| 전송 방식 분리 선택 | 서버에서 클라이언트로 가는 단방향은 SSE, 양방향 저지연이 실제로 필요할 때만 WebSocket. SSE에는 HTTP/1.1 기준 브라우저당 도메인당 **6개** 연결 상한이 있다 | https://html.spec.whatwg.org/multipage/server-sent-events.html , https://datatracker.ietf.org/doc/html/rfc6455 |
| 연결 수명주기는 상태 기계 | 생존 확인 → 끊김 감지 → 백오프 재연결 → 유실 구간 복구까지의 순환. SSE 스펙은 프록시 타임아웃 방지를 위해 15초 정도마다 주석 줄을 보낼 것을 권고 | https://html.spec.whatwg.org/multipage/server-sent-events.html , https://dev.twitch.tv/docs/eventsub/handling-websocket-events/ |
| 백프레셔와 그 부재 | 백프레셔는 소비자의 여유 용량을 생산자에게 되돌려 발행 속도를 조절하는 것이다. 되돌릴 채널이 없으면 남는 선택지는 흘려보내기(load shedding)와 중복 제거뿐이다. Node.js 가이드의 87.81 MB → 1.52 GB 벤치마크는 대상이 Node 스트림이라 여기에 그대로 적용되지 않는 참고 자료다 | https://nodejs.org/learn/modules/backpressuring-in-streams |
| 브라우저 소스 오버레이 | 방송 화면을 웹 런타임으로 취급. 배포 단위가 설치형 앱이 아니라 URL 하나 | https://obsproject.com/kb/browser-source |

#### vzzk의 구현

**SSE를 기본값으로 골랐고, 6-연결 상한을 알고 대응했다.** 오버레이 하나가 SSE 하나를 영구 점유하므로 단일 오리진이면 7번째부터 조용히 Pending으로 얼어붙는다. 같은 `App` 팩토리를 26210~26219에 바인딩해 오리진 수를 늘렸고, 이유가 코드 주석에 그대로 남아 있다(`src-tauri/src/overlay.rs:879-896`). 기록된 대안은 `127.0.0.2` 같은 IP 앨리어싱 하나였고, macOS가 기본 차단한다는 이유로 배제됐다(`.agent/skills/overlay-architecture/SKILL.md:17`) — 이 차단은 내부 문서에 남은 실측 기록이고 벤더 문서로는 확인하지 못했다.

**검토하지 않은 대안이 있다.** 6-연결 상한은 HTTP 연결에 걸리는 것이라, 전송을 WebSocket으로 옮겼으면 오리진 하나로 해결됐다. 그리고 이 제품은 이미 WebSocket 경로를 갖고 있다 — 원격 프롬프터가 socket.io로 붙는다(`src-tauri/overlay/private/prompter.html:436-441`, 서버는 `vzzk-api/src/prompter/prompter.gateway.ts`). 다만 그것은 원격 sync 경로이고, 로컬 오버레이 전송에서 WebSocket을 후보로 올린 기록은 레포에 없다. **당시 검토하지 않았다**가 정확한 서술이다.

포트 증설의 대가는 (1) 오버레이를 추가할 때마다 Rust 상수와 HTML 상수를 손으로 맞춰야 하고 (2) 사용자 방화벽에 뜨는 리스닝 포트가 10개라는 것이다. **얻은 것은 오버레이 수가 늘어도 연결 한도가 상수라는 점이다.**

**연결 수명주기는 끊김을 정상 경로로 다룬다.** 세 층으로 나뉜다.

- **마이크로 단절 내성**: 하트비트는 500ms지만 5회 연속 실패(2.5초)까지는 플레이어를 내리지 않는다(`src-tauri/overlay/sr.html:192-198`, `:216-222`). 0.5초 하트비트를 그대로 죽음 판정에 쓰면 평범한 CPU 스파이크에도 YouTube iframe이 언마운트되고 재부팅 루프가 돈다. **장애 감지를 2.5초 늦추는 대신 오탐으로 인한 재생 중단을 없앴다.**
- **늦은 구독자 복구(2트랙)**: `fetchInitData()`(즉시 Pull)와 `connectSSE()`(스트림 Push)를 병렬 실행하고 `isDataInitialized` 플래그로 중복 렌더를 막는다(`src-tauri/overlay/vote.html:405-436`, `:465-466`). broadcast 채널은 구독 이전 메시지를 주지 않으므로, 앱이 먼저 켜지든 OBS가 먼저 켜지든 같은 결과가 나오려면 서버가 상태를 들고 클라이언트가 당겨가야 한다. **요청이 항상 1회 더 나가는 비용을 받아들이고 시작 순서 의존을 완전히 제거했다.**
- **예정된 재연결과 진짜 장애의 분리**: `onerror`만 `wasDisconnected` 플래그를 세워 데이터 재조회를 하고, 서버가 보낸 `reconnect_required`는 플래그를 세우지 않는다(`src-tauri/overlay/subtitle.html:352-391`). `location.reload()`가 가장 쉬운 복구지만 DOM·사용자 입력·진행 중 애니메이션을 전부 날린다. 상태 플래그 3개를 손으로 관리하는 복잡도를 감수하고 soft recovery를 택했다.

**고빈도 push는 백프레셔가 아니라 흘려보내기로 처리한다.** 음성인식 중간 결과가 기본 활성이라(`src/app/service/subtitle.service.ts:90`) 초당 수 회 push가 발생한다. **이 시스템에 백프레셔 신호는 없다** — 생산자(음성인식 엔진)는 소비자의 여유를 모르고, 넘치면 버린다. 아래 세 층은 전부 흘려보내기와 중복 제거이지 발행 속도 조절이 아니다.

1. **서버 상한을 실측에 맞춰 따로 뚫었다.** 전역 레이트 리밋 300/60초(초당 5회)를 그대로 적용하면 방송 중 자막이 조용히 끊긴다. push 라우트에만 1800/60초(초당 30회)를 주고 그 근거를 코드 주석에 남겼다(`vzzk-api/src/sync/sync.controller.ts:119-122`).
2. **캐시를 누적 큐가 아니라 마지막 상태 1건 덮어쓰기로 정의했다**(`vzzk-api/src/sync/sync.service.ts:145-161`). 재연결한 오버레이가 밀린 자막 수백 건을 한꺼번에 받는 대신 최신 1건만 받는다 — 재연결 비용이 O(1)로 고정된다. 대가는 재연결 구간의 자막이 영구 유실된다는 것이고, **자막은 최신 값만 의미가 있다는 도메인 판단**에 근거한다.
3. **클라이언트는 동일 페이로드면 렌더를 건너뛴다**(`lastEventData` 문자열 비교). 이 변수를 `connectSSE()` **안**에 두면 재연결마다 초기화되어 방어가 무력화되므로, 스코프 위치 자체를 스킬 문서에 경고로 못박았다.

**디바운스를 넣지 않은 것이 명시적 선택이다.** 중간 결과는 그 자체가 실시간성이 목적이므로 발행에 지연을 넣지 않기로 했다. 다만 트레일링 스로틀 — 최종 결과의 지연을 늘리지 않으면서 중간 발행량만 줄이는 방식 — 은 검토하지 않았다. 즉 "지연을 넣지 않는다"와 "발행량을 줄이지 않는다"를 분리해 판단한 선택은 아니다.

Rust 쪽 broadcast 채널(용량 100)도 같은 태도다. `RecvError::Lagged`를 흘려보내는 것은 백프레셔의 정반대이며, 밀린 소비자에게 속도를 되돌리는 대신 밀린 만큼을 버린다. 흘려보내고 계속 수신하며 `Closed`에서만 스트림을 끝낸다(`src-tauri/src/overlay.rs:479-488`). 느린 구독자가 밀리면 메시지를 잃지만, 스트림을 끊고 재연결시키면 상태 조회로 다시 메워야 한다. **개별 메시지 유실을 감수하고 연결 유지를 택했고, 근거는 대부분의 오버레이 프로토콜이 전체 스냅샷을 보내는 상태 기반이라 유실이 누적되지 않는다는 점이다.**

이 전제는 전수 확인 결과 **후원목표에서 성립하지 않는다.** 로컬 SSE는 갱신된 goal 하나만 보내고(`src-tauri/src/overlay.rs:417-419`), 전체 배열은 `/init`과 클라우드 push 경로에만 실린다(`:651-654`). 다중 goal을 운영하는 중에 특정 goal의 갱신이 `Lagged`로 버려지면 그 goal은 다음 갱신이나 재연결 전까지 낡은 값으로 남는다. 상태 기반이라는 근거가 적용되지 않는 유일한 경로이며 8.1에 남긴다.

**남용 방어는 레이트 리밋이 아니라 연결 수 상한으로 옮겼다.** SSE·init·정적 HTML 라우트는 스로틀 예외다. OBS 브라우저 소스는 끊기면 1초 간격으로 재연결하므로, 오버레이 6개가 동시에 끊기면 재연결만으로 분당 360회가 되어 리밋에 걸리고 **그 순간 방송 화면이 영구히 복구되지 않는다.** 장애 복구 경로에는 절대 리밋을 걸지 않는다를 원칙으로 삼고, 대신 채널당 SSE 18연결 상한과 FIFO eviction을 뒀다(`vzzk-api/src/sync/sync.service.ts:203-207`, `:426-452`). 429 거부가 아니라 오래된 것부터 끊는 FIFO를 택한 이유는 방금 연 창이 반드시 살아 있어야 한다를 우선했기 때문이다.

**대가는 이 문단 앞부분과 겹쳐 읽어야 정확해진다.** 축출된 오버레이도 1초 뒤 재연결하므로, 19연결 이상에서는 "첫 창이 조용히 죽는" 것으로 끝나지 않고 **가장 오래된 연결이 돌아가며 축출되는 순환 상태**로 퇴화한다. 현재 규모(채널당 오버레이 6~10개)에서는 상한에 닿은 적이 없어 관측되지 않았을 뿐, 구조적 결함이다. 축출 시 재연결 억제 신호를 함께 내려보내는 것이 정해진 다음 단계이며 8.1에 남긴다.

### 4.3 보안 · 인증 — 클라이언트를 신뢰 경계 밖에 두기

#### 패러다임

| 패러다임 | 정의 / 규정 | 출처 |
|---|---|---|
| 공개 클라이언트 모델 | RFC 8252 8.5절: 여러 사용자에게 배포된 앱에 정적으로 포함된 비밀은 기밀로 취급되어서는 안 된다. 공유 비밀 기반 클라이언트 인증은 NOT RECOMMENDED | https://www.rfc-editor.org/rfc/rfc8252.html |
| PKCE | RFC 8252 6절: 공개 네이티브 앱 클라이언트는 PKCE를 MUST 구현. OAuth 2.1은 이를 모든 클라이언트로 확대 | https://www.rfc-editor.org/rfc/rfc7636 , https://oauth.net/2.1/ |
| 공개키 서명 검증 | 클라이언트는 검증만 하고 어떤 비밀도 보관하지 않는다. Ed25519는 키와 서명이 작아 설정 파일에 직접 넣을 수 있다 | https://www.rfc-editor.org/rfc/rfc8032.html , https://github.com/jedisct1/minisign |
| 서버 권위 엔타이틀먼트 | CWE-602는 서버가 클라이언트에 놓인 보호 메커니즘에 의존하는 것을 별도 취약점 유형으로 분류한다. 완화책은 모든 보안 검사의 서버 측 중복 수행 | https://cwe.mitre.org/data/definitions/602.html |

#### vzzk의 구현

원칙은 vzzk-api README 한 문장에 고정돼 있다 — **배포되는 클라이언트 바이너리에 대칭 비밀을 넣지 않는다.** 근거도 같은 문서에 있다: 대칭 비밀은 양쪽이 같은 값을 가져야 하므로, 한쪽이 사용자 PC에 있으면 그 값은 사용자 것이고 `strings` 한 번이면 추출된다.

이 원칙은 2026-07-28 두 저장소에 25초 간격으로 커밋된 한 쌍의 변경으로 구현됐다 — 서버가 먼저(`d288032` 16:51:59), 앱이 다음(`1dfaeec` 16:52:24). 순서가 이 방향인 이유는 **서버가 먼저 넓어져야 앱이 새 경로를 쓸 수 있기** 때문이고, 서버 배포가 깨지면 전 사용자가 영향받으므로 신규 경로뿐 아니라 구버전 경로가 그대로 동작하는지도 함께 테스트했다.

네 개의 대칭 비밀이 각각 다른 방식으로 제거됐다.

| 제거 대상 | 대체 방식 | 증거 |
|---|---|---|
| 딥링크 토큰 복호화 키 | PKCE 교환 코드 — 토큰이 딥링크에 실리지 않음 | `src-tauri/src/oauth.rs:47-57`, `:189-209` |
| 모든 설치본이 공유하는 정적 API 키 | 로그인 사용자의 Bearer 토큰 | `src-tauri/src/sync.rs:271-276` |
| Pro 자격 HMAC 서명 키 | Ed25519 공개키 검증 (앱에는 공개키 상수만) | `src-tauri/src/pro.rs:29-59` |
| 테스트 스크립트의 서버 키 사본 | 서버가 평문 토큰을 반환하도록 변경 | vzzk-api `README.md` |

현재 `src-tauri/src/` 전체에 이 네 시크릿의 **이름**이 하나도 남아 있지 않다(grep 확인). 다만 식별자가 소스에서 사라진 것과 비밀 소재가 배포 바이너리에서 사라진 것은 다른 층위다. 이 절의 출발점이 "`strings` 한 번이면 추출된다"였으므로 검증도 같은 층위여야 하는데, 릴리스 바이너리에 대한 문자열 스캔은 수행하지 않았다.

**서명만으로는 부족했다.** Ed25519 서명을 검증한 뒤 payload의 `channelId`가 현재 로그인 계정과 같은지 대조한다(`src-tauri/src/pro.rs:89-93`). 서명만 확인하면 다른 Pro 사용자의 **정상** 응답을 복사해 쓸 수 있기 때문이다 — 여기서 닫은 것은 **타인의 응답을 복사해 쓰는 경로**다. 자기 과거 응답의 재생은 이 대조가 아니라 payload의 `exp` 검사가 막는다(`:100-109`, 만료 시 `isPro`를 false로 내린다). 대조에 상수시간 비교를 쓰긴 했지만(`src-tauri/src/oauth.rs:89-96`) `channelId`는 비밀이 아니어서 타이밍 오라클로 얻을 것이 없다 — 습관이 그대로 나온 자리이고 보안 효과는 없다.

**같은 문제를 정반대로 푼 두 사례가 이 축의 판단 기준을 보여준다.**

Pro 자격에서는 서버가 HMAC과 Ed25519를 **동시에** 내려보낸다. 이것은 순수한 손해다 — 구버전 규약으로 요청하면 위조 가능한 검증이 그대로 남아 있으므로, Ed25519를 추가한 시점에도 Pro 자격 위조는 여전히 가능하다. 그럼에도 이 형태를 택한 이유는 앱과 서버의 배포 시점이 분리되어 있고 사용자가 업데이트를 미룰 수 있기 때문이다.

반면 시청자 세션에서는 구버전 CBC 형식을 형식 검사 단계부터 거부한다(`vzzk-api/src/auth/viewer-session.ts:83-88`). 인증 태그 없는 CBC로는 `channelId` 위조를 탐지할 수 없었기 때문이다.

판단이 갈린 근거는 **안전한가가 아니라 깨졌을 때 사용자가 치르는 비용이 얼마인가**다. 시청자 세션은 수명이 짧아 재로그인 한 번이면 끝나지만, Pro 자격은 앱 바이너리 교체가 필요하다.

**실패 모드의 진단 가능성도 이름의 깔끔함보다 위에 뒀다.** HMAC 제거 시 `signatureEd25519`를 `signature`로 이름을 바꾸지 말라는 지침이 문서에 명시돼 있다. 이름을 바꾸면 구버전 앱이 Ed25519 서명을 HMAC으로 해석해 검증 실패로 끝나지만, 필드가 아예 없으면 원인이 응답에 그대로 드러난다.

**토큰 저장은 실패 시 안전한 쪽으로 무너지게 만들었다.** OS 자격증명 저장소(Windows Credential Manager / macOS Keychain / freedesktop Secret Service)에 접근할 수 없으면 **평문 파일로 되돌아가지 않고** 세션을 메모리에만 두고 다음 실행 때 재로그인을 요구한다(`src-tauri/src/keychain.rs:17-20`). 이전 저장소였던 `tauri-plugin-store`의 설정 파일은 암호화되지 않은 JSON이라 같은 사용자로 실행되는 모든 프로세스가 읽을 수 있었다. 반면 **읽기는 어떤 경우에도 오류를 던지지 않아 시작 경로를 막지 않는다**(`:42-45`).

**키 부재 시 부팅은 허용했다.** 서명 시드 환경변수가 없으면 경고만 남기고 HMAC만 내려보낸다(`vzzk-api/src/pro/pro.service.ts:76-104`). fail-fast를 포기한 대신, 환경변수 하나 때문에 서비스 전체가 내려가는 상황을 신버전 앱만 Pro 판정을 못 받는 상태로 축소했다. 그 대가로 잘못된 배포가 조용히 통과할 수 있어 README에 배포 후 수동 확인 절차를 명시했다.

### 4.4 데이터 — 이벤트 택소노미와 분포 지표

#### 패러다임

| 패러다임 | 정의 / 규정 | 출처 |
|---|---|---|
| 이벤트 택소노미 | 이벤트 이름과 프로퍼티를 사전 문서화한 트래킹 플랜으로 고정한 뒤 그 위에서 퍼널·리텐션을 계산. Amplitude는 대소문자만 달라도 서로 다른 이벤트로 잡힌다고 명시 | https://amplitude.com/docs/data/data-planning-playbook |
| 분포 지표 | Google SRE 서적: 평균 지연은 대부분이 빠르지만 롱테일이 훨씬 느린 상황을 감춘다. 대부분의 지표는 평균이 아니라 분포로 생각하는 편이 낫다 | https://sre.google/sre-book/service-level-objectives/ |
| 웨어하우스 네이티브 | 벤더 UI 집계를 최종 산출물로 쓰지 않고 원본 이벤트를 웨어하우스로 내보내 SQL에서 지표를 재정의. 데이터 소유권이 사용자에게 있다 | https://support.google.com/analytics/answer/9358801?hl=en |

#### vzzk의 구현

**택소노미를 별도 레지스트리 테이블 대신 문자열 컨벤션에 실었다.** 이벤트 이름을 `카테고리_액션` 형태로 강제하고, 접미사로 3계층을 판정한다 — `_open`은 활성화, `_closed`와 `Fail`을 제외한 나머지는 가치창출, `Fail` 접미사는 에러(`script/generate-dashboard.js:283-286`).

**얻은 것**: 새 봇을 추가할 때 리포터 메서드 하나만 만들면 26개 쿼리가 자동으로 그 봇을 인식한다. **포기한 것**: 컴파일 타임 보증. 그리고 이 대가를 실제로 치렀다 — BigQuery `LIKE`에서 언더스코어는 단일 문자 와일드카드라 PRO 접두 패턴이 `prompter_` 계열 3종을 퍼널로 빨아들였다. 6장 케이스 3에서 다룬다.

**3계층을 나눈 이유는 분석 편의가 아니라 질문의 종류가 다르기 때문이다.** `_open`은 메뉴에서 켜기만 한 상태라 관심도를, core action은 봇이 실제로 동작한 상태라 가치 창출을, `Fail`은 이탈 원인을 측정한다. 셋을 한 덩어리로 세면 켜두기만 한 봇과 실제로 쓰인 봇이 구분되지 않는다. 실제로 최근 30일 활성화에서 동작으로 가는 전환율은 subtitle 96.6%에서 vote 1.8%까지 넓게 흩어진다. 다만 이 값들을 봇끼리 나눠 배수로 말하지는 않는다 — "열기"의 의미가 봇마다 달라(자막은 인식 시작, 투표는 패널 열기) 분모의 뜻이 같지 않다. 이 지표는 한 봇의 시계열 비교에 쓸 값이다(5.3절).

**계측 공백은 클라이언트가 메웠다.** GA4 export의 `device.operating_system`이 Tauri WebView에서 비어 오는 사례가 있어, 앱이 모든 이벤트 파라미터에 `os`를 함께 실어 보내고(`src/app/service/analytics.service.ts:383`, 값 산출은 `:361-367`) raw 뷰에서 `COALESCE(event_params.os, device.operating_system)`로 병합했다(`script/ga4-backfill.js:395-398`). 이벤트 페이로드가 커지는 대신 OS 분포가 단일 값으로 정합하게 나온다.

**이 보정에는 두 가지 범위 제한이 있다.** 첫째, `COALESCE`는 `advanced_events`(98일)에만 걸려 있고 759일 통합 뷰에는 적용되지 않는다. 둘째, 앱의 `getOS()`도 GA4와 같은 `navigator.userAgent`를 읽으므로 왜 한쪽에서만 값이 비는지 설명하지 못한다 — 비는 비율도 측정하지 않았다. 즉 이 대응은 원인을 규명한 결과가 아니라 결손을 덮은 것이다.

**평균 대신 P90을 BigQuery 단에서 계산한다.** 후원 금액과 방송 길이는 0이 압도적으로 많은 극단 롱테일이라 평균이 미결제·테스트 세션에 끌려 내려간다. `APPROX_QUANTILES`로 P90을 냈다(`script/generate-dashboard.js:154-162`, `:223-231`). **P90은 대표값이 아니라 상위 10% 값이라는 점을 감수하는 대신 아웃라이어 하향 평준화를 제거했다.** 추가 대가로 `APPROX_QUANTILES`는 근사값이라 정확도를 스캔 비용과 맞바꾼 것이다.

**같은 이유로 총수익은 점추정을 버렸다.** 이벤트수 곱하기 평균단가라는 점추정은 98일짜리 표본으로 26개월 누적을 외삽하면서 소수점까지 확정된 것처럼 보이는 **거짓 정밀도**를 만든다. ARPE는 Normal, 유료비율은 Beta로 모델링해 10,000회 샘플링한 뒤 2.5/50/97.5 백분위수를 냈다(`script/monte-carlo-revenue.js:93-100`, `:239-242`, `:279-284`). 구간 상단이 하단의 3.5배라는 사실 자체가 이 추정의 신뢰도를 정직하게 드러낸다.

**중복 카운팅도 상수로 가정하지 않고 관측했다.** 후원 목표는 다른 수익 기능과 같은 후원 1건에서 동시에 트리거되므로 단순 합산하면 이중 계상된다. 세션 레벨 교집합으로 22.2%를 관측해 차감했고, 데이터가 없을 때만 0.5 기본값으로 폴백한다 — **모르는 구간에서는 수익을 보수적으로 절반 깎는다**(`script/monte-carlo-revenue.js:166-176`).

---

## 5. 정량 임팩트

### 5.0 관측창 두 개, 그리고 지표를 믿기 전에 한 일

모든 수치는 아래 두 소스 중 하나에서 나온다. 어느 쪽인지에 따라 커버리지가 완전히 다르다.

| 소스 | 기간 | 일수 | 성격 |
|---|---|---:|---|
| `daily_summary` (통합 VIEW) | 2024-07-07 ~ 2026-08-05 | 759일 (활동일 758일) | GA4 Data API 백필 + Daily Export 통합. 날짜·이벤트·국가·기기·OS 차원별 집계 행 |
| `advanced_events` (raw VIEW) | 2026-05-01 ~ 2026-08-06 | 98일 | GA4 Daily Export 원본. 세션 식별자·사용자 식별자·가격 파라미터 보유 |

**세션 단위·가격 단위 분석(잔존율, 세션 깊이, 전환율, 수익)은 98일 창에서만 가능하다.** 이 제약이 이 장의 거의 모든 한계의 출처다.

**두 소스의 세션 정의가 같은지는 검증하지 않았다.** 통합 VIEW의 앞 구간은 GA4 Data API 집계(모델링·임계값 처리를 거친 값)이고 뒤 구간은 Daily Export의 `session_start` 카운트다. 접합면에서 세션의 뜻이 달라질 수 있으나 두 소스를 겹쳐 뽑아 차이를 실측한 적이 없다. 5.1의 누적 세션 55,193에는 이 미검증 항목이 붙는다.

#### 수정한 집계 결함 4건 (2026-08-06)

이 문서를 쓰기 위해 대시보드 쿼리를 전수 검토해 결함 4건을 찾아 수정하고 재생성했다. **이 장의 모든 수치는 수정 후 값이다.**

| # | 증상 | 근본 원인 | before → after |
|---|---|---|---|
| 1 | PRO 결제 퍼널에 프롬프터 이벤트가 섞임 | BigQuery `LIKE`에서 언더스코어는 단일 문자 와일드카드다 | 7행 → **4행** |
| 2 | 잔존율이 뒤쪽 주차로 갈수록 급락 | 첫 주 활성 사용자를 모든 주차의 공통 분모로 써서 우측 절단 | W14 8.2% → **38.4%** |
| 3 | 자막 봇 활성화→동작 전환율 842.3% | 열기와 동작을 세션 교집합 없이 따로 셈 | 842.3% → **96.6%** |
| 4 | 열어만 두고 안 쓴 세션이 항상 0건 | 동작 카운트가 세션 시작 등 비봇 이벤트를 포함해 분자가 퇴화 | 0건 / 9,305세션 → **23건 / 9,124세션** |

세 번째 결함의 성격이 특히 나쁘다. 842%는 대시보드에 그대로 렌더링되고 있었고 **비율이 100%를 넘는다는 사실 자체가 정의 오류를 자백하고 있었는데도** 방치돼 있었다. 두 번째는 반대 방향이다 — W14 8.2%는 쓸 만한 잔존율로 보이지 않아서 제품 판단을 잘못된 쪽으로 밀 수 있었다. 실제 값은 38.4%다.

#### 수정하지 않은 알려진 한계 5건

원인이 계측 공백이거나 관측 기간 부족이라 쿼리 수정으로 해결되지 않는다. 해당 지표를 인용할 때마다 함께 적는다.

| id | 내용 | 영향받는 지표 |
|---|---|---|
| `dau-dimension-double-count` | `daily_summary`는 차원별 행이라 활성 사용자 합산이 차원 조합 간 중복된다 | DAU, 누적 DAU-일 |
| `sr-price-not-instrumented` | 신청곡 큐 추가 33,077건 중 가격이 실린 건은 **1건(0.003%)** | 수익 추정의 SR 기여분 |
| `donationgoal-degenerate-prior` | 후원목표 기여 이벤트는 기여가 있을 때만 발생하므로 유료비율 1.0이 구조적으로 참. Beta(1013,1) 사전분포가 불확실성을 거의 0으로 만든다 | 수익 추정 구간 폭 |
| `os-windows-only` | 최근 30일 macOS/Linux 세션 미관측. macOS 지원 코드와 릴리스 아티팩트는 존재한다 | OS 분포 |
| `revenue-backcast` | 가격 파라미터는 2026-05-01부터 98일만 관측. 26개월 중 관측 4개월(2026-05~08)을 뺀 **22개월(2024-07~2026-04)이 역추정**이며 시간에 따른 ARPE 변동을 반영하지 않는다 | 누적 수익 추정 전체 |

#### 집계 스크립트에서 추가로 고친 값 4건

원본 쿼리 결과에서 지표를 다시 계산했을 때 집계 스크립트의 산출값과 어긋난 것이 4개 있었다. 스크립트를 고쳐 재생성했으므로 **아래 오른쪽 값이 이 장에서 쓰는 값이다.** 왼쪽 값을 병기하는 이유는, 넷 중 둘이 단순 반올림 표기가 아니라 계산 정의의 문제였기 때문이다.

| 지표 | 이전 값 | 정정 값 | 무엇이 틀렸나 |
|---|---|---|---|
| 스트리머별 수취액 중앙값 | ₩183,640 | **₩130,220** | n=44는 짝수라 중앙값은 22·23번째의 평균이다. 183,640은 상위 중앙값이라 중앙값 정의를 만족하지 않는다. 최대/중앙값 배수도 43.9배 → **61.9배**로 바뀐다 |
| 세션당 상호작용 P90 | 1,357 | **1,356.6** | 백분위를 선형 보간으로 계산 |
| 방치 세션 비율 | 0.3% | **0.25%** | 23/9,124. 1% 미만은 유효숫자 2자리로 적는다 |
| 30일 에러/세션 | 0.2% | **0.16%** | 5/3,128. 같은 이유 |

앞의 두 값은 원본 세션 배열·수취액 배열에서 다시 계산한 것이고, 그 배열 자체는 식별자를 포함하므로 이 문서에 싣지 않는다. 즉 **이 두 정정은 이 문서만으로는 재현되지 않는다.**

---

### 5.1 규모와 성장

| 지표 | 값 |
|---|---:|
| 누적 세션 | **55,193** |
| 누적 DAU-일 | 45,984 |
| 최고 DAU | **117** (2026-02-13) |
| 최근 DAU (2026-08-05) | 99 |
| 최근 30일 평균 DAU | **90.6** |
| 직전 30일 평균 DAU | 90.1 |
| 전년 동기 평균 DAU | 75.6 |
| 관측 기간 | 759일 (활동일 758일) |

월별 세션 추이:

```
2024-07    250 |=
2024-12    775 |===
2025-03  2,124 |========
2025-06  2,857 |===========
2025-09  2,704 |==========
2025-12  2,393 |=========
2026-03  3,568 |==============
2026-05  3,380 |=============
2026-07  3,291 |=============
```

최근 30일 신규/재방문: 신규 **92 사용자-일**, 재방문 **2,625 사용자-일**, 사용자-일 기준 재방문 비중 **96.6%**. **단위는 사람이 아니라 사용자-일이다** — 합계 2,717은 평균 DAU 90.6 × 30일과 같은 자릿수이고, 같은 창의 고유 사용자 수와는 자릿수가 다르다(5.5절의 98일 창 고유 사용자 749명 참조).

**의미**: 첫 12개월(2024-07~2025-06)은 250 → 2,857 세션으로 늘었다. 다만 기저인 2024-07은 첫 릴리스(v0.0.0, 2024-07-09) 이후 3주만 포함하는 부분월이라 **배수를 성장률로 쓰지 않는다.** 첫 완결월 2024-08(473세션)을 기저로 잡으면 2025-06까지 11개월간 6.0배다. 그 뒤 13개 완결월(2025-07~2026-07)은 2,393~3,568 사이에서 진동한다 — **성장 곡선이 아니라 평탄 구간이다.** 2026-08은 5일치 부분월(536세션)이라 이 범위에서 제외했다. 전년 동기 대비 DAU는 75.6 → 90.6으로 +19.8%지만 직전 30일 대비는 +0.6%로 사실상 정지다.

재방문 비중 96.6%는 "남은 사람은 계속 쓴다"는 쪽을 지지한다. 다만 **신규 유입의 절대 수는 이 지표로 말할 수 없다** — 92는 사람 수가 아니라 사용자-일이고, 고유 사용자 기반의 신규/기존 분해는 현재 계측으로 산출되지 않는다. 여기까지 말할 수 있는 것은 **현재 규모가 신규 유입이 아니라 기존 사용자의 잔존 위에 서 있다**는 방향뿐이다.

**한계**:
- **DAU는 상한값이다.** `daily_summary`가 차원별 행이므로 활성 사용자 합산이 차원 조합 간 중복된다. 절대 수준을 "동시 사용자 117명"으로 읽으면 안 되고, **같은 방식으로 계산된 값끼리의 추세 비교**로만 써야 한다. 위의 전년 대비·직전 대비 비교는 이 조건을 만족한다.
- 누적 세션 55,193은 중복 합산 문제가 DAU보다 작지만 같은 차원 구조 위에 있으므로 완전히 자유롭지는 않다. 여기에 5.0의 소스 정의 미검증(백필 구간과 Export 구간의 세션 정의가 같은지 확인하지 않음)이 함께 붙는다.
- 초기 구간(2024-07)에 재방문 사용자 음수 행이 있다. 신규 사용자가 세션 시작 기준 활성 사용자를 초과하는 GA4 집계 특성이다.

---

### 5.2 정착 — 사용자는 얼마나 오래 남는가

**정의**: 코호트 사용자 중 **해당 주차에 1회 이상 활성**인 비율이다. "그 주차까지 계속 활성"이 아니므로 단조 감소하지 않는다. 분모는 그 주차만큼 실제로 관측된 코호트다(`script/generate-dashboard.js:233-271`).

| 주차 | 코호트 | 활성 | 잔존율 |
|---:|---:|---:|---:|
| W0 | 746 | 746 | 100% |
| W1 | 732 | 336 | **45.9%** |
| W2 | 701 | 274 | 39.1% |
| W4 | 652 | 239 | **36.7%** |
| W8 | 499 | 161 | **32.3%** |
| W9 | 469 | 149 | 31.8% |
| W12 | 321 | 121 | 37.7% |
| W13 | 254 | 98 | 38.6% |
| W14 | 159 | 61 | 38.4% |

세션 품질:

- 세션 길이 P90 **712분 (11시간 52분)** — 5분 초과 세션 대상
- 세션당 상호작용 P50 **357회**, P90 **1,356.6회**, 최대 53,552회
- 분석 대상 10,002행(98일 창). 쿼리에 `LIMIT`은 없으나 결과가 1만에 근접해 **상한 도달 여부를 확인하지 않았으므로 98일 창의 전체 고유 세션과 같다고 단정하지 않는다.**

**의미**: W1 45.9% → W9 31.8%로 떨어진 뒤 **W8~W14 구간이 31.8~38.6%에서 평탄해진다.** 초기 이탈이 끝나면 남은 코호트는 더 이상 줄지 않는 형태다. W12~W14의 상승은 개선 신호가 아니라 **표본 축소에 따른 노이즈로 읽어야 한다** — W14의 분모는 159명뿐이고, 오래 남은 코호트일수록 초기부터 활성이던 사용자만 남는 선택 효과가 섞인다.

세션 길이 P90 712분은 이 제품이 방송 1회 전체에 걸쳐 켜져 있는 종류의 도구임을 시사한다. 다만 이 값은 **세션 분포의 상위 10% 지점**이지 사용자 상위 10%가 아니다 — 한 사람이 긴 세션을 여러 번 만들면 그 사람만으로 이 구간이 채워진다. 방송 시간으로 환산해서도 안 된다(아래 한계).

**한계**:
- **관측 가능한 최대 주차가 14주다.** raw 데이터가 2026-05-01부터라 W15 이상은 존재 자체가 불가능하다. "3개월 잔존 38%"까지만 말할 수 있고, 6개월·12개월 잔존은 데이터가 없다.
- **뒤쪽 주차일수록 표본이 작다.** W14의 코호트는 159명뿐(W0의 21%)이라 38.4%의 표준오차가 앞쪽 주차보다 훨씬 크다. 우측 절단을 고쳐 정확성을 얻은 대신 통계적 안정성을 잃었다.
- P90 세션 길이는 5분 초과 세션만 대상으로 하는 조건부 분포다.
- 세션 경계는 GA4의 30분 무활동 정의를 따른다. 방송 중 앱이 계속 이벤트를 보내면 세션이 이어지므로 **712분이 곧 방송 시간이라는 등식은 성립하지 않는다.**

---

### 5.3 기능 채택 — 11종 중 무엇이 실제로 쓰이는가

같은 세션 안에서 봇을 연 뒤 실제 동작까지 간 비율 (최근 30일):

| 봇 | 연 세션 | 동작까지 간 세션 | 전환율 |
|---|---:|---:|---:|
| prompter (프롬프터) | 21 | 21 | 100% |
| sync (원격 오버레이) | 27 | 27 | 100% |
| subtitle (자막) | 326 | 315 | **96.6%** |
| speech (TTS) | 2,047 | 1,728 | **84.4%** |
| autoreply (자동응답) | 1,623 | 848 | 52.2% |
| tits (던지기) | 547 | 254 | 46.4% |
| sr (신청곡) | 1,281 | 453 | 35.4% |
| donationgoal (후원목표) | 1,095 | 79 | **7.2%** |
| roulette (룰렛) | 1,142 | 64 | **5.6%** |
| raffle (추첨) | 1,198 | 30 | **2.5%** |
| vote (투표) | 1,250 | 22 | **1.8%** |

누적 핵심 동작 (전체 기간, 단위는 해당 이벤트가 발생한 세션-일 수):

```
speech_speak          33,886
autoreply_replied     16,261
sr_queueAdd           14,247
tits_trigger           7,685
roulette_roll            858
subtitle_translate       396
vote_start               127
prompter_activate        123
donationgoal_success      14
```

봇별 30일 사용량을 통합 VIEW에서 뽑으면 순서가 다르게 나온다. 정의는 **일별 최대 세션 수를 30일에 걸쳐 합산**한 값이다(`script/generate-dashboard.js:370-382`).

| 봇 | 일별 최대 세션 합 (30일, 통합 VIEW) |
|---|---:|
| subtitle (자막) | 2,482 |
| speech (TTS) | 2,356 |
| autoreply (자동응답) | 1,646 |
| sr (신청곡) | 1,287 |
| vote (투표) | 1,259 |
| raffle (추첨) | 1,205 |
| roulette (룰렛) | 1,147 |
| donationgoal (후원목표) | 1,113 |
| tits (던지기) | 556 |
| sync (원격 오버레이) | 27 |

앞 표는 11행인데 이 표는 10행이다. 쿼리에 `LIMIT 10`이 걸려 있어(`script/generate-dashboard.js:381`) 가장 작은 prompter가 잘렸을 뿐, 데이터가 없는 것이 아니다.

방치 세션(**98일 창, 2026-05-01~08-06**): 봇을 1개 이상 연 9,124세션 중 **어떤 봇도 동작에 이르지 못한 세션 23건(0.25%)**.

**의미**: 봇 11종은 전환율 기준으로 세 무리로 갈린다.

- **상시형 (84~100%)**: subtitle, speech, sync, prompter. 켜두면 알아서 돈다.
- **중간대 (35~52%)**: autoreply, tits, sr. 켜둔 뒤 조건이 맞을 때 동작하는 형태다.
- **이벤트형 (1.8~7.2%)**: vote, raffle, roulette, donationgoal. 투표를 연 세션은 1,250건인데 실제로 투표를 시작한 세션은 22건이다.

이벤트형 봇의 낮은 전환율은 두 가지로 읽을 수 있고 **데이터만으로는 구분되지 않는다.** (가) 스트리머가 방송 시작 시 습관적으로 켜두고 필요한 순간에만 쓴다 — 이 경우 낮은 전환율은 정상이며 오히려 "필요할 때 이미 준비돼 있다"는 뜻이다. (나) 켜본 뒤 쓰는 법을 몰라 포기한다 — 이 경우는 온보딩 결함이다. 판정하려면 봇을 연 뒤 첫 동작까지의 시간 분포가 필요한데 **현재 계측에 없다.**

**방치 세션 0.25%는 이 판정에 쓸 수 없다.** 이 지표는 "어떤 봇도 동작하지 않은 세션"이라 같은 세션에 speech(84.4%)나 subtitle(96.6%)이 함께 열려 있으면 거의 항상 0에 수렴한다 — 이벤트형 봇을 켠 사람이 쓸 줄 알았는지에 대해 독립적인 정보를 주지 않는다. 게다가 관측창도 다르다(방치 세션 98일, 위 전환율 표 30일). 판정은 계측 공백 항목으로 남긴다.

**한계**:
- **위의 두 표는 소스가 다르므로 가로로 나눠 읽으면 안 된다.** 전환율 표의 분모는 raw VIEW의 고유 세션 수(자막 326)이고, 사용량 표는 통합 VIEW의 일별 최대 세션 합(자막 2,482)이다. 자막이 326 대 2,482로 어긋나는 것은 세션 정의가 아니라 집계 단위가 다르기 때문이다.
- 누적 핵심 동작의 단위는 이벤트 수가 아니라 세션-일 수다. 헤비 유저 1명이 하루 500번 눌러도 1로 센다. 절대 호출량이 아니라 **그 기능이 실제로 쓰인 방송 횟수**로 읽어야 한다.
- 전환율 표에서 "열기"의 뜻이 봇마다 다르다(자막은 인식 시작, 투표는 패널 열기). 봇 간 배수 비교에 쓰지 않고, 한 봇의 시계열 비교에만 쓴다.
- prompter(21세션)·sync(27세션)의 100% 전환율은 표본이 작아 유효숫자를 믿을 수 없다.
- 전환율은 최근 30일 창이라 계절성(방송 성수기)을 흡수하지 못한다.

---

### 5.4 신뢰성과 운영

#### 런타임 에러 (최근 30일: 2026-07-07 ~ 2026-08-05)

| 지표 | 값 |
|---|---|
| 총 에러 이벤트 | **5건** |
| 총 세션 | 3,128 (통합 VIEW 기준) |
| 세션당 에러율 | **0.16%** (5 / 3,128) |
| 에러 0건인 날 | **26일 / 30일** |

에러 5건은 **전부 `chzzk_loginFail` 한 종류**다(2026-07-12 1건, 07-18 2건, 07-28 1건, 08-05 1건). 즉 최근 30일 동안 **명시적으로 로깅된** 봇 로직 실패는 0건이고, 관측된 실패는 전량 외부 플랫폼 로그인 경로에 있다. 98일 전체 창에서도 에러가 있었던 세션은 10,002행 중 19건(0.19%)이고, 그중 가이드라인을 클릭한 세션은 0건이다.

**한계**:
- **0건은 신뢰성 성과가 아니라 계측 커버리지의 함수다.** 에러율은 명시적으로 로깅된 실패 이벤트만 센다 — 분모가 작은 것이 아니라 **분자의 커버리지가 계측 규약에 달려 있다.** 새 봇 추가 시 실패 이벤트를 명시적으로 로깅하라는 규약은 스킬 문서에 못박혀 있으나 준수 여부는 검증되지 않았고, **미처리 예외·크래시를 수집하는 텔레메트리는 없다.** 3,128세션에서 로그인 실패 외 에러가 0이라는 사실은 "로깅이 실질적으로 로그인 경로에만 있다"로도 똑같이 설명된다.
- 에러 후 이탈 여부는 표본이 5건이라 산출하지 않는다.
- 총 세션 3,128은 통합 VIEW 기준이다. 2.3절의 OS 분포 2,821세션은 raw VIEW 기준이라 같은 30일 창인데도 307건 차이가 나며, 차이의 원인은 확인하지 않았다.

#### 릴리스 케이던스

| 지표 | 값 |
|---|---|
| 총 태그 | **82개** (v0.0.0 2024-07-09 ~ v1.2.13 2026-07-28) |
| 그중 GitHub Release가 게시된 태그 | **72개** (82 = 게시 72 + 초안만 1 + 릴리스 없음 9. `gh release list --json tagName,isDraft` 기준) |
| 기간 | 749일 |
| 태그 간 평균 간격 | 9.2일 |
| 태그 간 **중앙값** 간격 | **2일** |
| 최장 공백 | 111일 |
| 하루 이내 후속 태그 | 40건 / 81구간 |
| 최근 6개월 릴리스 | **29개** |
| 연도별 | 2024년 28 / 2025년 21 / 2026년 33 |

평균 9.2일과 중앙값 2일의 차이는 핫픽스 패턴이다. 81개 구간 중 40개가 하루 이내이므로 **릴리스 절반은 직전 릴리스의 후속 수정**이다. 커밋 1,196개의 월별 분포도 극단적으로 불균등하다 — 2026-04에 177개가 몰려 있고 2025년에는 0개인 달이 5개다. 개발이 **집중 스프린트와 완전 정지를 반복**하는 형태이며, 이는 1인 개발에서 본업·컨디션·플랫폼 이슈가 그대로 커밋 그래프에 찍힌 결과다.

**한계**: 태그 82개 중 10개(릴리스 없음 9 + 초안만 1)는 게시된 릴리스가 없으므로, **케이던스를 말할 때의 기준 수는 82가 아니라 72다.** 게시된 릴리스 72개가 실제로 사용자 기기에 도달했는지(업데이터 매니페스트 배포 여부)는 별도로 확인하지 않았다. 같은 날 3~4개가 몰린 구간은 워크플로 디버깅 태그일 수 있다.

#### 자동화가 없앤 수작업

| 자동화 | 커맨드 | 수동으로 하면 |
|---|---|---|
| Rust↔TS 바인딩 생성 | `npm run gen:bindings` | 8단계. 커맨드 28개 + 타입 20개에 모듈 경로 접두사 부착, 임시 바이너리 생성·실행·삭제. 산출물 606줄 |
| 오버레이 동기화 | `npm run sync:overlays` | 21단계 (7파일 × 3작업). minify 4옵션 + 경로 평탄화 복사 |
| 코드서명 인증서 갱신 | `npm run gen:cert` | 7단계. 인증서 생성 → PFX/base64 내보내기 → 설정 파일 갱신 → CI 시크릿 2건 반영 → 만료일 기록. **유효기간 1년이라 매년 반복** |
| 분석 대시보드 생성 | `npm run dashboard` | 28단계. BigQuery 쿼리 26개 개별 실행 + Monte Carlo + HTML 주입 |
| 버전 범프 | `npm run bump` | 3단계. 세 파일을 서로 어긋나지 않게 수정 |

**릴리스 파이프라인은 대체 자체가 불가능하다.** 개발 워크스테이션이 Windows 단일이므로 macOS·Linux 아티팩트는 로컬에서 생산할 방법이 없다.

**절감 시간은 주장하지 않는다.** 자동화 이전의 수작업 시간을 계측한 적이 없고, 릴리스는 애초에 로컬에서 수행 불가능하므로 "없앤 시간"을 곱할 기저가 존재하지 않는다. 이 표가 말하는 것은 시간이 아니라 **대체 불가능한 항목(4플랫폼 빌드)과 대체 가능한 항목(바인딩, 오버레이 동기화, 대시보드)의 구분**까지다.

#### 분석 인프라 비용

| 서비스 | 무료 한도 | 비용 |
|---|---|---|
| BigQuery 저장 | 활성 스토리지 10 GiB (상시) | **$0** |
| BigQuery 쿼리 | 1 TB/월 | **$0** |
| GA4 Daily Export | 일 100만 이벤트 (초과 시 내보내기 중단) | **$0** |

759일치 이벤트와 98일치 raw 세션 데이터를 이 한도 안에서 돌리고 있고 청구액은 $0이다. **실제 사용량은 계측하지 않았으므로 한도 대비 몇 %인지는 적지 않는다.**

**한계**:
- **이 표는 분석 인프라만 다룬다.** API 서버(Railway 단일 인스턴스), GCS 공개 버킷의 스토리지·이그레스, 도메인은 포함하지 않았고, 이들을 합친 총 월 운영비는 이 문서에서 산출하지 않았다. 따라서 이 $0을 "제품 운영비 0"으로 읽으면 안 된다.
- GA4 Daily Export의 일 100만 이벤트 상한을 넘기면 내보내기가 조용히 멈춘다. raw VIEW는 이 장의 세션·수익 분석의 유일한 소스이므로 그 시점에 지표가 끊긴다.

#### 서버 선택이 만든 구조적 제약

API 서버는 Railway 단일 인스턴스이고, 이 선택이 만든 제약이 그대로 설계에 나타난다 — 채널당 SSE 18연결 상한, Railway 15분 하드 타임아웃에 대응하는 14분 재연결 지시, Cloudflare 100초 유휴 타임아웃에 대응하는 45초 하트비트.

**한계**: 상태가 인메모리 세션 맵과 영속 볼륨 양쪽에 걸쳐 있어 **수평 확장 시 둘 다 동시에 깨진다.** 현재 규모(DAU 90 수준)에서는 문제가 관측되지 않았지만 **확장 경로는 열려 있지 않다.** 8장 로드맵의 첫 항목이다.

---

### 5.5 수익화

서로 다른 두 이야기가 있다. **PRO 구독(vzzk 자신의 수익)** 과 **vzzk가 실행 중인 방송에서 관측된 후원(스트리머의 수익)** 이다. 섞으면 안 된다.

#### PRO 구독 퍼널 — vzzk 자신의 수익

| 단계 | 이벤트 수 | 전환 |
|---|---:|---:|
| `pro_dialogOpen` | **697** | — |
| `pro_checkout` | **158** | 개시 대비 **22.7%** |
| `pro_success` | **11** | 결제 시도 대비 **7.0%** / 개시 대비 **1.6%** |

진입 경로: banner 253 / badge 88 / 미표기 97. 사용자 기준 전환은 98일 창 749명 중 9명(**1.2%**).

#### 그래서 vzzk는 얼마를 버는가

PRO는 별도 결제 시스템이 아니라 **치지직 채널 구독**으로 게이팅된다. 구독자가 곧 PRO 사용자이고 매출은 플랫폼 정산으로 들어온다. PRO는 2026-06에 시작했으므로 정산 실적은 두 달치다.

| 월 | 정산액 |
|---|---:|
| 2026-06 | **₩89,341** |
| 2026-07 | **₩89,341** |

**가정으로 재구성한 값과 실적이 맞지 않는다.** 구독자 24명 내외 × 1티어 ₩4,900 × 정산 70%로 계산하면 ₩82,320인데, 실제 정산은 ₩89,341이다(약 8.5% 차이). 실적에서 역산하면 그 가정 하에서는 26명이 나온다(₩89,341 ÷ ₩3,430 = 26.05). **어느 쪽이 틀렸는지 확정하지 않았다** — 구독 티어 구성, 부가세 처리 방식, 정확한 구독자 수 중 하나 이상이 가정과 다르다. 정산 명세를 확인하기 전까지는 **실적 ₩89,341만 사실로 쓰고 구독자 수와 정산 비율은 가정으로 표기한다.**

두 달이 원 단위까지 동일한 것은 구독자 구성이 그대로 유지됐다는 뜻이며, 동시에 **이 기간에 순증이 없었다**는 뜻이기도 하다.

**구독자 데이터는 이 문서에서 유일하게 분석 파이프라인 밖에 있는 값이다.** 다른 모든 수치는 대시보드 쿼리나 저장소에서 재현할 수 있지만, PRO 자격 확인은 서버가 치지직에 물어보는 방식이라(`vzzk-api`의 Pro 모듈) 구독자 수 자체가 vzzk가 보유한 데이터가 아니다. 정산액은 정산 실적이라 관측치에 가깝지만, 그 내부 구성은 vzzk에서 볼 수 없다.

`pro_success` 11건과 구독자 20여 명이 어긋나는 것도 여기서 설명된다. 앱 안의 결제 다이얼로그를 거치지 않고 **치지직 채널 페이지에서 직접 구독한 경우 vzzk에는 이벤트가 남지 않는다.** 즉 11건은 전체 구독의 부분집합이며, 5.5절 퍼널의 "결제 완료율 7.0%"는 앱 내 경로에 한정된 수치다. 이 가설을 확인할 계측은 없다(8.4).

#### 세 금액을 나란히 놓으면

| | 금액 | 성격 |
|---|---:|---|
| vzzk가 관측한 스트리머 후원 (98일) | ₩30,027,318 | **남의 돈.** vzzk 매출 아님, 인과 판정 불가 |
| vzzk 자신의 월 정산액 | **₩89,341** | **내 돈.** 정산 실적, 2개월 동일 |
| 월 인프라 비용 | 약 **₩7,000** | API 서버 $5/월 (₩1,400/USD 가정). 분석 인프라는 무료 한도 안 |

월 순이익은 약 **₩82,000**, 연환산 매출 약 ₩1,072,000이다. 다만 **도메인, 릴리스 아티팩트를 올리는 오브젝트 스토리지의 저장·이그레스, Firebase RTDB 사용량은 계측하지 않았다.** 무료 한도 안일 가능성이 높지만 확인하지 않았으므로 위 순이익은 상한으로 읽어야 한다.

**이 대비가 이 제품의 현재 위치를 가장 정확하게 말한다.** vzzk는 98일간 3천만 원대의 후원이 오가는 방송에 얹혀 있으면서, 그 위에서 월 8만 원대를 번다. 흑자이긴 하나 인건비를 한 푼도 계산에 넣지 않은 흑자다. 규모를 감당하는 기술적 구조와 그 구조를 지탱할 수익 구조 사이의 간극이 이 프로젝트의 가장 큰 미해결 문제다(8.5).

**의미**: 병목은 결제 시도에서 완료로 가는 구간이다. 158건이 결제 화면까지 갔는데 11건만 완료됐다. 7%는 결제 UX나 결제 수단 문제를 의심할 만한 수치다.

**한계**:
- **"전환율이 낮다"와 "완료 이벤트가 누락된다"를 데이터만으로 구분할 수 없다.** 성공 이벤트가 결제 완료 시점에 정확히 발신되는지 자체가 검증되지 않았다.
- **경로별 효과 비교는 두 겹의 공백 때문에 불가능하다.** 경로 행이 붙은 438건 중 22.1%(97건)가 미표기이고, 더 큰 문제는 `pro_dialogOpen` 697건 중 경로 행이 붙은 것이 438건뿐이라는 점이다 — dialogOpen 기준으로는 **51.1%(356/697)가 경로 미상**이다. 일부 호출부가 경로 파라미터를 붙이지 않고 있다.

#### 스트리머 후원 수취 — 관측된 98일

여기 나오는 금액은 **vzzk의 매출이 아니다.** 후원은 치지직에서 발생해 스트림 이벤트로 앱에 들어오고, vzzk는 그 이벤트에 실린 `price`를 읽어 봇을 트리거한다(`src/app/service/tits.service.ts:94`). 즉 아래 값은 **vzzk가 실행 중인 방송에서 관측된 후원 이벤트의 `price` 합계**이며, vzzk의 존재가 이 후원을 유발했는지는 이 데이터로 판정할 수 없다.

| 지표 | 값 |
|---|---|
| 후원 이벤트가 관측된 스트리머 | **44명** |
| 98일간 관측된 후원 `price` 합 | **₩30,027,318** |
| 상위 1명 점유율 | **26.8%** |
| 상위 5명 점유율 | **70.8%** |
| 상위 10명 점유율 | **86.4%** |
| 최대 1인 수취액 | ₩8,057,155 |
| 중앙값 | **₩130,220** (n=44 짝수이므로 22·23번째의 평균) |
| 유료 세션 P90 수취액 | ₩102,320 |

> **레이블 정정**: 원본 데이터의 필드명은 후원자(supporter)로 되어 있었으나, GA4 이벤트는 전부 스트리머의 데스크톱 앱에서 발신된다(계측 지점이 `src/app/service/analytics.service.ts` 하나뿐이고, 오버레이 HTML 10개에 분석 태그 호출이 없음을 확인했다). 따라서 이 분포는 **후원자별이 아니라 스트리머별 수취 후원금 분포**다. 이 문서를 쓰면서 발견해 정정했다.

기능별로 나눈 세션당 평균 수취액 — 해당 봇을 트리거한 후원의 세션당 합계다: 던지기 ₩54,843(458세션), 룰렛 ₩18,277(74세션), 후원목표 ₩14,223(250세션), 신청곡 ₩1,000(**1세션**).

**의미**: 극단적으로 편중돼 있다. 상위 5명이 관측 총액의 70.8%를 가져가고, 최대값은 중앙값의 **61.9배**(8,057,155 / 130,220)다. **이 편중은 제품 문제가 아니라 라이브 스트리밍 시장의 구조를 그대로 반영한다.**

이 44명에 분모를 붙이지 않은 이유는 2.2절과 같다 — PRO 전환 쿼리가 관측한 사용자 749명으로 나누면 5.9%가 되지만 두 값은 서로 다른 쿼리의 산출물이고 모집단이 같은지 확인하지 않았다(같은 98일 창의 잔존율 코호트는 746명으로 또 다르다). 비율은 쓰지 않는다.

제품 관점의 함의는 **어느 쪽 수익인지를 분리해서** 적어야 한다. 상위 44명이 이탈하면 붕괴하는 것은 **후원 수취 관측치**이고, 이는 vzzk의 매출이 아니라 제품이 얼마나 큰 방송에 얹혀 있는지를 보여주는 노출·의존도 지표다. vzzk 자신의 수익은 월 정산 실적 ₩89,341(2026-06·07 동일)이며, **구독자 단위 데이터가 분석 파이프라인에 없어 집중도·이탈률을 계산할 수 없다.** 후원 수취는 상위 5명이 70.8%를 가져가는 편중을 관측할 수 있는데, 정작 자기 매출의 편중은 관측할 수단이 없다.

#### 누적 후원 추정 — Monte Carlo

10,000회 시뮬레이션, 2024-07 ~ 2026-08 (26개월 누적):

| | 금액 |
|---|---|
| 표본 불확실성 구간 하한 (2.5th) | **₩115,553,946** |
| 중앙값 (50th) | **₩254,493,960** |
| 표본 불확실성 구간 상한 (97.5th) | **₩400,822,458** |

**이 추정치는 "약 2.5억"이 아니라 "1.16억에서 4.01억 사이"로 읽어야 한다.** 상단이 하단의 3.5배다.

**이 구간을 "95% CI"라고 부르지 않는다.** 시뮬레이션이 분포를 부여한 것은 ARPE와 유료비율 두 가지뿐이다. 이 추정의 지배적 오차원은 아래 한계 1 — 98일의 ARPE·유료비율을 그 이전 22개월에 그대로 적용한다는 가정 — 인데 여기에는 분포가 부여되지 않았다. 따라서 위 구간은 **표본 불확실성의 구간이지 추정치 전체의 구간이 아니다.**

가정을 전부 명시한다: ARPE는 Normal(음수 클램프), 유료비율은 Beta(균등 사전분포), 이벤트 누락 보정 ×1.03, 후원목표 중복 차감 ×(1−0.222), 구간은 2.5~97.5 백분위. 기능별 앵커 통계는 던지기 평균 ₩2,410·유료비율 50.1%, 후원목표 ₩3,510·100%, 룰렛 ₩2,247·23.7%, 신청곡 ₩1,000·0.003%다. **ARPE에 Normal을 가정한 것은 보수적이지 않다** — 상위 1인이 26.8%를 차지하는 우편향 분포에 대칭 분포를 얹었고, 음수 클램프는 평균을 위로 민다. 로그정규나 부트스트랩 재표본이 이 형태에 더 맞으나 적용하지 않았다.

**내부 정합성 검사**: 관측창(2026-05~08) 안에서 모델 구간 수익 ₩30,077,088과 직접 관측된 가격 합 ₩30,027,318의 차이는 **₩49,770(0.17%)**다. 단, **이 검사는 부분적으로 순환적이다** — ARPE와 유료비율을 바로 그 창에서 추정했으므로 독립 검증이 아니다. 게다가 두 보정 계수는 관측창에서 서로 상쇄한다: ×1.03은 전 항목에 걸리지만 중복 차감은 후원목표 항에만 걸리고(`script/monte-carlo-revenue.js:259-264`), 후원목표는 관측 총액의 약 12%라 순효과가 1% 미만이다. **즉 이 검사는 산술적 재구성이 맞는지만 확인하며, 보정 계수의 타당성에 대해서는 아무것도 보증하지 않는다.**

**한계 — 이 추정치를 인용할 때 반드시 함께 적을 것**:

1. **2026-05 이전 22개월(2024-07~2026-04)은 역추정이다.** 26개월 중 가격이 실제로 관측된 것은 4개월(2026-05~08)뿐이고, 나머지는 최근 98일의 유료비율·ARPE를 과거 월의 이벤트 수에 그대로 적용했다. 후원 문화·물가·기능 구성이 2년간 변하지 않았다는 가정이 깔려 있고 **이 가정은 검증되지 않았다.**
2. **신청곡 기여는 신뢰할 수 없다.** 33,077건 중 가격이 실린 건이 1건이다. 호출부를 추적한 결과 발신 지점 두 곳 중 하나는 가격을 0으로 하드코딩하고 다른 하나는 기본값이 0이며, 가격을 실제로 넘기는 경로는 한 곳뿐이다. 호출부 결함일 수도, 실제로 유료 신청곡이 거의 없을 수도 있어 **원인을 단정하지 않는다.** 어느 쪽이든 수익 모델에서 이 항은 사실상 0으로 들어간다.
3. **후원목표의 사전분포가 퇴화했다.** 유료비율 1.0이 구조적으로 참이라 Beta(1013,1)이 불확실성을 거의 0으로 만들어, 이 항이 위 구간 폭에 기여하는 몫이 실제보다 좁게 나온다.
4. **월별 이벤트 수가 통합 VIEW에서 오므로** 5.1절의 차원 중복 합산 문제가 부분적으로 전파된다.
5. **PRO 수익은 이 추정에 포함되지 않는다.** 위 금액은 전부 스트리머가 받은 후원이며 vzzk의 매출이 아니다.
6. **OS는 Windows만 관측됐다.** macOS 지원 코드와 릴리스 아티팩트는 존재한다. 실사용자 부재인지 계측 문제인지 **단정할 수 없다 — 관측되지 않았다고만 말한다.**

---

## 6. 사고와 구조적 해결 — 케이스 스터디

세 건 모두 "증상을 덮는 수정"이 아니라 "그 계열의 문제가 다시 나올 수 없는 구조"로 바꾼 사례다. 각 케이스의 마지막 절에는 **아직 닫히지 않은 위험**도 함께 적었다.

### 케이스 1 — 배포 바이너리에 들어 있던 대칭 비밀 4종

#### 무슨 일이 있었나

2026-07, 외부 서비스의 개인정보 유출 사고를 계기로 자체 보안 감사를 돌렸다. 서버 측 커밋 메시지가 그 계기를 기록하고 있다 — 감사에서 도출된 항목 중 API 서버 수정만으로 완전히 닫을 수 있는 것들을 먼저 적용한다는 내용이다(vzzk-api `6796723`, 2026-07-28). 서버만으로 닫히는 항목(OAuth 리다이렉트 허용목록, state nonce, 전역 검증 파이프, 시청자 세션 AES-256-GCM 전환)은 먼저 배포하고, **클라이언트 릴리스가 필요한 항목은 그 커밋 범위 밖**이라고 명시해 뒀다. 그 나머지가 vzzk `1dfaeec`다.

핵심 발견은 배포 바이너리에 대칭 비밀 4종이 들어 있다는 것이었다.

#### 근본 원인

대칭 비밀은 양쪽이 같은 값을 가져야 성립한다. 한쪽이 사용자 PC에 있으면 그 값은 사용자 것이고, `strings` 한 번이면 추출된다. 여기서 두 가지가 필연적으로 따라왔다.

- **Pro 서명 키는 검증 키가 곧 서명 키**여서 사용자가 자기 Pro 자격을 직접 서명할 수 있었다.
- **정적 API 키는 "우리 앱인가"를 증명하려는 키**였는데, 그 질문은 클라이언트가 증명할 수 없다. 그 결과 push 엔드포인트가 대상 채널을 요청 헤더로 받고 있었고, 키 보유자는 **임의 스트리머의 방송 화면에 쓰기**가 가능했다.

#### 무엇을 바꿨나

증상별 패치가 아니라 **원칙을 먼저 문장으로 고정했다** — "배포되는 클라이언트 바이너리에 대칭 비밀을 넣지 않는다. 클라이언트가 갖는 것은 검증 능력뿐이다."

- **로그인**: PKCE 교환 코드로 전환(3.3절 시퀀스). 딥링크 검증이 스킴만 보던 것에서 host·state·만료·1회 소비까지 확인하도록 바뀌었다.
- **Pro 자격**: HMAC → Ed25519. 앱에는 공개키 상수만 들어가고 개인키는 서버 환경변수에만 있다. 키 생성 스크립트는 개인키를 파일에만 쓰고 화면에 출력하지 않는다(`script/generate-pro-keypair.mjs:1-13`).
- **Push 대상 판정**: 헤더가 아니라 **서버가 Bearer 토큰에서 채널을 유도**하도록 뒤집었다.
- **토큰 저장**: OS 자격증명 저장소로 이동, 실패 시 평문 파일로 폴백하지 않음.
- **로컬 오버레이 서버**: Origin 화이트리스트 + Host 루프백 검사(DNS 리바인딩 차단) + 상태 변경 라우트에 프로세스별 컨트롤 토큰. OBS가 여는 읽기 라우트는 그대로 둬서 사용자가 저장한 URL이 깨지지 않게 했다.

#### 재발 가능성과 남은 위험

이 방어들은 **전부 Rust 테스트로만 검증되는데 CI는 웹 테스트만 돌리고 있었다.** 즉 보안 로직 전체가 CI 사각지대였다. 그래서 같은 날 PR CI에 Rust 테스트와 오버레이 린트를 추가하고(`9bd8f3c`), 브랜치 보호를 켤 수 없는 저장소라 릴리스 잡이 테스트를 `needs`로 걸도록 했다(`650f79f`).

**아직 닫히지 않았다.** 서버에는 구버전 앱을 위한 경로 L1~L5가 살아 있고, Pro 상태 응답은 여전히 HMAC 서명과 Ed25519 서명을 함께 내려준다. 구버전 경로는 "덜 안전한 대안"이 아니라 **공격자가 고를 수 있는 우회로**다. 그래서 감으로 지우지 않고 경로별 호출 카운터와 마지막 관측 시각을 서버가 집계하고, 신버전 릴리스 직후 관측 창을 열어 **연속 14일 0**을 제거 조건으로 삼았다.

14일이라는 값에도 근거가 붙어 있다 — 방송을 하지 않는 사용자는 push를 보내지 않으므로 최소 2주는 봐야 주간 단위로만 방송하는 사용자까지 포착된다.

**그리고 이 설계 자체에서 결함이 하나 더 나왔다.** 카운터가 프로세스 메모리에 있어 Railway 재배포마다 관측 시작 시각이 부팅 시각으로 리셋됐고, 그러면 **"재시작 이후 0"을 "2주간 0"으로 읽게 된다.** 커밋 본문이 그 실패를 정확히 명명한다 — 우리는 "마지막 재시작 이후 0"을 읽으면서 "2주간 0"이라고 믿고 있었을 것이다. 관측 장치를 만든 커밋과 그것이 측정하고 있지 않았음을 발견한 커밋이 **51분 간격**이다. 판단을 내리기 전에 관측 장치 자체를 검증한 사례다.

수정은 네 가지였다. 카운터를 볼륨에 영속화하고, 0→1 전이는 즉시 write-through(판정을 뒤집는 신호라 유실되면 안 됨), 이후 증가는 30초 병합(고빈도 경로라), 임시 파일 rename으로 원자적 기록(잘린 JSON이 남으면 관측 창을 통째로 잃음), 손상 파일은 부분 복구 대신 창 재시작(**없는 값은 알아챌 수 있지만 왜곡된 값은 알아챌 수 없다**).

측정할 수 없는 것을 측정한 척하지도 않았다. Pro HMAC 경로에는 별도 카운터가 없다 — 응답을 내려줄 뿐 어느 서명을 검증했는지는 서버가 알 수 없기 때문이다. 대신 로그인 경로 카운터가 0이면 구버전 앱이 로그인 자체를 못 하고 있다는 뜻이므로 함께 지운다는 **간접 추론**을 문서에 명시했다.

**남은 위험**: 원격 오버레이 조회 경로 무인증 — 채널 ID를 아는 제3자가 방송 중 자막 텍스트·신청곡 큐·참여자 닉네임을 실시간으로 받아갈 수 있다(capability 토큰을 붙이면 사용자가 OBS에 저장한 URL이 깨진다. 8.1절). 그리고 서버측 토큰 폐기 API 부재.

---

### 케이스 2 — 정적 검사 경로가 없는 오버레이 HTML → 자체 린트 게이트

#### 무슨 일이 있었나

오버레이는 독립 HTML 10개(최상위 7 + `private/` 3)이고 로직이 전부 인라인 스크립트 안에 있다. Angular 앱과 달리 **번들러·타입체커·유닛테스트를 하나도 거치지 않는다.** OBS 브라우저 소스가 파일을 직접 여는 순간이 첫 실행이다.

2026-04-26 프롬프터 개발 중 린트를 도입했고, 그 첫 실행이 TDZ 위반을 잡았다. 커밋 `451d2f0`의 `prompter.html` diff가 그 수정이다.

```diff
       const noteInput = document.getElementById('note-input');

+      let socket;
+
       if (!channelId || !token) {
         showError('유효하지 않은 링크입니다. VZZK 앱에서 다시 열어주세요.');
       } else {
         initSocket();
       }

-      let socket;
-
       function initSocket() {
```

`initSocket`은 함수 선언이라 호이스팅되지만 `socket`은 `let`이라 TDZ에 있다. 선언을 호출 위로 **옮기는** 수정이다. 유효한 링크로 프롬프터를 열면 초기화 전 접근 오류로 **항상** 실패하는 코드였다.

**이 결함이 사용자에게 도달한 적은 없다.** 린트 도입 첫 실행에서 잡혔고, 이 문서 어디에도 프로덕션 오버레이 사고 사례는 없다. 아래에서 다루는 "송출 중 실패"는 실제로 일어난 사건이 아니라 이 구조에서 일어날 수 있었던 위험이다.

#### 근본 원인

구조에서 필연이었다. 오버레이 JS에는 정적 검사 경로가 존재하지 않았으므로 문법적으로 파싱되는 모든 오류는 런타임까지 살아남는다. 그리고 오버레이의 런타임은 개발자 화면이 아니라 **송출 중인 OBS 씬**이다. 결함이 사용자에게 도달하면 **발견 시점이 곧 사고 시점**이 된다 — 이것이 이 케이스가 다루는 위험이고, 실현된 적은 없다.

배포 경로가 둘이라는 점이 문제를 키웠다. 오버레이 원본은 Tauri 리소스로 그대로 설치 패키지에 들어가고(`src-tauri/tauri.conf.json:50`), 동시에 원본 그대로 vzzk-api로도 복사된다(`script/sync-overlays.js:26-33`). **두 배포 경로 어디에도 코드를 보는 단계가 없다.**

#### 무엇을 바꿨나

임시 수정은 선언을 위로 올리는 한 줄이었지만, 그것과 함께 `script/lint-overlays.js`를 만들었다. HTML에서 스크립트 블록을 정규식으로 뽑아 **라인 오프셋까지 맞춰** Acorn으로 파싱하고 acorn-walk로 규칙을 돌린다. 에러가 있으면 종료 코드 1.

- **`no-use-before-define`**: 최상위 `let`/`const` 선언 위치를 모으고, 호이스팅된 함수가 그보다 앞에서 호출되면서 그 변수를 참조하면 보고한다(`:148-183`). 함수 지역 선언·파라미터 섀도잉은 제외해 오탐을 없앴다(`:216-274`).
- **`no-unescaped-innerhtml`** (2026-07-28 보안 감사 때 추가, `d5fca67`): `innerHTML` 대입의 템플릿 리터럴 보간이 이스케이프 함수를 거치는지 AST로 검사한다(`:396-437`). 숫자로만 평가됨을 보수적으로 증명할 수 있는 표현식만 예외다.
- 파싱 실패 자체를 에러로 취급.

**두 규칙 모두 기성 도구에 대응물이 있다.** `no-use-before-define`은 ESLint 코어 규칙이고, HTML 인라인 스크립트 추출과 라인 오프셋 보정은 `eslint-plugin-html` 계열이, `innerHTML` 검사는 `eslint-plugin-no-unsanitized`가 담당하는 영역이다. **그 조합으로 대체할 수 있는지는 검토하지 않았다.** 이 저장소에는 ESLint 설정 파일도 의존성도 없고(`package.json`의 개발 의존성에 파서 계열은 `acorn-walk`뿐), TypeScript 본체에도 린트가 없다 — 포맷터(Prettier)만 걸려 있다. 자체 구현은 의존성을 늘리지 않는 선택이었고, 그 대가는 아래 "남은 위험"의 미호출 규칙이다.

XSS 규칙이 필요한 이유가 구조에 있다. 오버레이가 렌더링하는 값(곡 제목, 신청자 닉네임, 투표 항목)은 **시청자가 통제하고**, 같은 HTML이 로컬 오버레이와 **시청자 세션 쿠키와 같은 오리진** 양쪽에서 실행된다. 이스케이프 누락 하나가 방송 화면 조작과 세션 탈취 양쪽으로 이어진다. 그래서 검사 대상에서 빠져 있던 `private/` 하위를 함께 포함시켰다 — 누락돼 있던 파일이 정확히 시청자 오리진에서 도는 파일이었다.

정적으로 안전을 증명할 수 없는 자리를 전부 막으면 쓸 수 없으므로, **주석으로 사유와 함께 예외를 선언**하게 했다(`:444-462`). 현재 예외는 3곳뿐이고 모두 사유가 붙어 있다. **"완전 차단" 대신 "예외가 코드 리뷰에 남는다"를 택했다.**

#### 재발 가능성과 남은 위험

린트는 `npm test`의 **첫 단계**이고(`package.json:23`), PR CI와 릴리스 워크플로 양쪽에서 돌며, 릴리스 빌드는 테스트 잡을 `needs`로 건다. 현재 10개 파일 0 에러다. 최상위 스코프 TDZ와 미이스케이프 `innerHTML`은 사람이 잊어도 통과할 수 없다.

세 단계 순서에도 이유가 있다. 오버레이 린트는 1초 안에 끝나고 실패 시 원인이 파일:라인으로 특정된다. `cargo test`는 176개가 컴파일 포함 수 분, Karma는 944 케이스가 브라우저 기동을 포함한다. **가장 싸고 가장 늦게 발견되는(방송 중 런타임) 실패를 맨 앞에 둔 배치다.**

**남은 위험은 규칙의 범위다.**
- TDZ 검사는 **최상위 스코프만** 보므로 함수 내부 블록 스코프의 TDZ는 잡지 못한다. 정확한 스코프 분석 대신 오탐 0을 택한 결과이며, 그 선택이 코드 주석에 명시돼 있다.
- XSS 규칙은 `innerHTML` + 템플릿 리터럴 조합만 보므로 문자열 결합, `insertAdjacentHTML`, `document.write`는 통과한다.
- **파일 상단 주석이 광고하는 "미선언 변수 접근" 검사는 실제로 호출되지 않는다.** 함수와 브라우저 전역 목록은 정의만 되어 있고 린트 본체에서 부르지 않는다(`:279-328` 정의, `:379-380` 호출부에 없음). 제대로 하려면 본격적인 스코프 추적이 필요한데 그 비용을 지불하지 않았다. **얻은 것은 없고 잃은 것은 문서와 동작의 불일치다 — 8장에 미해결 항목으로 남긴다.**
- 예외 주석의 사유를 심사하는 것은 여전히 사람이다.

---

### 케이스 3 — 대시보드가 이미 있었는데도, 수치를 믿기 전에 정의를 다시 읽었다

#### 무슨 일이 있었나

2026-08-06, 이 포트폴리오용 수치를 뽑으려고 기존 대시보드를 열었다가 **정의상 불가능한 값**을 봤다. 자막 봇의 활성화→동작 전환율이 842%였다. 정의상 100%를 넘을 수 없는 지표다. 거기서 멈추고 쿼리 정의를 전수로 다시 읽어 결함 4건을 찾았다(수정 커밋 `1fb3322`).

#### 근본 원인

네 건의 성격이 서로 다르다.

- **LIKE 와일드카드**: BigQuery `LIKE`에서 언더스코어는 임의의 한 글자다. 봇 이벤트 이름이 `봇_액션` 규약이라 언더스코어가 접두사 구분자인데, **같은 문자를 와일드카드로 쓰는 연산자를 골랐다.** PRO 접두 패턴이 프롬프터 이벤트를 삼켰다.
- **우측 절단**: 첫 주 활성 사용자를 모든 주차의 공통 분모로 썼다. raw 데이터가 2026-05-01부터라 뒤쪽 주차는 아직 그만큼 관측되지 않은 코호트가 분자에서만 빠진다. **생존 편향이 잔존율 급락처럼 보였다.**
- **세션 교집합 없음**: 열기와 동작을 따로 셌다. 한 번 켜두고 계속 도는 상시형 봇은 30일 창 안에 열기 이벤트 없이 동작만 발생한다. 분모가 분자를 포함하지 않으니 상한이 없었다.
- **분자 퇴화**: 동작 카운트가 세션 시작 같은 비봇 이벤트까지 세어 모든 세션이 동작 1회 이상이 됐다.

#### 무엇을 바꿨나

**값을 손보지 않고 쿼리의 형태를 바꿨다.**

- 문자열 접두/접미 매칭을 **와일드카드 없는 함수로 전량 교체**했다(`STARTS_WITH` / `ENDS_WITH`). **이제 언더스코어가 와일드카드로 해석될 자리가 없다.**
- 잔존율은 관측 상한 주차를 먼저 구하고, 주차별 분모를 **그 주차만큼 실제로 관측된 코호트**로 제한했다(`:249-270`). 차트 정규화도 함께 고쳤다.
- 전환율은 세션별로 열림/동작을 먼저 판정한 뒤 둘 다 참인 세션을 센다(`:306-338`). **분자가 분모의 부분집합이 되어 비율이 구조적으로 100%에 상한된다.**
- 방치 세션은 봇 이벤트만 카운트하고 분모를 "봇을 1개 이상 연 세션"으로 제한했다.

각 쿼리 위에는 값이 아니라 **왜 그렇게 써야 하는지**를 주석으로 남겼다.

**이런 감사가 처음은 아니다.** 2026-05 v1.2.6 개편에서 이미 같은 계열 4건을 처리했고 그 결과가 코드에 남아 있다 — 평균 대신 P90, 최근 30일 분모 정합, GA4 자동 이벤트가 유령 봇으로 잡히던 것을 쿼리 단에서 배제.

#### 재발 가능성과 남은 위험

LIKE 오매칭 계열은 **연산자 자체를 제거했으므로 구조적으로 불가능하다.** 비율 지표는 분자가 분모의 부분집합이 되도록 세션 단위로 재구성했으므로 100% 초과가 나올 수 없고, 그 계열의 오류를 다시 만들려면 쿼리 구조를 되돌려야 한다.

**다만 자동 검증은 없다.** 린트나 테스트가 아니라 사람이 정의를 다시 읽어서 잡은 것이고, 다음번에도 그래야 한다. "비율 지표가 100%를 넘으면 실패하는 어서션"조차 아직 없다 — 8장 로드맵 항목이다.

그리고 **고치지 않은 한계는 고치지 않았다고 기록했다.** 5.0절의 알려진 한계 5건이 그것이다. 데이터 품질 이슈를 숨기지 않고 `known_limits_not_fixed`라는 이름의 목록으로 자산화한 것이 이 케이스의 실제 산출물이다.

---

## 7. 개발 방법론 — AI 에이전트용 규칙·워크플로·스킬 정의로 1인 개발을 확장한 방식

이 장의 주제는 "AI를 썼다"가 아니라, **커밋 1,196개·오버레이 HTML 10개·feature-map 기능 26개 규모를 리뷰어 없이 유지하기 위해 어떤 컨텍스트 구조를 만들었는가**이다.

**먼저 범위를 좁혀 둔다.** 규칙·워크플로·스킬을 로드하고 라우팅하는 메커니즘은 에이전트 도구가 구현한다. 내가 작성한 것은 그 위에 올린 정의 파일 39개(규칙 3 + 워크플로 11 + 스킬 25)이며, 아래에서 "하네스"는 그 정의 묶음을 가리킨다. 도구 자체를 만든 것이 아니다.

### 7.1 왜 하네스가 필요한가

Anthropic은 컨텍스트 엔지니어링을 "LLM 추론 중 최적의 토큰 집합을 큐레이팅하고 유지하는 전략의 집합"으로 정의한다(https://www.anthropic.com/engineering/effective-context-engineering-for-ai-agents). DORA 2025 State of AI-assisted Software Development 보고서는 응답자 90%가 업무에 AI를 쓰고 80% 이상이 생산성 향상을 체감하지만, **AI 도입이 배포 처리량과는 양의 관계, 배포 안정성과는 음의 관계**를 보인다고 보고한다(https://dora.dev/research/2025/dora-report/). 결론은 AI가 증폭기(amplifier)라는 것 — 조직의 기존 강점과 약점을 함께 확대한다.

즉 도구 자체가 아니라 **규칙·테스트·리뷰 게이트를 레포에 명시하는 하네스 설계가 성과를 가른다.** 규칙 파일 포맷은 벤더 종속을 피하는 방향으로 표준화되어 AGENTS.md는 6만 개 이상 오픈소스 프로젝트가 채택했다(https://agents.md/).

vzzk의 하네스는 **"에이전트는 매 세션 기억을 잃는다"**를 전제로 설계됐다. 그래서 잃으면 안 되는 것만 상시 로드로 올렸고, 그 총량은 **4,065 bytes(규칙 3개 파일 합계)**로 눌러 뒀다.

### 7.2 상시 로드 규칙 3개 — 발동 시점이 다르면 파일도 다르다

| 규칙 | 크기 | 막으려는 실패 | 발동 시점 |
|---|---:|---|---|
| `01-mandatory-skill-reading.md` | 1,987 B | 에이전트가 기존 아키텍처를 모른 채 자기 방식으로 재구현 | **작업 시작 전** |
| `02-code-quality-invariants.md` | 1,240 B | 리뷰어가 없어서 같은 실수가 매번 새로 들어옴 | **작업 중** |
| `03-post-change-consistency.md` | 838 B | 코드는 고쳤는데 스펙·맵·문서가 뒤처지는 드리프트 | **작업 후** |

하나로 합치지 않은 이유는 **발동 시점이 다르면 위반 여부를 판정하는 시점도 다르기** 때문이다.

②의 불변식은 추상적 코딩 규칙이 아니라 이 프로젝트에서 실제로 대가를 치른 항목들이다.

- **`Math.random()` 금지** — 룰렛·추첨의 난수 엔트로피는 `window.crypto.getRandomValues()`에서 얻는다(`src/app/util/random.ts:2-3`). 다만 그 유틸 4행은 5개 워드 중 하나를 고르는 **인덱스 선택에 `Math.random()`을 쓴다**. 반환값 자체는 모두 CSPRNG 산출물이라 난수 품질은 떨어지지 않지만, 규칙이 자기 유틸에서 완결되지 않는다. 그리고 결과 산출이 클라이언트에 있으므로 난수원 선택이 추첨의 **공정성을 보증하지는 않는다** — 검증 가능한 추첨(커밋-리빌 등)은 미구현이며 8.2절에 남긴다.
- **NgZone 래핑** — EventSource/WebSocket 콜백이 Angular 존 밖에서 도착해 UI가 갱신되지 않는 문제(`src/app/service/analytics.service.ts:102-109`).
- **환경 가드** — 브라우저에서 Tauri API를 호출해 초기화가 깨지는 문제(`src/app/util/env.ts:32-33`, 코드 5곳에서 참조).
- **동기화 함수 내 await 금지** — 2절에서 다룬 경합 문제.

**어느 것도 코드만 봐서는 왜 이렇게 썼는지 알 수 없기 때문에** 상시 로드 대상이 됐다. 대가는 근거 자체는 상주하지 않는다는 점이다 — 룰을 의심하려면 결국 스킬을 열어야 한다.

**그리고 이 넷은 구조적 방어가 아니라 규약이다.** 위반을 잡는 자동 검사가 없다 — "동기화 함수 내 `await` 금지"도, "봇의 `onBeforeClose()`에서 overlay push 금지"(3.1절)도 사람과 에이전트가 기억해야 성립한다. 오버레이 린트가 검사하는 두 규칙과 달리 이 항목들은 CI를 통과한다. 8.2절에 검증 공백으로 남긴다.

### 7.3 3계층 인덱스 라우팅 — 전부 읽기와 아무것도 안 읽기 사이

스킬 25개 본문을 전부 상주시키면 컨텍스트가 남지 않는다. 그래서 **"무엇을 읽어야 하는지"만 상주시키고 본문은 지연 로드**한다. Anthropic이 정리한 progressive disclosure와 같은 구조다 — 메타데이터 → 본문 → 참조 파일 순으로 필요할 때만 읽는다.

```
.agents/rules/*.md          (상시 로드 — 규칙 3개)
.agent/AGENTS.md            (인덱스 — 워크플로 11 / 스킬 표 / 스크립트, 한 줄 설명만)
   → .agent/skills/best-practices/SKILL.md   (tech · experience 2차 라우터)
      → .agent/skills/<도메인>/SKILL.md      (25개: overlay-protocol, sync-architecture, ...)
```

**이 구조의 비용은 인덱스가 밀린다는 것이고, 실제로 밀렸다** — `.agent/AGENTS.md`의 스킬 표가 24행인데 실제 디렉터리(`.agent/skills/`)는 25개다(`analytics-bigquery`가 빠져 있다). 규칙 ③이 존재함에도 발생한 드리프트이며, **체크리스트가 CI가 아니라는 한계를 그대로 보여준다.** 8장에 미해결로 남긴다.

### 7.4 한 번에 담을 수 없는 크기를 자르는 두 가지 축

| 도구 | 자르는 기준 | 방법 | 잘라낸 부분을 기록하는 장치 |
|---|---|---|---|
| chunked-work | **출력 토큰** | 테이블 3개 또는 500줄 초과 시 `.agent/tmp/<작업>/`에 파일 단위 분할 | `depends_on` frontmatter |
| cross-feature-audit | **조합 폭발** | 26개 기능 전수 교차는 325쌍 × 5차원 = 1,625 매트릭스. `impacts` 양방향 탐색으로 범위 축소 | `feature-map.json`의 `impacts` |

두 경우 모두 **"완전함"을 포기하고 "실행 가능함"을 얻었으며, 대신 잘라낸 부분이 어디였는지를 각각 `depends_on`과 `impacts`로 기록에 남겼다.** 실제로 Pro 관련 감사는 전수 325쌍에서 28쌍으로 좁혀진다(실행 확인).

대가는 명확하다. `feature-map.json`은 **손으로 관리하는 레지스트리**라 코드와 자동 동기화되지 않는다. 갱신을 빠뜨리면 감사 범위에 구멍이 생긴다. 그래서 `impacts` 갱신은 규칙 ③·감사 스킬·PR 워크플로 **세 곳에서 중복 요구**된다. chunked-work의 미정리 잔여물은 `--check-all`을 PR 워크플로에 넣어 직전 게이트로 만들었다.

### 7.5 판단이 아니라 순서로 강제한다

`/create-pr` 워크플로가 이 모든 규약이 실제로 지켜졌는지 확인하는 단일 관문이다. 오버레이 린트 → vzzk-api 동기화 → Rust 테스트 → 웹 테스트 → 미정리 작업 점검을 **고정 순서**로 통과시키고, 실패가 하나라도 있으면 PR을 만들지 않는다.

막으려는 실패는 **에이전트가 "테스트는 통과할 것 같다"고 판단하고 건너뛰는 것**이다. 판단 여지를 없애고 명령 순서로 고정했다. 같은 체인이 `npm test`에도 존재하고, CI와 릴리스 워크플로에도 있다. 여기에 크로스 플랫폼 함정(PowerShell의 체이닝 연산자 미지원, PATH 미상속, 한글 파일은 셸 대신 파일 쓰기 도구 사용)까지 워크플로 문서에 명시해 도구 환경 차이로 인한 실패를 사전 차단했다.

**"판단이 아니라 순서로 강제한다"가 이 하네스 전체의 설계 태도를 요약한다.**

### 7.6 코드 생성이 담당하는 곳과 체크리스트가 담당하는 곳

봇 하나를 추가하는 데 필요한 지점이 **16곳**이다(서비스·설정 컴포넌트·라우트·콘솔 등록·오버레이 HTML·오버레이 타입·Rust 데이터 타입·위키·테스트·feature-map 등). 전부 자동화하면 생성기가 프로젝트만큼 복잡해진다.

**기계적으로 정확해야 하는 두 곳만 코드 생성했다** — 서비스 파일 스캐폴드(`script/generate-service.js:104`)와 설정 키→타입 맵 갱신(`:114-151`). 나머지는 판단이 필요하므로 16단계 체크리스트 문서로 남겼다(`.agent/workflows/create-bot-service.md:424-439`). **완전 자동화 대신 "자동화 + 검증 가능한 절차"의 절충이다.**

같은 태도가 타입 경계에도 적용됐다. Rust↔TypeScript 바인딩은 자동 생성이지만(커맨드 28개 + 타입 20개), **오버레이 HTML은 번들러도 TS도 거치지 않아 바인딩을 import할 수 없다.** 3자 구조에서 타입 시스템은 2자(Angular↔Rust)만 덮는다는 사실을 인정하고, 나머지 한 변은 **Rust 테스트가 JSON 키 이름을 문자열 리터럴로 직접 단언**해서 고정했다(`src-tauri/src/overlay.rs:1240-1337`). 라운드트립 테스트로는 이 실패를 못 잡는다 — Rust↔Rust는 직렬화 규칙을 지워도 항상 대칭이기 때문이다.

**16곳이 2곳으로 줄었다는 뜻은 아니다.** 생성기가 덮는 것은 위 두 곳이고, 손으로 새로 판단해야 하는 코드 작업은 서비스 로직·콘솔 등록·라우트·설정 컴포넌트가 그대로 남는다(체크리스트 2·6·7·9번. 이 중 설정 컴포넌트는 워크플로 문서에서 가장 분량이 큰 단계다 — `.agent/workflows/create-bot-service.md:183-338`). 위키·`feature-map`·스킬 갱신(8·15·16번)도 오버레이 유무와 무관하게 필요하다. 오버레이가 필요한 봇 — 11종 중 7종(자막·투표·추첨·룰렛·후원목표·신청곡·프롬프터)이 여기 해당한다 — 은 HTML·오버레이 타입 등록·Rust 타입·직렬화 테스트 7개 항목(3·4·5·11·12·13·14번)이 추가된다. **생성기가 없앤 것은 반복 타이핑이지 판단 지점이 아니다.**

### 7.7 이 구조의 효과는 측정하지 않았다

이 장은 처음부터 끝까지 설계 서술이고, 정의 파일 39개가 실제로 무엇을 개선했는지에 대한 측정치는 없다. 도입 전후를 비교할 기준선을 만들어 두지 않았다. 반대로 **이 구조가 막지 못한 실패는 목록으로 남아 있다** — 7.3절의 인덱스 드리프트(스킬 표 24행 대 디렉터리 25개)와 8.3절의 문서 드리프트 5건은 규칙 ③이 존재하는 상태에서 발생했다. 7.1절에서 인용한 "AI는 증폭기"라는 결론을 자기 사례로 입증하지는 않았다.

---

## 8. 한계와 로드맵

이 장은 **알고 있으면서 아직 닫지 않은 것들**의 목록이다. 각 항목은 문서나 코드에서 확인된 것만 적었다.

### 8.1 아키텍처

| 항목 | 현재 상태 | 왜 아직 안 했나 |
|---|---|---|
| **수평 확장 경로 없음** | PKCE 교환 코드가 프로세스 메모리에, SSE 세션 맵이 인메모리에, 레거시 카운터가 단일 볼륨에 있다. 셋 다 인스턴스가 2개가 되는 순간 깨진다 | 현재 규모(DAU 90)에서 문제가 관측되지 않았다. 확장이 필요해지는 시점에 외부 저장소(Redis 등)로 옮기는 것이 정해진 다음 단계 |
| **원격 오버레이 조회 무인증** | 채널 오버레이의 초기 상태·SSE 조회 경로에 인증이 없다. 채널 ID를 아는 제3자가 **방송 중 자막 텍스트(스트리머 발화 전문), 신청곡 큐, 추첨 참가자·당첨자 닉네임, 투표 집계, 후원목표 진행 상태**를 실시간으로 받아갈 수 있다(상태 스키마: `vzzk-api/src/sync/sync.types.ts:73-94`). 채널 ID는 공개 정보다 | capability 토큰을 붙이면 사용자가 OBS에 저장해 둔 URL이 전부 깨진다. 그 마이그레이션 비용을 위험보다 크게 봤으나, **노출 항목을 위와 같이 명명하기 전에 내린 판단**이다. 기존 URL 유지 + 신규 발급분부터 토큰 적용 같은 점진 이전은 검토하지 않았다 |
| **서버측 토큰 폐기 API 부재** | 유출된 토큰을 서버가 즉시 무효화할 수단이 없다 | 미구현. README에 남은 위험으로 기재됨 |
| **구버전 우회 경로 L1~L5 미제거** | Pro 상태 응답이 HMAC 서명을 여전히 함께 내려준다 = **Pro 자격 위조 경로가 열려 있다** | 관측 창(연속 14일 0)이 아직 충족되지 않았다. 제거 절차·순서(코드 제거 → 검증 → 키 로테이션)와 함께 지울 테스트 목록까지 문서에 준비돼 있다 |
| **자체 서명 코드 서명** | SmartScreen 경고가 사용자에게 그대로 노출된다 | 인증서 비용 대신 릴리스 노트 안내를 택했다. 무결성은 minisign이 담당한다 |
| **minisign 키 회전 불가** | 키를 바꾸면 기존 사용자가 자동 업데이트를 못 받는다 | 구조적 제약. 개인키 분실이 곧 자동 업데이트 경로 상실이며 README에 경고로 명시 |
| **빌드 프로버넌스 없음 (SLSA L1 미충족)** | minisign 아티팩트 서명만 있고 빌드 프로버넌스를 생성하지 않는다(`.github/workflows/`에 `attest`·`provenance` 계열 스텝 0건). 서명은 "이 파일이 내 키로 서명됐다"만 말하고, "이 파일이 이 소스에서 이 워크플로로 만들어졌다"는 말하지 않는다 | `actions/attest-build-provenance` 추가는 릴리스 워크플로 한 스텝이지만, 검증 측(업데이터)이 프로버넌스를 확인하지 않으면 효과가 없다. 양쪽을 함께 붙이는 작업으로 미뤘다 |
| **SSE 연결 상한의 순환 축출** | 채널당 18연결 상한 + FIFO eviction인데 축출된 오버레이가 1초 뒤 재연결한다. 19연결 이상에서는 가장 오래된 연결이 돌아가며 끊기는 순환 상태로 퇴화한다(4.2절) | 현재 채널당 오버레이 6~10개라 상한에 닿은 적이 없다. 축출 응답에 재연결 억제 신호를 실어 보내는 것이 정해진 다음 단계 |
| **후원목표 SSE는 스냅샷이 아니다** | `Lagged` 유실을 허용하는 근거가 "항상 전체 스냅샷"인데, 로컬 SSE의 후원목표만 개별 goal을 보낸다(`src-tauri/src/overlay.rs:417-419`). 다중 goal 중 하나의 갱신이 버려지면 다음 갱신까지 낡은 값이 남는다 | 전체 배열 push로 통일하면 goal 수에 비례해 페이로드가 커진다. 관측된 사례가 없어 우선순위를 낮게 뒀으나, 유실 허용의 전제가 깨지는 유일한 경로다 |
| **로컬 전송의 WebSocket 대안 미검토** | 브라우저 6-연결 한도를 포트 증설로 우회했는데, 이 한도는 HTTP/1.1 한정이고 WebSocket에는 적용되지 않는다. 프롬프터는 이미 socket.io를 쓰므로 레포 안에 경로가 존재한다 | 당시 검토하지 않았다. 오버레이 HTML을 의존성 없는 평문으로 유지하고 SSE의 자동 재연결을 그대로 쓰는 이점은 있으나, 그것이 비교 끝에 내린 결론은 아니다 |

### 8.2 검증 공백

| 항목 | 현재 상태 |
|---|---|
| **SSE 수신 경로에 자동 검증 없음** | 스트림 수신은 명시적 비-테스트 영역으로 문서화돼 있고 수동 E2E에 의존한다. 헤더 플러시 같은 회귀는 지금도 테스트가 잡지 못한다 |
| **CI가 바인딩 드리프트를 검사하지 않음** | 바인딩 최신성이 "개발자가 dev 빌드를 한 번은 돌린다"에 의존한다. 재생성 후 타입체크 의무화는 **CI가 아니라 워크플로 문서로만** 강제된다 — 현재의 약한 고리 |
| **린트의 미호출 규칙** | `lint-overlays.js` 상단 주석이 광고하는 "미선언 변수 접근" 검사가 실제로 호출되지 않는다. 문서와 동작의 불일치 |
| **린트 규칙 범위** | TDZ는 최상위 스코프만, XSS는 `innerHTML` + 템플릿 리터럴 조합만 검사한다 |
| **지표 정의 회귀 테스트 없음** | 케이스 3의 결함 4건은 사람이 정의를 다시 읽어서 잡았다. "비율 지표가 100%를 넘으면 실패"하는 어서션조차 없다 |
| **불변 규칙에 자동 검사 없음** | 7.2절 ②의 불변식 중 "동기화 함수 내 `await` 금지"와 "봇의 `onBeforeClose()`에서 overlay push 금지"는 규약이며, 위반해도 린트·테스트·CI를 통과한다 |
| **검증 가능한 추첨 미구현** | 룰렛·추첨 결과가 클라이언트에서 산출되므로 시청자가 결과를 검증할 수단이 없다. 난수 엔트로피가 CSPRNG인 것과 공정성 보증은 별개다. `src/app/util/random.ts:4`의 인덱스 선택에 남은 `Math.random()` 제거도 같은 항목에 묶여 있다 |
| **미처리 예외·크래시 텔레메트리 부재** | 관측되는 실패는 코드가 명시적으로 로깅한 `%Fail` 이벤트뿐이다. 패닉·미처리 예외·앱 강제 종료를 수집하는 장치가 없으므로 "봇 로직 실패 0건"(1장·2.3절·5.4절)은 그 계열을 관측할 수 없다는 뜻을 포함한다. 에러율의 분모는 신뢰할 수 있고 분자는 계측 규약에 달려 있다 |
| **릴리스 바이너리 문자열 스캔 미수행** | 4.3절의 시크릿 제거 검증은 소스 `grep` 층위에서 끝난다. 원래 논거가 "`strings` 한 번이면 추출된다"였으므로 검증도 빌드된 바이너리 문자열 스캔으로 대칭을 맞추는 것이 맞다. 식별자 기준 0건은 확인했으나 아티팩트 스캔은 돌리지 않았다 |

### 8.3 문서 드리프트 (확인됨)

| 항목 | 내용 |
|---|---|
| 인증 아키텍처 스킬 문서 | 삭제된 구조(대칭키 복호화, 딥링크에 토큰 탑재)를 여전히 기술한다 |
| Pro 구독 스킬 문서 | 존재하지 않는 HMAC 설정 절차를 안내한다 |
| `.agent/AGENTS.md` 스킬 표 | 24행인데 실제 디렉터리(`.agent/skills/`)는 25개 |
| `Cargo.toml`의 `hmac` 의존성 | 소스 어디에서도 쓰이지 않는다 |
| 수익 모델 문서의 표본 크기 | "6일"로 적혀 있으나 현재 관측은 98일 |

**이 목록 자체가 이 문서를 쓰는 과정에서 코드와 문서를 대조해 만든 산출물이다.** 정리는 다음 작업 단위로 잡혀 있다.

### 8.4 데이터·계측

| 항목 | 내용 | 우선순위 |
|---|---|---|
| **봇을 연 뒤 첫 동작까지의 시간 미계측** | 이벤트형 봇의 낮은 전환율이 "습관적 준비"인지 "온보딩 실패"인지 판정할 수 없다. **제품 판단에 직결되는 가장 비싼 공백** | 높음 |
| **신청곡 가격 계측 공백** | 33,077건 중 1건. 호출부 두 곳 중 하나가 0을 하드코딩, 다른 하나는 기본값 0 | 높음 |
| **PRO 완료 이벤트 검증** | 결제 시도 대비 완료 7%가 UX 문제인지 계측 누락인지 구분 불가 | 높음 |
| **PRO 진입 경로 미표기** | 경로 행이 붙은 438건 중 22.1%(97건)가 미표기이고, `pro_dialogOpen` 697건 기준으로는 51.1%가 경로 미상이다. 경로별 효과 비교 불가 | 중간 |
| **6개월 이상 잔존 데이터 없음** | raw export가 98일뿐이라 W15 이상은 구조적으로 불가능. 시간이 해결하는 항목 | 시간 경과 |
| **macOS 실사용 미관측** | 지원 코드와 릴리스 아티팩트는 존재. 실사용자 부재인지 계측 문제인지 단정 불가 | 중간 |
| **DAU 차원 중복 합산** | 절대 수준이 아니라 추세 비교로만 써야 한다 | 낮음(문서화로 대응) |
| **GA4 Daily Export 상한** | 표준 속성의 BigQuery 일일 내보내기는 일 100만 이벤트 상한이 있고 초과하면 그날 내보내기가 중단된다. `advanced_events`는 세션·수익 분석의 유일한 소스이므로, 성장 시 파이프라인이 조용히 멈출 수 있다. 현재 사용량은 한도 대비 여유가 있으나 알림을 걸어두지 않았다 | 중간 |
| **두 소스의 세션 정의 일치 여부 미검증** | 통합 VIEW는 Data API 집계(백필)와 Daily Export(raw)를 날짜로 봉합한다. 두 소스의 `session_start` 계수 방식이 같은지 접합 구간에서 겹쳐 뽑아 대조하지 않았다. 누적 세션 55,193은 이 가정 위에 있다 | 중간 |

### 8.5 제품

가장 큰 미해결 문제는 기술이 아니라 **성장 정체**다. 5.1절의 수치가 그대로 말한다 — 첫 완결월 기준 11개월간 6.0배 성장 뒤 13개 완결월이 평탄 구간이고, 직전 30일 대비 DAU는 +0.6%다. 사용자-일 기준 재방문 비중 96.6%가 현재 규모를 떠받치는 반면, **신규 유입의 절대 수는 현재 계측으로 산출할 수 없다**(92는 사람 수가 아니라 사용자-일이다). 정체의 원인이 유입 부족인지 이탈인지조차 지금 데이터로는 분해되지 않는다.

동시에 관측된 후원 수취는 상위 5명이 총액의 70.8%를 가져가는 편중 위에 있다. **다만 이는 vzzk의 매출이 아니라 제품의 노출·의존도 지표다.**

**수익 구조가 규모를 따라오지 못한다.** vzzk 자신의 매출은 월 ₩89,341이고 인프라 비용은 약 ₩7,000이다(5.5절). 흑자지만 인건비를 계산에 넣지 않은 흑자이고, 2026-06과 07의 정산액이 원 단위까지 같다 — **PRO 시작 후 두 달간 구독 순증이 0이다.** 최근 30일 평균 DAU 90.6에 대해 유료 전환은 한 자릿수 퍼센트에 머문다. 즉 정체는 유입에만 있는 것이 아니라 **이미 들어온 사용자를 매출로 바꾸는 구간에도 있다.** 여기에는 세 가지 미해결이 겹쳐 있다 — (가) 앱 내 결제 완료율 7.0%가 UX 문제인지, 채널 페이지 직접 구독으로 새는 것인지 구분되지 않고, (나) 구독자 데이터가 분석 파이프라인 밖에 있어 이탈·갱신·순증을 볼 수 없으며, (다) 스토리지·도메인 비용을 계측하지 않아 손익의 하한이 확정되지 않았다. 기술 구조는 현재 규모의 수십 배를 감당하도록 만들어 두고, 그 구조를 지탱할 수익 구조는 만들지 않은 상태다.

기술적 개선 항목들과 달리 이 문제는 코드로 닫히지 않으며, 이 문서는 그에 대한 답을 갖고 있지 않다고 적는 편이 정확하다.

---

## 9. 부록: 기술 스택

| 계층 | 기술 | 이 프로젝트에서의 역할 |
|---|---|---|
| **데스크톱 셸** | Tauri v2 | 시스템 WebView 재사용, 리소스 패키징, 딥링크 프로토콜 핸들러, 단일 인스턴스 |
| **프론트엔드** | Angular 17 | 제어 UI. 상태는 `signal`/`computed`, 이벤트만 RxJS `Subject`로 이분법 강제 |
| **네이티브 백엔드** | Rust | actix-web(로컬 HTTP/SSE 서버, 10포트 바인딩), tokio(비동기 분리 실행, broadcast 채널) |
| **타입 경계** | tauri-specta | Rust 커맨드 28개 + 타입 20개를 TypeScript 바인딩으로 자동 생성 |
| **자격증명** | OS 키체인 | Windows Credential Manager / macOS Keychain / freedesktop Secret Service |
| **암호** | Ed25519 (RFC 8032) | Pro 자격 서명 검증(앱은 공개키만), 업데이터 서명은 minisign |
| **인증** | OAuth 2.0 + PKCE (RFC 7636/8252) | 공개 클라이언트 모델. 교환 코드 방식, HttpOnly 쿠키 nonce |
| **클라우드 API** | NestJS | 원격 오버레이 sync, SSE 스트림, 시청자 세션 AES-256-GCM, 레이트 리밋, Joi 환경변수 검증 |
| **클라우드 저장** | Firebase RTDB | 재연결 복구용 캐시 전용. 미설정 시 no-op으로 격하 |
| **호스팅** | Railway | 단일 인스턴스 + 영속 볼륨. Cloudflare 앞단 |
| **오버레이 런타임** | OBS 브라우저 소스 (CEF) | 번들러 없는 평문 HTML/JS 10개(최상위 7 + `private/` 3). socket.io(프롬프터), YouTube iframe(재생기) |
| **음성** | Web Speech API | 자막(음성인식, 중간 결과 활성), TTS |
| **CI/CD** | GitHub Actions | PR 4잡(웹 테스트 / Rust 테스트 / 빌드 / 포맷), 릴리스 4플랫폼 매트릭스 + 테스트 게이트, 릴리스 자격증명 사전 검증 |
| **배포** | GCS 공개 버킷 + GitHub Releases | 아티팩트·서명·업데이트 매니페스트 |
| **테스트** | cargo test / Karma + Jasmine | Rust 176개(단위 152 + 통합 24), 웹 39 spec·944 케이스. 커버리지는 측정하지 않았고 무엇이 덮이지 않는지는 8.2절에 있다 |
| **정적 분석** | Acorn + acorn-walk | 오버레이 HTML 전용 자체 린트(규칙 2종 + 파싱 검사). **TypeScript 본체에는 린트가 없다** — 포맷터(Prettier)만 있다 |
| **분석** | GA4 → BigQuery | 통합 VIEW 759일 + raw VIEW 98일. 쿼리 26개 병렬 실행 |
| **대시보드** | Node 생성기 + ECharts | 서버 없는 단일 정적 HTML, 차트 19개 |
| **통계** | 자체 Monte Carlo | 10,000회 시뮬레이션, Normal/Beta 샘플링, 표본 불확실성 구간(5.5 참조 — 95% CI로 부르지 않는 이유) |
| **에이전트 컨텍스트** | 규칙 3 + 워크플로 11 + 스킬 25 (정의 파일 39개) | 상시 로드 4,065 bytes, 나머지는 지연 로드. 로딩·라우팅 메커니즘은 에이전트 도구가 제공한다 |

### 자체 제작 스크립트 (`script/`, 14개 중 주요 5개)

| 스크립트 | 명령 | 하는 일 |
|---|---|---|
| `generate-bindings.js` | `npm run gen:bindings` | dev 빌드 없이 Rust 소스를 복제·재컴파일해 TS 바인딩 재생성 |
| `lint-overlays.js` | `npm test` 1단계 | 번들러 없는 오버레이 HTML의 AST 정적 검사 |
| `generate-dashboard.js` | `npm run dashboard` | BigQuery 26쿼리 → Monte Carlo → 단일 HTML |
| `generate-cert.js` | `npm run gen:cert` | 코드서명 인증서 생성·갱신·CI 시크릿 반영, `--check`로 만료 사전 점검 |
| `generate-service.js` | `npm run gen:service` | 봇 서비스 스캐폴드 + 설정 타입 맵 자동 갱신 |

---

> **문서 작성 기준**
> 이 문서의 모든 기술적 주장은 2026-08-06 시점 `vzzk` / `vzzk-api` 저장소의 실제 파일을 읽고 확인했다.
> 수치는 두 계열이다 — GA4/BigQuery 유래 지표는 같은 날 생성한 대시보드 스냅샷에서, 저장소 통계(커밋·태그·테스트·파일 수)는 같은 날짜의 작업 트리에서 나왔다. 정의와 관측창을 각 수치에 병기했다.
> 확인하지 못한 것은 쓰지 않았고, 측정하지 않은 것은 추정임을 명시했다.
