# FONUMA 웹 프로젝트 0.2 개발 프리뷰

2026-10-05 · 실행 도구 `fonuma-app 0.2.0-dev.3` · 프로젝트 스키마 `fonuma.web-project/0.2`.
기존 공개 basic-0.1 / 웹 SDK 0.1과 별도 개발 프로필이다. 기존 베타 설치 파일에 이 기능이 들어 있다고 해석하면 안 된다.

dev.3의 화면·공유 저장·세 분야 예제·SDK 생성은 [고급 웹 개발 가이드](APPLICATION_WEB_ADVANCED_0.2.md)를 함께 읽는다. 공식 공개 다운로드 dev.2와 새 개발 후보를 구분한다.

## 지금 만들 수 있는 것

개인 업무관리와 회원 간 공유 웹앱을 만들 수 있다. 기존 작업 관리·개인 재고·개인 예약과 새 operations·commerce·community를 같은 호스트로 실행한다. 업무 규칙, 자료형, 화면·폼·표·행 동작은 `.fonuma`에 정의한다. Rust 공통 호스트가 HTTP·SQLite·비밀번호 해시·세션을 제공하고 공통 JavaScript가 DOM을 만든다. 업무별 Rust/JavaScript 코드를 추가하지 않아도 세 예제가 동작한다.

기존 booking은 사용자 개인 정수 슬롯이다. 새 commerce는 공유 자원의 정수 구간 예약과 충돌 방지를 제공한다. 실제 날짜·시간대·반복 예약·결제 처리는 구현하지 않았다. 새 operations는 개인 저장, commerce·community는 명시적 공유 저장이다.

## 시작하기

네이티브 묶음에 포함된 `bin/fonuma-app`을 바로 실행하거나 `python3 install.py --prefix "$HOME/.local/share/fonuma/application-preview"`로 설치한다. 설치 프로그램은 Mac/Linux용이고 PATH를 자동 수정하지 않는다. `--prefix`는 심볼릭 링크를 통과하지 않는 사용자 소유 경로여야 한다. 플랫폼이 다른 묶음은 거부한다.

```sh
fonuma-app new my-app --template taskboard
fonuma-app check my-app
fonuma-app inspect my-app
fonuma-app test my-app
fonuma-app serve my-app --data-dir my-app/.fonuma/data --port 3441
```

출력된 `http://127.0.0.1:3441`에서 계정을 만들고 로그인한다. 계정명은 ASCII 문자·숫자·`_.-` 3–64자이며 소문자로 정규화한다. 비밀번호는 UTF-8 12–256바이트다. 관리자 계정은 서버 운영자가 `user-add ... --role admin`을 실행해 만든다. `{ "username": "...", "password": "..." }`를 표준입력으로 전달하며 비밀번호를 명령줄 인자나 소스에 넣지 않는다. 공개 가입은 항상 user 역할이다.

소스 변경 후 `check`, `test`를 실행하고 서버를 재시작한다. 자동 재시작·핫 리로드는 없다. 데이터 디렉터리를 유지하면 재시작해도 계정과 데이터가 남는다. 기존 `examples/taskboard/main.fonuma`의 파일 호스트와 이 SQLite 호스트는 서로 다른 저장 형식이며 자동 전환하지 않는다.

## 프로젝트 구조와 계약

```text
fonuma.app.json        프로젝트 이름·소스·UI·내보낼 경로·저장 버전
src/main.fonuma        업무 함수와 screen()
src/models.fonuma      가져올 자료형 모듈
tests.json             순차 실행하는 업무 시험
```

```json
{
  "schema": "fonuma.web-project/0.2",
  "name": "My app",
  "module": "taskboard.main",
  "sources": ["src/main.fonuma", "src/models.fonuma"],
  "ui": "screen",
  "storage_version": 1,
  "routes": [
    {"id": "list", "entry": "list", "access": "user", "write": false},
    {"id": "create", "entry": "create", "access": "user", "write": true}
  ]
}
```

소스 목록을 명시해야 한다. 경로 탈출·심볼릭 링크·중복 모듈·순환 import·진입점에서 도달할 수 없는 모듈을 거부한다. 32개 모듈, 합계 1 MiB, 파일당 256 KiB까지 허용한다. `use taskboard.models as models;`로 가져오고 `models.Task`, `models.validate(...)`처럼 참조한다. 라이브러리는 record만 포함할 수도 있다. `check/inspect`는 화면 함수를 제한 예산으로 실행하고 UI 계약을 검사하므로 완전히 무실행인 단일 소스 check와 구분한다.

경로 접근은 public/user/admin이다. public 경로는 pure 함수만 내보낼 수 있다. personal에서는 호스트가 인증한 사용자 ID로 SQLite 저장 공간을 선택한다. shared에서는 서버가 주입한 Actor로 업무 함수가 소유자·작성자 권한을 확인한다. `write:false` 함수에서 직접·간접 `store_write`를 호출하면 프로젝트 검사에서 거부하고 실행 시에도 저장값 변경을 확인한다. 저장 읽기도 언어 효과는 storage다.

## 포누마 화면

```fonuma
fn screen() -> List<List<String>> effects [pure] {
    return [
        ["page", "My tasks", "개인 작업 관리"],
        ["form", "new", "작업 추가", "create"],
        ["input", "new", "title", "제목", ""],
        ["table", "tasks", "list"],
        ["column", "tasks", "title", "작업"],
        ["action", "tasks", "complete", "완료 전환", "id=@id", "done=!@done"]
    ];
}
```

| 요소 | 형식 |
|---|---|
| page | 제목, 설명. 정확히 하나 |
| text | 본문 |
| form | 폼 ID, 제목, API 경로 ID |
| input / number / checkbox | 폼 ID, 매개변수, 라벨, 기본값 |
| table | 표 ID, 인자가 없고 List<Record>를 반환하는 경로 |
| column | 표 ID, 레코드 필드, 라벨 |
| action | 표 ID, 경로, 버튼 라벨, 매개변수 바인딩들 |

`id=@id`는 행 값 전달, `done=!@done`은 Bool 반전, `title=?`는 사용자에게 값을 입력받는다. 모든 매개변수를 정확한 타입으로 연결해야 한다. 폼과 표는 하위 요소보다 먼저 선언한다. 최대 512개 요소다. dev.3의 view/group/collection/textarea/select/lookup 계약은 고급 가이드에 기록한다. 새 프로필은 표·행 동작을 지원하고 기존 UI 0.1의 모든 요소와 호환되는 것은 아니다. 해시 기반 화면 전환과 중첩 레이아웃은 지원한다. 임의 컴포넌트 시스템, 서버 경로 라우터, 업로드·이미지·차트·실시간 갱신·접근성 인증은 제공하지 않는다. 렌더러는 데이터에 `textContent`를 사용하고 임의 HTML을 실행하지 않는다.

## 저장·오류·동시성

personal 모드에서 SQLite의 kv를 인증 사용자별로 분리한다. shared 모드는 앱 전체 공유 저장을 사용하고 actor:true 경로의 첫 Actor 인자를 호스트가 주입한다. 앱이 소유자·작성자 권한을 검사해야 한다. 저장 모드는 DB에 고정한다. 쓰기 호출은 `BEGIN IMMEDIATE` 안에서 현재 값을 읽고, 포누마 함수를 실행하고, 데이터와 요청 결과를 같이 저장한다. 읽기 경로는 읽기 트랜잭션을 사용한다. 실행 오류나 최상위 `Result<T>`의 Err는 데이터 변경을 남기지 않는다. Err 응답의 `committed`는 false다. 중첩 함수의 Result는 일반 값이므로 중첩 savepoint나 자동 전파가 아니다.

쓰기 요청은 1–64자 `[A-Za-z0-9_-]` `request_id`가 필요하다. 같은 사용자·ID·프로젝트 식별자·경로·인자는 1시간 동안 이전 응답을 재사용한다. 같은 ID로 다른 내용을 보내면 409다. 보관 한도는 사용자당 1,024개다. **1시간 후 또는 코드 변경·마이그레이션 후까지 영구적인 exactly-once를 보장하지 않는다.** 실패한 응답을 받았다고 새 ID로 무조건 재전송하지 않는다. 런타임 실패는 저장·영수증을 남기지 않는다.

저장 공간별 합계 4 MiB, 키 128개다. shared에서는 앱의 회원들이 한도를 공유한다. DB 페이지 수를 16,384로 제한하지만 WAL·백업을 포함한 전체 디스크 사용량의 절대 상한은 아니다. 등록 한도 1,000명 및 시간당 60명, 로그인 계정별 분당 8회, 서버 전체 가입·로그인 분당 30회다. 이 값은 소규모 프리뷰 제한이며 대규모 서비스 성능 근거가 아니다.

## 인증과 HTTP 경계

Argon2id 해시(19 MiB, t=2, p=1, OS 난수 salt)를 저장한다. 세션은 OS 난수 32바이트 토큰, DB에는 SHA-256 해시만 저장한다. 만료 12시간, 사용자당 최대 16개 세션. 쿠키는 HttpOnly·SameSite=Strict이며 HTTPS 공개 origin을 설정하면 Secure도 붙는다. 로그인 후 POST는 세션에서 파생한 CSRF 토큰, 정확한 Origin, JSON, `X-Fonuma-Client: 1`을 요구한다. Host 검사와 중복 헤더·중복 JSON 키·Transfer-Encoding 거부를 적용한다. CORS는 지원하지 않는다.

HTTP 작업자 4개, 대기열 32개, 헤더 8 KiB, 본문 64 KiB, 수신 기한 3초. HTTPS·DDoS 방어·이메일 검증·비밀번호 재설정·MFA·관리자 UI·감사 로그·외부 보안 감사는 제공하지 않는다. 악의적인 동일 OS 사용자가 파일·디렉터리를 교체하는 공격에 대한 sandbox가 아니다. 데이터 폴더와 백업은 운영자가 접근을 제한해야 한다.

## 브라우저 SDK

`/sdk.mjs`에서 호스트와 동일한 프로필의 SDK를 제공한다. 설치 묶음에는 JS와 타입 선언을 함께 넣는다.

```javascript
import { createClient } from '/sdk.mjs';
const client = createClient();
await client.login(username, password);
const response = await client.call('create', ['새 작업'], { requestId: crypto.randomUUID() });
if (!response.result.ok) console.error(response.result.error);
const items = (await client.call('list', [])).result;
```

Int는 JSON에서 십진 문자열이다. `Result<T>`도 값으로 반환되므로 업무 오류를 확인해야 한다. SDK는 전송 TypeError만 같은 ID로 한 번 재시도한다. HTTP 오류에는 `FonumaError.status/requestId`가 있다. Node에는 브라우저 쿠키 저장소가 없으므로 별도 fetch/cookie 어댑터가 필요하다. SDK 시험에 그 예가 있다. 이 SDK는 기존 웹 SDK 0.1의 교체판이라는 배포 약속을 하지 않는다.

## 백업·업데이트·복구

```sh
fonuma-app backup my-app --data-dir my-app/.fonuma/data --output before.sqlite3
fonuma-app migrate my-app --data-dir my-app/.fonuma/data --output before-migration.sqlite3
# 서버를 종료한 뒤
fonuma-app restore my-app --data-dir my-app/.fonuma/data --backup before.sqlite3
```

기존 파일에 백업을 덮어쓰지 않는다. Unix 백업 권한은 0600이다. backup은 실행 중에도 SQLite snapshot으로 만들 수 있다. restore는 서버와 다른 DB 호스트가 모두 종료되어 있어야 하며 OS 독점 잠금을 요구한다. 독립 복사본의 프로젝트·버전·스키마·무결성을 검사하고 세션·요청 영수증을 제거한 후 목적지에 SQLite backup으로 반영한다. 복구 후 다시 로그인한다. 전원 차단·디스크 고장 주입은 미실시다.

저장 자료형을 바꾸면 manifest의 `storage_version`을 1씩 올리고 `migrations`를 정의한다. 변환은 pure 한 인자 함수다. 모든 사용자 값과 버전을 한 트랜잭션으로 이전한다. 하나라도 런타임 오류나 Err이면 전체 이전을 취소한다. migrate는 먼저 지정한 파일로 백업한다. 실행 중이던 구버전 서버는 이후 저장 호출을 거부하므로 새 코드로 재시작한다.

```json
"migrations": [{"from": 1, "to": 2, "entries": [{"key": "items", "function": "upgrade_items"}]}]
```

변환 함수의 입력 타입에는 이전 레코드 형태, 출력 타입에는 새 레코드 형태를 정의한다. 키의 데이터 타입 이름도 바뀌므로 저장 키마다 변환을 명시한다. 자동으로 필드를 추측해 바꾸지 않는다.

네이티브 도구 업데이트는 새 묶음의 install.py를 같은 prefix로 실행한다. 검증된 버전별 폴더를 보존하고 current 링크를 교체한다. `python3 install.py --prefix PREFIX --rollback`은 직전 도구로 전환한다. **실행 파일 롤백은 데이터 스키마를 되돌리지 않는다.** 해당 코드/스키마와 맞는 별도 백업으로 복구해야 한다. 설치 프로그램과 manifest/hash는 무결성 검사이며 코드서명·공식 배포자 인증을 대신하지 않는다.

## 별도 서버 실행

서버도 127.0.0.1에만 바인딩한다. SSH 터널로 독립 시험하거나, 운영자가 관리하는 HTTPS reverse proxy 뒤에서 `--public-origin https://your-host`로 실행한다. 프록시는 Host를 해당 공개 호스트로 전달하고 JSON Origin/쿠키/CSRF 헤더를 유지한다. 한 호스트의 루트 경로(`/`)에 설치하며 URL 하위 경로 마운트는 지원하지 않는다. 프록시가 TLS를 종료해도 쿠키는 Secure다. 이 프리뷰의 HTTP 파서·보안 경계 시험 통과만으로 공개 운영 준비 완료를 주장하지 않는다.

## 검증과 남은 범위

실제 결과는 `APPLICATION_WEB_VALIDATION_2026-10-05.md`에 기록한다. 브라우저가 연결되지 않은 상태의 API 시험은 시각 검증을 대신하지 않는다. 현재 목표는 범용 언어의 완성을 선언하는 것이 아니라, 서로 다른 실제 웹앱에 공통 코어·호스트·SDK를 적용하는 것이다. 공유 저장·actor 기반 권한·검색/페이지 나누기는 dev.3에서 추가했다. 다중 조직 권한·외부 API·파일·실시간 통신·비밀번호 복구·배포 자동화·새 프로필 Formatter/LSP는 후속 범위다. AI 토큰 절감·다른 언어 대비 속도/안정성/보안 우위는 이 구현으로 입증하지 않았다.


## dev.4 웹 연동 확장

파일·변경 감지·고정 외부 JSON 조회·서명 웹훅과 integrations 템플릿은 [웹 연동 확장 가이드](APPLICATION_WEB_EXTENSIONS_0.2.md)를 따른다. 모든 기능은 명시적으로 켜며 비밀값을 소스에 넣지 않는다. 변경 감지는 polling이고, 결제 예제는 결과 검증/상태 반영이며 실제 청구가 아니다. 확장 DB를 열기 전 백업하고 과거 dev.3과 DB 호환 경계를 확인한다.
