폴더 하나가 앱이다 — 번들에는 코드가 없다

9 분
목표무인매장 점주 앱을 만들었다. 소스는 JSON 네 개, 컴파일 없음. 라우트를 따라 두 번째 화면으로 가고, 텍스트를 고치면 화면이 바뀐다 — 빌드가 돌지 않는다. 다만 화면만 바뀌고 품목은 그대로다. 그 갈라짐이 이 편의 주제다.

앞선 다섯 편은 전부 코드였다. C 펌웨어와 Dart 서버. 매번 컴파일이 돌았다.

이번 편에는 컴파일이 없다. 무인매장 점주가 보는 앱을 만들 건데, 소스는 JSON 파일 네 개다.

unmanned_store.mbd/
  manifest.json            누구인지
  ui/app.json              라우트
  ui/pages/main.json       화면 하나
  ui/pages/restock.json    화면 둘

그리고 이 글 후반에는, 그 JSON을 고치면 앱이 바뀌는 것까지 실제로 돌려 본다. 빌드는 돌지 않는다.

결과부터

점주가 보는 첫 화면. 오늘 매출과 재고, 그리고 채워 넣어야 할 게 몇 개인지.

Ice cream — Elm Street branch · Shelf. 재고 열에 LOW 표식이 붙고, 오른쪽 위에 오늘 매출. 실제 렌더 캡처

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

보충 화면 — 가져올 품목 3. 줄마다 on hand · reorder at 과 함께 `bring N` 이 강조된다

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

주문을 걸면 목록이 비고 "Ordered 3 lines" 만 남는다

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

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 relabelled

JSON을 고치면 앱이 바뀐다

이 편의 주장이다. 원본은 그대로 두고 복사본을 고친다 — 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 ran

8,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

가져갈 것

  1. 번들 뼈대 — unmanned_store.mbd/ 네 파일. 이름만 바꾸면 다음 앱의 출발점이다
  2. 폴더를 내주는 헬퍼 40줄 — serve_bundle.dart. 아무 서버 옆에 복사하면 그 옆 폴더가 앱이 된다
  3. 번들 검사 — verify.sh 의 비-JSON 0 검사와 라우트 해석 검사

내 것으로 바꾸려면 어디를 고치나

문구·배치를 바꾸려면 — ui/pages/*.json. 개발자가 필요 없고 빌드도 없다.

화면을 하나 더 늘리려면 — ui/pages/ 에 파일 하나, ui/app.json 의 routes 에 한 줄. 그 페이지로 가는 버튼은 {"type":"navigation","action":"push","route":"/새이름"}.

데이터를 바꾸려면 — 번들이 아니라 서버다. 이게 경계다. 번들을 아무리 고쳐도 품목은 안 바뀐다(§ 반만 바뀌었다).

그래서 이 형식이 파는 것

제안서에 이런 문장이 있다 — "도메인 전문가가 코딩 없이 클릭만으로 도메인 전용 앱 번들을 융합한다."

이 글이 그 문장의 정확한 크기를 갚는다. 코딩 없이 되는 것은 화면이다. 화면은 JSON이고, JSON은 사람이 고칠 수 있고, 고치면 빌드 없이 반영된다. 거기까지는 사실이다.

그리고 안 되는 것도 같이 적어야 정직하다. 품목이 무엇인지, 재주문점이 몇인지, 주문이 어떻게 나가는지는 번들 밖에 있다. 그건 서버의 일이고 누군가는 그걸 짜야 한다. 반쯤 세탁소가 된 화면이 그 선을 보여 준다 — 그 선을 지우겠다고 약속하는 대신, 어디에 있는지 보여 주는 편이 낫다.

확인 과제

화면 파일을 고치면 다음 접속에서 무엇이 바뀌는지, 왜 설치 단계가 없는지 두 문장으로 설명해 보세요.

관련 글A Folder of JSON Is the App — There Is No Code in a Bundle