폴더 하나가 앱이다 — 번들에는 코드가 없다
9 분앞선 다섯 편은 전부 코드였다. C 펌웨어와 Dart 서버. 매번 컴파일이 돌았다.
이번 편에는 컴파일이 없다. 무인매장 점주가 보는 앱을 만들 건데, 소스는 JSON 파일 네 개다.
unmanned_store.mbd/
manifest.json 누구인지
ui/app.json 라우트
ui/pages/main.json 화면 하나
ui/pages/restock.json 화면 둘그리고 이 글 후반에는, 그 JSON을 고치면 앱이 바뀌는 것까지 실제로 돌려 본다. 빌드는 돌지 않는다.
결과부터
점주가 보는 첫 화면. 오늘 매출과 재고, 그리고 채워 넣어야 할 게 몇 개인지.

「What needs a visit」를 누르면 같은 폴더의 다른 화면으로 간다.

「Order the lot」을 누르면 목록이 비워진다.

이제 JSON을 고치고 다시 돌린다. 컴파일러는 실행되지 않았다.

마지막 화면을 잘 봐 주기 바란다. 라벨은 세탁소가 됐는데 품목은 여전히 아이스크림이다. 반쯤 바뀐 이 화면이 이 편에서 가장 중요한 그림이다. 뒤에서 다시 온다.
번들은 무엇인가
manifest.json 은 이 앱이 누구인지를 말한다. 코드가 아니라 신원이다.
{
"schemaVersion": "1.0.0",
"manifest": {
"id": "com.makemind.sample.unmanned_store",
"name": "Unmanned Store",
"type": "application",
"entryPoint": "ui.app",
"description": "What the owner of an unmanned store sees: stock, takings and what needs a visit. No code, only declarations.",
"category": "business",
"tags": ["retail", "unmanned", "sample"]
}
}ui/app.json 은 화면 지도다.
{
"type": "application",
"title": "Unmanned Store",
"initialRoute": "/",
"routes": {
"/": "ui://pages/main",
"/restock": "ui://pages/restock"
}
}그리고 페이지 하나하나가 화면이다. 버튼이 무엇을 하는지도 여기 적힌다 — 함수 이름이 아니라 도구 이름으로.
{
"type": "button",
"label": "What needs a visit",
"variant": "elevated",
"onTap": { "type": "navigation", "action": "push", "route": "/restock" }
},
{
"type": "button",
"label": "Refresh",
"variant": "outlined",
"onTap": { "type": "tool", "tool": "store.today", "params": {} }
}서버가 폴더를 내준다 — 40줄
이 폴더도 시리즈의 다른 서버 앱과 똑같이 열린다. 매장 서버가 화면을 내준다. serve_bundle.dart 가 그 전부다. ui://app 과 파일마다 ui://pages/<이름> 을 등록하고, 요청이 올 때마다 파일을 읽는다. 이 형식은 더 요구하는 게 없어서 코드가 짧다. 플레이어는 이미 화면 정의를 그릴 줄 알고, 번들은 화면 정의에 목차를 붙인 것이다.
void registerBundleUi(Server server, String bundleDir) {
final manifest = _json('$bundleDir/manifest.json')['manifest'];
void serve(String uri, String name, String description,
Map<String, dynamic> Function() document) {
server.addResource(
uri: uri, name: name, description: description,
mimeType: 'application/json',
handler: (requestedUri, params) async => ReadResourceResult(contents: [
ResourceContentInfo(uri: requestedUri,
mimeType: 'application/json', text: jsonEncode(document())),
]),
);
}
serve('ui://app', manifest['name'], 'The app: routes and theme',
() => _json('$bundleDir/ui/app.json'));
for (final f in Directory('$bundleDir/ui/pages').listSync().whereType<File>()) {
if (!f.path.endsWith('.json')) continue;
final name = f.uri.pathSegments.last.replaceAll('.json', '');
serve('ui://pages/$name', name, 'Screen "$name"', () => _json(f.path));
}
}검증은 번들이 실려 나가기 전에 폴더 안에 빌드할 것이 없는지부터 확인한다.
# If there is a build step this article's argument collapses, so start by
# checking there is nothing to build
BUILDISH=$(find unmanned_store.mbd -type f ! -name '*.json' | wc -l | tr -d ' ')
[ "$BUILDISH" -eq 0 ] || { echo "bundle contains non-json files"; exit 1; }페이지가 들어올 때 스스로 채운다
처음 돌렸을 때 두 번째 화면이 비어 있었다. 서버는 세 줄이 모자란다고 하는데 캡처는 "Nothing to bring" 이었다.
원인은 형식에 있었다. 페이지마다 자기 initialState 를 들고 있다.
"initialState": { "low": [], "lowCount": 0, "notice": "" }라우트를 옮기면 새 화면은 자기 초기값에서 시작한다. 앞 화면이 받아 둔 데이터는 넘어오지 않는다. 그래서 페이지마다 들어올 때 무엇을 물을지 적어 두고, 런타임이 그 답을 페이지 상태에 합친다.
"onInit": { "type": "tool", "tool": "store.today", "params": {} }검증에도 넣었다. 재입고 화면은 목록이 올라온 뒤에만 찍는다. 주문은 목록을 비워야 하고, 문구만 바뀌어서는 안 된다.
ap.tap("What needs a visit")
ap.wait_text("bring") # photographed only once the list is on it
ap.shot("02_restock.png")
ap.tap("Order the lot")
ap.wait_text("Ordered 3 lines")
ap.expect_no_text("bring 13") # the ordered line is gone, not just relabelledJSON을 고치면 앱이 바뀐다
이 편의 주장이다. 원본은 그대로 두고 복사본을 고친다 — ui/pages/main.json 의 문자열 셋.
after = (before
.replace("Shelf · open 24 h", "Laundry · open 24 h")
.replace("TAKINGS TODAY", "COIN BOX TODAY")
.replace("{{lowCount}} lines at or below reorder",
"{{lowCount}} machines need a look"))그리고 앱을 다시 열어 그린다.
edited ui/pages/main.json — 8921 B -> 8919 B, no compiler ran8,921 바이트가 8,919 바이트가 되고, 화면이 달라졌다. 그 사이에 컴파일러는 실행되지 않았다.
그런데 반만 바뀌었다
앞에서 다시 오겠다고 한 그림이다. 라벨은 세탁소가 됐는데 목록은 여전히 Cone vanilla, Bar mint, Tub 474ml 이고, 이름도 여전히 Ice cream — Elm Street branch 다. {{storeName}} 은 파일이 아니라 서버의 말이기 때문이다.
이건 데모의 흠이 아니라 구조가 드러난 것이다. 번들이 가진 것은 화면이고, 품목은 서버가 갖고 있다.
// 옆 폴더의 번들에는 코드가 없다. 화면을 선언하고 버튼이 어느 도구를
// 부르는지 말할 뿐이다. 도구는 여기 산다. 그 갈라짐이 이 글의 논지다 —
// 화면을 고치는 사람과 재고를 책임지는 사람은 같은 사람이 아니고,
// 같은 파일을 고치고 있어서도 안 된다.점주가 화면 문구를 바꾸는 데 개발자가 필요하지 않다. 대신 화면을 아무리 고쳐도 재고 규칙은 바뀌지 않는다. 무코드로 되는 일과 안 되는 일의 경계가 정확히 이 선이고, 반쯤 세탁소가 된 저 화면이 그 선을 그림 하나로 보여 준다.
검증이 찍는 것
$ bash verify.sh
[1/3] bundle is only json
[2/3] store_server (dart analyze)
No issues found!
[3/3] open in AppPlayer, walk it, capture
edited ui/pages/main.json — 8921 B -> 8919 B, no compiler ran
store-bundle: 2 routes walked, reorder cleared the list, json edit took effect with no build실측치
| 값 | |
|---|---|
| 번들 파일 수 | 4 (전부 JSON) |
| 번들 총 크기 | 21 KB (21,551 바이트) |
| 폴더를 내주는 헬퍼 | 40줄 (serve_bundle.dart) |
| 라우트 | 2 |
| 빌드 실행 | 0회 |
시간 값은 없다. 플레이어의 시계는 이 편의 주제가 아니고, 실행마다 바뀌는 숫자는 형식의 실측치가 아니다.
범위 밖
- 페이지가 수십 개인 번들의 로드 비용. 이 번들은 둘이다.
- 서버 없이
.mbd를 플레이어에 직접 설치하는 경로. 이 글은 페이지가 서버의 도구를 부르기 때문에 서버를 통해 폴더를 연다. 플레이어의 설치·서명·배포 경로는 다루지 않았다. - 번들 안에서
client.mcpStream같은 클라이언트측 채널을 쓰는 경우. 기존 번들 예제(ble_monitor.mbd)가 그걸 쓰지만 이 편에서는 안 붙였다.
이 샘플의 범위
이 글이 돌린 것은 번들 형식이다 — 자기 서버가 화면을 내주고 AppPlayer 가 그린다. 플레이어의 설치 파이프라인이 아니다. 이 구분을 흐리지 않겠다.
직접 돌려보기
( cd store_server && dart pub get )
bash verify.sh # bundle check + analyze + AppPlayer: two routes, the order, the edit손으로 열려면 AppPlayer 에 서버 앱을 하나 추가한다 — 명령 dart, 인자 run bin/server.dart, 작업 폴더 store_server/. 번들 자체는 도구가 필요 없다. 텍스트 에디터면 충분하다.
cat unmanned_store.mbd/ui/app.json
cat unmanned_store.mbd/ui/pages/main.json가져갈 것
- 번들 뼈대 —
unmanned_store.mbd/네 파일. 이름만 바꾸면 다음 앱의 출발점이다 - 폴더를 내주는 헬퍼 40줄 —
serve_bundle.dart. 아무 서버 옆에 복사하면 그 옆 폴더가 앱이 된다 - 번들 검사 —
verify.sh의 비-JSON 0 검사와 라우트 해석 검사
내 것으로 바꾸려면 어디를 고치나
문구·배치를 바꾸려면 — ui/pages/*.json. 개발자가 필요 없고 빌드도 없다.
화면을 하나 더 늘리려면 — ui/pages/ 에 파일 하나, ui/app.json 의 routes 에 한 줄. 그 페이지로 가는 버튼은 {"type":"navigation","action":"push","route":"/새이름"}.
데이터를 바꾸려면 — 번들이 아니라 서버다. 이게 경계다. 번들을 아무리 고쳐도 품목은 안 바뀐다(§ 반만 바뀌었다).
그래서 이 형식이 파는 것
제안서에 이런 문장이 있다 — "도메인 전문가가 코딩 없이 클릭만으로 도메인 전용 앱 번들을 융합한다."
이 글이 그 문장의 정확한 크기를 갚는다. 코딩 없이 되는 것은 화면이다. 화면은 JSON이고, JSON은 사람이 고칠 수 있고, 고치면 빌드 없이 반영된다. 거기까지는 사실이다.
그리고 안 되는 것도 같이 적어야 정직하다. 품목이 무엇인지, 재주문점이 몇인지, 주문이 어떻게 나가는지는 번들 밖에 있다. 그건 서버의 일이고 누군가는 그걸 짜야 한다. 반쯤 세탁소가 된 화면이 그 선을 보여 준다 — 그 선을 지우겠다고 약속하는 대신, 어디에 있는지 보여 주는 편이 낫다.
확인 과제
화면 파일을 고치면 다음 접속에서 무엇이 바뀌는지, 왜 설치 단계가 없는지 두 문장으로 설명해 보세요.